php2shell脆弱性からゲームサーバーを守る:6時間パッチ対応ランブック
要点まとめ
実践解説:php2shell脆弱性(CVE-2026-109618 / CVE-2026-109619)からゲームサーバーを守る6時間パッチ対応手順。PHPランタイムの棚卸し、修正版へのアップグレード、ワーカー再起動、エッジでのリクエスト制限、侵害検出までを具体的なコード付きで網羅。
あなたのゲームのPHP層は、スタックの中で誰も見ていない部分だろう。コスメを販売するウェブストア。コミュニティマネージャーが使う管理パネル。2022年に書いて以来触っていないレガシーなリーダーボードエンドポイント。そういったところがphp2shell攻撃の攻撃面になります。
2026年10月21日、Calif.io、Wiz、OpenAI、Quarkslabは、CVE-2026-109618およびCVE-2026-109619として追跡される2件のPHPランタイム脆弱性を公開しました。総称して php2shell と呼ばれます。CVSS v4.0スコアは9.2です。これはPHP 5.4以降のすべてのPHPインストールに影響し、認証なしのリモートコード実行、データ漏えい、サービス拒否(DoS)を許します。Cloudflareは、公式公開前に、実顧客を標的としてペイロードを変異させる攻撃トラフィックを観測していました。
これはWordPressプラグインの問題ではありません。PHPランタイム自体のメモリ安全性バグです。フレームワーク、CMS、Composer依存関係を更新しても何も変わりません。PHPが少しでも含まれているゲームバックエンドを運用しているなら、このランブックはあなた向けです。
php2shellが実際に壊すもの
どちらのCVEも、リクエスト処理が異常系に入ったときにPHPがメモリをどう扱うかに起因します。通常、PHPはリクエスト用にメモリを確保し、使用し、リクエスト完了時に解放します。バグは、そのクリーンアップ経路が、そもそも有効になったことがない状態に対して実行されたときに現れます。
CVE-2026-109618:OOMによるメモリ破壊
PHPがリクエスト処理中にメモリ上限に達し、内部構造の初期化が完了する前に中断します。その後、クリーンアップルーチンはその未完成データを正規のものとして扱います。大きなリクエストボディ、深くネストされた入力、過大なアップロードなど、OOM状態を確実に引き起こせる攻撃者には、その未完成メモリの内容を制御する機会が生まれます。
ゲームバックエンドでの現実的なトリガーは明白です。サイズ上限なしでボディを受け付けるエンドポイントです。対戦結果の提出、スクリーンショットのアップロード、Modメタデータ、テレメトリのバッチなど。
CVE-2026-109619:リクエスト間で残る古いグローバル状態
致命的なエラーがリクエストを中断すると、PHPプロセスにはリクエスト終了時に解放されるメモリへの参照が残ります。同じワーカープロセスが次のリクエストを処理するとき、その未解決参照(ダングリング参照)をたどる可能性があります。それはデータ漏えいです。あるプレイヤーのセッションデータやDB資格情報が別のプレイヤーのレスポンスに混入します。そして条件が揃えばコード実行に至ります。
ゲームサーバーにとってこれはより厄介なバグです。PHP-FPMワーカーは設計上、長寿命だからです。汚染された1つのワーカーが、リサイクルされるまでに数百の後続リクエストを処理できます。pm.max_requests = 0(デフォルト、つまりリサイクルなし)で運用している場合、破損したワーカーがコンテナの寿命の間ずっと残り続ける可能性があります。
ステップ1:所有するすべてのPHPランタイムを棚卸しする
見つけていないものにパッチは当てられません。多くのスタジオはインシデントの最中に、3つのサービスがPHPを実行していて、そのうち1つが2年前に作られたベースイメージのPHP 8.1であることに気づきます。このスクリプトを何よりも先に実行してください。
#!/usr/bin/env bash
# php2shell exposure inventory (CVE-2026-109618 / CVE-2026-109619)
set -euo pipefail
declare -A FIXED=( [8.5]="8.5.12" [8.4]="8.4.27" [8.3]="8.3.36" [8.2]="8.2.35" )
SERVICES=("game-api" "web-store" "admin-panel" "forums")
for svc in "${SERVICES[@]}"; do
ver=$(docker compose exec -T "$svc" php -r 'echo PHP_VERSION;' 2>/dev/null | tr -d '[:space:]') || {
echo "SKIP $svc (no php binary)"; continue; }
branch="${ver%.*}" # "8.3.12" -> "8.3"
min="${FIXED[$branch]:-}"
if [[ -z "$min" ]]; then
echo "EOL $svc on $ver -> no upstream patch, upgrade required"
elif php -r 'exit(version_compare($argv[1], $argv[2], ">=") ? 0 : 1);' "$ver" "$min"; then
echo "OK $svc on $ver"
else
echo "VULN $svc on $ver (fixed in $min)"
fi
done
このスクリプトが意図的に行っていることが2つあります。EOLブランチを個別にフラグすることと、ホスト上の php -v がコンテナの実際のランタイムを反映していると決して仮定しないことです。Kubernetesの場合は、docker compose exec を kubectl exec に置き換え、サービス名ではなくdeploymentを繰り返し処理してください。
修正済みのアップストリーム版は 8.5.12、8.4.27、8.3.36、8.2.35 です。8.1、8.0、7.x、5.xはすべてEOLであり、修正が提供されることはありません。これはパッチではなくアップグレードです。
ステップ2:アプリケーションではなくランタイムにパッチを当てる
この種のインシデントで最初の1時間に最もよくある失敗パターンは、アプリケーションコードを更新し、再デプロイし、CIがグリーンになるのを見て、完了したと判断してしまうチームです。まだ完了していません。
- PHP-FPM と mod_php:ベースイメージを再ビルドするか、パッケージを更新します。Debian/Ubuntu:
apt-get install --only-upgrade php8.3-fpm php8.3-cli php8.3-common。Alpine:apk upgrade php83。実行中のコンテナ内部でphp -r 'echo PHP_VERSION;'を使って確認してください。ビルドホスト上では確認しないこと。 - FrankenPHP / php-cgi:これらはランタイムを同梱しているため、パッケージの更新ではなく、新しいリリースバイナリまたはベースイメージが必要です。
- マネージドホスティング:プロバイダーがPHPを管理している場合は、すぐにチケットを発行し、アドバイザリの参照情報を尋ねてください。「認識しています」を解決策として受け入れないでください。
- Dockerベースイメージ:タグではなくダイジェストで固定してください。
php:8.3-fpmは次回のpullで異なるビルドに解決される可能性があります。これはパッチ適用には良いですが、再現性には最悪です。バージョンを確認したらダイジェストを固定してください。
パッチ適用後、既存のワーカーをすべて停止してください。ローリング再起動で古いプロセスが共有ソケット上に残ると、脆弱なワーカーがトラフィックを処理し続けます。docker compose restart または kubectl rollout restart を実行し、プロセスの開始時刻が変わったことを確認してください。
ステップ3:待っている間に爆発半径を縮める
パッチ適用には時間がかかります。「把握した」から「すべての環境が再ビルドされる」までには時間差があります。その時間を使いましょう。
エッジでリクエストボディを上限で制限する
# Shrink the php2shell trigger surface
client_max_body_size 2m; # game APIs rarely need more than this
client_body_timeout 10s;
client_header_timeout 10s;
large_client_header_buffers 4 8k;
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php-fpm:9000;
fastcgi_read_timeout 15s;
fastcgi_buffering on;
}
2MBの上限では、執拗な攻撃者を止められません。多数の小さなリクエストでOOMを誘発できるからです。しかし、単純な1リクエストでのトリガーを排除でき、コストはゼロです。
ワーカーを積極的にリサイクルする
; /usr/local/etc/php-fpm.d/zz-php2shell.conf
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 20
pm.max_requests = 500
pm.max_children = 24
request_terminate_timeout = 20s
ここでトレードオフを計算します。24ワーカーで毎秒200リクエストを処理する場合、各ワーカーはおよそ8.3 req/sを処理します。pm.max_requests = 500 では、すべてのワーカーが約60秒ごとにリサイクルされます。これによりCVE-2026-109619で破損したプロセスがトラフィックを処理できる時間は制限されますが、毎分24回のプロセス生成が発生します。コールドスタートが重いフレームワークでは測定可能なオーバーヘッドです。
memory_limit を128Mから256Mに上げるとOOMの発生頻度は下がります(CVE-2026-109618のトリガーが減る)が、最悪時のメモリは2倍になります。24子プロセス × 256M = ノードに必要な6GBの余裕です。ノードが4GBしかないなら、メモリがあるかのように振る舞わず、pm.max_children を下げてください。
これはどれも修正ではありません。 数時間の猶予を買うだけです。トリアージとして扱ってください。
ステップ4:すでに攻撃を受けたかどうかを検出する
公開イコール最初の悪用ではありません。公式公開日より前に攻撃トラフィックが観測されています。すでに探知された(プローブされた)前提で行動してください。
# 1. OOM aborts — strongest single signal for CVE-2026-109618
grep -c "Allowed memory size" /var/log/php-fpm/error.log
# 2. Worker crashes — signal for both CVEs
journalctl -u php8.3-fpm --since "48 hours ago" \
| grep -Ei "segfault|SIGSEGV|exited on signal 11"
# 3. Which paths are generating 500s
awk '$9 == 500 {print $7}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20
# 4. Same source IP hitting the same 500-ing path repeatedly
awk '$9 == 500 {print $1, $7}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20
まずベースラインを確認します。PHP-FPMログに、メモリを大量消費するエクスポートエンドポイント由来のOOM中断がすでに1日40回記録されているなら、その数字はノイズです。探すべきはレートの変化です。シグナル11の終了の急増、500リストに新しいパスが現れる、単一IPが1つのエンドポイントに対して何十もの失敗を生成する、などです。
悪用成功の証拠を見つけた場合は、完全な侵害として扱ってください。データベース資格情報、セッション署名キー、決済プロバイダーのシークレット、PHPプロセスから到達可能なAPIキーをすべてローテーションします。そのためのプレイブックはPHP固有ではありません。スタジオがゲームバックエンドを侵害に耐えるように設計するための規律と同じものがここにも当てはまります。そしてそれは、攻撃者が読み取りアクセスだけでなくコード実行を持っていたと想定することから始まります。
すでにバックエンドがリクエストを処理しきれず、負荷なのか攻撃なのか判断できない場合、このサーバーフリーズ診断プロトコルにあるクラッシュトリアージの順序が当てはまります。設定に触れる前に、障害が発生している層を切り分けてください。
リクエストのみのWAFルールが失敗した理由
Cloudflareの報告書には、このインシデント全体で最も応用できるエンジニアリングの教訓が含まれています。彼らの初期の保護はリクエストベースでしたが、攻撃者はペイロードを変異させることで応答しました。どのバリアントが一致し、どのバリアントがすり抜けるかを、一致/不一致のシグナルをオラクルとして使って調べたのです。
リクエストデータだけでは、エクスプロイトトラフィックと正規トラフィックを分離できません。入れ子配列を含む大きなPOSTは、通常のゲームクライアントが対戦結果を送信するときにも送られるからです。曖昧さを解消したシグナルはレスポンスでした。オリジンがPHPのFatal errorトレース付きの500を返したかどうか。不審なリクエスト+診断的なレスポンスという組み合わせは信頼性が高いです。リクエストだけでは不十分です。
Cloudflareはこれを「full transaction detection(トランザクション全体の検出)」として提供しました。同じHTTPトランザクション内でリクエストとレスポンスを関連付け、レスポンスがリクエスト元に届く前にブロックします。自前のエッジやリバースプロキシを運用しているなら、このパターンを真似る価値があります:
def is_probable_php2shell(resp) -> bool:
"""Correlate request shape with origin response, not request alone."""
body = resp.body[:4096]
return (
resp.status == 500
and b"Fatal error" in body
and resp.origin_latency_ms < 50 # exploit aborts fast
and b"Allowed memory size" in body # OOM path = 109618 signature
)
この教訓はこのCVEを超えて一般化できます。インバウンドトラフィックしか見ない検出システムは、アウトバウンドの結果を見られる攻撃者に迂回される。 レスポンスをログに残し、関連付けてください。それが、機能し続けるルールと、パズルになるルールの違いです。
ベストプラクティス
- 定期的にパッチし、公開を待たない。 「CVE公開」から「実戦での悪用」までの間隔は、今では数時間単位、時にはマイナスです。何も発表がなくてもPHPベースイメージを毎週再ビルドし、ロールバック手段を維持してください。
- 本番で
pm.max_requests = 0を実行しない。 ワーカーの寿命が無制限だと、あらゆるメモリ安全性バグが永続的な足場になります。ゲームAPIでは500〜1000が妥当なデフォルトです。 - エッジですべてのリクエストボディに上限を設定する。 JSON APIは2MB、アップロードは10MB、Content-Typeが一致しないものは拒否します。これにより、メモリ圧迫型のエクスプロイトのクラス全体をPHPに到達する前に排除できます。
- 5xxエラーのレスポンスボディをサンプリングでログに残す。 オリジンレスポンスが可観測性スタックから見えなければ、full transaction detectionは構築できません。10%をサンプリングし、7日間保持してください。
- 運用しているすべてのランタイムのインベントリを保持する。 PHPだけでなく、Node、Python、Goなど、Web層を動かすあらゆるランタイムです。php2shellに1時間以内で対応できたスタジオにはサービスカタログがありました。1日かかったスタジオはDockerfileをgrepしていました。
より大きな教訓:Web層こそが攻撃面
php2shellは、ゲームバックエンドに技術的負債が蓄積していくという事実を思い出させてくれます。1年目に、すぐ作れるからとPHP管理パネルをリリースしました。3年目になってもそれは残っています。まだ8.1のままで、プレイヤーテーブルへの書き込み権限を持つDB資格情報を保持しています。そして、堅牢化したゲームサーバーバイナリではなく、その管理パネルが突破されるのです。
構造的な修正は、自分が所有するランタイムの量を減らすことです。PHPのWeb層を運用している理由が、プレイヤーアカウント、クラウドセーブ、リーダーボードを処理するためだけなら、それらはまさにhorizOnがマネージドAPIとして提供している機能です。アカウントに紐づくセッションを使ったエンドユーザー認証、クライアント側で競合解決を行うリビジョン対応の読み書きができるCloud Save、リーダーボード、リモート設定。PHP-FPMプールが存在しないので、午前3時にパッチを当てる必要もありません。
これは、マネージドサービスが脆弱性に無縁だという主張ではありません。運用しているすべてのランタイムは、棚卸し、パッチ、監視が必要だという主張です。そして、ほとんどのスタジオが考えているよりも多くのランタイムを抱えている、ということです。
今すぐこれを行う
この記事から1つだけ行動を起こすなら、ステップ1のインベントリスクリプトを実行してください。本番、ステージング、あのパブリッシャー向け提案で忘れられたデモサーバーまで、すべての環境に対して実行します。ほぼ間違いなく、忘れていたランタイムが見つかります。そしてそれはほぼ間違いなくEOLブランチです。
そのうえでパッチを適用し、ワーカーをリサイクルし、エッジルールは残しておいてください。WAFルールとボディ上限は多層防御であって、ベンダー修正の代わりにはなりません。PHPアドバイザリが信頼できる基準であり、実際に穴を塞ぐのはそれです。
インベントリで、今後も所有し続けたくないPHPが大量に見つかった場合、horizOnはアカウント、セーブ、リーダーボード層をマネージドサービスとしてカバーします。これは、パッチ適用の責任を負う攻撃面を縮小する最速の方法です。どちらにせよ、まずインベントリ、次にパッチ。そして、スタックの退屈な部分が安全な部分だと思い込むのはやめましょう。