【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したときに増えます。上の例では、cmallocのときにカウンタが0になるため、dmallocのときには別のところから領域が確保されます。

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

abが統合され、0x840のチャンクがunsorted binに格納されています。それに伴いcのprev_sizeが書き換えられていることが確認できます。
3回目のfreeの後を見てみます。

gdb-peda$ x/4gx 0x0000555555559000+0x290
0x555555559290: 0x0000000000000000      0x0000000000020d71
0x5555555592a0: 0x00007ffff7fb7be0      0x00007ffff7fb7be0

ccの上にある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