Codegate CTF Qualifier 2019 Writeup

Jan 27, 2019 10:32 · 2569 words · 13 minute read ctf cyber-security write-up

MIC check

Problem

Let the hacking begins ~

Decode it : 9P&;gFD,5.BOPCdBl7Q+@V’1dDK?qL

Solution

The text is encoded with base85, and you can decode it using tools such as CyberChef.

flag: Let the hacking begins ~

20000

Problem

nc 110.10.147.106 15959

Download

Solution

For this problem, you are given a single binary along with 20000 .so libraries.

A quick look at the binary reveals that it is just a wrapper for calling the .so libraries using dlopen:

signed __int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  char *v3; // rax
  signed __int64 result; // rax
  void *v5; // rdi
  char *v6; // rax
  int v7; // [rsp+Ch] [rbp-94h]
  void (__fastcall *v8)(void *, const char *); // [rsp+10h] [rbp-90h]
  void *handle; // [rsp+18h] [rbp-88h]
  char s; // [rsp+20h] [rbp-80h]
  int v11; // [rsp+80h] [rbp-20h]
  int v12; // [rsp+84h] [rbp-1Ch]
  unsigned __int64 v13; // [rsp+88h] [rbp-18h]

  v13 = __readfsqword(0x28u);
  sub_400A06(a1, a2, a3);
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(stdout, 0LL, 2, 0LL);
  setvbuf(stderr, 0LL, 2, 0LL);
  memset(&s, 0, 0x60uLL);
  v11 = 0;
  printf("INPUT : ", 0LL, &v12);
  __isoc99_scanf("%d", &v7);
  if ( v7 <= 0 && v7 > 20000 )
  {
    printf("Invalid Input");
    exit(-1);
  }
  sprintf(&s, "./20000_so/lib_%d.so", (unsigned int)v7);
  handle = dlopen(&s, 1);
  if ( handle )
  {
    v5 = handle;
    v8 = (void (__fastcall *)(void *, const char *))dlsym(handle, "test");
    if ( v8 )
    {
      v8(v5, "test");
      dlclose(handle);
      result = 0LL;
    }
    else
    {
      v6 = dlerror();
      fprintf(stderr, "Error: %s\n", v6);
      dlclose(handle);
      result = 1LL;
    }
  }
  else
  {
    v3 = dlerror();
    fprintf(stderr, "Error: %s\n", v3);
    result = 1LL;
  }
  return result;
}

At this point, I started analyzing the 20000 .so libraries. There are three symbols that are present in the binaries: test, filter1, and filter2. test is the function the main program is going to invoke, it is either a empty shell with only a read function or a function that reads input, filters it with filter1 and filter2 from two other binaries, and call system with the input if it passes the filters. filter1 and filter2, on the other hand, are similar as they each detect a certain set of characters/words.

I decided to generate a list of the binaries that contain each symbol using r2pipe:

import r2pipe

test = []
filter1 = []
filter2 = []

for i in range(1, 20000+1):
  r2 = r2pipe.open('./20000_so/lib_{}.so'.format(i))
  if 'dlopen' in r2.cmd('ii'):
    test.append(i)
  if 'filter1' in r2.cmd('is'):
    filter1.append(i)
  if 'filter2' in r2.cmd('is'):
    filter2.append(i)
  if i % 12 == 0:
    print i
  r2.quit()

print test
print filter1
print filter2

Now with the generated list, I begin to look for anomalies:

for i in filter1:
  r2 = r2pipe.open('./20000_so/lib_{}.so'.format(i))
  r2.cmd('aaa')
  keys = ['0x3b', '0x2a', '0x7c', '0x26', '0x24', '0x60', '0x3e', '0x3c', '0x72']
  out = r2.cmd('pdf @ sym.filter1')

  same = True
  for k in keys:
    if k not in out:
      same = False
  
  if not same:
    print str(i)

  if i % 12 == 0:
    print '.'
  r2.quit()

In the end, I landed on lib_4323.so, the only anomaly. Here its filter1 function compared to a normal filter1 function:

filter1 in lib_4323.so:

char *__fastcall filter1(const char *a1)
{
  char *result; // rax

  if ( strchr(a1, ';') )
    exit(0);
  if ( strchr(a1, '*') )
    exit(0);
  if ( strchr(a1, '`') )
    exit(0);
  if ( strchr(a1, '&') )
    exit(0);
  if ( strchr(a1, '$') )
    exit(0);
  if ( strchr(a1, '>') )
    exit(0);
  if ( strchr(a1, '<') )
    exit(0);
  result = strchr(a1, 'r');
  if ( result )
    exit(0);
  return result;
}

filter1 in lib_5091.so:

char *__fastcall filter1(const char *a1)
{
  char *result; // rax

  if ( strchr(a1, ';') )
    exit(0);
  if ( strchr(a1, '*') )
    exit(0);
  if ( strchr(a1, '|') )
    exit(0);
  if ( strchr(a1, '&') )
    exit(0);
  if ( strchr(a1, '$') )
    exit(0);
  if ( strchr(a1, '`') )
    exit(0);
  if ( strchr(a1, '>') )
    exit(0);
  if ( strchr(a1, '<') )
    exit(0);
  result = strchr(a1, 'r');
  if ( result )
    exit(0);
  return result;
}

You can see that there’s less restrictions. Now, I just have to look for the library that uses this filter:

for i in test:
  r2 = r2pipe.open('./20000_so/lib_{}.so'.format(i))

  if len(r2.cmd('iz~./20000_so/lib_4323.so')) > 0:
    print i

  if i % 12 == 0:
    print '.'
  r2.quit()

That leads me to lib_17394.so, the vulnerable library. I examined the filter2 used by lib_17394.so and discovered that it doesn’t filter out sh which is different from all other filter2 functions.

Now knowing the vulnerable library and what command to use, I am able to get the flag:

❯ nc 110.10.147.106 15959

   /$$$$$$   /$$$$$$   /$$$$$$   /$$$$$$   /$$$$$$
  /$$__  $$ /$$$_  $$ /$$$_  $$ /$$$_  $$ /$$$_  $$
 |__/  \ $$| $$$$\ $$| $$$$\ $$| $$$$\ $$| $$$$\ $$
   /$$$$$$/| $$ $$ $$| $$ $$ $$| $$ $$ $$| $$ $$ $$
  /$$____/ | $$\ $$$$| $$\ $$$$| $$\ $$$$| $$\ $$$$
 | $$      | $$ \ $$$| $$ \ $$$| $$ \ $$$| $$ \ $$$
 | $$$$$$$$|  $$$$$$/|  $$$$$$/|  $$$$$$/|  $$$$$$/
 |________/ \______/  \______/  \______/  \______/

INPUT : 17394
This is lib_17394 file.
How do you find vulnerable file?
sh
cat flag
flag{Are_y0u_A_h@cker_in_real-word?}

flag: Are_y0u_A_h@cker_in_real-word?

algo_auth

Problem

I like an algorithm

nc 110.10.147.104 15712

Solution

This is a classic programming problem where you have to find the least cost path from the left column to the the right column of a 7x7 matrix.

Because of the time limitation, I decided to search for existing solutions instead of writing one on the the spot. I quickly found this article describing the same problem with only slight variations.

I took the code and altered it a bit to fit my need, and here is the final version:

// https://www.geeksforgeeks.org/minimum-cost-path-left-right-bottom-moves-allowed/
#include <bits/stdc++.h> 
using namespace std; 
  
#define ROW 7
#define COL 7 
  
// structure for information of each cell 
struct cell 
{ 
    int x, y; 
    int distance; 
    cell(int x, int y, int distance) : 
        x(x), y(y), distance(distance) {} 
}; 
  
// Utility method for comparing two cells 
bool operator<(const cell& a, const cell& b) 
{ 
    if (a.distance == b.distance) 
    { 
        if (a.x != b.x) 
            return (a.x < b.x); 
        else
            return (a.y < b.y); 
    } 
    return (a.distance < b.distance); 
}

// Utility method to check whether a point is 
// inside the grid or not 
bool isInsideGrid(int i, int j) 
{ 
    return (i >= 0 && i < COL && j >= 0 && j < ROW); 
} 
  
// Method returns minimum cost to reach bottom 
// right from top left 
int shortest(int grid[ROW][COL], int row, int col, int sx, int sy) 
{ 
    int dis[row][col]; 
  
    // initializing distance array by INT_MAX 
    for (int i = 0; i < row; i++) 
        for (int j = 0; j < col; j++) 
            dis[i][j] = INT_MAX; 
  
    // direction arrays for simplification of getting 
    // neighbour 
    int dx[] = {-1, 0, 1, 0}; 
    int dy[] = {0, 1, 0, -1}; 
  
    set<cell> st; 
  
    // insert (0, 0) cell with 0 distance 
    st.insert(cell(sx, sy, 0)); 
  
    // initialize distance of (0, 0) with its grid value 
    dis[sx][sy] = grid[sx][sy]; 
  
    // loop for standard dijkstra's algorithm 
    while (!st.empty()) 
    { 
        // get the cell with minimum distance and delete 
        // it from the set 
        cell k = *st.begin(); 
        st.erase(st.begin()); 
  
        // looping through all neighbours 
        for (int i = 0; i < 4; i++) 
        { 
            int x = k.x + dx[i]; 
            int y = k.y + dy[i]; 
  
            // if not inside boundry, ignore them 
            if (!isInsideGrid(x, y)) 
                continue; 
  
            // If distance from current cell is smaller, then 
            // update distance of neighbour cell 
            if (dis[x][y] > dis[k.x][k.y] + grid[x][y]) 
            { 
                // If cell is already there in set, then 
                // remove its previous entry 
                if (dis[x][y] != INT_MAX) 
                    st.erase(st.find(cell(x, y, dis[x][y]))); 
  
                // update the distance and insert new updated 
                // cell in set 
                dis[x][y] = dis[k.x][k.y] + grid[x][y]; 
                st.insert(cell(x, y, dis[x][y])); 
            } 
        } 
    } 
  
    // uncomment below code to print distance 
    // of each cell from (0, 0) 
    for (int i = 0; i < row; i++, cout << endl) 
        for (int j = 0; j < col; j++) 
            cout << dis[i][j] << " "; 
    // dis[row - 1][col - 1] will represent final 
    // distance of bottom right cell from top left cell 
    return dis[row - 1][col - 1]; 
} 
  
// Driver code to test above methods 
int main() 
{ 
    int sx, sy;
    cin >> sx >> sy;
    int grid[ROW][COL];
    for (int i = 0; i < ROW; i++) {
        for (int j = 0; j < COL; j++) {
            cin >> grid[i][j];
        }
    }
    // for (int i = 0; i < ROW; i++) {
    //     for (int j = 0; j < COL; j++) {
    //         cout << grid[i][j] << endl;
    //     }
    // }



    shortest(grid, ROW, COL, sy, sx);
    return 0; 
}

Now able to solve any 7x7 matrix, I wrote a short script in python to communicate with the server and extract the flag:

from pwn import *

context.log_level = 'error'

r = remote('110.10.147.104', 15712)

r.sendlineafter('>>', 'G')

answers = []
for count in range(100):
  output = r.recvuntil('>>').strip().split('\n')[1:8]
  sols = []
  for i in range(7):
    sh = process('./solve')
    sh.sendline('0 {}'.format(i))
    for each in output:
      sh.sendline(each)
    sol = min(map(lambda x: int(x.strip().split(' ')[-1]), sh.recvall().strip().split('\n')))
    sols.append(sol)
    sh.close()
  answers.append(min(sols))
  r.sendlineafter('>', str(min(sols)))
  print 'stage{} done'.format(count+1)
print answers
r.interactive()

After completing all 100 stages, you are told that the answers for all 100 matrixes form the flag. It turns out to be a simple base64 encoded string, and here is a python snippet to decode it:

code = [82, 107, 120, 66, 82, 121, 65, 54, 73, 71, 99, 119, 77, 71, 57, 118, 84, 48, 57, 107, 88, 50, 111, 119, 81, 105, 69, 104, 73, 86, 57, 102, 88, 51, 86, 117, 89, 50, 57, 116, 90, 109, 57, 121, 100, 68, 82, 105, 98, 71, 86, 102, 88, 51, 77, 122, 89, 51, 86, 121, 97, 88, 82, 53, 88, 49, 57, 112, 99, 49, 57, 102, 98, 106, 66, 48, 88, 49, 56, 48, 88, 49, 57, 122, 90, 87, 78, 49, 99, 109, 108, 48, 101, 83, 69, 104, 73, 83, 69, 104]
code = map(chr, code)
print ''.join(code).decode('base64')
❯ python decode.py
FLAG : g00ooOOd_j0B!!!___uncomfort4ble__s3curity__is__n0t__4__security!!!!!

flag: g00ooOOd_j0B!!!___uncomfort4ble__s3curity__is__n0t__4__security!!!!!

KingMaker

Problem

nc 110.10.147.104 13152

Download

Solution

This is the problem that I spent the most amount of time on. The binary is a text-based adventure game that is divided into 5 stages. The code for each stage is xor encrypted with a different key with either a length of 4 or 10. Here is the decryption function:

_BYTE *__fastcall xor(_BYTE *start_addr, int end_addr, const char *key)
{
  _BYTE *result; // rax
  char *s; // [rsp+8h] [rbp-28h]
  signed int counter; // [rsp+20h] [rbp-10h]
  unsigned int length; // [rsp+24h] [rbp-Ch]
  _BYTE *addr; // [rsp+28h] [rbp-8h]

  s = (char *)key;
  counter = 0;
  length = strlen(key);
  for ( addr = start_addr; ; ++addr )
  {
    result = &start_addr[end_addr];
    if ( result <= addr )
      break;
    *addr ^= s[counter];
    counter = (counter + 1) % length;
  }
  return result;
}

I am able to crack the first four bytes of the xor key by knowing that almost all 64 bit binary functions start with push rbp; mov rbp, rsp; which is 554889e5 in hex. Using this method, I am able to get the keys for the first two stages which both have a key length of 4.

For the last three stage, I have to use another fact that the first function of each stage always print out SYSTEM : We will start test NUMBER\n. Knowing that plus the location of the string, I am able to recover all ten bytes of the xor key.

With all 5 keys, I can now patch the binary. For this CTF, I did all the patching manually with a python script although better solutions definitely exist (put your suggestions in the comment section below). Here is the patching script:

import string
from pwn import *
context.arch='amd64'

main_key = 'lOv3'
main_key_2 = 'D0l1'
main_key_3 = 'HuNgRYT1m3'
main_key_4 = 'F0uRS3aS0n'
main_key_5 = 'T1kT4kT0Kk'

patches = [
  (0x341D, 0xf0, main_key),
  (0x33FF, 0x1e, main_key),
  (0x330F, 0xf0, main_key),
  (0x32DE, 0x31, main_key),
  (0x32C0, 0x1e, main_key),
  (0x3197, 0x129, main_key),
  (0x30D4, 0x0C3, main_key),

  (0x2D55, 0x0FA, main_key_2),
  (0x2C25, 0x112, main_key_2),
  (0x2D37, 0x1e, main_key_2),
  (0x27E9, 0x44, main_key_2),
  (0x29B9, 0x0E6, main_key_2),
  (0x2B2B,0x0FA, main_key_2),
  (0x271C, 0x0CD, main_key_2),
  (0x28B5, 0xe6, main_key_2),
  (0x299B, 0x1e, main_key_2),
  (0x2A9F, 0x4E, main_key_2),
  (0x2AED, 0x3e, main_key_2),
  (0x282D, 0x44, main_key_2),
  (0x2871, 0x44, main_key_2),

  (0x20E2, 0x18d, main_key_3),
  (0x201F, 0xc3, main_key_3),

  (0x1B0A, 0xf0, main_key_4),
  (0x19F2, 0x0FA, main_key_4),
  (0x1AEC, 0x1e, main_key_4),
  (0x192C, 0xa8, main_key_4),
  (0x19D4, 0x1e, main_key_4),
  (0x16D0, 0xc3, main_key_4),

  (0x11BB, 0x131, main_key_5),
  (0x0F25, 0x0DC, main_key_5),
  (0x108B, 0x130, main_key_5),
  (0x0DE7, 0x120, main_key_5),
  (0x0F07, 0x1e, main_key_5),
  (0x1001, 0x1e, main_key_5),
  (0x101F, 0x4e, main_key_5),
  (0x106D, 0x1e, main_key_5),
  (0x0C8C, 0x158, main_key_5)
]

with open('patched', 'wb') as patched:
  with open('./KingMaker', 'rb') as binary:
    data = bytearray(binary.read())
    
    for offset, size, key in patches:
      data[offset:offset+size] = xor(data[offset:offset+size], key)

    patched.write(data)
    patched.close()

Now with the decrypted binary, I just have to find the path through the game that will lead to the attribute 5/5/5/5/5 in the end. Again for time sake, I did a nested for loop to find the correct path (not the best code that I have written, but it works ;) ):

t1 = ['20010', '20100', '20210']
t2 = ['00102','0x00x','02000']
t3 = ['x0x10','12000','11000']
t4 = ['11112', '12212', '11020', '11120']
t5 = ['00110','0x200','0x110']
t6 = ['1xx22','00000','10001']
t7 = ['01120']
t8 = ['01110','01000']
t9 = ['x0011', '00121', '00022']

def toNum(e):
  i = []
  for c in e:
    if c == '1':
      i.append(1)
    if c == '2':
      i.append(2)
    if c == '0':
      i.append(0)
    if c == 'x':
      i.append(-1)
  return i

def check(e1, e2, e3, e4, e5, e6, e7, e8, e9):
  i1 = toNum(e1)
  i2 = toNum(e2)
  i3 = toNum(e3)
  i4 = toNum(e4)
  i5 = toNum(e5)
  i6 = toNum(e6)
  i7 = toNum(e7)
  i8 = toNum(e8)
  i9 = toNum(e9)

  correct = True
  for i in range(5):
    if i1[i]+i2[i]+i3[i]+i4[i]+i5[i]+i6[i]+i7[i]+i8[i]+i9[i] != 5:
      correct = False
      break
  
  return correct
    
for e1 in t1:
  for e2 in t2:
    for e3 in t3:
      for e4 in t4:
        for e5 in t5:
          for e6 in t6:
            for e7 in t7:
              for e8 in t8:
                for e9 in t9:
                  if check(e1, e2, e3, e4, e5, e6, e7, e8, e9):
                    print e1, e2, e3, e4, e5, e6, e7, e8, e9

# 20100 02000 11000 11112 0x200 10001 01120 01000 00022

In addition, the challenge also includes a cipher text to decrypt. I reversed the verification function and recovered the plain text. Here is the script for that:

from pwn import *
cipher1 = p64(5208208757389214273)
cipher1 += p64(5786930140093827657)
cipher1 += p64(6365651522798441041)
cipher1 += p64(23129)

print cipher1

cipher2 = p64(6077397987897199681)
cipher2 += p64(5788062684281653841)
cipher2 += p64(5856724921871653456)
cipher2 += p64(5783551345105850969)

print cipher2.encode('hex')

key = 'ALICE'
count = 0
text = ''
for e in cipher2[5:]:
  c = ord(e) - 0x41 - (ord(key[count])-0x41)
  s = 0x41+c
  if s >= 0x41+26:
    s -= 26
  if s < 0x41:
    s += 26
  text += chr(s)
  count = (count + 1) % 5

print text

# ALICEALLOFMYPROPERTYISYOURS

Now with both the sequence generated above and the plain text decrypted, I wrote another script to interact with the server and retrieve the flag:

import string
from pwn import *
import sys
argv = sys.argv

DEBUG = True
context.binary = './KingMaker' 

if DEBUG:
  context.log_level = 'debug'

if len(argv) > 1:
  BINARY = argv[1]

  stdout = process.PTY
  stdin = process.PTY

  sh = process(BINARY, stdout=stdout, stdin=stdin)
else:
  sh = remote('110.10.147.104', 13152)

sh.sendlineafter('around\n', '1')

sh.sendlineafter('1\n', 'lOv3')
sh.sendlineafter('not\n', '1')
sh.sendlineafter('.\n', '2')
sh.sendlineafter('release\n', '2')
sh.sendlineafter('.\n', '3')

sh.sendlineafter('2\n', 'D0l1')
sh.sendlineafter('not\n', '1')
sh.sendlineafter('.\n', '2')
sh.sendlineafter('.\n', '1')
sh.sendlineafter('cleary.\n', '1')
sh.sendlineafter('brother.\n', '2')
sh.sendlineafter('you.\n', '1')

sh.sendlineafter('3\n', 'HuNgRYT1m3')
sh.sendlineafter('exile.\n', '2')
sh.sendlineafter('Nope!\n', '2')
sh.sendlineafter('first.\n', '3')

sh.sendlineafter('4\n', 'F0uRS3aS0n')
sh.sendlineafter('not.\n', '1')
sh.sendlineafter('can\'t\n', '1')
sh.sendlineafter('chance.\n', 'ALICEALLOFMYPROPERTYISYOURS')
sh.sendlineafter('room\n', '2')

sh.sendlineafter('5\n', 'T1kT4kT0Kk')
sh.sendlineafter('country.\n', '3')
sh.sendlineafter('together\n', '2')

sh.interactive()

flag: He_C@N'T_see_the_f0rest_foR_TH3_TRee$

tweet Share