jailnet
The Janet compiler runs outside the sandbox it is compiling for, and nothing verifies the bytecode it emits.
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_flags → ffi/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_PUSHgrows the fiber stack. If it reallocates,stackmoves 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:
- Obtain a table
t2whose struct and KV array both sit above A. - Scan the spray for a real W0 boundary using the signature
(
S+16..23 = C,C,S+24..31 = A). - Write the forged nanbox value at that address into a KV value slot of
t2using R, so that(get t2 7)returns the forged buffer. - Rewriting W’s
datafield (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; thefd == bkpair is decisive. - Descend page by page from there to
\x7fELF→libc_base. (The libc mapping is contiguous with no PROT_NONE gap; confirmed via/proc/self/maps.) - Verify
systemby checking the first five bytes atlibc_base + 0x53110are48 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(glibctcbhead_t.tcb/.self). Measured:TP = libc_base - 0x3d80. - Find
janet_vm:sandbox_flags(+240) == 0xFFFFFFFFandblocks(+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
| File | Role |
|---|---|
gen_poc6.py | Source generator (dummy fn, giant if, constant array, newline tuning, fixed-length padding) |
probe.py | Payload (multi-probe) + prelude (spray) + the follow-up Janet stages |
run_probe.py | Landing-PC auto-alignment driver (measure → compute → rebuild → run) |
fire.py | Remote launcher |
local/janet_src_copy | Instrumented 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
- A typo in the nanbox tag constant. I had written
4294443008believing it was0xFFFD8000, but that value is0xFFF80000(the correct constant is0xFFFD8000 = 4294803456). With the tag bits off, every forged value read back asJANET_NUMBER. It only surfaced after dumping the spray buffer as raw bytes. Always verify a forged value against the bytes actually written to memory. - 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=64unchanged). Non-tail recursion via(+ 1 (gr (- n 1)))is what actually stacks frames. - 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.