Ouroboros — Rev CTF Write-up Flag: r00t{0ur0b0r0s_6f86002f214455d445fd6522} Description "Can you escape it?"
The challenge is a VM inside a VM. The outer VM's program is a single HLT. After it runs, the ouroboros binary derives an 8-byte XOR key from two RAM blobs and decrypts inner.enc into inner.orb. The inner VM checks 20 bytes from stdin, and its ISA doc lies: opcode 0x06 jumps on not equal. Solving the inner check gives 20 raw bytes. The first 24 hex characters of their SHA-256 are the flag suffix. validator is a decoy: it holds a fake flag and its output is inverted. The outer stage only produces the key, the inner VM produces the secret, and the hash of that secret is what check embeds. validator sits off the chain. Files provided The zip holds nine files: four stripped x86-64 ELF binaries, two data files and three text hints. One of the hints (outer_disasm.txt) is partly misleading. File Size (bytes) What it really is ouroboros 14552 Outer VM. Runs outer.orb, then derives a key and decrypts inner.enc into inner.orb outer.orb 263 Outer program: ORB1 header, 256-byte RAM image holding the key material, 1 byte of bytecode (HLT) inner.enc 680 inner.orb XOR-encrypted with a repeating 8-byte key inner_vm 14520 Inner VM. Reads 20 bytes from stdin and exits 0 if they pass the inner program check 14488 Real flag checker. Compares stdin against a XOR-0x5c-encoded flag validator 14488 Decoy. Same code shape as check, but holds a fake flag and prints the opposite result isa_outer.txt 734 Outer ISA reference. Opcodes 0x03 and 0x06 are wrong isa_inner.txt 686 Inner ISA reference. The jump condition of opcode 0x06 is wrong outer_disasm.txt 499 Pre-made disassembly of outer.orb. Correct about the bytecode, misleading about opcode 0x03 The __MACOSX/ folder in the zip is just macOS metadata and can be ignored. Recon Recon alone gives away the decryption key, before any disassembly. file shows four stripped PIE binaries, and strings hints at each one's job: • ouroboros: ORB1, inner.orb, outer VM: OK, usage <outer.orb> <inner.enc>. • inner_vm: INR1, usage echo -n <20bytes> | %s <inner.orb>. • check and validator: fgets, strcmp, INCORRECT, plus an odd printable blob each (j:djlln:nmhhii8hhi:8jinn! in check, oh/% and 4=4=ll! in validator). These turn out to be XOR-encoded flags (Stage 5). The ISA docs describe both VMs the same way: eight 8-bit registers, 256 bytes of RAM, opcodes 0x01 to 0x07. Both file formats are a 4-byte magic, a 2-byte big-endian program length, a 256-byte RAM image, then bytecode at file offset 0x106. outer_disasm.txt says the outer bytecode is a lone HLT and the key material sits at RAM[0x10..0x1F]. The hex dump of inner.enc shows the same 8 bytes repeating over long runs: 000000 12 61 60 50 db 42 c3 d8 5b 2f 32 61 da e0 c3 d8 000010 5b 2f 32 61 da e0 c3 d8 5b 2f 32 61 da e0 c3 d8 That is a repeating 8-byte XOR key over zero bytes (the mostly empty RAM image). The run starting at offset 0x0e is key bytes 6, 7, 0, 1 and so on, so the key is 5b 2f 32 61 da e0 c3 d8. Check: 12 61 60 50 XOR the key gives 49 4e 52 31, which is INR1, the magic inner_vm expects. Stage 1: Outer VM and key derivation The outer VM does nothing useful. The key comes from fixed code that runs after the VM halts. main() in ouroboros works like this:
- Opens outer.orb, checks the magic ORB1 (cmp dword, 0x3142524f), reads the big-endian length (rol ax, 8), the 256-byte RAM image and the bytecode.
- Reads all of inner.enc into memory.
- Zeroes the registers, copies in the RAM image, and runs the fetch-decode loop. Any opcode above 0x06 ends the loop (cmp byte [rbx+rdx], 6; ja). The program's only byte is 0x07, so it stops at once.
- Derives the key, XORs inner.enc with it, writes inner.orb and prints outer VM: OK. The jump table at 0x2050 shows the outer ISA doc is wrong about two opcodes: Opcode isa_outer.txt says Binary actually does 0x00 (not listed) Stop 0x01 LDI r, imm8 LDI r, imm8 0x02 XOR r, r2 XOR r, r2 0x03 ADD r, r2 STR addr, r: RAM[addr] = r 0x04 LDR r, addr LDR r, addr 0x05 JNZ r, offset JNZ r, offset (relative to the next instruction) 0x06 STR addr, r ADD r, imm8 0x07 and up HLT Stop The swap doesn't matter here because the program is a lone HLT. It would trap anyone who built an emulator from the doc, and the outer_disasm.txt note about 0x03 being ADD r, r2 is a red herring. The key derivation at 0x13f3, in pseudo-assembly: movq xmm0, [rsp+0x140] ; RAM[0x18..0x1F] = raw2 movq xmm1, [rsp+0x138] ; RAM[0x10..0x17] = raw1 pxor xmm0, xmm1 ; k = raw1 ^ raw2 for i in 0..7: k[i] = ror(k[(i+1) & 7], 3) ^ k[i] ^ 0x3c ; in place: k[7] uses the new k[0] out[j] = enc[j] ^ k[j % 8] With the values from the RAM image: Value Bytes raw1 = RAM[0x10..0x17] 3e97a1d3e239c7ba raw2 = RAM[0x18..0x1F] f9921126a724c935 raw1 ^ raw2 c705b0f5451d0e8f Final key 5b2f3261dae0c3d8 The final key matches the one found by eye in Recon. isa_outer.txt claims the key "is stored at RAM[0x10:0x18]", but that is only raw1. Using it directly gives garbage. Stage 2: Decrypting inner.enc Decryption yields a clean INR1 file with two 20-byte tables and 418 bytes of bytecode. Running ./ouroboros outer.orb inner.enc gives a file byte-identical to the hand-XORed one. Offset Bytes Meaning 0x000 49 4e 52 31 Magic INR1 0x004 01 a2 Program length, big-endian: 418 bytes 0x006 256 bytes RAM image 0x106 418 bytes Bytecode The sizes add up: 6 + 256 + 418 = 680 bytes, the size of inner.enc. The RAM image is all zeros except two tables. inner_vm loads the 20 stdin bytes at RAM[0x00..0x13], so each table lines up one-to-one with the input: A = RAM[0x20..0x33] = 9f 57 1c da 2c 9b fa fc 26 41 7e 19 c1 99 2e 74 2a 59 b2 4d B = RAM[0xE0..0xF3] = 8a 85 e1 f4 27 ae cb e6 a4 e3 71 ed 5b b2 d3 c4 87 8d 70 23 The first bytes of the program at 0x106: 01 06 00 01 03 14 01 07 01 04 00 00 04 01 20 02 00 01 04 02 e0 05 00 02 06 03 03 06 01 ... Stage 3: Reversing the inner VM The inner ISA doc is right about every opcode but one: 0x06 jumps when the values are not equal. The VM keeps its whole state on the stack: Stack offset Field rsp+0x120 Registers r0 to r7 (1 byte each) rsp+0x128 RAM, 256 bytes (stdin lands at the start) rsp+0x228 Pointer to the bytecode rsp+0x230 Program length rsp+0x232 PC rsp+0x234 cmp_flag rsp+0x23c Exit code, zeroed at start and returned from main If fread returns fewer than 20 bytes, the binary exits with 1 before running anything. The dispatcher indexes a jump table at 0x2048, and opcodes above 0x07 stop the VM. Decoding the table: Opcode Doc Binary 0x00 (not listed) Stop, exit code stays 0 0x01 LDI r, imm8 Same 0x02 XOR r, r2 Same 0x03 ADD r, imm8 Same 0x04 LDR r, addr Same 0x05 CMP r, r2 Same: cmp_flag = (r == r2) 0x06 JNZ offset, "jump if equal" Jump if NOT equal (cmp_flag == 0) 0x07 HLT code Same: exit with code The handler for 0x06: 1283: add eax, 2 ; pc = address of next instruction 1286: mov [rsp+0x232], ax 128e: cmp byte [rsp+0x234], 0 ; cmp_flag 1296: jne 1220 ; flag == 1 (equal): no jump 1298: movsx dx, byte [rsi+rdx] ; signed 8-bit offset 12a0: add eax, edx ; flag == 0 (not equal): pc += offset The jump target is relative to the next instruction, and the offset is signed. Stage 4: Solving the inner program The inner program passes only if in[i] ^ A[i] == B[i] for all 20 bytes, so the answer is in[i] = A[i] ^ B[i]. Disassembled with the corrected 0x06: 0000: LDI r6, 0x00 ; match counter 0003: LDI r3, 0x14 ; 20 0006: LDI r7, 0x01 ; never used ; one 20-byte block per input byte, i = 0..19 0009: LDR r0, [i] ; input byte 000c: LDR r1, [0x20+i] ; A[i] 000f: XOR r0, r1 0012: LDR r2, [0xE0+i] ; B[i] 0015: CMP r0, r2 0018: JNE +3 ; mismatch: skip the increment 001a: ADD r6, 1 ... 0199: CMP r6, r3 ; matches == 20 ? 019c: JNE +2 019e: HLT 0 ; pass 01a0: HLT 1 ; fail The layout checks out: 9 bytes of setup, 20 blocks of 20 bytes, then 9 bytes of epilogue, for 418 bytes total. The broken doc semantics are also a hint. Read with "jump if equal", r6 would count mismatches, and the program would only fail when all 20 bytes are wrong. Almost any input would pass, which makes no sense for a checker. XORing the two tables gives the secret: A = 9f 57 1c da 2c 9b fa fc 26 41 7e 19 c1 99 2e 74 2a 59 b2 4d
B = 8a 85 e1 f4 27 ae cb e6 a4 e3 71 ed 5b b2 d3 c4 87 8d 70 23 A ^ B = 15 d2 fd 2e 0b 35 31 1a 82 a2 0f f4 9a 2b fd b0 ad d4 c2 6e These 20 bytes are not printable, so they are not the flag itself. They do pass the VM: $ ./inner_vm inner.orb < secret.bin ; echo $? 0 $ printf 'AAAAAAAAAAAAAAAAAAAA' | ./inner_vm inner.orb ; echo $? 1 Stage 5: check vs validator check holds the real flag, and its hex part is the SHA-256 of the Stage 4 secret. validator holds a decoy and lies about results. Both binaries decode a string from .rodata at 0x2020 with XOR 0x5c, read a line with fgets, strip the newline and strcmp: 1085: mov eax, 0x2e ; first encoded byte, hoisted by the compiler 108a: lea rdx, [rip+0xf8f] ; 0x2020, the rest of the string 10e0: xor eax, 0x5c ; decode loop until NUL Decoding the two strings: Binary Encoded tail Decoded check j:djlln:nmhhii8hhi:8jinn! r00t{0ur0b0r0s_6f86002f214455d445fd6522} validator oh/% ... 4=4=ll! r00t{0ur0b0r0s_wr0ng_t00_34sy_haha00} Diffing the two disassemblies shows one real difference. .rodata holds INCORRECT at 0x2004, so 0x2006 is the same string minus IN, which reads CORRECT. check prints 0x2006 on a match. validator prints 0x2004 on a match and 0x2006 on anything else, with the exit codes flipped to match: Input check validator Real flag CORRECT, exit 0 CORRECT, exit 0 Decoy flag INCORRECT, exit 1 INCORRECT, exit 1 Anything else INCORRECT, exit 1 CORRECT, exit 0 So validator says CORRECT to every input except its own flag. It can't confirm anything, and its flag literally says "wrong, too easy". The SHA-256 of the 20-byte secret ties the real flag to the VM chain: $ sha256sum secret.bin 6f86002f214455d445fd6522d4dcd049e45a2f0478ef103e90df9924229beebe secret.bin The first 24 hex characters, 6f86002f214455d445fd6522, are exactly the suffix in check's flag. The decoy has no such link. You can pull the real flag out of check with strings and XOR alone. The hash is what proves it's the real one and not a second decoy. Solve script This script goes from the two data files to the flag in one run and cross-checks it against check. Save it as solve.py and run python3 solve.py . #!/usr/bin/env python3 """Ouroboros solver: outer.orb + inner.enc -> inner.orb -> 20-byte secret -> flag.""" import hashlib import sys from pathlib import Path
d = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
def ror8(v, n): return ((v >> n) | (v << (8 - n))) & 0xFF
Stage 1: derive the XOR key exactly like ouroboros does after the outer VM halts
outer = (d / "outer.orb").read_bytes() assert outer[:4] == b"ORB1" ram = outer[6:6 + 256] k = bytearray(a ^ b for a, b in zip(ram[0x10:0x18], ram[0x18:0x20])) for i in range(8): k[i] = ror8(k[(i + 1) & 7], 3) ^ k[i] ^ 0x3C # in place, k[7] uses new k[0] print("key :", k.hex())
Stage 2: decrypt inner.enc -> inner.orb
enc = (d / "inner.enc").read_bytes() inner = bytes(b ^ k[i % 8] for i, b in enumerate(enc)) assert inner[:4] == b"INR1" (d / "inner.orb").write_bytes(inner)
Stage 4: inner program requires in[i] ^ RAM[0x20+i] == RAM[0xE0+i]
iram = inner[6:6 + 256] secret = bytes(iram[0x20 + i] ^ iram[0xE0 + i] for i in range(20)) (d / "secret.bin").write_bytes(secret) print("secret :", secret.hex())
Stage 5: the flag suffix is the first 24 hex chars of sha256(secret)
digest = hashlib.sha256(secret).hexdigest() flag = "r00t{0ur0b0r0s_%s}" % digest[:24] print("sha256 :", digest)
Cross-check against the XOR-0x5c string embedded in ./check (.rodata at 0x2020)
chk = (d / "check").read_bytes() blob = chk[0x2020:chk.index(b"\0", 0x2020)] embedded = bytes(c ^ 0x5C for c in blob).decode() print("embedded :", embedded) print("flag :", flag, "(matches check)" if flag == embedded else "(MISMATCH)") Output, plus a check against the real binaries: $ python3 solve.py . key : 5b2f3261dae0c3d8 secret : 15d2fd2e0b35311a82a20ff49a2bfdb0add4c26e sha256 : 6f86002f214455d445fd6522d4dcd049e45a2f0478ef103e90df9924229beebe embedded : r00t{0ur0b0r0s_6f86002f214455d445fd6522} flag : r00t{0ur0b0r0s_6f86002f214455d445fd6522} (matches check)
$ ./inner_vm inner.orb < secret.bin ; echo $? 0 $ echo 'r00t{0ur0b0r0s_6f86002f214455d445fd6522}' | ./check CORRECT Traps and takeaways Every trap in this challenge is a document or binary that says something untrue. The fix each time was to trust the machine code over the text. • Both ISA docs lie. The outer doc swaps 0x03 and 0x06, and the inner doc flips the jump condition of 0x06. The jump tables in the binaries are the ground truth. • "The key is at RAM[0x10:0x18]" is half true. That is raw material. The real key is mixed from two blobs. • Repeating XOR over zero padding leaks the key. The key is visible in a hex dump, so Stage 2 needs no reversing at all. • Absurd logic means a wrong model. Under the documented jump, the inner check would pass almost any input. That alone shows the doc is wrong. • Two checkers means one is a decoy. validator prints CORRECT for anything except its own fake flag, so its approval means nothing. • The shortcut works, but the chain proves it. strings plus XOR 0x5c on check gives the flag in a minute. The SHA-256 of the inner secret is what confirms it's the real one.
