This is another challenge from the Volga CTF Quals 2016, involving an x64 ELF executable that encodes files. Our objective is to recover the clear text data from the encrypted file.
Here’s the description for this challenge:
This binary does something with the data. The transformation must be reversible, but the details are unknown. It shouldn’t be too difficult to reverse that transformation and obtain the flag, should it?
You can download the executable from the CTF Writeup repository on GitHub.
What I’m going to do in this writeup is simple:
- Reverse the algorithm. I will use IDA Pro, but you can also use radare2.
- Write a compatible encoder in C++.
- Invert the transformation and implement decoding functionality.
For starters, you may have noticed that this executable does not like to be run inside a debugger; you can easily pinpoint where the anti-debugger check is performed by setting a breakpoint to the exit() function.
00401070 Protection proc near
00401070 sub rsp, 8
00401070
00401070 ; terminate the process if the LD_PRELOAD variable
00401070 ; is set
00401070
00401074 mov edi, offset name ; "LD_PRELOAD"
00401079 call _getenv
00401079
0040107e test rax, rax
00401081 jnz short _terminate
00401081
00401081 ; terminate if the PTRACE_TRACEME ptrace request
00401081 ; returns -1 (meaning that a debugger is already
00401081 ; attached)
00401081
00401083 xor ecx, ecx
00401085 xor edx, edx
00401087 xor esi, esi
00401089 xor edi, edi ; request (PTRACE_TRACEME)
0040108b xor eax, eax
0040108d call _ptrace
0040108d
00401092 test rax, rax
00401095 js short _terminate
00401095
00401097 add rsp, 8
0040109b retn
0040109c
0040109c _terminate:
0040109c xor edi, edi ; status (0)
0040109e call _exit
0040109e Protection endp
We could have choosen to hook the ptrace() function and disable this check by using the LD_PRELOAD environment variable, but the code at address 401074 would have prevented us from being able to do it. What we really want to remove instead is the debugger protection and to be honest it’s easier to just patch the program. It’s not required if you don’t plan on stepping through the code or if you don’t mind skipping it manually with a breakpoint each time you start the program.
Now that we got rid of the protection, let’s take a look at the caller.
004019C0 init proc near
004019C0 push r15
004019C2 mov r15d, edi
004019C5 push r14
004019C7 mov r14, rsi
004019CA push r13
004019CC mov r13, rdx
004019CF push r12
004019CF
004019CF ; array contents:
004019CF ; Initialization01 (004013D0)
004019CF ; Protection (00401070)
004019CF ; CppIoStreamConstructor (004012F0)
004019CF
004019D1 lea r12, InitializationFunctionsList1
004019D8 push rbp
004019D8
004019D8 ; array contents: Initialization02 (004013B0)
004019D8
004019D9 lea rbp, InitializationFunctionsList2
004019E0 push rbx
004019E1 sub rbp, r12
004019E4 xor ebx, ebx
004019E6 sar rbp, 3
004019EA sub rsp, 8
004019EE call _init_proc
004019F3 test rbp, rbp
004019F6 jz short loc_401A16
004019F6
004019F6 ; it's pretty obvious that someone has messed up
004019F6 ; this opcode. replace it with a function call
004019F6 ; to address 004013B0
004019F6
004019F8 nop dword ptr [rax+rax+00000000h]
00401A00
00401A00 loc_401A00:
00401A00
00401A00 mov rdx, r13
00401A03 mov rsi, r14
00401A06 mov edi, r15d
00401A06
00401A06 ; functions called:
00401A06 ; 1. Initialization01: program initialization
00401A06 ; 2. Protection: LD_PRELOAD/debugger check
00401A06 ; 3. CppIoStreamConstructor: constructor and destructor
00401A06 ; (using atexit) for the std::ios_base c++ object
00401A06
00401A09 call qword ptr [r12+rbx*8]
00401A0D add rbx, 1
00401A11 cmp rbx, rbp
00401A14 jnz short loc_401A00
00401A16
00401A16 loc_401A16:
00401A16
00401A16 add rsp, 8
00401A1A pop rbx
00401A1B pop rbp
00401A1C pop r12
00401A1E pop r13
00401A20 pop r14
00401A22 pop r15
00401A24 retn
00401A24 init endp
If you played the previous level (named Broken) you will remember that the init() function had been patched to remove a function call that was required for the algorithm to work as intended; this challenge is no exception and you will have to fix the instruction located at address 004019F8. Again, the function we need to call can be found inside the second array.
There’s not much else to say about the rest of the functions referenced here; they’re just used for initialization and we don’t really care to analyze them, provided that we take a memory snapshot once it’s done. Let’s see how the rest of the program works; it’s pretty long, so I will use annotated pseudo-code to illustrate its internals.
// 004010B0
int main(int argc, char *argv[])
{
// 004010d0
if (argc != 3)
return 0;
const char *input_file_path = argv[1];
const char *output_file_path = argv[2];
// 0040111a
std::fstream input_file(input_file_path);
// 00401135
std::fstream output_file(output_file_path);
// 004011c5
input_file.seekg(0, std::ios_base::end);
// 004011d2
std::streamsize file_size = input_file.tellg();
// 004011ee
input_file.seekg(0, std::ios_base::beg);
// 004011fa
std::uint8_t *buffer = new std::uint8_t[file_size];
// 0040121D
input_file.read(buffer, file_size);
//
// we don't care much about the following function, as it doesn't touch
// our buffer in any way. Take a memory snapshot after this call
//
// 0040122C
call sub_401932;
//
// this is what we need to analyze
//
// 0040123A
call sub_401836;
// 00401248
output_file.write(buffer, file_size);
return 0;
}
// 00401836
void sub_401836(uint8_t *input_buffer, uint8_t *output_buffer, uint32_t buffer_size)
{
// rdi = input_buffer
// rsi = output_buffer
// rdx = buffer_size
// an xmmword is 16 bytes long; this function encodes one block
// at a time by calling sub_40179e
// 00401860
rdx = shr rdx, 0x04;
// 00401870
if (rdx == 0)
return;
do
{
// 00401877
xmm0 = *rdi;
// 0040187b
call sub_40179e;
// 00401880
*rsi = xmm0;
// 00401884
rdi += 0x10;
// 00401888
rsi += 0x10;
// 0040188c
rdx--;
} while (rdx != 0); // 00401864
// 0040189f
return;
}
// 0040179e
void sub_40179e()
{
// this is the real encoding function; it's nothing too fancy
// and you can easily invert each transformation by performing
// the same operations in reverse order
// 004017ac
xmm7 = xmm0;
// 004017c8
xmm0 = xmmword_602128;
// 004017d1
pxor xmm7, xmm0;
// 004017d6
r15 = 0x10;
do
{
// 004017e1
call sub_4015d0;
// 004017e6
call sub_40167d;
// 004017eb
call sub_40169f;
// 004017f0
xmm0 = xmmword_602128[r15];
// 004017f9
pxor xmm7, xmm0;
// 004017fe
r15 += 0x10;
} while (r15 < 0x0A); // 00401802
// 0040180b
call sub_4015d0;
// 00401810
call sub_40167d;
// 00401815
xmm0 = xmmword_602128[r15];
// 0040181e
pxor xmm7, xmm0;
// 00401823
xmm0 = xmm7
// 00401835
return;
}
// 004015d0
void sub_4015d0()
{
// 004015d9
[rsp] = xmm7;
// 004015de
for (offset = 0; offset < 0x0c; offset += 0x04)
{
ebx = [rsp + offset];
call sub_40161f;
[rsp + offset] = eax;
}
// 00401615
xmm7 = [rsp];
// 0040161e
return;
}
// 0040167d
void sub_40167d()
{
// 00401694
xmm0 = xmmword_401683;
// 00401698
pshufb xmm7, xmm0;
// 0040169e
return;
}
// 0040169f
void __usercall sub_40169f()
{
// 004016a8
[rsp] = xmm7;
// 004016ad
for (offset = 0; offset < 0x0c; offset += 0x04)
{
edi = [rsp + offset];
call sub_4016e9;
[esp] = eax;
}
// 004016df
xmm7 = [rsp];
// 004016e8
return;
}
// 0040161f
void __usercall sub_40161f()
{
// input: ebx
// output: eax
movzx ecx, bl
mov al, byte ptr qword_602268[ecx]
movzx ecx, bh
mov ah, byte ptr qword_602268[ecx]
shl eax, 10h
shr ebx, 10h
movzx ecx, bl
mov al, byte ptr qword_602268[ecx]
movzx ecx, bh
mov ah, byte ptr qword_602268[ecx]
rol eax, 10h
add rsp, 8
nop
retn
}
There’re two more functions we need to analyze: sub_40161f and sub_4016e9; I’ve added some spacing to make the listings easier to follow and understand.
0040161F sub_40161f proc near
0040161F
0040161F ; input: ebx
0040161F ; output: eax
0040161F
0040161F push rcx
00401620 jmp short loc_40162B
00401620
00401622 db 0x00
00401623 db 0x00
00401624 db 0x00
00401625 db 0xE9
00401626 db 0xA6
00401627 db 0x01
00401628 db 0x40
00401629 db 0xE9
0040162A db 0x04
0040162B
0040162B loc_40162B:
0040162B
0040162B xor rax, rax
0040162E jmp short loc_401646
........
00401646 loc_401646:
00401646
00401646 movzx ecx, bl
00401649 mov al, byte ptr qword_602268[ecx]
00401649
00401650 movzx ecx, bh
00401653 mov ah, byte ptr qword_602268[ecx]
0040165A
0040165A shl eax, 10h
0040165D shr ebx, 10h
00401660
00401660 movzx ecx, bl
00401663 mov al, byte ptr qword_602268[ecx]
0040166A
0040166A movzx ecx, bh
0040166D mov ah, byte ptr qword_602268[ecx]
00401674
00401674 rol eax, 10h
00401677
00401677 add rsp, 8
0040167B nop
0040167C retn
0040167C sub_40161f endp
This function is pretty easy to invert; just execute the same opcodes in reverse order and you will obtain the input value. Let’s take a look at the second procedure:
004016E9 sub_4016e9 proc near
004016E9
004016E9 ; input: edi
004016E9 ; output: eax
004016E9
004016E9 xor eax, eax
004016EB jz short loc_4016EE
004016EB
004016ED db 0xE8
004016EE
004016EE loc_4016EE:
004016EE movzx r8, dil
004016F2 shr edi, 8
004016F2
004016F5 movzx r9, dil
004016F9 shr edi, 8
004016F9
004016FC movzx r10, dil
00401700 shr edi, 8
00401700
00401703 movzx r11, dil
00401707 xor eax, eax
00401707
00401709 mov r12b, byte_602394[r11]
00401710 mov dil, byte_602494[r8]
00401717 xor edi, r9d
0040171A xor edi, r10d
0040171D xor edi, r12d
00401720 and edi, 0FFh
00401726 or eax, edi
00401728 shl eax, 8
00401728
0040172B mov r12b, byte_602494[r11]
00401732 mov dil, byte_602394[r10]
00401739 xor edi, r8d
0040173C xor edi, r9d
0040173F xor edi, r12d
00401742 and edi, 0FFh
00401748 or eax, edi
0040174A shl eax, 8
0040174D jb short loc_401752
0040174F jnb short loc_401752
0040174F
00401751 db 0E9h
00401752
00401752 loc_401752:
00401752
00401752 mov r12b, byte_602494[r10]
00401759 mov dil, byte_602394[r9]
00401760 xor edi, r8d
00401763 xor edi, r12d
00401766 xor edi, r11d
00401769 and edi, 0FFh
0040176F or eax, edi
0040176F
00401771 shl eax, 8
00401774 mov r12b, byte_602494[r9]
0040177B mov dil, byte_602394[r8]
00401782 xor edi, r12d
00401785 xor edi, r10d
00401788 xor edi, r11d
0040178B and edi, 0FFh
00401791 or eax, edi
00401791
00401793 retn
0040109e sub_4016e9 endp
This is slightly harder, and will require some work; first of all, write down how the sub_4016e9 function encodes the input value:
output[3] = byte_602494[input[0]] ^ input[1] ^ input[2] ^ byte_602394[input[3]];
output[2] = byte_602394[input[2]] ^ input[0] ^ input[1] ^ byte_602494[input[3]];
output[1] = byte_602394[input[1]] ^ input[0] ^ input[3] ^ byte_602494[input[2]];
output[0] = byte_602394[input[0]] ^ input[2] ^ input[3] ^ byte_602494[input[1]];
Then, invert the operands so that the input value is on the left:
input[1] = output[3] ^ byte_602494[input[0]] ^ input[2] ^ byte_602394[input[3]]
input[0] = output[2] ^ byte_602394[input[2]] ^ input[1] ^ byte_602494[input[3]]
input[3] = output[1] ^ byte_602394[input[1]] ^ input[0] ^ byte_602494[input[2]]
input[2] = output[0] ^ byte_602394[input[0]] ^ input[3] ^ byte_602494[input[1]]
We can now brute force the system and obtain the input value:
// ...
for (std::uint16_t input1 = 0x00; !found && input1 <= 0xFF; input1++)
{
input[1] = static_cast<std::uint8_t>(input1);
for (std::uint16_t input2 = 0x00; !found && input2 <= 0xFF; input2++)
{
input[2] = static_cast<std::uint8_t>(input2);
for (std::uint16_t input3 = 0x00; !found && input3 <= 0xFF; input3++)
{
input[3] = static_cast<std::uint8_t>(input3);
input[0] = output[2] ^ byte_602394[input[2]] ^ input[1] ^ byte_602494[input[3]];
if (input[1] != (output[3] ^ byte_602494[input[0]] ^ input[2] ^ byte_602394[input[3]]))
continue;
if (input[2] != (output[0] ^ byte_602394[input[0]] ^ input[3] ^ byte_602494[input[1]]))
continue;
if (input[3] != (output[1] ^ byte_602394[input[1]] ^ input[0] ^ byte_602494[input[2]]))
continue;
found = true;
}
}
}
// ...
We have pretty much analyzed everything we needed to invert the transformation algorithm, and we can now decode the encrypted file:
alessandro at tachikoma in ~/Projects/untransformer (master)
$ untransformer decode flag.transformed flag.decoded && cat flag.decoded
VolgaCTF{transf0rming_dat@_with_hardc0ded_key_is_not_saf33}
I have published the whole source code on my GitHub page in case you want to take a look at the decoder.
If you managed to get this far, thanks for reading! I hope you found this writeup interesting; I know I at least had fun writing it.