乗っ取りは「コードが動く前」に始まる

画像: www.php.net より
2026 年 10 月 21 日、PHP の公式ページは二つの Critical 脆弱性を同時に告知した。修正版は 8.5.12、8.4.27、8.3.36、8.2.35。影響範囲として挙がっていたのは、PHP-FPM、Apache の mod_php、php-cgi、FrankenPHP、LiteSpeed だ。しかも公式の説明には、mod_php では「アプリケーションコードが動く前に、認証なしのリクエストで攻撃が成立する」とある。ここがまず異様だ。普通の Web 攻撃は、ログイン画面、ルーティング、ミドルウェア、フレームワークのどこかを通ってから問題になる。だが今回の話は、その入口より前にある。
PHP2Shell という呼び名が広がったのは、二つの脆弱性をまとめて指す文脈だった。片方は CVE-2026-109618、もう片方は CVE-2026-109619。どちらも Critical で、CVSS v4.0 は 9.2。しかも「どうせアプリ側の実装が悪いのだろう」と切り分けられない。Wiz の公開資料では、これは PHP 本体の欠陥であり、WordPress や Drupal や Joomla のような定番 CMS だけの問題ではないとされている。極端に言えば、Hello World のような単純な PHP アプリでも巻き込まれる。
まず壊れるのは、アプリではなく内部状態
CVE-2026-109618 は、ひとことで言うと「メモリ不足への対処が途中で打ち切られた結果、未完成の内部データを正常なものとして扱ってしまう」問題だ。GitHub のセキュリティ告知では、PHP は OOM、つまり out-of-memory を扱うために longjmp() を使っていたと説明されている。これは、処理中の場所から抜け出してリクエストを中断する仕組みだ。ところが、ある文脈ではリクエスト用メモリがまだ完全に初期化されていないのに触れられる状態になる。その瞬間に割り当て失敗が起きると、PHP は「途中で抜けた」ことを十分に考慮できず、壊れた状態を残す。
問題は、その失敗が「その未完成なメモリと直接関係した割り当て」でなくても起きることだ。ある場所で先に崩れると、後片付けの段階で、まだ整っていない値を本物だと思って読んでしまう。公式の警告では、Apache の mod_php はリクエスト開始に失敗しても shutdown を無条件に呼ぶので、アプリケーションコードが一切走る前にこの不整合へ到達できる、とされていた。ここで重要なのは、攻撃者が「アプリの処理を壊す」のではなく、「PHP のリクエスト初期化そのものを壊す」点だ。
どのリクエストが危ないのか
この脆弱性の難しさは、単にメモリが足りなくなるだけでは成立しないところにある。GitHub の解説では、multipart の扱いが複数の入口になっていた。たとえば、宣言された Content-Length をうまく使えない multipart リクエストは、最初の post_max_size チェックをすり抜けて、decoded body data を供給し続けられる。また、multipart の各 part に入るヘッダー数に制限がなく、wire 上は小さく見えるのに PHP の内部表現では大きなメモリを食う。空のファイル入力は max_file_uploads に数えられないのに、メタデータ配列は普通に作られる。さらに、ネストした field name は、配列生成の仕事を何倍にも増やす。
つまり、表面上は「制限内」のリクエストでも、PHP の中では別のコスト構造で膨らむ。ここが怖い。memory_limit を越えたかどうかは、単なる容量チェックではなく、どの段階でどの形のメモリを確保するかに左右される。たとえば検証用スクリプトでは memory_limit=6M、session.upload_progress.enabled=1、session.upload_progress.cleanup=1 で再現条件が組まれていた。別の分析では memory_limit=8M と単一の prefork worker が条件として挙げられている。数字は小さいが、攻撃の本質は「低い値を設定しているから危ない」ではない。低い値が、未初期化状態のまま抜けるきっかけを作る。
もう一つの脆弱性は、前のリクエストの残り火を拾う
CVE-2026-109619 は、同じく深刻だが手触りが少し違う。こちらは stale global state、つまり「古いグローバル状態」が問題になる。PHP には error、session、object、extension といった状態があり、ワーカーはそれを保持したまま次のリクエストをさばく。その状態は、実体としては前のリクエストのメモリを指していることがある。前の処理が fatal error や OOM で途中終了すると、対応する reset が抜け落ちることがある。すると、ワーカーのグローバル変数だけが「もう解放されたメモリ」を見続ける。
この話で面白いのは、消費側が必ずしも fatal である必要がないことだ。未初期化の段階で、warning や deprecation のような軽い診断が古い state を読んでしまう。しかも error_reporting をいじるとか、@ を付けるとか、display_errors を切るとかでは防げない。見えなくする話ではなく、触る順番の話だからだ。PHP の公式告知では、session.upload_progress.enabled=0 を設定して全 worker を再起動する、fatal_error_backtraces=0 を使う、といった緩和策が並んでいたが、これはあくまで個別の攻撃経路を狭めるだけだと明記されている。
では、なぜ「memory_limit に触れた瞬間」なのか
ここまで読むと、ひとつの疑問が残る。なぜ攻撃者は memory_limit に触れさえすればいいのか。なぜ、そこがアプリ実行前の乗っ取りにつながるのか。実は、この問いの答えは「PHP が OOM をどう抜けるか」にある。だが、その核心はまだ話し切れていない。longjmp() を捨てたあと、どのように安全な停止へ切り替えたのか。soft OOM と hard OOM をどう分けたのか。memory_limit=-1 がなぜ緊急回避として最初に出てくるのか。そして、なぜ front-end 側の制御が必要でも、それだけでは足りないのか。
ここから先は、数字の意味がひっくり返る。memory_limit に触れた瞬間、何が残り、何が残ってはいけなかったのか。
👉 続き(核心はここから)は PaPoo で読めます: https://papoo.work/doc/3002df88dac3ced1