Flare-On 5 CTF WriteUp (Part 4)

Flare-On 5 CTF WriteUp (Part 4)

. 12 min read

This is part 4 of the Flare-On 5 CTF writeup series.

6 - Magic

The challenge starts with a humorous intro.

Wow you are a really good reverse engineer! Keep it up! You can do it!

How to tell if your child is a computer hacker:
1. They are using Lunix, an illegal hacker operating system
2. Are they struggling to maintain contact with the outside world, due to spending their time reading gibberish on the computer screen

This means this is a Lunix challenge, ahem, I mean Linux. The binary for our purpose is an x86-64 ELF. Let's give it a run.

$ ./magic 
Welcome to the ever changing magic mushroom!
666 trials lie ahead of you!
Challenge 1/666. Enter key: abcdefghijkl
No soup for you!

Fair enough. The binary asks us for a key which we have no idea at present. But what about those 666 trials, do we really need to input 666 keys? Let's have a look in IDA.

6-1

fgets read in the key which can have a max length of 128 bytes. do_trial checks our key.

6-2

If our key doesn't match, it calls no_soup which terminates the program.

6-3

Going back to main there's a call to sub_4073BF right after do_trial which will only be called if our level key is correct. sub_4073BF calls some other function sub_403167.

6-4

Going through the disassembly we can see it creates a temporary file. Some data is written to it and then renamed. We all look at it later. Let's focus on do_trial for now.

6-5

There's a loop which is run 33 times. In each iteration, the failure function no_soup will be called if -

  1. If the sum of the corresponding elements of array1 is greater than the key length.
  2. The indirect function call inside the if statement returns 0.

Condition 1 can be satisfied by providing a sufficiently long key close to 128 characters. Satisfying the condition requires finding the correct characters at the corresponding position.

Recovering the key check algorithms

Let's debug this binary to know the target of the indirect call. Set a breakpoint at 402F06 which corresponds to the indirect function call.

6-6

When the breakpoint is hit, let us step into it. The call target is sub_400C55.

1. nth Fibonacci term

Decompiling this function we get the following code.

6-7

The function takes in 3 parameters where a1 and a3 are pointers to an array. a1 points to a character in the input array. a3 points to target array. After calculation on the input, this must match with the element in the target array. a2 is the number of sequential characters to check.

The code is very similar to how Fibonacci numbers are calculated. In fact, it is calculating the nth Fibonacci number where n is the ASCII value of the input character. For success, the calculated value must match with the corresponding element in a3. We can easily brute-force the correct values.

To keep the post short and readable, I will skip the details and won't show the debugging process where we can find the values in a1 and a3. But this is easy to find by setting a breakpoint at the appropriate location.

At the first run, the ASCII value of the three characters from position 2 (zero-based) is used to calculate the nth Fibonacci number. This must equal [0x12062F76909038C5, 0xEC9B9E4287BCE2D, 0x000000000035C7E2] for success. Bruteforcing, we get 'ds ' (quotes excluded) which matches with the target array. The Bruteforcer code will be presented shortly.

At each run of do_trial a different function checks for the correctness of the characters in out input key. Till now we have just seen the Fibonacci code. In total there are 7 different type of checking Functions.

2. A sort of checksum function

6-8
This calculates a hash of the characters and compares it with a3.

3. XOR with a table

6-9
A table of values is generated. The characters of the input key are then xored. The details of the algorithm are skipped for brevity.
6-10

4. An unknown algorithm

6-11
I didn't need to figure out what the function was doing. The only thing worth noting the function must return 1. There is only a single line where the function returns 1 - the last line.
6-12

5. Plus 13

This simply adds 13 to the input character and compares it with the target.
6-13

6. Equals

This is the simplest. It just compares input character with target as-is.
6-14

7. XOR with 0x2a

Input char is xored with 0x2a and compared with the target.
6-15

Recall that there was a function which wrote some data to a temporary file. The function would only be called if we were able to successfully find the level key. The purpose of the function was to modify the challenge file itself. The next time we run the challenge binary, the order of the in which the checking functions are called would be randomized. Hence it is important to replace the challenge binary with the original after every unsuccessful attempt or we run the risk of facing a moving target.

There're 666 levels in total. For each level, the key was checked using the above functions in some order. We do not fancy solving 666 levels by hand. We need to automate the process.

Automating the solving process

For each level, the checking functions were called in some order which we don't know beforehand. We need to devise a method such that it can identify the type of function being called and apply the corresponding technique to solve it.

Taking a hash of the function bytes seem to be a good choice. After all, the function addresses may vary but not the content of the function themselves. After dumping all seven different type of checking functions the minimal size in bytes was found to be 124. This means taking the hash of the first 124 bytes was enough. We can compare this hash to a list and call the corresponding solver automatically.

For this, I used radare2 to debug the binary. The binary requires input from stdin. This can be done by setting up a rarun2 dbg.profile.

stdout=/dev/pts/5
stdin=./input.txt

input.txt contains 666 lines filled with garbage characters. This will serve as a placeholder for feeding the fgets buffer.
6-16

r2-solver.py

The code for the solver is as follows

#r2-solver.py
import r2pipe
import os
import get_solver
import time

r2 = r2pipe.open('./magic')
r2.cmd('e dbg.profile=./dbg.rr2')
r2.cmd('doo')

# .text:0000000000402F03 mov     rdi, rax
# .text:0000000000402F06 call    rcx
# .text:0000000000402F08 test    eax, eax
r2.cmd('db 0x402F06')
r2.cmd('db 0x402F08')

max_buffer_address = 0

for j in xrange(666):
    print '[*] Trial %d--------------------' %(j+1)
    for i in xrange(33):
        # Continue
        r2.cmd('dc')
        
        # Now we are at 402F06 call    rcx
        solver = get_solver.get_solver(r2)
        result = solver(r2)
        print '[+] Solver output', ''.join(map(chr, result))
        
        input_buffer = r2.cmdj('drj rdi')['rdi']
        max_buffer_address = max(max_buffer_address, input_buffer + len(result))
        
        #write result to input buffer
        formatted_value = ''.join(map(lambda t:format(t,'x'), result))
        r2.cmd('wx {} @rdi'.format(formatted_value))
        
        # Continue
        r2.cmd('dc')
        # Now we are at 402F08 test    eax, eax
        r_rax = r2.cmdj('drj rax')['rax']
        assert r_rax == 1
        
        if i == 32:
            r2.cmd('wx 00 @{}'.format(max_buffer_address))
    time.sleep(0.1)

r2.cmd('dc')
time.sleep(1)    
r2.quit()
os.system('chmod +rwx magic')
os.system('rm magic')
os.system('cp -f magic.bak magic')
os.system('chmod +rwx magic')

get_solver.py

get_solver.py calculates the hash of first 124 bytes of the function and calls the corresponding solver. The sign_array contains the md5 hashes.

#get_solver.py
import solver_cbmc
import solver_checksum
import solver_equals
import solver_fibo
import solver_plus13
import solver_tablexor
import solver_xor2a
import sys

def get_solver(r2):
    # Calculate hash of first 124 bytes of function
    hash_124b = r2.cmd('ph md5 124 @rcx')
    
    sign_array = [
        solver_cbmc.SIGN, solver_checksum.SIGN, 
        solver_equals.SIGN, solver_fibo.SIGN, 
        solver_plus13.SIGN, solver_tablexor.SIGN, 
        solver_xor2a.SIGN]
    
    solver_fns = [
        solver_cbmc.solve, solver_checksum.solve, 
        solver_equals.solve, solver_fibo.solve, 
        solver_plus13.solve, solver_tablexor.solve, 
        solver_xor2a.solve]
        
    
    if hash_124b not in sign_array:
        print '[!] No solver of sign {} found'.format(hash_124b)
        print '[X] Bailing out...'
        sys.exit(1)
        
    return solver_fns[sign_array.index(hash_124b)]

Finally, let's have a look at the code for solvers.

1. Fibonacci solver

Straightforward. The nth Fibonacci number is given. Find n.

#solver_fibo.py
import struct

SIGN ='dc0c722a7bb7498ea259b8066dd20828'

def solve(r2):
    print '[+] Running Fibonacci solver'
    letter_count =  r2.cmdj('drj')['rsi']
    
    # Read quad words
    buf = r2.cmd('p8 {} @rdx'.format(letter_count*8))
    buf = buf.decode('hex')
    fmt = '<{}Q'.format(letter_count)
    target = struct.unpack(fmt, buf)
    print '[+] Target ', target
    
    soln = []
    for t in target:
        a=0
        b=1
        s=0
        ctr=0
            
        while True:
            ctr += 1
            s = a + b
            s &= 0xFFFFFFFFFFFFFFFF
            a = b
            b = s
            
            if s == t:
                soln.append(ctr)
                break
    
    return soln

2. Checksum solver

We use the Z3 Theorem Prover to solve this.

#solver_checksum.py
import z3
import struct

SIGN = 'ac9c75e071087d1d234d51fcaaa8f70c'


def solve(r2):
    print '[+] Running Checksum solver'
    letter_count = r2.cmdj('drj')['rsi']
    
    # Read single dword
    buf = r2.cmd('p8 4 @rdx')
    buf = buf.decode('hex')
    target = struct.unpack('<L', buf)[0]
    print '[+] Target ', target
    
    i_vecs = [z3.BitVec('i{}'.format(i), 32) for i in xrange(letter_count)]
    s = z3.Solver()
    
    v4 = z3.BitVecVal(0xFFFFFFFF, 32)
    for i in xrange(letter_count):
        s.add(i_vecs[i] >= 22)
        s.add(i_vecs[i] <= 126)
        v4 ^=  (i_vecs[i] & 0xFF)
        j = 7
        while j >= 0:
            v4 = z3.LShR(v4, 1) ^ (-(v4 & 1)) & 0xEDB88320
            j -= 1
            
    
    s.add(target == ~v4)
    assert s.check() == z3.sat
    m = s.model()
    soln = [m[v].as_long() for v in i_vecs]
    return soln

3. Table XOR solver

We calculate the table and reverse the algorithm.

#solver_tablexor.py
import struct

SIGN = 'cff927fd6f27f0ed5a42ee3743b9513f'

table = [220, 72, 143, 86, 188, 54, 176, 100, 
64, 39, 230, 44, 210, 63, 194, 52, 93, 82, 238, 
205, 170, 202, 129, 141, 113, 35, 40, 215, 150, 
78, 127, 107, 161, 62, 163, 18, 145, 38, 228, 3, 
96, 140, 1, 68, 121, 227, 132, 53, 184, 74, 193, 
85, 26, 157, 17, 231, 146, 164, 212, 104, 55, 133, 
98, 102, 252, 191, 216, 152, 154, 142, 50, 32, 22, 
56, 87, 14, 24, 91, 244, 23, 30, 160, 221, 83, 95, 
6, 248, 246, 225, 12, 2, 116, 243, 192, 195, 240, 
60, 148, 16, 219, 4, 249, 8, 214, 31, 190, 239, 
149, 229, 80, 182, 171, 90, 25, 173, 36, 153, 67, 
203, 165, 235, 57, 204, 103, 185, 197, 201, 166, 
106, 144, 122, 15, 217, 59, 28, 232, 168, 126, 196, 
114, 139, 99, 27, 89, 7, 73, 174, 5, 169, 236, 0, 
198, 49, 20, 105, 186, 130, 29, 101, 70, 112, 41, 
175, 200, 222, 208, 253, 119, 115, 43, 109, 223, 
108, 254, 48, 47, 241, 120, 33, 124, 77, 206, 19, 
181, 151, 42, 158, 255, 234, 207, 37, 189, 172, 76, 
13, 128, 233, 178, 75, 250, 84, 65, 137, 58, 81, 
247, 0, 138, 45, 218, 51, 242, 34, 155, 213, 162, 
69, 110, 79, 211, 118, 61, 134, 46, 187, 123, 10, 
66, 177, 94, 237, 156, 224, 136, 125, 21, 147, 209, 
131, 111, 251, 180, 159, 71, 9, 167, 135, 245, 97, 
226, 199, 179, 117, 11, 88, 92]

def solve(r2):
    global table
    
    print '[+] Running table xor solver'
    letter_count = r2.cmdj('drj')['rsi']
    
    # Read bytes
    buf = r2.cmd('p8 {} @rdx'.format(letter_count))
    buf = buf.decode('hex')
    fmt = '<{}B'.format(letter_count)
    target = struct.unpack(fmt, buf)
    
    table_copy = table[:]
    soln = []
    v12 = 0
    v11 = 0
    
    for t in target:
        v12 += 1
        v12 &= 0xff
        v11 += table_copy[v12];
        v11 &= 0xff
        table_copy[v12] ^= table_copy[v11]
        table_copy[v11] ^= table_copy[v12]
        table_copy[v12] ^= table_copy[v11]
        v13 = table_copy[v12] + table_copy[v11]
        v13 &= 0xff
        soln.append(t^table_copy[v13])
    
    return soln        

4. The unknown algorithm solver

This was solved in the most interesting way. All we knew was that the function must return 1 which was at the last line in the decompiled code. We took the decompiled code from Hex-Rays and used the Bounded Model Checker (CBMC) tool from Carnegie Mellon. This tool is generally used to verify the correctness of C/C++ programs but can also be used as a SAT solver.

The idea was to tell CBMC that given the target array there is no such input which would make the function return 1. CBMC would then try to refute our claim by finding an input which made the function return 1. This is the answer we are after.

The C code fed to CBMC is as follows.

//solver_cbmc.c
unsigned char nondet_unsigned_char();

int solve()
{
  #include "cbmc_clauses.h"
  
  char v3; // al@3
  int v4; // eax@8
  int v5; // eax@9
  int v7; // eax@13
  int v8; // eax@14
  int v9; // eax@18
  int v10; // eax@19
  int v11; // eax@23
  int v12; // eax@24
  //char v13[64]; // [sp+18h] [bp-50h]@1
  unsigned char v14; // [sp+5Dh] [bp-Bh]@7
  short v15; // [sp+5Eh] [bp-Ah]@5
  int v16; // [sp+60h] [bp-8h]@1
  unsigned int v17; // [sp+64h] [bp-4h]@1

  char v13[] = {42, 57, 95, 100, 194, 167, 70, 35, 83, 107, 116, 71, 40, 77, 112, 66, 73, 37, 82, 106, 98, 56, 64, 74, 105, 69, 68, 89, 45, 49, 36, 80, 103, 121, 84, 33, 76, 118, 113, 102, 43, 99, 104, 109, 81, 87, 79, 48, 101, 78, 90, 52, 117, 110, 51, 108, 55, 72, 38, 50, 119, 97, 122, 75, 86, 0};
  v17 = 0;
  v16 = 0;
  while ( v17 <= a2 )
  {
    if ( v17 >= a2 )
      v3 = 0;
    else
      v3 = a1[v17];
    // LOBYTE(v15) = v3;
    v15 = (v15 & 0xFF00) | v3;
    if ( v17 % 3 || v17 >= a2 )
    {
      if ( v17 % 3 == 1 )
      {
        v14 = v15 >> 4;
        // HIBYTE(v15) = v15 & 0xF;
        v15 = (v15 & 0xff) | ((v15 & 0xF) << 8);
        if ( a3[v16] )
        {
          v8 = v16++;
          if ( a3[v8] != v13[v14] )
            return 0LL;
        }
      }
      else if ( v17 % 3 == 2 )
      {
        v14 = v15 >> 6;
        if ( a3[v16] )
        {
          v10 = v16++;
          if ( a3[v10] != v13[v14] )
            return 0LL;
        }
        
        if ( v17 < a2 )
        {
          v14 = v15 & 0x3F;
          if ( a3[v16] )
          {
            v12 = v16++;
            if ( a3[v12] != v13[v14] )
              return 0LL;
          }
        }
        // HIBYTE(v15) = 0;
        v15 &= 0xFF;
      }
    }
    else
    {
      v14 = (unsigned char)v15 >> 2;
    //   HIBYTE(v15) = v15 & 3;
        v15 = (v15 & 0xFF) | ((v15 & 3) << 8);
      if ( a3[v16] )
      {
        v5 = v16++;
        if ( a3[v5] != v13[v14] )
          return 0LL;
      }
    }
    ++v17;
  }
  __CPROVER_assert(0, "If we reach here, we found a solution!");
  return 1;
}

The driver python program.

#solver_cbmc.py
import re
import struct
import commands

SIGN = '6b8cc66a8eec31e4272202f7b0ca8d4d'

def solve(r2):
    print '[+] Running cbmc solver'
    letter_count = r2.cmdj('drj')['rsi'] + 1
    
    # Read bytes
    buf = r2.cmd('p8 {} @rdx'.format(letter_count))
    buf = buf.decode('hex')
    fmt = '<{}B'.format(letter_count)
    target = struct.unpack(fmt, buf)
    print '[+] Target', target
    
    f = open('cbmc_clauses.h', 'w')
    for t in xrange(1, letter_count):
        print >>f, 'unsigned char char%d = nondet_unsigned_char();' %t
        print >>f, '__CPROVER_assume(char%d >= 22 && char%d <= 126);' %(t,t)
        
    print >>f, "unsigned char a1[] = {",
    print >>f, ','.join([" char%d" %i for i in xrange(1,letter_count)]),
    print >>f, "};"
    
    print >>f, "unsigned int a2=%d;" %(letter_count-1)
    
    print >>f, "unsigned char a3[] = {",
    print >>f, ','.join([" %d" %i for i in target]),
    print >>f, "};"
    
    f.close()
    
    output = commands.getoutput('./cbmc solver_cbmc.c --trace  --function solve --verbosity 4')
    
    # means cbmc was able to find a solution
    assert 'VERIFICATION FAILED' in output
    
    soln = []
    for t in xrange(1, letter_count):
        val = int(re.search(r'char%d=([123456789]\d*)' %t, output).group(1))
        soln.append(val)
        
    return soln

5. Plus 13 solver

#solver_plus13.py
import struct

SIGN = '84545d65b725693a3c388be64b81502c'

def solve(r2):
    print '[+] Running plus13 solver'
    letter_count = r2.cmdj('drj')['rsi']
    
    # Read bytes
    buf = r2.cmd('p8 {} @rdx'.format(letter_count))
    buf = buf.decode('hex')
    fmt = '<{}B'.format(letter_count)
    target = struct.unpack(fmt, buf)
    
    soln = map(lambda t:t-13, target)
    return soln

6. Equals solver

#solver_equals.py
import struct

SIGN = 'fd8f5d96b293b5779b1711f0dcdda8dd'

def solve(r2):
    print '[+] Running equals solver'
    letter_count = r2.cmdj('drj')['rsi']
    
    # Read bytes
    buf = r2.cmd('p8 {} @rdx'.format(letter_count))
    buf = buf.decode('hex')
    fmt = '<{}B'.format(letter_count)
    target = struct.unpack(fmt, buf)
    
    soln = target
    return soln

7. XOR with 0x2a solver

#solver_xor2a.py
import struct

SIGN = '406aceb9202e9b1dc718cb09eb61c250'

def solve(r2):
    print '[+] Running xor2a solver'
    letter_count = r2.cmdj('drj')['rsi']
    
    # Read bytes
    buf = r2.cmd('p8 {} @rdx'.format(letter_count))
    buf = buf.decode('hex')
    fmt = '<{}B'.format(letter_count)
    target = struct.unpack(fmt, buf)
    
    soln = map(lambda t:t^0x2a, target)
    return soln

Running r2-solver.py takes a couple of minutes and we finally are greeted with the flag.

6-17

All 666 levels solved without breaking our keyboard!.

FLAG: mag!iC_mUshr00ms_maY_h4ve_g!ven_uS_Santa_ClaUs@flare-on.com

Continue to the next part: Flare-on 5 CTF Write-up (Part 5)