This is a writeup for the TFC CTF 2024.
For the challenge we are provided with a single binary called license
.
Let’s check the file:
$ file license
license: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=336b3d35e851f9b302e938e557e766e57ed406b7, for GNU/Linux 3.2.0, stripped
What I expected, a regular linux ELF binary, so let’s see the security measures:
$ checksec license
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Ok, everything looking normal so far.
When the binary is executed, it asks for a license, this should be the flag we have to find.
I open the ELF in binary ninja and go to the decompilation of the main
function.
+0x14ae int32_t main(int32_t argc, char** argv, char** envp)
+0x14ae {
+0x14c4 puts("Please enter your license key to…");
+0x14e2 fgets(&buffer_start, 18, stdin);
+0x14f1 uint64_t rax = strlen(&buffer_start);
+0x14f1
+0x14ff if (rax != 0x11)
+0x14ff {
+0x1506 exit(0);
+0x14ff }
+0x14ff
+0x1527 if ((rax != 0 && *(uint8_t*)(rax + 0x405f) == 0xa))
+0x1538 *(uint8_t*)(rax + 0x405f) = 0;
+0x1538
+0x1555 strncpy(&first_8, &buffer_start, 8);
+0x155a data_4088 = 0;
+0x155a
+0x1573 if (check_1st_half(&first_8) == 1)
+0x1573 {
+0x157f puts("Nope");
+0x1589 exit(0);
+0x1573 }
+0x1573
+0x1597 if (_9th_byte != '-')
+0x1597 {
+0x159e exit(0);
+0x1597 }
+0x1597
+0x15bc strncpy(&last_8, &buffer_10th, 8);
+0x15bc
+0x15d3 if (check_2nd_half(&last_8) != 1)
+0x15d3 {
+0x15f8 puts("Congrats! Get the flag on remote…");
+0x1603 return 0;
+0x15d3 }
+0x15d3
+0x15df puts("Nope");
+0x15e9 exit(0);
+0x14ae }
Due to the call to strlen
and the conditional below it, we know that the
flag is 17 characters long.
Then there is:
- function that checks the first 8 chars of the flag.
- a conditional to check that the 9th char is
-
. - a function to analyze the last 8 chars of the flag.
So we already know that the 9th byte has to be -
, so the flag format is
XXXXXXXX-XXXXXXXX
.
This is the decompilation of the check_1st_half
function
+0x1209 int64_t check_1st_half(void* input_buffer)
+0x1209 {
+0x1219 void* fsbase;
+0x1219 int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
+0x12e6 void to_compare;
+0x12e6
+0x12e6 for (int32_t i = 0; i <= 7; i += 1) // copy input_buffer to to_compare with a few modifications
+0x12e6 {
+0x1254 int32_t rax_7 = (i % 3);
+0x1254
+0x1259 if (rax_7 == 2)
+0x12c1 *(uint8_t*)(&to_compare + ((int64_t)i)) = (*(uint8_t*)((char*)input_buffer + ((int64_t)i)) - 0x25);
+0x1259 else if (rax_7 == 0)
+0x1285 *(uint8_t*)(&to_compare + ((int64_t)i)) = (*(uint8_t*)((char*)input_buffer + ((int64_t)i)) ^ 0x5a);
+0x1262 else if (rax_7 == 1)
+0x12a3 *(uint8_t*)(&to_compare + ((int64_t)i)) = (*(uint8_t*)((char*)input_buffer + ((int64_t)i)) + 0x10);
+0x12a3
+0x12da *(uint8_t*)(&to_compare + ((int64_t)i)) ^= 0x33;
+0x12e6 }
+0x12e6
+0x12ec int32_t iter = 0;
+0x1328 int64_t result;
+0x1328
+0x1328 while (true) // this checks if to_compose == global_flag_buffer_0
+0x1328 {
+0x1328 if (iter > 7)
+0x1328 {
+0x132a result = 0;
+0x132a break;
+0x1328 }
+0x1328
+0x1317 if (((uint32_t)*(uint8_t*)(&to_compare + ((int64_t)iter))) != ((int32_t)global_flag_buffer_0[((int64_t)iter)]))
+0x1317 {
+0x1319 result = 1;
+0x131e break;
+0x1317 }
+0x1317
+0x1320 iter += 1;
+0x1328 }
+0x1328
+0x1333 *(uint64_t*)((char*)fsbase + 0x28);
+0x1333
+0x133c if (rax == *(uint64_t*)((char*)fsbase + 0x28))
+0x1344 return result;
+0x1344
+0x133e __stack_chk_fail();
+0x1209 }
Reverse Engineering is the art of understanding how things work. So, reading the code we know that the function:
- Copies the input buffer to a new buffer, doing a few operations on each character.
- Compares the new buffer against a global buffer, which is
"Xsl3BDxP"
So, to solve this part of the challenge we just need to provide 8 characters
that after they’re copied to that new buffer they are "Xsl3BDxP"
.
There are multiple ways to do this - even just bruteforcing char by char - in this chal I used angr to do this. This kind of problem is a classical application of angr - it’s a symbolic execution engine -
base = 0x400000
def first_check(project):
check0_start = base + 0x1211
check0_end = base + 0x12f5
initial_state = project.factory.entry_state(
addr = check0_start,
add_options = { angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS })
simulation = project.factory.simgr(initial_state)
p0 = claripy.BVS('p0', 8 * 8)
input0_addr = initial_state.regs.rdi
initial_state.memory.store(input0_addr, p0)
simulation.explore(find=check0_end)
if simulation.found:
solution_state = simulation.found[0]
print('found!')
to_compare_addr = solution_state.regs.rbp - 0x10
constraint_sym = solution_state.memory.load(to_compare_addr, 8)
constraint_value = 'Xsl3BDxP'.encode()
solution_state.add_constraints(constraint_sym == constraint_value)
print(solution_state.solver.eval(constraint_sym,cast_to=bytes))
solution = solution_state.solver.eval(p0,cast_to=bytes)
return solution
else:
raise Exception('Could not find the solution')
I don’t mean to explain how do use angr here, but a quick summary of what this script does is:
- Create a symbolic state where the execution will start (
0x1211
the function’s start). - Create a symbolic variable with 8 bytes.
- Mark where the symbolic variable will be during the initial stage (
RDI
), which is the register for the 1st argument in the x64 linux call convention. - Mark where the execution will stop (
0x12f5
right before comparing the strings). - Add a constraint that the new buffer (stored at
RBP-0x10
) should be equal to the string that’s going to check against.
With this script we have the solution for the first half, mazal tov!
Due to some limitations of angr, the differences in the second function make it harder to solve using this approach, so for the second function the approach was different.
+0x1345 int64_t check_2nd_half(void* input_buffer)
+0x1345 {
+0x1463 for (int32_t i = 0; i <= 7; i += 1)
+0x1463 {
+0x1366 uint16_t* rdx_1 = *(uint64_t*)__ctype_b_loc();
+0x1366
+0x1390 if ((((uint32_t)rdx_1[((int64_t)*(uint8_t*)((char*)input_buffer + ((int64_t)i)))]) & 0x200) == 0)
+0x1390 {
+0x13e4 uint16_t* rdx_11 = *(uint64_t*)__ctype_b_loc();
+0x13e4
+0x140e if ((((uint32_t)rdx_11[((int64_t)*(uint8_t*)((char*)input_buffer + ((int64_t)i)))]) & 0x100) != 0)
+0x1459 *(uint8_t*)((char*)input_buffer + ((int64_t)i)) = (((int8_t)((((int32_t)*(uint8_t*)((char*)input_buffer + ((int64_t)i))) - 0x30) % 0x1a)) + 0x41);
+0x1390 }
+0x1390 else
+0x13db *(uint8_t*)((char*)input_buffer + ((int64_t)i)) = (((int8_t)((((int32_t)*(uint8_t*)((char*)input_buffer + ((int64_t)i))) - 0x5c) % 0x1a)) + 0x61);
+0x1463 }
+0x1463
+0x1469 int32_t iter = 0;
+0x1469
+0x14a5 while (true) // just checking the equality
+0x14a5 {
+0x14a5 if (iter > 7)
+0x14a7 return 0;
+0x14a7
+0x1494 if (*(uint8_t*)((char*)input_buffer + ((int64_t)iter)) != global_flag_buffer_1[((int64_t)iter)])
+0x1494 break;
+0x1494
+0x149d iter += 1;
+0x14a5 }
+0x14a5
+0x1496 return 1;
+0x1345 }
This function is only slightly different:
- The buffer gets modified in place.
- The modification uses
__ctype_b_loc
.
This is enough to break a simple angr approach here.
I wrote a bash script and gdb
script to run the binary with every possible value for a byte, and print the value of the modified input. I ran this manually for all the bytes until I had the flag at the end.
for i in {1..255}
do
I=$(printf '%02x' $i)
printf "${I}\x30\x84\x5a\x61\x9c\x11\x53\x2d\x68\x75\x47\x76\x59\x55\x75\x41" > input_x
Y=$(gdb -q -x ./table.gdb --batch --args ./license | grep '0x555555558090')
echo "$I = $Y"
done
break *0x55555555547c
run < input_x
x/8xb $rax
kill
quit