heap: tcache

Heapの基礎とtcache poisoning

Challenge

[Distribution File]

1
nc sc.skb.pw 49402

heapの概要

C/C++におけるheapはしばしばバグの原因となり、クリティカルな攻撃に繋がる可能性を大いに秘めています。 例えば2022年のCWE Top 25 Most Dangerous Software Weaknessでは、前年に続いて UAF: Use-After-Free が7位になっています。 C/C++でこのようなUAF/UAR(Use-After-Reallocation) が起こる主な原因は、C/C++が GC: Garbage Collection を持たないためです。 ほとんどのメモリ管理がプログラマに一任されるため、heap objectの生存期間やサイズを正しく管理していないことに起因するバグが発生してしまいます。 もちろんこれらのメモリバグへの対処もいたるところで研究・開発されています(eg: ChromeにおけるMiraclePtr)が、 未だにユニバーサルに利用することの出来る対策は知られていません。

heapはRealWorld/CTFの両方で、またuserland/kernelの両方で頻繁にexploitされます。 一方で、heapはライブラリやKernelの開発者によって頻繁にmitigationが実装される領域でもあります。 セキュリティと攻撃者のTHE・イタチごっこみたいな分野です。 したがって、heap exploitには「その仕組みに関する基礎的な理解」と「exploitの対策として実装し続けられるmitigation」の両方が必要となります。

本challengeではglibcで用いられるメモリアロケータである ptmalloc について扱います(厳密にはptmallocから「派生した」)。 最初から全ての機能を理解しようとすると日が暮れてしまうので、まずは tcache について考えます。 heapに触ったことがない人は、ぜひ実際に手を動かしつつ・メモリを読みつつ・GDBとお話しつつ読み進めてください。 たまにheap問題は難しそうで敬遠する人もいますが、個人的にはpwnの中でもパズル的面白さが分かりやすい有数の領域の一つだと思います。

challenge概要

challengeのソースコードの概要は以下のとおりです:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
struct User {
  unsigned long id;
  unsigned long age;
  char name[0x30];
};
struct User *users[NUM_USER];

int main(int argc, char *argv[]) {
  int choice, index;
  printf("[DEBUG: puts=%p]\n\n", puts);

  while (1 == 1) {
    printf("Read(0) / Write(1) / Delete(2) > ");
    scanf("%d", &choice);

    if (choice < 0 || choice > 2) {
      puts("\nBye!");
      return 0;
    }

    printf("User Index > ");
    scanf("%d", &index);
    if (index < 0 || index >= NUM_USER) {
      puts("Invalid User Index");
      return 1;
    }

    switch (choice) {
      case 0:  // READ
        if (users[index] == NULL) {
          puts("User not found");
          break;
        } else {
          printf("ID: 0x%lx\n", users[index]->id);
          printf("Age: %ld\n", users[index]->age);
          printf("Name: %s\n", users[index]->name);
        }
        break;
      case 1:  // WRITE
        if (users[index] == NULL) {
          users[index] = (struct User *)malloc(sizeof(struct User));
        }
        printf("ID > ");
        scanf("%ld", &users[index]->id);
        printf("Age > ");
        scanf("%ld", &users[index]->age);
        printf("Name > ");
        readn(users[index]->name, sizeof(struct User));
        break;
      case 2:  // DELETE
        if (users[index] == NULL) {
          puts("User not found");
          break;
        } else {
          free(users[index]);
          users[index] = NULL;
        }
        break;
    }
  }
}

まず最初にputs関数のアドレスを自ら教えてくれています。 続いて、ループの中でいくつかの機能を実行しています:

  • READ : users[index] のユーザ情報を表示
  • WRITE : users[index] のユーザ情報を入力
  • DELETE: users[index] のユーザ情報を削除

struct Userはユーザ情報としてID/年齢/名前を保持する構造体であり、malloc()によってheap上に確保されます。

heapはバージョンごとの差異が大きい領域であり、バージョンごとに使える攻撃が異なります。 バージョンが新しいほどexploitが難しくなる傾向にあり、今回は問題をシンプルにするために glibc-2.31 を使っています。 そのため、今回の配布バイナリは単体ではおそらく実行できません。以下に示す方法でローダとライブラリを指定してください:

1
2
3
patchelf --set-interpreter $(realpath ./ld-2.31.so) ./tcache
patchelf --set-rpath $(realpath .) ./tcache
LD_PRELOAD=$(realpath ./libc-2.31.so) ./tcache

プログラムを実行すると、以下のようなユーザ管理コンソールが開きます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ LD_PRELOAD=$(realpath ../build/libc-2.31.so) ./tcache
## User Management System ##

[DEBUG: puts=0x7f6ec0873420]

Read(0) / Write(1) / Delete(2) > 1
User Index > 0
ID > 0
Age > 32
Name > Jogh Marks
Read(0) / Write(1) / Delete(2) > 0
User Index > 0
ID: 0x0
Age: 32
Name: Jogh Marks
Read(0) / Write(1) / Delete(2) >

ソースを読みつつ、ひとしきり操作をしてみてください。

heapの大まかな構造

まずは何も考えずにしばらく実行して、heapがどうなっているかを見てみましょう。 以下では、WRITE操作のことを WRITE(<ID>, <AGE>, <NAME>) の用に表記します。 今回は WRITE(i, i, ('A'+i) * 0x20) という操作を5回ほどしたあとのheapを見てみます。

gefを使っている場合には、heapの一覧は chunks コマンドで見ることができます:

gef> chunks
Chunk(addr=0x55b9ddb6f000, size=0x290, flags=PREV_INUSE, fd=0x0, bk=0x0)
Chunk(addr=0x55b9ddb6f290, size=0x50, flags=PREV_INUSE, fd=0x0, bk=0x10)
Chunk(addr=0x55b9ddb6f2e0, size=0x50, flags=PREV_INUSE, fd=0x1, bk=0x10)
Chunk(addr=0x55b9ddb6f330, size=0x50, flags=PREV_INUSE, fd=0x2, bk=0x10)
Chunk(addr=0x55b9ddb6f380, size=0x50, flags=PREV_INUSE, fd=0x3, bk=0x10)
Chunk(addr=0x55b9ddb6f3d0, size=0x50, flags=PREV_INUSE, fd=0x4, bk=0x10)
Chunk(addr=0x55b9ddb6f420, size=0x20be0, flags=PREV_INUSE, fd=0x0, bk=0x0, fd_nextsize=0x0, bk_nextsize=0x0)  <-  top chunk

アドレス0x55b9ddb6f290から数バイトを表示してみましょう(#####は表示されません):

gef> x/54gx 0x55b9ddb6f290
##########################################################
0x55b9ddb6f290: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f2a0: 0x0000000000000000      0x0000000000000010
0x55b9ddb6f2b0: 0x4141414141414141      0x4141414141414141
0x55b9ddb6f2c0: 0x4141414141414141      0x4141414141414141
0x55b9ddb6f2d0: 0x0000000000000000      0x0000000000000000
##########################################################
0x55b9ddb6f2e0: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f2f0: 0x0000000000000001      0x0000000000000010
0x55b9ddb6f300: 0x4242424242424242      0x4242424242424242
0x55b9ddb6f310: 0x4242424242424242      0x4242424242424242
0x55b9ddb6f320: 0x0000000000000000      0x0000000000000000
##########################################################
0x55b9ddb6f330: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f340: 0x0000000000000002      0x0000000000000010
0x55b9ddb6f350: 0x4343434343434343      0x4343434343434343
0x55b9ddb6f360: 0x4343434343434343      0x4343434343434343
0x55b9ddb6f370: 0x0000000000000000      0x0000000000000000
##########################################################
0x55b9ddb6f380: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f390: 0x0000000000000003      0x0000000000000010
0x55b9ddb6f3a0: 0x4444444444444444      0x4444444444444444
0x55b9ddb6f3b0: 0x4444444444444444      0x4444444444444444
0x55b9ddb6f3c0: 0x0000000000000000      0x0000000000000000
##########################################################
0x55b9ddb6f3d0: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f3e0: 0x0000000000000004      0x0000000000000010
0x55b9ddb6f3f0: 0x4545454545454545      0x4545454545454545
0x55b9ddb6f400: 0x4545454545454545      0x4545454545454545
0x55b9ddb6f410: 0x0000000000000000      0x0000000000000000
##########################################################
0x55b9ddb6f420: 0x0000000000000000      0x0000000000020be1
0x55b9ddb6f430: 0x0000000000000000      0x0000000000000000

この ######## で囲まれた部分のことを、 Chunk と呼ぶことにします。 現在見えているものは、大きさが0x50のchunkです。この0x50は、sizeof(struct User)から決定されます。 厳密にはsizeof(struct User) == 0x40ですが、malloc()ではユーザにリクエストされたサイズに対してメタデータとしての0x10byteを加算します。

この文章で説明した内容をコードベースで追ってみましょう。対象のglibcはバージョン2.31です。

まず、chunkに該当する構造体は struct malloc_chunk (/malloc/malloc.c)です

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct malloc_chunk {

  INTERNAL_SIZE_T      mchunk_prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      mchunk_size;       /* Size in bytes, including overhead. */

  struct malloc_chunk* fd;         /* double links -- used only if free. */
  struct malloc_chunk* bk;

  /* Only used for large blocks: pointer to next larger size.  */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};
typedef struct malloc_chunk* mchunkptr;

mchunk_size(以下size)は、chunkの大きさを表します。 但し、chunkは必ず8byteアラインされるため、下3bitは必ず0になってしまいます。 それではもったいないため、下3bitはchunk管理用のフラグとして使われます(TBD)。 fd/bkfd_nextsize/bk_nextsizeもchunk管理用のフィールドですが、今は置いておきます。

これらのフィールドは基本的にfree()されたchunkを管理するためのものであり、 malloc()されて利用中のchunkではprev_size/size以外は使いません。 malloc()で返されたchunkでは、prev_size/size以外の領域を自由に使うことができます:

1
2
3
4
5
6
7
8
-------------------
prev_size | size
------------------- <= malloc()で返されるアドレス
User Data
...
...
...
-------------------

heapがめんどくさいのは、free()された後です。heapはfree()するとキャッシングされます。 これは、プログラムが普遍的に持つ性質として「同じサイズのchunkをすぐに再度malloc()することが多い」ためです。

glibcのアロケータはfree()されたchunkを bins と呼ばれるリストにキャッシュします。 binsには以下の種類があります (以下のサイズには、メタデータ用の0x10byte分を含めています) (?は自信ない・-は存在しないことを表します):

min sizemax sizegranularitymax numlist
smallbin0x200x3F00x10double-linked
largebin0x400★1double-linked
fastbin0x200xB0?0x10?single-linked
unsortedbin----double-linked
tcache0x200x4100x107single-linked

各binはchunkをリスト構造で管理しています。 malloc()でchunkが要求されたときには、このリストから適切なものを選択してリストから外しユーザに提供します。 各キャッシュの用途と特徴を説明すると、以下のようになります:

  • tcache: スレッドごとに用意されているため早い。binに入るchunkの個数上限が決まっている。
  • fastbin: 隣接するchunkとマージ( consolidate )されることがない一匹狼。よって単純なLIFOでchunkを取得できて早い。
  • smallbin: 他のchunkとマージされたりする。かと思えば、分割されてtcacheに入ったりもする。
  • largebin: でかい。とにかくでかい。
  • unsortedbin: tcacheかfastbinに入れられないchunkは取り敢えずこいつに入れておいて、あとでsmallbin/largebinに入れる。

定義の話ばかりになってしまったので、GDBで実際に確認してみましょう。

先程のheapの状態から、1番目と3番目のchunkをfree()してみます。 binsの様子はgefbinsコマンドで確認できます:

gef> bins
---------------------------------------------------------- Tcachebins for arena '*0x7f70d8a91b80' ----------------------------------------------------------
[+] Tcachebins[idx=3, size=0x50, @0x55b9ddb6f0a8] count=2
 ->  Chunk(addr=0x55b9ddb6f380, size=0x50, flags=PREV_INUSE, fd=0x55b9ddb6f2f0, bk=0x55b9ddb6f010)
 ->  Chunk(addr=0x55b9ddb6f2e0, size=0x50, flags=PREV_INUSE, fd=0x0, bk=0x55b9ddb6f010)
[+] Found 2 chunks in tcache.
----------------------------------------------------------- Fastbins for arena '*0x7f70d8a91b80' -----------------------------------------------------------
[+] Found 0 chunks in fastbin.
--------------------------------------------------------- Unsorted Bin for arena '*0x7f70d8a91b80' ---------------------------------------------------------
[+] Found 0 chunks in unsorted bin.
---------------------------------------------------------- Small Bins for arena '*0x7f70d8a91b80' ----------------------------------------------------------
[+] Found 0 chunks in 0 small non-empty bins.
---------------------------------------------------------- Large Bins for arena '*0x7f70d8a91b80' ----------------------------------------------------------
[+] Found 0 chunks in 0 large non-empty bins.

どちらもtcacheに入りましたね。 これは、今回使っているchunkのサイズが0x50(< 0x410)であるためです。 メモリをダンプしてみるとこんな感じです:

gef> x/100gx 0x55b9ddb6f290
##############################################################
0x55b9ddb6f290: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f2a0: 0x0000000000000000      0x0000000000000010
0x55b9ddb6f2b0: 0x4141414141414141      0x4141414141414141
0x55b9ddb6f2c0: 0x4141414141414141      0x4141414141414141
0x55b9ddb6f2d0: 0x0000000000000000      0x0000000000000000
#### A: freeしたchunk #########################################
0x55b9ddb6f2e0: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f2f0: 0x0000000000000000      0x000055b9ddb6f010
0x55b9ddb6f300: 0x4242424242424242      0x4242424242424242
0x55b9ddb6f310: 0x4242424242424242      0x4242424242424242
0x55b9ddb6f320: 0x0000000000000000      0x0000000000000000
##########################################################
0x55b9ddb6f330: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f340: 0x0000000000000002      0x0000000000000010
0x55b9ddb6f350: 0x4343434343434343      0x4343434343434343
0x55b9ddb6f360: 0x4343434343434343      0x4343434343434343
0x55b9ddb6f370: 0x0000000000000000      0x0000000000000000
#### B: freeしたchunk #########################################
0x55b9ddb6f380: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f390: 0x000055b9ddb6f2f0      0x000055b9ddb6f010
0x55b9ddb6f3a0: 0x4444444444444444      0x4444444444444444
0x55b9ddb6f3b0: 0x4444444444444444      0x4444444444444444
0x55b9ddb6f3c0: 0x0000000000000000      0x0000000000000000
##############################################################
0x55b9ddb6f3d0: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f3e0: 0x0000000000000004      0x0000000000000010
0x55b9ddb6f3f0: 0x4545454545454545      0x4545454545454545
0x55b9ddb6f400: 0x4545454545454545      0x4545454545454545
0x55b9ddb6f410: 0x0000000000000000      0x0000000000000000
##############################################################
0x55b9ddb6f420: 0x0000000000000000      0x0000000000020be1

tcacheはfree()したchunkをリストの根本に挿入します。 その際chunkfdを、挿入前にリストの根本にあったchunkのアドレスに書き換えます。 今回は1 -> 3の順でfreeしたため、Chunk Bが根本に入っています。 確かにChunk Bfdに該当する部分には、Chunk Aのアドレス0x000055b9ddb6f2f0が書き込まれていますね:

#### B: freeしたchunk #########################################
0x55b9ddb6f380: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f390: 0x000055b9ddb6f2f0 =fd  0x000055b9ddb6f010
0x55b9ddb6f3a0: 0x4444444444444444      0x4444444444444444
0x55b9ddb6f3b0: 0x4444444444444444      0x4444444444444444
0x55b9ddb6f3c0: 0x0000000000000000      0x0000000000000000

また、tcacheにchunkが繋がっている状態で同じサイズのmalloc()が呼ばれると、 リストの根本からchunkを取り出してユーザに返します。 上記のheapの状態で新たなユーザを作ってみると以下のようになります:

gef> bins
---------------------------------------------------------- Tcachebins for arena '*0x7f70d8a91b80' ----------------------------------------------------------
[+] Tcachebins[idx=3, size=0x50, @0x55b9ddb6f0a8] count=1
 ->  Chunk(addr=0x55b9ddb6f2e0, size=0x50, flags=PREV_INUSE, fd=0x0, bk=0x55b9ddb6f010)
[+] Found 1 chunks in tcache.

Chunk Bが根本から取り除かれ、Chunk Aが根本になっていることが分かります。

tcache poisoning

さて、tcacheを含めたbinsはリスト構造でchunkを管理しています。 これらのリストはfd/bkによって互いに繋がっています(single-linked listの場合はfdのみ)。

ということはfree済みchunkのfdを書き換えてしまうと、このリストは不正な場所に対して繋がってしまうはずです。 さらにその状態でmalloc()をしてリストからchunkを取得すると、不正な場所に対してchunkが配置されユーザに提供されます。

このようなリストの書き換えをとりわけtcacheに行い、不正なアドレスにchunkを配置することを tcache poisoning と呼びます。 tcache poisoningでは、確保したオブジェクトを中身をユーザが読み書きできるかによって得られるプリミティブが変わります。 この問題のケースでは、ユーザがオブジェクト(struct User)の中身を自由に読み書きできるため、 tcache poisoningで配置した不正なオブジェクトによってRWプリミティブが得られます。

tcache poisoningの手順

ここからは再びchallengeに沿って実際のtcache poisoningの手順を見ていきます。

まず、本challengeの脆弱性は2つあります。1つ目はUser.nameの入力にあります:

1
        readn(users[index]->name, sizeof(struct User));

誤ってnameのサイズではなくstruct Userのサイズを指定しているため、 name以外のフィールドのサイズ分の0x10byteだけオーバーフローできます。 2つ目はreadnの実装にあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int readn(char *buf, int max) {
  int n = 0;
  char c;

  while (n++ < max) {
    read(0, &c, 1);
    if (c == '\n') break;
    *buf++ = c;
  }

  *buf = '\x00';
  return n;
}

max文字だけ入力された場合、1byte文だけ\x00(NULL文字)が溢れてしまいますね。 オーバーフローの中でも、このようなNULL Terminationに起因するものを NULL-byte Overflow と呼びます(呼ばないかも)。

例として、以下のようなheapの状態を考えます:

##############################################################
0x55b9ddb6f290: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f2a0: 0x0000000000000000      0x0000000000000010
0x55b9ddb6f2b0: 0x4141414141414141      0x4141414141414141
0x55b9ddb6f2c0: 0x4141414141414141      0x4141414141414141
0x55b9ddb6f2d0: 0x4141414141414141      0x4141414141414141
##############################################################
0x55b9ddb6f2e0: 0x0000000000000000      0x0000000000000051 =size  <== ここまで自由にオーバーフローできる
0x55b9ddb6f2f0: 0x000055b9ddb6f3e0 =fd  0x000055b9ddb6f010 =bk
0x55b9ddb6f300: 0x4242424242424242      0x4242424242424242
0x55b9ddb6f310: 0x4242424242424242      0x4242424242424242
0x55b9ddb6f320: 0x0000000000000000      0x0000000000000000

この状態で、上にいるユーザのnameを最大限オーバーフローすると、下にいるユーザのsizeまでを自由に書き換えることができます。 また、NULL-byte overflowによって fdの下1byteをNULLにすることができます 。 たったの1byteで、かつNULLでしか書き換えられませんが、これはheap exploitにおいてとても大きいプリミティブになります。

現在tcacheが以下のようになっていると考えましょう:

(tcacheの根本) -> 0x55b9ddb6f2f0 -> 0x000055b9ddb6f3e0 -> some -> some

この場合、fdの下1byteをNULL-overflowすると以下のようになります:

(tcacheの根本) -> 0x55b9ddb6f2f0 -> 0x000055b9ddb6f300 -> ???
                                                    ^^

書き換えられたリストに繋がっている0x000055b9ddb6f300を見てみると、 これは 下のchunkのname部分を指すことになります 。 つまり、 name内に偽物のchunkを作ってやるとリストに任意のアドレスを繋げることができます

上の図では下のchunkのnameとしてB(0x42)を沢山入力してあります。 ここで、偽のchunkとしてnamep64(0) + p64(0x51) + p64(0xDEADBEEFCAFEBABE)を入力します:

##############################################################
0x55b9ddb6f290: 0x0000000000000000      0x0000000000000051
0x55b9ddb6f2a0: 0x0000000000000000      0x0000000000000010
0x55b9ddb6f2b0: 0x4141414141414141      0x4141414141414141
0x55b9ddb6f2c0: 0x4141414141414141      0x4141414141414141
0x55b9ddb6f2d0: 0x4141414141414141      0x4141414141414141
##############################################################
0x55b9ddb6f2e0: 0x0000000000000000      0x0000000000000051 =size
0x55b9ddb6f2f0: 0x000055b9ddb6f3e0 =fd  0x000055b9ddb6f010 =bk
0x55b9ddb6f300: 0x0000000000000000      0x0000000000000051    -- fake chunk -----
0x55b9ddb6f310: 0xDEADBEEFCAFEBABE      0x4242424242424242    fake fd | fake bk |
0x55b9ddb6f320: 0x4242424242424242      0x4242424242424242                      |
0x55b9ddb6f330: 0x0000000000000000      0x0000000000000000                      |
0x55b9ddb6f340: 0x0000000000000000      0x0000000000000000    -------------------

先程新しくtcacheに繋げられた0x000055b9ddb6f300を見てみると、新しいfake chunkを作り上げることができています。 このfake chunkのfdは、先程適当に決めた0xDEADBEEFCAFEBABEになっています。 よってtcache listは以下のとおりです:

(tcacheの根本) -> 0x55b9ddb6f2f0 -> 0x000055b9ddb6f300 -> 0xDEADBEEFCAFEBABE
                                                    ^^    ^^^^^^^^^^^^^^^^^^

任意のアドレスがtcacheに繋がりました。 この状態で3回malloc()してやれば、0xDEADBEEFCAFEBABEというアドレスにchunkが配置されます。 あとはユーザの情報を好きに入力してやれば、任意のアドレスに任意の値を書き込めるようになります(AAW)。

今は亡き__free_hook

さて、AAWが実現できました。 あとは何を書き換えるかですが、今回は最もシンプルな方法を使います。

glibcのメモリアロケータは、malloc()free()といった関数をフックできるように __malloc_hook / __free_hook という変数を用意しています(/malloc/malloc.c):

1
2
void *weak_variable (*__malloc_hook)
  (size_t __size, const void *) = malloc_hook_ini;

この関数ポインタは、_int_malloc()_int_free()の先頭で呼ばれます(/malloc/malloc.c):

1
2
3
4
  void *(*hook) (size_t, const void *)
    = atomic_forced_read (__malloc_hook);
  if (__builtin_expect (hook != NULL, 0))
    return (*hook)(bytes, RETURN_ADDRESS (0));

よって、この変数を書き換えて任意のアドレスに設定することで、malloc()free()の呼び出しをトリガーとして任意のアドレスにRIPを飛ばすことができます。


以上でtcache poisoningを使って本Challengeを解くことが出来るかと思います。 ぜひリモートサーバでflagを取得してみてください。


Exercise

1. tcache2hook

[Distribution File]

1
nc sc.skb.pw 49402
Last modified November 15, 2023: add warning about challenge server (55ad5da)