$ cat writeup.md…
$ cat writeup.md…
umdctf
Task: a stripped ELF self-decrypts a hidden code section, validates a base64 ticket through a custom VM, and processes an encrypted market feed archive. Solution: decrypt .xtext, decode the double-encrypted feed, recover the VM semantics and true ticket input source, solve the eight dword constraints with Z3, and use the derived 32 bytes as AES key plus IV to decrypt the final record.
No separate organizer description was present in the provided workspace files.
English summary: the challenge ships a stripped ELF named oracle plus an encrypted feed.bin. The binary behaves like a market-settlement verifier: it reads a base64 ticket from ticket.txt, validates the decoded 32-byte body with a custom VM, derives 32 bytes of key material on success, and uses that material to decrypt the final archive record containing the flag.
At a high level, the solve path was:
.xtext code that main unseals at runtime.feed.bin, which needs two outer decrypt passes before an ARC\x01 archive decode with a rolling XOR stage.b and fix the critical misunderstanding about where the 32-byte ticket body is actually read from.AES-128 key || IV to decrypt record g.The useful local artifacts created during solving were:
decrypt_xtext.pypatch_oracle.pydecode_strings.pydecode_feed.pyemulate_crypto.pyrun_vm.pysolve_ticket.pyThe ELF is a stripped PIE x86-64 binary with a hidden encrypted section named .xtext. The visible main function is mostly a loader:
chk section with seed 0xc0def1ab.rodata.rodata.xtextThe important detail is that the hidden section is not plain AES or TEA-CBC. It is a TEA-based CFB-like stream mode:
TEA_encrypt(state, key)ciphertext XOR keystreamFor a normal non-debugged run, the recovered values were:
0x72196a90[0x1a2b3c4d, 0x5e6f7081, 0x92a3b4c5, 0xd6e7f809]00 11 22 33 44 55 66 77That logic was reimplemented in decrypt_xtext.py, which produced xtext.dec for static analysis.
After opening the decrypted code, the next blocker was that several user-facing strings were still stored in an obfuscated form and decoded through helper routines at 0x1510 and 0x1540.
Using decode_strings.py, the important runtime strings became:
oracle v1 - market settlement verifierticket.txtfeed.binresolution: Those strings immediately clarified the intended flow:
ticket.txtfeed.binresolution: ... string on successfeed.bin and the custom archive formatfeed.bin turned out to have two different protection layers.
The outer wrapper is not a standard archive. It must be decrypted twice with the same custom pass. Each pass:
.rodatakey = AES-ECB-decrypt(block_b, key_seed)AES-CBC(key, iv)In code this was captured by:
def decode_outer_feed(data: bytes) -> bytes: out = one_pass(one_pass(data)) if out[:4] != b"ARC\x01": raise ValueError("bad inner header") x = struct.unpack("<I", out[4:8])[0] body = bytearray(out) for j in range(8, len(body)): x = (((x & 0xFFFF) * 0x4650) + (x >> 16)) & 0xFFFFFFFF body[j] ^= x & 0xFF body[4:len(body) - 4] = body[8:] return bytes(body[:-4])
After the double outer decryption, the blob starts with ARC\x01. The archive body is additionally protected by a rolling XOR stream seeded from offset 4, then parsed as a tiny container:
4: record countThe parsed records were:
a: 14 bytesb: 848 bytesg: 96 bytesb contains the VM bytecode and constants, and one embedded string inside it is the salt:
umdctf-v2026-unseal-salt
The decrypted logic base64-decodes ticket.txt, then parses a strict binary ticket structure.
The accepted raw ticket format is:
raw_ticket = b"TKT\x01" + struct.pack("<I", 0x20) + body32
So the file on disk must contain the base64 encoding of those 40 raw bytes.
The final accepted ticket.txt content is:
VEtUASAAAAApD5RkMvZB09nhNlWQ8FQK7DNS3ozstXxn7UgRITVa1g==
Decoding that base64 gives:
TKT\x010x20The hardest part of the challenge was the custom VM in record b.
My initial misunderstanding was assuming that the VM consumed the ticket body from the object passed in rsi to fcn.000041f0. That was wrong.
The critical reversing insight is:
fcn.000041f0 does not read the input ticket body from the output object in rsistate + 0x10 = body pointerstate + 0x18 = body lengthrsi is still used as the output/result objectThat bug in my mental model explained why early VM emulation attempts looked inconsistent. Once run_vm.py wrote the 32-byte body into the root state, the VM behavior became coherent.
The VM first loads eight little-endian dwords from the 32-byte ticket body into registers r0..r7, then runs an ARX/mix routine parameterized by constants from segment 0 of record b.
The recovered semantics were:
[3, 10, 17, 24, 31, 6, 13, 20]0x5ABC7F01The reconstructed transform was:
def transform(words, seg0_words): r = list(words) C = 0x5ABC7F01 r[1] ^= seg0_words[0] r[4] += seg0_words[1] r[7] = rol(r[7], 3) r[2] ^= r[5] r[0] += r[1] r[3] *= C r[6] ^= seg0_words[2] r[1] += seg0_words[3] r[4] = rol(r[4], 10) r[7] ^= r[2] r[5] += r[6] r[0] *= C r[3] ^= seg0_words[4] r[6] += seg0_words[5] r[1] = rol(r[1], 17) r[4] ^= r[7] r[2] += r[3] r[5] *= C r[0] ^= seg0_words[6] r[3] += seg0_words[7] r[6] = rol(r[6], 24) r[1] ^= r[4] r[7] += r[0] r[2] *= C r[5] ^= seg0_words[8] r[0] += seg0_words[9] r[3] = rol(r[3], 31) r[6] ^= r[1] r[4] += r[5] r[7] *= C r[2] ^= seg0_words[10] r[5] += seg0_words[11] r[0] = rol(r[0], 6) r[3] ^= r[6] r[1] += r[2] r[4] *= C r[7] ^= seg0_words[12] r[2] += seg0_words[13] r[5] = rol(r[5], 13) r[0] ^= r[3] r[6] += r[7] r[1] *= C r[4] ^= seg0_words[14] r[7] += seg0_words[15] r[2] = rol(r[2], 20) r[5] ^= r[0] r[3] += r[4] r[6] *= C return r
Once the VM semantics were correct, the tail compare-chain reduced the problem to eight 32-bit equations:
for yi, target in zip(transform(x, seg0), seg1): s.add(yi == target)
Z3 solved the eight dword constraints directly.
The valid 32-byte ticket body is:
290f946432f641d3d9e1365590f0540aec3352de8cecb57c67ed481121355ad6
Split as little-endian dwords, that is the body that the VM accepts when it is placed at state + 0x10 with length 0x20 at state + 0x18.
The accepted base64 ticket file is therefore:
VEtUASAAAAApD5RkMvZB09nhNlWQ8FQK7DNS3ozstXxn7UgRITVa1g==
On success, the VM returns 32 bytes of derived material:
e46caa0c59a1da91689f629dc1664c6523c2e633d9331d5c5ca40c40f51626b0
Those 32 bytes are used as:
Decrypting record g with AES-CBC and removing PKCS#7 padding yields the final plaintext and the flag.
This compact script reproduces the last stage from the already recovered helpers:
#!/usr/bin/env python3 from pathlib import Path from base64 import b64encode from Crypto.Cipher import AES from decode_feed import decode_outer_feed, parse_arc from run_vm import run_ticket BODY = bytes.fromhex( "290f946432f641d3d9e1365590f0540aec3352de8cecb57c67ed481121355ad6" ) def unpad_pkcs7(data: bytes) -> bytes: pad = data[-1] if pad == 0 or pad > 16 or data[-pad:] != bytes([pad]) * pad: raise ValueError("bad padding") return data[:-pad] def main(): raw_ticket = b"TKT\x01" + (0x20).to_bytes(4, "little") + BODY print("ticket.txt =", b64encode(raw_ticket).decode()) items = parse_arc(decode_outer_feed(Path("feed.bin").read_bytes())) vm = run_ticket(items["b"], items["a"], BODY) material = vm["data"] print("derived =", material.hex()) key, iv = material[:16], material[16:32] pt = AES.new(key, AES.MODE_CBC, iv).decrypt(items["g"]) print(unpad_pkcs7(pt).decode()) if __name__ == "__main__": main()
Expected output includes:
ticket.txt = VEtUASAAAAApD5RkMvZB09nhNlWQ8FQK7DNS3ozstXxn7UgRITVa1g== derived = e46caa0c59a1da91689f629dc1664c6523c2e633d9331d5c5ca40c40f51626b0 UMDCTF{oh_no_my_prediction_market_feed_has_been_compromised_what_ever_will_i_do}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar