jailnet

The Janet compiler runs outside the sandbox it is compiling for, and nothing verifies the bytecode it emits.

2026.09.30 NNS CTF 2026 Pwn
FLAG NNS{7h15_saNdox_1s_boRDeRliN3_uNF1xaB13_tru5t_No7h1ng_4ND_no0ne}

Janet 1.41.2 sandbox escape. Under (sandbox :all) plus a filter that leaves 405 of 707 bindings, the chain is: compiler heap bug → arbitrary bytecode execution → arbitrary read/write → neutralise sandbox_flagsffi/call system().

First shot on the remote landed (P=13449): the compile-time heap layout of my instrumented local build and the official release binary matched, so njt0 carried over unchanged.

NNS{7h15_saNdox_1s_boRDeRliN3_uNF1xaB13_tru5t_No7h1ng_4ND_no0ne}

0. Attack surface

server.janet compiles the user’s form with (compile form env "submission") first, and only then applies (sandbox :all) and runs it. The compiler therefore executes outside the sandbox. janet_verify is never called on the compiler’s output either, whatever bytecode the compiler emits enters the VM unverified.

Every error-handling construct (try / protect / fiber/new / defer) has been removed, so a single failure ends the connection. Every stage has to be right on the first attempt.

1. Arbitrary bytecode execution

1-1. The bug: 16-bit jump encoding overflow in janetc_if

An if’s branch distance goes into the instruction’s 16-bit ES field. If the body exceeds 32,767 instructions the distance is truncated and the jump lands somewhere else. That shape is known. To actually gain control you also have to be able to choose where it lands.

1-2. Choosing the landing site: an OOB read in janet_bytecode_remove_noops

The NOOP-removal pass computes the new jump target as pc_map[old_jump_target] without checking that old_jump_target is in range or non-negative. It ends up reading freed sourcemap memory, and what sits there are the column values of our own source.

So putting a single newline at position P in a one-line form reduces the column of every token after it by exactly P. One value of P chooses the final landing PC:

final PC = sign_extend_16(njt0 - P)

1-3. Landing zone: def->constants

The memory we land in must (a) still be alive during execution and (b) have contents we control. The sourcemap is freed between compilation and execution, dead ground, confirmed by instrumentation. funcdef->constants is referenced by the executing function, so it stays alive.

The reachable distance backwards from the bytecode is 131,072 bytes. To place constants immediately before bytecode, pick the constant count NC so that bc - constants = align16(8*NC+8) (locally: nc_bulk=5240 → NC=6453 → 51,632 bytes, within reach).

1-4. Raising the mmap threshold

Problem: at 131,088 bytes the bytecode allocation goes to mmap, lands far from the heap, and adjacency is lost.

Solution: make the compiler raise the threshold itself. Put a dummy fn with 8,193 constants in front; the janet_v vector grows to capacity 16,384 (131,080 B), which is mmap-backed, and when janetc_popscope’s janet_v_free releases it, glibc’s munmap_chunk raises mp_.mmap_threshold. Every later allocation comes from the arena.

1-5. Planting instructions as double literals

One double in constants is two fully controlled 32-bit instruction words. The only values to avoid are the NaN-normalisation ranges (hi16 0x7FF0-0x7FFF / 0xFFF0-0xFFFF). For a NOOP sled to absorb landing error, use q = k·256, q < 2^32 so that both words have opcode 0.

2. The E problem, and multi-probing

MOVE_NEAR slot0, E promotes stack[E] (E ≤ 65,535, 8-byte slots → a 524 KB window) straight into a Janet value. Put a forged nanbox value there and you materialise an arbitrary object.

The problem is that E cannot be predicted remotely: the spray scatters into recycled free chunks in the arena, and there is no instrumentation on the remote.

Solution: since one double is two words, [MOVE_NEAR s0,E][PUSH s0] fits exactly in one constant. Emit 1,010 of those and finish with [MAKE_ARRAY s0][RETURN s0], that reads 1,010 different E values in a single run and returns them as a Janet array. A stride of 65 covers the whole window.

Two traps here:

  • JOP_PUSH grows the fiber stack. If it reallocates, stack moves and every later E is wrong, so it has to be grown in the prelude. Note that (gr (- n 1)) is a tail call and compiles to TAILCALL, which does not grow the stack, you need genuine non-tail recursion, e.g. (+ 1 (gr (- n 1))).
  • The array ends up containing garbage NaNs, so when the GC marks it, it dereferences bogus pointers and dies. Disable it with (gcsetinterval 1000000000) and re-enable after (array/clear ARR).

3. Forging a JanetBuffer, a 32-byte self-referential pattern

JanetBuffer = { gc(16), count@16, capacity@20, data@24 }
buffer nanbox = 0xFFFD8000_00000000 | addr        (verified against a real buffer)

With a 16-byte period [pointer][TGT], count = low32(pointer), so any heap address with bit 31 set gives a negative count and every read returns nil, a 50% failure rate. A 32-byte period fixes it:

W0 = P = 0xFFFD8000_00000000 | S      W1 = P+16      W2 = C      W3 = TGT

At a W0 boundary this gives count = low32(C) and data = TGT, independent of the pointer. Using C = 0x10000000 makes (length b) == 0x10000000 a unique signature for a valid forged buffer.

malloc only guarantees 16-byte alignment, so plant both P and P+16 to remove the mod-32 phase problem. Candidate S values are seeded 2,048 times at 8 KB intervals (a 64 KB period = one spray buffer). Even when the spray scatters, dozens still point inside it.

4. Promoting to arbitrary read/write

R, the forged buffer obtained from probing, has to keep data = A, a leaked heap address. Rewriting its own struct requires data ≤ struct address, and moving it to libc breaks that relation with no way back.

So build a second, movable buffer W:

  1. Obtain a table t2 whose struct and KV array both sit above A.
  2. Scan the spray for a real W0 boundary using the signature (S+16..23 = C,C, S+24..31 = A).
  3. Write the forged nanbox value at that address into a KV value slot of t2 using R, so that (get t2 7) returns the forged buffer.
  4. Rewriting W’s data field (SW+24) through R lets W be re-aimed at any address.

Reading above the heap top is a SIGSEGV, so the scan needs an upper bound: grow t2 to 4,000 entries so its KV array (131,072 B, just under the mmap threshold) is cut near the top, and use that address as a conservative limit.

5. Finding libc

Create a (buffer/new 131000) decoy near the top (with a guard behind it to prevent top consolidation), drop it, and (gccollect). The lone chunk left in the unsorted bin has fd and bk both pointing at the bin head inside &main_arena.

  • Walk the heap at 8-byte alignment looking for two consecutive identical pointers into an mmap region. A weaker filter matches decoys such as 0x75d30000000c; the fd == bk pair is decisive.
  • Descend page by page from there to \x7fELFlibc_base. (The libc mapping is contiguous with no PROT_NONE gap; confirmed via /proc/self/maps.)
  • Verify system by checking the first five bytes at libc_base + 0x53110 are 48 85 ff 74 0b.

The remote Dockerfile pins a debian:trixie-slim digest and uses the official Janet release binary, so the libc offsets match the local ones.

6. Neutralising janet_vm.sandbox_flags

janet_vm is JANET_THREAD_LOCAL, so it lives in TLS. The anonymous rw TLS region sits directly below the libc mapping, which makes a descending scan safe.

  • Find the TCB: mem[X] == X && mem[X+16] == X (glibc tcbhead_t.tcb / .self). Measured: TP = libc_base - 0x3d80.
  • Find janet_vm: sandbox_flags(+240) == 0xFFFFFFFF and blocks(+144) is a heap pointer. Measured: janet_vm = TP - 0x1b00. (The struct offsets come from the source definition and are build-independent.)
  • Write 0 to sandbox_flags.

7. Flag

(ffi/call (ffi/lookup (ffi/native) "system")
          (ffi/signature :default :int :ptr) "cat /flag.txt")

The ffi/ prefix is not on the filter’s ban list, only ffi/context and ffi/defbind are blocked individually. Only (sandbox :all) was standing in the way, so zeroing the flags opens it straight up.

Files

FileRole
gen_poc6.pySource generator (dummy fn, giant if, constant array, newline tuning, fixed-length padding)
probe.pyPayload (multi-probe) + prelude (spray) + the follow-up Janet stages
run_probe.pyLanding-PC auto-alignment driver (measure → compute → rebuild → run)
fire.pyRemote launcher
local/janet_src_copyInstrumented build ([LAYOUT] / [DBG] / [VMJMP] / [VMSTK] / [SPRAY])

Why the source length has to be fixed

When an E value changes, the printed length of the payload double changes, which shifts the total source length, which changes the compile-time heap layout, which changes njt (the column read out of bounds) entirely. Padding the end of the source, after every token’s column, with spaces to a fixed 131,000 bytes keeps it stable.

A transmission trap (remote only)

The server reads with (file/read stdin 4096). If the total number of bytes sent is not a multiple of 4096, the final read blocks and nothing comes back at all, even (print (+ 1 2)) gets no response. Send exactly 131,072 B (= 32 × 4096) with space padding. The server’s limit is (> size 0x20000), so 131,072 passes, and trailing spaces do not change any token’s column, so P stays valid.

Three things that cost the most time

  1. A typo in the nanbox tag constant. I had written 4294443008 believing it was 0xFFFD8000, but that value is 0xFFF80000 (the correct constant is 0xFFFD8000 = 4294803456). With the tag bits off, every forged value read back as JANET_NUMBER. It only surfaced after dumping the spray buffer as raw bytes. Always verify a forged value against the bytes actually written to memory.
  2. Tail calls. (defn gr [n] (if (> n 0) (gr (- n 1)) 0)) compiles to TAILCALL and does not grow the fiber stack at all (cap=64 unchanged). Non-tail recursion via (+ 1 (gr (- n 1))) is what actually stacks frames.
  3. The scan upper bound. Reading above the heap top is a SIGSEGV, so the range has to be known, but a small table lands in a free chunk below A and is useless as a bound. Growing a table already above A to 4,000 entries puts its KV array near the top, and that address serves as the limit.
#jail#0day#janet