Trip to Trick
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 ofsystem
, 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 tostdout->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 inlibc
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.