技術約7分で読めます

php2shellのUAFを突くCTF「Steal My Bitcoin」の公開PoCを読んだ

いけさん目次

TL;DR

対象 KillaBoiが公開したCTFの検証コード StealMyBitcoin-PoC。標的はPHP 8.4.26で、php2shell(CVE-2026-109618/109619)のUAFを突いた単一ファイルのPythonスクリプト

中身 メモリ上限到達による中途半端な状態の残留、セッションクッキーからのヒープ・コードアドレス特定、偽のHashTableによる後片付け時の zif_system 呼び出しまで、一続きの流れを外部ライブラリなしで再現

位置づけ パッチ前の8.4.26で攻撃側の連鎖が成立する条件を示す教材。修正版(8.4.27など)を適用した環境や、設定を無効化した環境では途中で遮断される


PHPのファイルアップロード進捗記録(session.upload_progress)に潜むUse-After-Free(UAF、解放後メモリ使用)について、これまでは設定による回避や公式パッチの適用など、対策側を中心に追ってきた。

そんな中、パッチが当たる前のPHP 8.4.26に対して攻撃が最初から最後まで成立する様子を再現した検証コードがGitHubに上がっていた。KillaBoiという人物が解いたCTF「Steal My Bitcoin」の手順を、標準ライブラリだけのPython 1ファイルにまとめた StealMyBitcoin-PoC だ。

標的はPHP 8.4.26。php2shellが修正された8.4.27の一つ前で、脆弱性が残った最後の8.4系にあたる。競技のフラグはビットコインの秘密鍵で、鍵を取り出せれば賞金ウォレットを動かせるという設定になっていた。対策側から「どう防ぐか」を見ていたUAFが、攻撃側から一続きに通る手順としてどう組み立てられているのか、コードを追ってみた。

UAFからコマンド実行までの流れ

PoCが狙うのは、進捗記録の処理で発生するUAF(CVE-2026-109618/109619)を起点に、PHPプロセスの中で任意のコマンドを1回実行させることだ。

flowchart TD
    A["稼働確認<br/>PHP 8.4.26の応答を確認"] --> B["ヒープアドレスの漏えい<br/>メモリ上限(128MiB)到達で状態を残し<br/>使い回しワーカーのクッキーから読む"]
    B --> C["コードアドレスの漏えい<br/>同じ手順で内部関数の配置を特定"]
    C --> D["後片付けの乗っ取り<br/>偽のHashTableを配置し<br/>デストラクタをzif_systemへ向ける"]
    D --> E["system(コマンド)が実行される"]

全体の流れは大きく分けて次のステップで進む。

まず小さなアップロードを送り、PHPにメモリ上限(既定で128MiB)まで消費させる。進捗記録の処理の途中で上限に達するため、中途半端な解放状態を抱えたまま、リクエストを処理したPHPのワーカープロセスが後続のリクエストへと使い回される。

続けて細工したリクエストを送ると、その使い回しワーカーに残っていたメモリ上のデータの一部がセッションクッキー(Set-Cookie)に紛れて返ってくる。これを解析して、ヒープ(変数が置かれるメモリ領域)のアドレスを特定する。同じ手順をもう一度繰り返し、今度はPHP内部関数が置かれているコード側のアドレスを割り出す。

両方のアドレスが特定できたら、最後のリクエストでメモリ上に偽の内部構造を配置し、PHPの後片付け処理にコマンドを実行させる。

HashTableのデストラクタ呼び出しを利用したコマンド実行

PHPは変数の管理にHashTableというデータ構造を多用している。リクエスト終了時には、不要になったメモリを解放するために、構造体内に登録されたデストラクタ(後片付け用の関数ポインタ)を自動で呼び出す。

PoCは、割り出したヒープアドレスを使って「偽のHashTable」をメモリ上に配置し、その中にあるデストラクタの飛び先を、コードアドレスから計算した zif_system(PHPの system 関数の内部実装)の番地に向けておく。

PHPがリクエストを終えて正常に後片付けを走らせようとした瞬間に、偽の構造体に仕込まれた zif_system("実行したいコマンド") がPHP自身によって呼び出され、開いたままの接続越しにコマンドが実行される仕組みになっている。

アドレスの使い回しが効かない理由

この攻撃ではヒープとコードの両方のアドレスが必要になる。現在のOSはASLR(アドレス空間配置の無作為化)により、PHPのコードとヒープの配置をプロセスごとにランダムな番地へずらす。偽の構造体を置く場所はヒープのアドレスが決め、飛び先の zif_system の場所はコードのアドレスが決めるため、片方でも外れればメモリ不正で落ちるだけで攻撃は通らない。

作者の解説によると、過去の実行や別のワーカーから得たアドレスを使い回そうとすると毎回失敗したという。アドレスはプロセスが生存している間しか有効でなく、同じワーカープロセスが応答している間は同じ番地を指すが、別のワーカーに当たったりプロセスが再起動したりすれば別のアドレスになる。

動作に必要なビルド条件

このPoCに書かれている数値は、Alpine版のPHP 8.4.26を、組み込みのCLIサーバー(php -S)でワーカー4つで動かした環境に合わせて調整されている。

バージョン番号が同じでも、別ビルドに向けると途中で止まる。Debianの公式イメージ(php:8.4.26-fpm)でも PHP/8.4.26 という文字列は返るが、バイナリ内のシンボル配置が異なるため、コードアドレスの指紋(期待するバイトパターン)が一致せず、コード漏えいの段階で処理が中断した。PoC側にもこの指紋チェックが入っており、想定と異なるバイナリには攻撃を送らないようになっている。

動かすためには、Alpineビルドであること、実行方式(SAPI)が cli-server であること、そして使い回しワーカーを引くための複数ワーカー構成であることが前提になる。別環境で検証する場合は、オフセット値の再測定が必要になる。

検証環境での成功率

漏えいの各段階では、狙ったワーカーが後続リクエストを処理するかどうか、ほかのワーカーとの競争になる。1回の実行で確実に通るわけではなく、作者がDocker検証環境に対して10回連続で回した結果はこんな感じ。

結果回数
コマンド実行が確認できた7
最終段階まで進んだが、実行の痕跡が出なかった2
ヒープ漏えいが成立しなかった1

10回中7回成功している。同じコンテナが上がっている間はASLRがプロセス単位で適用されるため、特定ワーカーのアドレス自体は変わらない。実行ごとに成否が分かれるのは、どのワーカーがどのリクエストを引いたかによる。

失敗パターンは2通りある。1つはヒープ漏えいが取れずに偽構造の配置前に止まる場合。もう1つはコード漏えいまで通って最後のPOSTもHTTP 200で返るものの、偽のデストラクタが後片付けの経路で参照されず実行痕跡が出ない場合だ。どちらも自動再試行は入っておらず、失敗した場合は最初から回し直す作りになっている。

実証された実行結果と未達の点

作者が残したCTF本番の実行ログでは、正常なPHP 8.4.26の応答確認から始まり、メモリ上限エラーと256バイトのクッキーが返ったヒープ段階、16バイトのクッキーが返ったコード段階を経て、最後のPOSTレスポンス内にチェックサムの通ったWIF(ビットコイン秘密鍵の形式)が1つ出力されていた。PHPプロセス内でコマンドが実際に実行されたと判断できる記録だ。

一方で、成立しきらなかった点も作者自身が書き残している。

  • 送信したHTTPリクエスト全体やレスポンスヘッダーを生ログとしては保存しておらず、後から組み立て直したコードであること
  • 標的のPHPバイナリやフラグ出力コマンドを手元に持ち帰ったわけではなく、コマンド実行にとどめたこと
  • 取り出したWIFが、サイト上に表示されていた賞金ウォレットのアドレスと一致しなかったこと(出題側の不備であることがメールで確認されたという)

攻撃の連鎖自体は最後まで通ったものの、得られた鍵が出題側の設定不備で賞金アドレスと結びつかなかった、という顛末になっている。

過去記事の対策との関係

この攻撃が通るのは、php2shellが残ったままの8.4.26だからだ。

修正版(8.5.12、8.4.27、8.3.36、8.2.35、および8.1以前向けのパッチ)を当てた環境では、進捗記録のUAF自体が塞がれているため最初のステップが成立しない。

また、修正版を適用できない暫定対応として挙げた session.upload_progress.enabled=0 を設定しておくと、進捗記録の処理自体が走らなくなるため、最初のヒープ漏えいの段階で止まる。メモリ上限(128MiB)に達しない小さなアップロードしか来ない環境でも、このPoCの引き金は引けない。

攻撃側のコードを読むと、各対策が攻撃連鎖のどのステップを物理的に遮断しているかがはっきり見えてくる。