Union CTF - epitaph

Fun small virtual machine challenge.


epitaph - 9 solves

Reversing

It lives on in our hearts and minds.
Author: Aurel300

Files:

Reversing

The challenge gives us a link to a website which contains an SWF file, a file format for Adobe Flash applications. I used ffdec from the AUR to decompile it, and extracted the useful ActionScript files.

boot_23bc

This code initializes the virtual machine, populating VM.SBOX, setting VM.code with interesting looking instructions, and finally calling VM.main.

VM.SBOX = _loc1_;
VM.code = [O.O1(285,0),O.O2(272,264),O.O2(273,265),O.O2(274,266)...
...
VM.main();

VM

Looking at the VM file yields a few interesting functions: VM.P, VM.X, VM.E, and VM.main. There was also a NUMROUNDS class member, which led me to believe it was implementing some sort of crypto scheme.

The VM.main function created the input box and check button. Pressing the check button would call the event listener, which would pass our input text as an integer array to VM.P and VM.X.

VM.P was simply a padding function to ensure that our input length was a multiple of 8. This was likely the block size that the block cipher would be dealing with. This is unimportant to us, because we know from VM.main that the input length must match the flag length, which is 40.

VM.X starts to get into the actual interesting code. It populates an array with the VM.SBOX from the boot file, a secret value of S3CRET__, an inital value of initiªl!, some padding, and finally our input. This function calls VM.E and returns the array starting from the index of our input.

VM.E implements the fetch, decode, and execute loop of the VM. It takes the VM.code from the boot file and runs it on our input array. Most of the custom instructions acted on the input array, reading and writing to different indices. There were only 11 instructions and I was able to write a simple disassembler.

vm_code = ["O.O1(285,0)","O.O2(272,264)","O.O2(273,265) ... ]"

for addr, instr in enumerate(vm_code):
    instr = instr[2:]
    op_code = instr[:2]
    p = instr[instr.find("(")+1:instr.find(")")].split(",")
    disasm = ""
    if op_code == "O1":
        disasm = f"mov [{p[0]}], {p[1]}"
    elif op_code == "O2":
        disasm = f"ld [{p[0]}], [{p[1]}]"
    elif op_code == "O3":
        disasm = f"add [{p[0]}], [{p[1]} + [{p[2]}]]"
    elif op_code == "O4":
        disasm = f"ld [{p[0]} + [{p[1]}]], [{p[2]}]"
    elif op_code == "O5":
        disasm = f"add [{p[0]}], [{p[1]} + [{p[2]}]]"
    elif op_code == "O6":
        disasm = f"inc [{p[0]}]"
    elif op_code == "O7":
        disasm = f"xor [{p[0]} + [{p[1]}]], [{p[2]}]"
    elif op_code == "O8":
        disasm = f"and [{p[0]}], {p[1]}"
    elif op_code == "O9":
        disasm = f"rol [{p[0]}]"
    elif op_code == "OA":
        disasm = f"cmp [{p[0]}], {p[1]} ; jl {hex(int(p[2]))}"
    elif op_code == "OB":
        disasm = f"jge [{p[1]} + [{p[1]}]]"
    else:
        print("not valid...")
    print(f"{addr:04x} : {disasm}")

With this I obtained this more readable bytecode.

0000 : mov [285], 0
0001 : ld [272], [264]
0002 : ld [273], [265]
0003 : ld [274], [266]
0004 : ld [275], [267]
0005 : ld [276], [268]
0006 : ld [277], [269]
0007 : ld [278], [270]
0008 : ld [279], [271]
0009 : ld [280], [272]
000a : mov [281], 0
000b : mov [282], 0
000c : add [280], [256 + [282]]
000d : add [283], [0 + [280]]
000e : inc [282]
000f : ld [284], [282]
0010 : and [284], 3
0011 : add [283], [272 + [284]]
0012 : ld [280], [283]
0013 : rol [280]
0014 : ld [272 + [284]], [280]
0015 : cmp [282], 8 ; jl 0xc
0016 : inc [281]
0017 : cmp [281], 32 ; jl 0xb
0018 : ld [264], [272]
0019 : xor [286 + [285]], [264]
001a : inc [285]
001b : ld [265], [273]
001c : xor [286 + [285]], [265]
001d : inc [285]
001e : ld [266], [274]
001f : xor [286 + [285]], [266]
0020 : inc [285]
0021 : ld [267], [275]
0022 : xor [286 + [285]], [267]
0023 : inc [285]
0024 : ld [268], [276]
0025 : xor [286 + [285]], [268]
0026 : inc [285]
0027 : ld [269], [277]
0028 : xor [286 + [285]], [269]
0029 : inc [285]
002a : ld [270], [278]
002b : xor [286 + [285]], [270]
002c : inc [285]
002d : ld [271], [279]
002e : xor [286 + [285]], [271]
002f : inc [285]
0030 : jge [285 + [285]]
0031 : cmp [284], 9 ; jl 0x1

The control flow was fairly simple, it first sets vm_input[272 : 280] = vm_input[264 : 272].

0001 : ld [272], [264]
0002 : ld [273], [265]
0003 : ld [274], [266]
0004 : ld [275], [267]
0005 : ld [276], [268]
0006 : ld [277], [269]
0007 : ld [278], [270]
0008 : ld [279], [271]
0009 : ld [280], [272]

It then loops for a total of 32 * 8 times. Inside the loop it seems to be doing the block cipher logic.

000a : mov [281], 0
000b : mov [282], 0
000c : add [280], [256 + [282]]
000d : add [283], [0 + [280]]
000e : inc [282]
000f : ld [284], [282]
0010 : and [284], 3
0011 : add [283], [272 + [284]]
0012 : ld [280], [283]
0013 : rol [280]
0014 : ld [272 + [284]], [280]
0015 : cmp [282], 8 ; jl 0xc
0016 : inc [281]
0017 : cmp [281], 32 ; jl 0xb

It then sets vm_input[264 : 272] = vm_input[272 : 280] and XORs vm_input[286+[285] : 294+[285]] with vm_input[264 : 272].

0018 : ld [264], [272]
0019 : xor [286 + [285]], [264]
001a : inc [285]
001b : ld [265], [273]
001c : xor [286 + [285]], [265]
001d : inc [285]
001e : ld [266], [274]
001f : xor [286 + [285]], [266]
0020 : inc [285]
0021 : ld [267], [275]
0022 : xor [286 + [285]], [267]
0023 : inc [285]
0024 : ld [268], [276]
0025 : xor [286 + [285]], [268]
0026 : inc [285]
0027 : ld [269], [277]
0028 : xor [286 + [285]], [269]
0029 : inc [285]
002a : ld [270], [278]
002b : xor [286 + [285]], [270]
002c : inc [285]
002d : ld [271], [279]
002e : xor [286 + [285]], [271]
002f : inc [285]

There is a check that determines vm_input[285] + 286 if exceeds the length of the vm_input array. If this is the case, the execution is halted. Otherwise, we jump back to address 0x1.

0030 : jge [285 + [285]]
0031 : cmp [284], 9 ; jl 0x1

The VM seemed to have implemented some sort of block cipher, but we could not determine which one it was. (After the CTF was over, an admin told me it was implementing Treyfer).

I initially tried to solve it using z3 to just treat the crypto as more of a black box, but it did not work during the CTF. However, I did get it to work after the CTF was over after realizing I mistyped the secret key in my emulator 😢.

The actual way I solved it during the CTF was with some help from my teammate, noopnoop. He realized that the XOR was the only part that mattered, because the rest of the program did not touch our input.

If we encrypted all A’s for example, got the ciphertext, and then XOR’d it against the A’s, we would obtain the keystream. With the keystream we could XOR it against the flag ciphertext and obtain the flag.

a = [0x41]*40
ct_a = [203, 193, 6, 56, 40, 235, 45, 96, 183, 55, 99, 11, 40, 235, 45, 96, 163, 111,
	222, 129, 40, 235, 45, 96, 108, 67, 81, 78, 40, 235, 45, 96, 190, 235, 1, 245,
	40, 235, 45, 96]

ct_flag = [255, 238, 46, 22, 7, 209, 30, 68, 133, 2, 125, 35, 7, 245, 28, 18, 131,
	77, 172, 159, 26, 194, 92, 66, 70, 117, 36, 59, 31, 153, 51, 27, 215, 215,
	70, 178, 111, 172, 106, 39]

keystream = [a[i]^ct_a[i] for i in range(len(a))]
flag = ''.join([chr(keystream[i]^ct_flag[i]) for i in range(len(a))])
print(flag)

Flag: union{rest_in_p3ac3_sh0ckw44v3_:(}

Conclusion

The VM reversing was fun, I definitely enjoyed creating the disassembler and emulator. I wish I had found my spelling mistake during the competition because I definitely would have gotten second blood on the challenge.

This was a well made CTF, with interesting and difficult challenges. Thank you to cr0wn for hosting it!