CTFするぞ

CTF以外のことも書くよ

ヒープ系問題におけるstdout / stderrを利用したメモリリーク

はじめに

数カ月前から始めた鬼のpwn特訓により最近CTFでもヒープ系問題に挑戦できるようになりつつあるのですが、たまにShow機能がない(=一見libcのアドレスがリークできない)ヒープ系問題が出ます。 HITCONのbabytcacheという問題のwriteupを読んでstdoutの _IO_write_ptr だか何だかをいじればメモリリークができるよー、と書いてあったのですが「ふーん」という感じで放置していました。 ところがSecurity Fest CTFのHalleb3rryで同じテクニックが要求されるなど、割とちらほら見かけるので必要かと思い勉強することにしました。 日本語の記事は皆無だったのでまとめたいと思います。

_IO_FILE構造体

いつぞやの記事でも説明しましたが再掲。(結局あの記事で説明した_IO_str_overflowは一度も使ってないが......)

struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

_IO_jump_tに各種ポインタがあることおは前の記事で説明しましたが、今回は_IO_FILEの方に注目します。

struct _IO_FILE
{
  int _flags;                /* High-order word is _IO_MAGIC; rest is flags. */
  /* The following pointers correspond to the C++ streambuf protocol. */
  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;
  __off_t _old_offset; /* This used to be _offset but it's too small.  */
  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];
  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

ファイル構造体は_chainで繋がっており、初期状態ではstdinが0番目、stdoutが1番目、stderrが2番目になっています。

_IO_list_all --> stderr --> stdout --> stdin

fopenなどでファイルを開くと_IO_list_allに新しい_IO_FILE構造体の領域がリンクされ、そいつのchainはstderrに繋がります。

_IO_list_all --> file --> stderr --> stdout --> stdin

さて、_IO_FILE構造体の定義を見ると、_IO_read_ptr_IO_write_baseといったポインタが定義されています。 これらのポインタは次のような役割があります。

名前 役割
_IO_read_ptr readでデータを読み込む時に参照するバッファのカレントポジション
_IO_read_end readでデータを読み込む時に参照するバッファの終端
_IO_read_base readでデータを読み込む時に参照するバッファの先頭
_IO_write_base writeでデータを書き込む先のバッファの先頭
_IO_write_ptr writeでデータを書き込む先のバッファのカレントポジション
_IO_write_end writeでデータを書き込む先のバッファの終端

書き込むところはこんな感じになってます。

if (ch == EOF)
  return _IO_do_write (f, f->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base);

なので、_IO_write_ptr_IO_write_baseより先に進めておけば、その分だけ出力されます。

_IO_write_ptrをいじる

例えば次のようなプログラムを動かすと、結果はどうなるでしょうか。

#include <stdio.h>

int main()
{
  FILE *fp = stdout;
  printf("Hello, World!\n");
  fp->_IO_write_ptr += 14;
  printf("Hi.\n");
  return 0;
}

最初のprintfで"Hello, World!\n"を出力すると_IO_write_baseおよび_IO_write_ptrは"Hello, World!\n"の先頭になります。

pwndbg> x/32xg 0x7ffff7dd0760
0x7ffff7dd0760 <_IO_2_1_stdout_>: 0x00000000fbad2a84  0x0000555555756260
0x7ffff7dd0770 <_IO_2_1_stdout_+16>:  0x0000555555756260  0x0000555555756260
0x7ffff7dd0780 <_IO_2_1_stdout_+32>:  0x0000555555756260  0x0000555555756260
0x7ffff7dd0790 <_IO_2_1_stdout_+48>:  0x0000555555756260  0x0000555555756260
0x7ffff7dd07a0 <_IO_2_1_stdout_+64>:  0x0000555555756660  0x0000000000000000
0x7ffff7dd07b0 <_IO_2_1_stdout_+80>:  0x0000000000000000  0x0000000000000000
0x7ffff7dd07c0 <_IO_2_1_stdout_+96>:  0x0000000000000000  0x00007ffff7dcfa00
0x7ffff7dd07d0 <_IO_2_1_stdout_+112>: 0x0000000000000001  0xffffffffffffffff
0x7ffff7dd07e0 <_IO_2_1_stdout_+128>: 0x0000000000000000  0x00007ffff7dd18c0
0x7ffff7dd07f0 <_IO_2_1_stdout_+144>: 0xffffffffffffffff  0x0000000000000000
0x7ffff7dd0800 <_IO_2_1_stdout_+160>: 0x00007ffff7dcf8c0  0x0000000000000000
0x7ffff7dd0810 <_IO_2_1_stdout_+176>: 0x0000000000000000  0x0000000000000000
0x7ffff7dd0820 <_IO_2_1_stdout_+192>: 0x00000000ffffffff  0x0000000000000000
0x7ffff7dd0830 <_IO_2_1_stdout_+208>: 0x0000000000000000  0x00007ffff7dcc2a0
0x7ffff7dd0840 <stderr>:  0x00007ffff7dd0680  0x00007ffff7dd0760
0x7ffff7dd0850 <stdin>:   0x00007ffff7dcfa00  0x00007ffff7a05eb0
pwndbg> x/1s 0x0000555555756260
0x555555756260: "Hello, World!\n"

したがって、次に_IO_write_ptrを14進めると、次にstdoutがflushされるときにまだ"Hello, World!\n"を出力していないと勘違いしてもう一度出力することになります。 今回の場合は次に"Hi.\n"を出力しようとしますので、これが_IO_write_ptrの指す先に書き込まれ、_IO_write_ptr_IO_write_baseより18進んだ状態で最後に一気に"Hello, World!\nHi.\n"が出力されます。

pwndbg> x/32xg 0x7ffff7dd0760
0x7ffff7dd0760 <_IO_2_1_stdout_>: 0x00000000fbad2a84  0x0000555555756260
0x7ffff7dd0770 <_IO_2_1_stdout_+16>:  0x0000555555756260  0x0000555555756260
0x7ffff7dd0780 <_IO_2_1_stdout_+32>:  0x0000555555756260  0x0000555555756260
0x7ffff7dd0790 <_IO_2_1_stdout_+48>:  0x0000555555756260  0x0000555555756260
0x7ffff7dd07a0 <_IO_2_1_stdout_+64>:  0x0000555555756660  0x0000000000000000
0x7ffff7dd07b0 <_IO_2_1_stdout_+80>:  0x0000000000000000  0x0000000000000000
0x7ffff7dd07c0 <_IO_2_1_stdout_+96>:  0x0000000000000000  0x00007ffff7dcfa00
0x7ffff7dd07d0 <_IO_2_1_stdout_+112>: 0x0000000000000001  0xffffffffffffffff
0x7ffff7dd07e0 <_IO_2_1_stdout_+128>: 0x0000000000000000  0x00007ffff7dd18c0
0x7ffff7dd07f0 <_IO_2_1_stdout_+144>: 0xffffffffffffffff  0x0000000000000000
0x7ffff7dd0800 <_IO_2_1_stdout_+160>: 0x00007ffff7dcf8c0  0x0000000000000000
0x7ffff7dd0810 <_IO_2_1_stdout_+176>: 0x0000000000000000  0x0000000000000000
0x7ffff7dd0820 <_IO_2_1_stdout_+192>: 0x00000000ffffffff  0x0000000000000000
0x7ffff7dd0830 <_IO_2_1_stdout_+208>: 0x0000000000000000  0x00007ffff7dcc2a0
0x7ffff7dd0840 <stderr>:  0x00007ffff7dd0680  0x00007ffff7dd0760
0x7ffff7dd0850 <stdin>:   0x00007ffff7dcfa00  0x00007ffff7a05eb0
pwndbg> x/1s 0x0000555555756260
0x555555756260: "Hello, World!\nHi.\n"

ということで、結果は次のようになります。

$ ./a.out 
Hello, World!
Hello, World!
Hi.

ちなみに_IO_write_baseの方を前に戻すと以降出力してくれなくなるので、_IO_write_ptrを先に進めるように注意しましょう。

さて、このバッファがどこにあるかというと、ヒープ上に確保されています。

pwndbg> x/32xg 0x7ffff7dd0760
0x7ffff7dd0760 <_IO_2_1_stdout_>: 0x00000000fbad2a84  0x0000555555756260
0x7ffff7dd0770 <_IO_2_1_stdout_+16>:  0x0000555555756260  0x0000555555756260
...
pwndbg> vmmap
...
    0x555555756000     0x555555777000 rw-p    21000 0      [heap]
...

したがって、ヒープ系問題で考えられるケースは次のような場合でしょう。

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

int main()
{
  FILE *fp = stdout;
  printf("Hello, World!\n");
  
  char *ptr1 = malloc(0x500);
  char *ptr2 = malloc(0x10); // dummy
  strcpy(ptr1, "YYYYYYYY: 1st Big Chunk Allocated!!!");
  free(ptr1);

  printf("_IO_write_ptr = %p\n", fp->_IO_write_ptr);
  printf("ptr1 = %p\n", ptr1);
  
  fp->_IO_write_ptr += 0x1000;
  
  printf("Hi.\n");
  return 0;
}

あらかじめ大きなチャンクをfreeしておくことでヒープ上にmain_arenaへのポインタを用意しておき、その後double free等で_IO_write_ptrの下位2バイトくらい書き換えてヒープの内容を大量に出力させます。 PIEが有効な場合は知りませんが、PIEが無効ならstderrやstdoutという変数がアドレス固定で存在し、そいつらは_IO_2_1_stderr__IO_2_1_stdout_を指しているため、tcache poisoning等で

tcache --> chunkX --> stderr --> _IO_2_1_stderr_

のようなリンクが作れるので、_IO_write_ptrを書き換えることができます。

$ ./a.out 
Hello, World!
_IO_write_ptr = 0x5624c074a260
ptr1 = 0x5624c074a670
ptr1 = 0x5624c074a670
c074a260
�L�"P�L�"Pd!!! qHi.
$ ./a.out | hexdump -C | less
00000000  48 65 6c 6c 6f 2c 20 57  6f 72 6c 64 21 0a 5f 49  |Hello, World!._I|
00000010  4f 5f 77 72 69 74 65 5f  70 74 72 20 3d 20 30 78  |O_write_ptr = 0x|
00000020  35 35 61 39 30 35 65 62  32 32 36 65 0a 70 74 72  |55a905eb226e.ptr|
00000030  31 20 3d 20 30 78 35 35  61 39 30 35 65 62 33 32  |1 = 0x55a905eb32|
00000040  37 30 0a 00 00 00 00 00  00 00 00 00 00 00 00 00  |70..............|
00000050  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00001000  00 00 00 00 00 00 00 00  11 05 00 00 00 00 00 00  |................|
00001010  a0 1c c2 5f 4e 7f 00 00  a0 1c c2 5f 4e 7f 00 00  |..._N......_N...| <-- ここでlibcのアドレスをゲット
00001020  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00001030  64 21 21 21 00 00 00 00  00 00 00 00 00 00 00 00  |d!!!............|
00001040  00 00 00 48 69 2e 0a                              |...Hi..|
00001047

これでShow機能の無いヒープ系問題も怖くありませんね。(解けるとは言っていない。)

参考文献

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