This year is my first take on RITSEC CTF. Members of my team solved most of the challenges. I only solved one reversing challenge, Gauntlet, because it is particularly interesting to me. This post will also be my first public write-up after a long time.

Survey

Gauntlet - 250 points

Four parts of a key are being assembled within this file. The adversary has implemented four corresponding anti-debug checks that need to be bypassed. Good luck!

The challenge simply asks the player to bypass four anti-debugging mechanisms to receive the flag. It only provides a binary, which raises two questions:

  • Where is the flag stored?
  • Since there are anti-debugging mechanism, will the program print out the flag when running the challenge without debugger?

Considering the challenge description, it is likely the flag is stored in memory at runtime. As to whether it prints out flag when running without a debugger, sadly, that is not expected when we read the binary in decompiler.

When passing an anti-debug check, a part of the flag is copied into a specific buffer on stack (as if they were concatnated) and the program prints out a message that the mechanism has been bypassed. In case of failure, only the first two checks will inform so. The other two will silently skip the copying.

Attempt 1: Reversing string encryption

What if we extract the source string of those memcpy calls? Turns out, this is possible, although not obvious. Cutter/Rizin has the ability to demangle C++ symbol, and we were able to find a symbol named ay::obfuscated_data. Searching this online reveals a library the program likely uses to encrypt the flag at compile-time and decrypt it at runtime: adamyaxley:Obfuscate. The library’s “Obfuscate” method is XOR-ing the data using 64-bit little-endian key. Since template data is also stored in debug symbol, we can recover the initial template parameters: data size and key. We simply find XOR-encrypted data in binary and XOR again to reveal the unobfucated data.

For example, here we can find the encrypted string of the first part of the flag. (This was taken from Cutter decompiler - ghidra plugin)

iVar4 = FirstChallenge(char*, int*)::{lambda()#1}::operator()() const((int64_t)&var_21h);
*(undefined8 *)0x0 = ay::obfuscated_data<16ull, 3004872806184872195ull>::operator char*()(iVar4);

It looks ugly, but stay with me, this is just a normal day in C++. Here is how the “compile-time encrypted string” library works at runtime. First, the data is already encrypted at compile-time. The lambda call in the first line abstracts the instantiation of ay::obfuscated_data, decryption of the data, and store decrypted data at the memory which can be referred through iVar4, which is likely an ay::obfuscated_data object. The second line is just C-string casting call (i.e. (char*)s), likely used to access the underlying decrypted data. We can confirm this by looking at the library source code adamyaxley:Obfuscate/obfuscate.h#L205-L213

Now let’s try to decrypt data given the template above. We know 16 is the data size, and the encryption key (according to the cipher function in library) is 03 69 E1 8F E5 73 B3 29. Now for the data, let’s inspect the lambda code. In first few lines of assembly, it seems like we have found it:

mov byte [var_28h], 0x51
mov byte [var_27h], 0x3a 
mov byte [var_26h], 0x9a 
mov byte [var_25h], 0xde 
mov byte [var_24h], 0x90 
mov byte [var_23h], 0x1a 
mov byte [var_22h], 0xd0 
mov byte [var_21h], 0x42 
mov byte [var_20h], 0x5c 
mov byte [var_1fh], 0xa
mov byte [var_1eh], 0x89 
mov byte [var_1dh], 0xea 
mov byte [var_1ch], 0x86 
mov byte [var_1bh], 0x18 
mov byte [var_1ah], 0xec 
mov byte [var_19h], 0x29 

The number of line is 16, so it cannot be wrong. And with that, we found the first part of the flag: RS{Quick_check_

Spending one hour on this to extract and concatnate data, I was able to extract all instances of ay::obfuscated_data. But when concanating, the output did not look like a flag at all. It seems like there are additional conditions during copies.

Attempt 2: Binary patching

Instead of figuring out the conditions for individual copies of each part of the flag, I attempted to run the binary this time. Opening the binary in Windows Sandbox, the program only runs like what I expect from reading decompiled code in Cutter.

Running in sandbox

Looking at this, I suddenly had another idea. Since the buffer that stores flag is on stack, which is predictable, can we modify the binary to print it out instead? This idea came out partially due to the fact that I was revising for midterm of one of my course. I didn’t find any integrity checking in the binary, and anti-debugging mechanisms likely have nothing to do with binary integrity. Additionally, the binary was statically compiled, meaning printf can be easily referenced.

Luckily, we didn’t have to go far. After the forth part of the flag is copied, before the end of the main function, I found a printf call printing out “C4: No check available.\n”.

Bug in gauntlet

printf call receives the format string address stored in rcx register. rcx is copied from rax, which in turn is computed by lea instruction.

Meanwhile, thanks to the memset function call at the beginning of main function, it is easily to calculate the offset of buffer that is suspected to be the flag: rbp - 0xc0.

Secret of gauntlet

With that, we will change the last print call to use the address of the flag instead. One way is to open Ghidra, click on the lea assembly line, right-click, and select Patch Instruction. Then we can rewrite the instruction as we like and then export it. The other way is to search for those assembly bytes in the hex dump and directly modify it. I chose the latter.

Old code:

$ cstool "x64" "48 8d 05 70 9c 01 00"
 0  48 8d 05 70 9c 01 00        lea        rax, [rip + 0x19c70]

New code:

$ kstool "x64" "lea rax, [rbp - 0xc0]"
lea rax, [rbp - 0xc0] = [ 48 8d 85 40 ff ff ff ]

When modifying binary, it is best not to change the size of the function. I used Keystone engine to generate assembly, and luckily, the new code has a similar size of 7. (Although later I realized there is a similar assembly line before memset call that I can just copy.) Opening the binary in HxD, I searched for the targeted code bytes and replaced them. Then I copied the “patched” binary to the virtual machine to test it. As expected, it prints out the flag without needing a debugger.

Exploitation of gauntlet

The flag:

RS{Quick_check_vector_this_exception_important_time_is_hash_Wa-kcdftcteeioi}

Conclusion

However, during the competition, it seems like the intended solution assumes the presence of a debugger, so the server flag was different from my submitted flag. According to the author’s fix note, the program can incorrectly generate the fourth part of the flag depending on the debugger in use. From my perspective, this issue remains even when the debugger does not exist, which makes no sense in terms of practicality. My flag was accepted anyway after the challenge was updated.

Overall, this is a fun challenge for me to learn the fact that static analysis can be greatly useful when dynamic analysis is difficult.