This deceptively simple challenge proved a great exercise in abusing FILE structs.
Literally gifting you the entire libc of the process, as well as two arbitrary QWORD writes – you’d expect exploitation to be a walk in the park. In the following sections we’ll dive into what the program does and how we slew the beast.
Into thickening plots? Look into [the last section](# The Plot Thickens), where we dissect why compromising this system in any other is really hard
Quick, grab the supplies!
We’re given several things:
a simple instruction to nc <ip> <port>
trip_to_trick – the executable attached to the nc
libc – libc.so.6 is available, as well as the offset of system, which changes every time you connect
The ability to write anything anywhere, twice.
Wait, That’s It?
The main function does pretty much nothing.
Setup
Seccomped in sandbox allowing only open, read, write, mmap, and exit.
stdin/out/err are set as non-buffering
and some irrelevant place in libc is mprotected to read-only.
Then, logic
Which is all contained in the two screenshots above.
That’s it.
IF you could get it to execute, that is
Let’s say an ELF needs another ELF if it crashes when ran without it. Following this definition, trip_to_trick needs the givenlibc.so.6 to correctly initialize, and libc.so.6 needs a specific ld.so.2 as well. We started off by patching the functions that crash when initializing on an ubuntu18.04 libc.so, but later had the nerve to look for ld.so compiled with libc 2.2.9, and the following command loads, initializes, and runs:
LD_PRELOAD=./libc.so.6 ld.so.2 ./trip_to_trick
Improvise, Adapt, Overcome
Two arbitrary writes and the address of libc sounds like a lot, sadly – it isn’t. All the ideas we scratched, described [here](# Epilogue), have lead us to understanding we have to get more than 16 bytes into the address space of the process to pwn, and that we have to do that by breaking stdin. But… how?
1 Byte at a Time
stdin is a pointer to a struct embedded in the .DATA section of libc, defined as struct FILE _IO_2_1_stdin_. This is a good-enough reference to the struct. Our objective is to cause scanf to read as much as we can into stdin->IO_read_ptr. Since stdin is set as _IONBF first thing, it’s not going to buffer any data, and just read as much as scanf requires, which is one byte at a time.
First attempt
It would stand to reason that if we change the stdin->flags field to show the file is fully buffered, suddenly the stream would fill its internal buffer, and just copy the requested size out. That assumption proved plain false, as changing the flags and breaking on read shows:
But why?
scanf sinks into __vfscanf_interal which in turn sinks into __uflow through _IO_getc_unlocked, which in turn calls the file operation function IO_file_underflow. Then we sink into: read(stdin->_fileno, stdin->IO_read_ptr, stdin->IO_read_end - stdin->IO_read_ptr);
8 Bytes to ∞ Bytes
Surprisingly, stdin->IO_read_end - stdin->IO_read_ptr is always 1, regardless of the buffering state. What more, all stdin->IO_read_* point into a 8-byte scratch space in _IO_2_1_stdin_ itself (seems like it’s to _old_offset)! By just controlling stdin->IO_read_end we can overflow as much as we want.
It’s decided that the first scanf will be used to overwrite stdin->IO_read_end, then the second scanf will effectively read(1, &_IO_2_1_stdin_, <size we control>).
Constraints
The following scanf is going to overwrite some part of the .DATA section, starting somewhere inside _IO_2_1_stdin. However, the lock field must be preserved. Otherwise, upon returning from the read, attempting to unlock it will SIG_SEGV. Lucky for us it points right back into libc so we can predict it’s address. stdin->lock resides just 5 bytes after our overwrite begins, thus we can’t use the first 0xd bytes for anything useful, let alone hope that scanf could parse them as integers. This also means this read overflow is (currently) our last way to influence the flow.
∞ Bytes to ⚑ Bytes
[Recall](# Then, logic) that the first thing that happens after the second scanf is fclose(stdout). Since stdout is our only way of getting information out (seccomp and all), it stands to reason we’d like to get the file before it is closed. stdout, like stdin is a pointer to _IO_2_1_stdout, which is a struct FILE in libc. Luckily,
We can overwrite it entirely with our new read primitive, and so we do.
Every Good File Stream Must Come to fClose
To avoid having stdout be closed we have to look at the inner workings of fclose. It basically has some menial tasks to take care of:
un_link the stream if it was linked
Flush the stream if it’s an input stream and has data yet to be written
Call the stream specific close function (which in our case closes the file descriptor)
We control the data in IO_2_1_stdout entirely, so we can easily make it skip or flow through any path we want.
un_link
is handled by IO_un_link and is the least useful action of the three, as it contains no indirect calls. It’s easily skipped, as it only does something if (fp->flags & _IO_LINKED)
The rest occurs only if (fp->_flags & _IO_IS_FILEBUF), and is handled by the function IO_file_close_it.
Flush
To flush, the stream must be an input one. This means its _IO_CURRENTLY_PUTTING flag is set, and _IO_NO_WRITES is not. If all is well, we simply sink into IO_file_do_write(stdout, stdout->_IO_write_base, stdout->_IO_write_ptr - stdout->_IO_write_base). Here we 2 ‘virtual’ calls to choose from,
stdout->vtable->seek
stdout->vtable->write
Note that _IO_SYS<SOMETHING> is a macro that calls the vtable function of the fp with the given args.
8 static ssize_t IO_file_do_write (FILE *fp, const char *data, size_t to_do)
439 {
...
441 if (fp->_flags & _IO_IS_APPENDING) fp->_offset = _IO_pos_BAD;
448 else if (fp->_IO_read_end != fp->_IO_write_base)
449 {
...
451 = _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
...
455 }
456 count = _IO_SYSWRITE (fp, data, to_do);
The call to seek is a bummer to use, since the arguments are 2 numbers rather than pointers to our data. It can be easily avoidable as it only happens if (!fp->flags & _IO_IS_APPENDING). We control them both, and don’t have any constraints over fp->IO_read_end.
The call to write, on the other hand, is amazingly useful. We control the entire buffer in the first argument, the pointer passed as the second argument, as well as the value passed in the third. I’ll take that any day.
Close
To close the underlying file descriptor, the _IO_file_close_it function simply calls stdout->vtable->close with stdout as an argument, and there are no real constraints over this call. It just happens if the flags are right.
128 _IO_new_file_close_it (FILE *fp) {
...
142 int close_status = ((fp->_flags2 & _IO_FLAGS2_NOCLOSE) == 0
143 ? _IO_SYSCLOSE (fp) : 0);
...
}
The Minor Hitch
Modern libc releases realized the weakness in having a pointer to a file operation vtable that can be easily overwritten in the FILE struct. Before each call to a pointer from the vtable, libc now checks that the vtable address used is in the __libc_IO_vtables section of libc, and crashes the program if it isn’t. Implementation details of that here. The above doesn’t protect the vtables from being misaligned, though. Our objective thus becomes to generate an arbitrary call by slightly sliding the vtable of stdout.
The Royal Straight Flush
Overwrite outline:
flags is to have _IO_CURRENTLY_PUTTING, _IO_IS_FILEBUF, IO_IS_APPENDING set, and must have _IO_LINKED, _IO_NO_WRITES off.
fileno == fileno_stdin
_IO_read_end == _IO_write_base to avoid the call to stdout->vtable->flush(...)
_IO_write_base to be ((char*)&_IO_file_jumps.close) - (&_IO_file_jumps.write - &IO_file_jumps.read))
_IO_write_ptr to be (_IO_write_base + POINTER_SIZE)
vtable is to be ((char*)&_IO_file_jumps) + (&_IO_file_jumps.write - &IO_file_jumps.read)
stdin->lock, stdout->lock to remain unchanged. These are simply pointers into another place in libc so we overwrite them with their original value.
The very first byte is '\0' since then scanf assumes there is no data to read, and fails without changing the output arguments, allowing safe passage through the second *where = what
The rest can be whatever you like.
_IO_file_jumps is the vtable that the I/O streams use, and as such is in the __libc_IO_vtables section. In this overwrite, we are offsetting the vtable pointer by the exact amount needed for the pointer to write to actually be read. Then we effectively get read(stdin, &close_function_to_be_used, POINTER_SIZE).
We can then send the address of an gadget we want, and it will be called when _IO_file_close_it calls stdout->vtable->close.
The Endgame
To quickly and easily create a ROP chain that reads the file, we used pwntools. It’s neat, if you’re not in on the fun, make sure you familiarize yourself with it.
Looking at the state of the registers when stdout->vtable->close is closed we can see:
$rbp is pointing to the start of the address of the vtable, which we control, which is great because it’s just enough scratch space for a stack pivot. A quick run of pwntools.rop.ROP(ELF("libc.so.6")) swiftly comes up with a leave; ret. The address of this gadget is the address we’ll send to be the address of close. Now all that is left is to make sure a rop.migrate(larget_scratch_space_in_our_buffer) chain is at where $rbp is pointing to, and a VERY simple rop chain that opens /home/pwn/flag, reads it into the .data section, and writes it to stdout is what we migrate to.
The end product looks somewhat like this, this is written to &_IO_2_1_stdin + 0x83(which is where the _IO_read_ptr points to):
0000: '0' * 5
0005: &stdin_lock
....
0b45: &_IO_2_1_stdout.vtable + 8 ; this will overwrite the stdin->_IO_read_end
....
; fake STDOUT starts here, at offset 0cdd. Written offsets are now relative to 0xcdd
0000: 0x3882 ; these are the fake flags of stdout.
...
0020: &stdout_vtable.close - 8 ; overwrites IO_write_base.
; it's skewed since the we also skew the vtable
0028: &stdout_vtable.close ; overwrites IO_write_end
....
0048: 0 ; required to not crash inside do_write (unsaved markers)
0070: stdin_fileno
0074: 0
0078: &stdout_lock
00c0: 0
00d8: &_IO_file_jumps - 8
00e0: READ_FLAG_ROP_GADGETS
....
0440: MIGRATE_TO_00E0_GADGETS
and we are sending the address of the leave; ret pivot over the socket, it’s not part of the overwrite buffer.
Conclusion
I loved the way this exercise gives you everything about libc, and 2 arbitrary writes, and still drags your face through the dirt looking for simple solutions that are covered by libc mitigations. While being a fun exercise for exploiting memory corruptions, it was also a good lesson on libc mitigations and stream management (speaking as someone who knew close to nothing about how libc manages streams prior to this). So long and thanks for all the fish. And for reading.
Epilogue
The Plot Thickens
Described here are ideas we tried and scratched, in order of them happening.
#### Seccomp sucks!
The first think we wanted to do was overwrite stdout->IO_backup_space to a /bin/sh that exists in libc, then j_free_hook with the gifted pointer to system and that would have been the end of it.
We had this up and running pretty fast as a POC locally, and got a SIGSYS as expected.
pthread::pointer_guard sucks!
While looking for ways to get more data in, we came across we found this:
This is a monster call, since calling something like gets (or something similar that reads from stdin) allows you to read whatever you want into the stack, allowing instant ROP. It also does not require any tampering with the inner workings of libc. We didn’t know what fs:30 was at first, but quick discovered it’s akin to the stack canary, which gave this method an F.
IO_validate_vtable sucks!
We spent some time looking for a call that is not protected by the IO_validate_vtable function. This function is the one that checks if the vtable is in the __libc_io_vtable section. This time was wasted as we did not realize (at the time) this was a cannon mitigation introduced into libc. No such unprotected calls were found and moving the vtable to point into our overridden buffer resulted in a SIGKILL or a SIGSYS.
Comments