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 doesread -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 subscriptvar[$(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:
| |
compiles to:
| |
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.
| |
Output:
| |
Flag
| |