license - tfc 2024

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