
PHP2Shellの詳細な技術解説:メモリ不足によるクラッシュとuse-after-freeという2つのPHPコアCVEを連鎖させ、ヒープグルーミング、ASLRリーク、リモートコード実行へと至る手法を解説します。

この研究は2026年10月26日に書き始めたもので、いつ完成するかは正直なところまだ分かっていません。
執筆時点では、PHP2Shellに関する情報はまだ非常に少なく、特に脆弱性の詳細な技術分析や悪用プロセスに関する情報はほとんど存在していません。
正直なところ、表面をなぞるだけで内部で実際に何が起きているのかを説明していない解説を見つけるのに疲れていました。そこで、何か違うことをしたいと考えました。それは、プロセス全体を探求し、重要な詳細を一つひとつ検証し、何が起きるかだけでなく、なぜ、どのように起きるのかを説明する、きちんとした詳細な技術解説です。
この研究は、PHPのソースコードに関する私自身の理解、実験、そしてこれらの脆弱性を理解しようとする中で行った調査に基づいています。これらの断片をすべてまとめ、自分の発見を共有し、根底にある概念のいくつかを他の人にとって少しでも分かりやすくできればと思っています。
主な目標の一つは、技術的な深さを犠牲にせずに、この内容を理解しやすくすることでした。C言語の基礎知識と一般的なプログラミング経験があれば、低レベルのメモリ悪用の経験がなくても読み進められるようにしたいと考えています。
また、現在あまり説明されていない、あるいは誤解されている事柄のいくつかを、この研究が明らかにする助けになればとも思っています。ただし、正直に言っておきたいのは、これは私自身による脆弱性の技術的理解であり、公式な分析でも絶対的な信頼に足るリファレンスでもないということです。私自身まだ学び、調査し、点と点をつなげている途中です。途中で誤りや不正確さ、誤解があるかもしれません。
もし何か誤りに気づいたり、異なる解釈をお持ちだったり、さらに調査する価値があると思われる点があれば、ぜひ私にご連絡ください。この研究をより正確で、誰にとっても役立つものにする助けとなる訂正や議論、貢献を心から歓迎します。
結局のところ、これを共有する目的は、共に学び、より多くの人が内部で実際に何が起きているのかを理解できるようにすることなのです。
基本はもうご存じですか?興味のある部分へ直接ジャンプしてください:
ヒープエクスプロイトが初めての方、あるいは全体像を体験したい方は、そのまま読み進めてください。これは最初から最後まで通読できるように書きました。
本題に入る前に、誰もが心に留めておくべきことがいくつかあります。これらは研究を進めるにつれて特に役立つようになります。先ほども述べたように、このウォークスルーは誰にでも理解できるものにしたいので、より基礎的な概念についても時間をかけて説明します。すでにご存じの方は、読み飛ばしていただいて構いません!
典型的なプログラムでは、メモリはスタックやヒープを含むいくつかの異なる領域に整理されています。
たとえば、C++ では次のようになります。
int main() {
int a = 30; // Typically stored on the stack
int* b = new int(30); // b is typically on the stack,
// but the integer it points to is on the heap
}
ここで、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つは、元のオブジェクトにもはや属さないデータを読み取ること、そして古いポインタを通じて書き込むことで新しいオブジェクトを破壊することです。
ユーザーアカウント用のメモリを割り当てる場面を想像してみましょう:
struct User {
char username[24];
int is_admin;
};
int main() {
// 1. Allocate memory for a user
User* a = (User*)malloc(sizeof(User));
// Initialize the original user
strcpy(a->username, "alice");
a->is_admin = 0;
// We keep a copy of the pointer to demonstrate the bug
User* a_stale = a;
// 2. Free the memory. The object is gone, but a_stale still holds the address!
free(a);
ここで、この直後に、プログラムが別の場所で別の割り当てを行い、アロケータが同じアドレスを再利用するとします:
User* b = (User*)malloc(sizeof(User));
strcpy(b->username, "jack");
b->is_admin = 1;
つまり、b は今や a が以前存在していたまさにその場所に存在しています。しかし、a を解放したため a_stale は依然としてそこを指しており、a_stale を更新するものは何もありませんでした。
そのため、依然として a_stale を使用しているコードは、Alice のデータを見ていると考えていますが、実際には Jack のデータにアクセスしている可能性があります:
if (a_stale->is_admin) {
// This may run, even though Alice was never an admin
printf("Welcome admin %s\n", a_stale->username);
}
これにより、本来見てはいけないデータ(Jackのユーザー名など)にアクセスできる可能性があり、プログラムが誤った認可判断を下すかもしれません。
技術的には、解放済みメモリへのアクセスはC/C++における「未定義動作」であるため、コンパイラやシステムによって正確な結果は保証されません。しかし、これは根本的な問題を明らかにしています。
ただし、現実にはそれほど単純ではありません。おそらくこう思っているでしょう:「そうか、これはいいね、でも……」
これが機能するには、まず最初に古いポインタ(stale pointer)が必要です。実際の攻撃では、被害者のプログラムを書いているわけではないので、a_stale でやったように意図的に変数へのポインタを保存することはできません。バグはすでにコード内に存在していなければなりません……
攻撃者が影響を与えられるのは、その解放済みメモリスロットに何が入るかだけです。
今のところは、これを深く理解しておくことだけを心がけてください。なぜなら、その問いへの答えが、これから議論するPHP CVEの最も重要な部分になるからです。
次に逆のケースを想像してみましょう。プログラムが、メモリが解放された後も古いポインタを使い続けて書き込みを行う場合です。
#include <cstdlib>
#include <cstdio>
int main() {
int* a = (int*)malloc(sizeof(int));
*a = 42;
int* stale = a; // Keep another pointer to the allocation
free(a);
// Another allocation may reuse the same address
int* b = (int*)malloc(sizeof(int));
if (b != nullptr) {
*b = 100;
// BUG: stale still holds the old address.
// Writing through it is undefined behavior.
*stale = 999;