The Many Heads of DEF CON Quals
The Slow Realization
Tiamat was a reversing problem released near the halfway point of DEF CON CTF Quals. Having just solved Rick, a different reversing problem, I was at least warmed up (and resigned to the lack of web). The download came with all of the materials needed to build the problem yourself, including a Dockerfile, an executable, and concerningly, a custom build of Qemu.
Ignoring Qemooo’s threatening glare for the time being, I popped open the executable in Ida. To my surprise, the executable was a Mips ELF. Strangely, however, the first few instructions disassembled cleanly, but then everything went quickly downhill.
“Ok,” I thought to myself, “Clearly I just don’t understand Mips very well.” Fortunately, Qemu has a way to let GDB attach to a running program, and Qemooo seemed to support that as well. To this end, I installed
gdb-multiarch inside of the container, connected to Qemooo, and started stepping through the program. Or at least I tried to; after only 2 instructions the program had bypassed GDB’s breakpoints and was just running normally. Clearly, it was time to crack open the
Since this binary was sufficiently large, Ida took a hot second to analyze it. With this downtime, I poked around the source repository on Gitlab to figure out where I should be looking. At this point I was assuming that the author had introduced or changed the Mips opcodes in Qemu, so I looked around until I found a promising looking function in
/target/mips/translate.c. Glancing at the code, all operations passed through this function, so I decided it would be worth trying to debug it to see what opcodes were being run.
qemooo itself into GDB, I set a breakpoint for
decode_opc, and began stepping through as it ran
liccheck.bin. I had barely started poking around, when I noticed something in GDB’s output that made my heart sink.
That’s right! We’re hitting
decode_opc in both
/target/riscv/translate.c. Suddenly, all of the corrupted instructions made a lot more sense.
Now that the binary had loaded in Ida, I found the Mips
decode_opc and jumped up the xref stack, until I came to this beauty of a function
It turns out it was event worse than I had previously thought! The current instruction pointer was used to index into a map that would determine if it should be run as
arm, and then whether as little endian or big endian. Clearly off-the-shelf tools were going to be useless for reversing this problem, so I was going to need to write my own. I quickly dumped
tmap_arch out of the process’ memory, and got started on my disassembler.
If you want something done…
I’ll skip the boring bits of writing the dissassembler. You can find it here, but there’s not much to it. It uses Capstone (the
next branch) to disassemble each instruction based off of the
tmap, and then searches for references in order to break the code into basic blocks. In the end, it gave out approximately readable assembly, with forward and backward references to easily map out the control flow.
Now, in parallel with this work, my teammate Ubuntor had been poking around with the executable, effectively hand-fuzzing it. One of the things that he found was that pressing
e followed by
ctrl+d would bring up a prompt:
Enter the license key encrypted with the pin generated by your Protovision Fob
This was nice because it gave us a starting point for reversing. A quick search of the disassembly brought up this function
Looking at this a bit, we can see that we have a few different input options. Namely, these are
By playing around with them, we were able to determine what a few did.
e: Enter a verification code
p: Print the verification code
v: Check the verification code
Clearly, there was still a lot more to be understood about the program. However, finding the
#0x65 constant in the code gave me the idea to search for other constants that were not part of jumps. I noticed very quickly a couple occurances of
0x7568736f, which in ASCII is
oshu. Given the numerous Wargames references, I jumped straight to the executable and tried entering in
joshua<enter>. This lead to a nice “GREETINGS PROFESSOR FALKEN” message, and unlocked the use of the
l command, which allowed you to read one of 15 identical files on the filesytem.
At this point, we mostly just needed to spend a lot more time reversing.
After a lot of time spent digging through the code, we determined what the
n command was doing. Specifically, it would read
4 bytes from
urandom and then use it to XOR a buffer that followed the user input on the stack. With the help of janky-gdb, I determined that the buffer it was XORing was the key value that the input was being checked against.
We also noticed that the
v function would also
xor the desired key with
urandom before checking it, unless the
joshua backdoor had been activated, in which case it would just check it normally.
Finally, and most crucially, we found that after at least one validation attempt, for every use of the
n command, the
p command would print one byte past the user’s input into the xored buffer. However, due to limits on how many times
n could be invoked, this would only leak 27 of the 32 characters of the encrypted key.
This was quite strange, and we tracked it down to a register called
r0 on instruction
0x102bc, but could never figure out what it was being assigned to. We noted it down as an oddity of the system, and went on our way.
However, since the data that was being leaked was the encrypted key, and the encryption key was only 4 bytes long, we realized that we could simply try all the keys one byte at a time to see which ones gave us valid ascii hex characters. Scripting this was straightforward, and yielded 8 possible prefixes:
from typing import List, Tuple import itertools from pwn import * def solve(data: bytes): def brute_set(bs: bytes): res: List[int] =  for i in range(256): success = True for b in bs: if chr(b ^ i) not in "abcdef0123456789": success = False break if success: res.append(i) return res def try_key(key: Tuple[int, int, int, int]): return "".join([ chr(d ^ key[i % 4]) for i, d in enumerate(data) ]) a = brute_set(data[::4]) b = brute_set(data[1::4]) c = brute_set(data[2::4]) d = brute_set(data[3::4]) keys = itertools.product(a, b, c, d) for key in keys: print(try_key(key)) connection = remote("tiamat.challenges.ooo", 5000) connection.send("e00000000111111112222222233333333v") connection.recvuntil("""Authorization failed! READY """) connection.send("np" * 25) for i in range(25): connection.recvuntil("00000000111111112222222233333333") data = connection.recvuntil("\nREADY")[:-6] if len(data) > 26: print("------") solve(data)
d64ce88b7426f0245c5102f91a9 d64be88c7427f0255c5002f81a9 c64cb88b0426a0242c5172f96a9 c64bb88c0427a0252c5072f86a9 764c688bd4265024fc51c2f9ba9 764b688cd4275025fc50c2f8ba9 064c188bc4262024ac51d2f9ea9 064b188cc4272025ac50d2f8ea9
With Apologies to OOO’s Infrastructure
At this point, we had only two and a half hours left in the competition, and were in a close third place. Sleep had been a luxury all weekend, and I was less than confident in our ability to find the missing piece of the puzzle. However, we had found
8 possible prefixes, missing only
5 nibbles each, for a total of
23 bits of entropy.
Now, for those keeping score at home, this means that there were a total of
8,388,608 possible keys to try. On a whim and a prayer, I hacked together a script to try all of them. Limiting myself to 32 simultaneous connections (in hopes that it wouldn’t prevent other players from hitting the problem) and 8 checks per connection, I was able to achieve a throughput of roughly 250 keys per second. Unfortunately, this meant that I only had roughly a 1 in 4 chance of solving it before the competition ended. My options at this point were: risk causing a DOS for other teams, or eat the 25%.
Ultimately, I decided that since this was almost certainly not the intended solution, I should not risk making the service unavailable, so I accepted the fact that I was unlikely to actually get the flag.
However, by sheer luck, the flag happened to be in the first 1.8 million or so of my enumeration. Thus, with only 30 minutes left in the game, I was able to solve the problem and we took the lead.
The Missing Link
Speaking to the problem’s author afterward, I realized just how close we had come to solving it the intended way. That register I mentioned earlier,
r0, whose value I couldn’t pinpoint was actually the result of a
sys_open ARM syscall. That call returns the number of open file descriptors of the program, a number that was ever increasing due to a mishandling of
close in the
n command. Howeverw I had noticed a while back that the
v command also did not close its file descriptor. I assumed this was just a bug, since I didn’t see any way to use it.
Consequently, if we had caught the origin of that register, we would have had all the information we needed to solve it with only 8 tries, not 8 million.
Regardless of my own ineptitude, Tiamat was an excellently silly challenge, and a lot of fun to solve. Full kudos to Erik for putting it together, and to my teammates for solving it with me!