はじめに
数カ月前から始めた鬼の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