2026年10月21日夜に公表された PHP のリモートコード実行の脆弱性「php2shell」に対して、弊社が運用する WordPress サイト5台を、翌22日の1日で新しいサーバーへ丸ごと入れ替えました。古いサーバーには修正版の PHP を入れる手段がなかったためです。切り替えは固定 IP の付け替えで行い、DNS は変えず、サイトの停止は数分のメンテナンス表示だけで済みました。あわせて、サーバーの堅牢化と、不正侵入の痕跡を毎時見張る仕組みも入れています。この記事は、その判断と手順、つまずいた点をまとめた記録です。
この記事は、サーバー名・IP アドレス・お客様名は伏せています。
php2shell とは
php2shell は、PHP 本体に見つかった2件のリモートコード実行(RCE)の脆弱性、CVE-2026-109618 と CVE-2026-109619 の通称です。CVSS v4.0 はどちらも 9.2 です。ある HTTP リクエストが残した不完全なメモリの状態を、別のリクエストで悪用する仕組みです。
- 状態を汚す入口として知られているのは、既定で有効な Session Upload Progress 機能です
- mod_php・php-fpm、WordPress・Laravel・Symfony など、よくある構成で再現したと報告されています
- 修正版は 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 で、日本時間の10月21日 22時40分ごろに公開されました
- 8.1 以前はサポートが終わっており、修正版は出ません
公開されている Web サイトならどこからでも狙え、突かれればサーバーそのものを乗っ取られます。そのため「次の定期メンテナンスで」ではなく、修正版が出たその日のうちに当てると決めました。(参考: GMO Flatt Security Blog、PHP 公式の告知)
当夜の暫定対策
修正版が出る前の夜は、「攻撃の入口を狭める」ことだけに絞りました。どれも根本対策ではなく、修正版を当てるまでの時間稼ぎです。
- 全台のスナップショットを取る(いつでも戻れる状態にしてから触る)
session.upload_progress.enabled = 0とsession.use_strict_mode = 1を設定する- Apache で
multipart/form-dataの POST を、自社の VPN 以外からは拒否する
3 は、お問い合わせフォームの添付や管理画面からの画像アップロードも止まる強い手です。管理画面を使うのは社内と一部の関係者だけなので、VPN の IP だけを例外にして一晩だけ使いました。
確認と適用は、サーバーごとに同じスクリプトを check と apply の2段で流しました。サーバーの構成によって設定ファイルの場所が違うため、スクリプト側で場所を判定させています。
パッチではなく入れ替えを選んだ理由
5台のうち4台は、サーバーの中で PHP だけを更新する手段がありませんでした。PHP や Web サーバーが一式で組み込まれた構成で、OS を更新しても PHP は新しくなりません。修正版の PHP を入れるには、サーバーを作り直すしかありませんでした。
| サイト | サーバー内での PHP 更新 | 対応 |
|---|---|---|
| お客様A(EC あり)本番 | できない | 新サーバーへ入れ替え |
| お客様A ステージング | できない | 新サーバーへ入れ替え |
| お客様B | できない | 新サーバーへ入れ替え |
| 個人サイト | できない | 新サーバーへ入れ替え |
| 自社サイト | できる | その場で PHP 8.4 に更新 |
移り先は、OS のパッケージで PHP を更新できる Debian 12 のサーバーです。PHP は Sury リポジトリ の 8.4 を使い、unattended-upgrades でセキュリティ更新が自動で入るようにしました。次に同じことが起きても、修正版が出れば一晩で勝手に当たります。「更新できない構成」そのものをやめることが、今回いちばん大きな成果です。
止めずに入れ替える手順
要は、新サーバーを完成させてから固定 IP を付け替えることです。固定 IP はサーバーの間で数秒で付け替えられるので、DNS の浸透を待つ必要がありません。毎回同じ順番で進められるよう、各段階をスクリプトのサブコマンドにしました。
- prep: 新サーバーに PHP 8.4(php-fpm)、必要なパッケージ、監視エージェントを入れる
- sync: 旧から新へ
wp-contentと運用スクリプトを rsync、DB をダンプして取り込む。差分だけなので何度でも流せる - config: wp-config・Web サーバー・cron を新サーバー用に書き換え、旧サーバー固有のパスを DB 内も含めて置換する
- verify:
curl --resolveで本番ドメイン名のまま新サーバーの仮 IP につなぎ、主要ページ・カート・管理画面を旧と比べる - freeze: 旧をメンテナンス表示にし、cron と監視を止める(二重実行・二重通知を防ぐ)
- 最後の sync と config → 固定 IP を新へ付け替える
- enable: 新で証明書を取り直し、cron・常駐監視・メトリクス送信を始める
切り戻しは unfreeze(旧のメンテナンス表示を外し、cron と監視を戻す)と IP の付け戻しだけです。旧サーバーは停止して残し、切り替え直前のスナップショットも取っています。
EC のあるお客様Aの本番は、切り替える時刻を事前に先方へ伝え、その時刻どおりに実施しました。メンテナンス表示が出ていたのは freeze から IP 付け替えまでの数分です。切り替え後は、新旧の設定とコンテンツの差分を別のスクリプトで洗い出し、漏れがないか確かめています。
つまずいたところ
いちばん大きかったのは、切り替えたばかりのお客様Bのサイトが朝に約10分、応答しなくなったことです。特定の IP から接続を開けたままにする Slowloris 型のアクセスが来ていて、ログには 408(タイムアウト)が並んでいました。その時点の新サーバーは Apache prefork + mod_php で、1接続に1プロセスを占有するため、開けっ放しの接続で枠が尽きます。
最初はネットワーク側の問題と見誤り、同時接続数(MaxRequestWorkers)を下げるという逆効果の手も打っています。最終的には、接続を少ないスレッドで持てる mpm_event + php-fpm に切り替え、mod_reqtimeout でヘッダーや本文を遅く送る接続を早めに切るようにしました。このあとは全台を同じ構成にしています。
そのほかは、イメージの違いや古い設定の名残りが原因でした。
| 起きたこと | 原因 | 対処 |
|---|---|---|
| cron が動かない | 新しいイメージに cron パッケージが入っていない | prep で cron を入れて有効化 |
| 初期の PHP が未パッチ | イメージ作成時点の版のまま | 最初に apt upgrade、そのあと 8.4 へ |
| テーマ内の監視スクリプトが落ちる | 旧サーバーのパスを決め打ちしていた | cron 実行時だけ auto_prepend_file で先に定義(テーマは触らない) |
| 旧サーバーでメンテナンス表示にならない | 旧の wp-cli が何も言わず失敗 | .maintenance を直接書く |
| DB の 3306 番が外から見える | MariaDB が systemd のソケット起動で、bind-address が効かない | mariadb.socket の待ち受けを 127.0.0.1 に上書き |
| バージョン表示とディレクトリ一覧が消えない | Debian 標準の security.conf と apache2.conf が自分の設定より優先される | 設定ファイル名を zz- 始まりにして最後に読ませ、apache2.conf の Indexes も外す |
| 大きなプラグインの削除で wp-cli が固まる | 削除時にプラグイン自身の処理が走る | 止めて、ディレクトリごと退避 |
| ステージングの同期が途中で止まる | 旧に rsync がない、入れ子の ssh が標準入力を食う、wp-config がシンボリックリンク | rsync を入れる(入らなければ tar)、</dev/null、rsync -L |
入れ替えのついでに固めたこと
入れ替えで空のサーバーから作り直すので、何年も手を付けていなかった設定も一緒に見直しました。どれも、攻撃者が最初に測る「入口」を減らすためのものです。
| 対象 | やったこと |
|---|---|
| PHP | 8.4(php-fpm)。セキュリティ更新は自動適用 |
| Apache | バージョン非表示、ディレクトリ一覧なし、.git・ログ・バックアップ・readme へのアクセス拒否、xmlrpc.php 拒否、uploads 内での PHP 実行禁止 |
| WordPress | 未ログイン時のユーザー一覧 API と ?author= を隠す、アプリケーションパスワード無効、管理画面からのテーマ・プラグイン編集禁止(DISALLOW_FILE_EDIT)。これらは mu-plugin 1枚にまとめて全サイト共通 |
| DB・phpMyAdmin | MariaDB は 127.0.0.1 のみ。phpMyAdmin はサーバー内からのみ(使うときは SSH トンネル) |
| アカウント | イメージが作る初期管理者を削除。今年ログインのない管理者は権限を下げた |
| プラグイン・テーマ | 無効のまま残っていたもの、移行ツール、使っていない標準テーマを削除。開発が止まったプラグインは代わりのものに置き換え、記事も直した |
| SSH | 接続元を自社 VPN に限定 |
削除したプラグインやファイルはすぐに消さず、数日はサーバー内の退避先に移しておきます。何かが壊れても、元に戻すのは mv 一つで済みます。
侵入の痕跡を見張る仕組み
修正版を当てる前にすでに入られていたら、パッチでは消えません。そこで全台に、「正常な状態」を記録して毎時そこからの差分を見る小さなシェルスクリプトを入れました。新しいツールを入れず、find・sha256sum・wp-cli・systemd のタイマーだけで動きます。
見ているのは、侵入者が居座るためにほぼ必ず触る場所です。
- OS: ログインできるユーザー、sudo 権限、SSH 鍵、cron、systemd のサービス、待ち受けポート、SUID ファイル
- WordPress: 管理者、重要な設定(
siteurl・admin_email・登録可否など)、すべての PHP と.htaccessのハッシュ、uploadsに置かれた PHP - その場の状態:
/tmpなどから動いているプロセス、許可 IP 以外からの SSH ログイン、WordPress 標準以外の PHP への POST、本体ファイルの改ざん
設計でいちばん気を使ったのは、通知を少なくすることです。毎週のように誤報が来ると、いずれ本物も見過ごされます。
- WordPress 本体とプラグインの更新は、wordpress.org の公式チェックサムと一致すれば正当とみなす
- Git で管理しているテーマは、作業ツリーが clean(反映スクリプト経由の変更)なら正当とみなす
- 同じ内容は二度通知しない。解消したら一度だけ知らせる
- 毎週月曜の朝に「異常なし」を一度だけ送る。静かなのが「健全」なのか「止まっている」のかを区別するため
通知先は担当者だけの非公開 Slack チャンネルとメールです。正当な変更だと確かめたら intrusion-check --accept で記録し直します。
導入したその日の午後、さっそく2台から通報が来ました。「PHP ファイルが 74 件と 27 件変わった」「管理者メールアドレスが変わった」という内容です。調べると、標準テーマと翻訳ファイルの WordPress 自動更新が同じ分にまとまっていて、メールアドレスは担当者が自分で変えたものでした。テーマと翻訳ファイルには公式のチェックサムがないため、正当な更新でも鳴っていたのです。
そこで、除外を広げるのではなく、「正当だと確かめる方法」を足しました。
- テーマ: 同じバージョンの公式 zip を wordpress.org から取ってきて、変わったファイルが完全に一致すれば正当
- 翻訳ファイル(
*.l10n.php): PHP のトークナイザーで解析し、文字列の配列を返すだけで関数呼び出しも変数もなければ正当

差分があっても、公式の配布物や Git と照らして正当だと確かめられれば、基準を更新して黙ります。通報が来るのは、確かめられなかったときだけです。
改ざんした翻訳ファイルや、テーマに足された不審なファイルは、今も通報されます。一方で、WordPress が自動で更新しただけなら鳴りません。無視する範囲を広げると、そこがそのまま隠れ場所になります。「安全だと証明できたものだけ黙る」のが、誤報を減らすときの原則だと考えています。
運用の見直し
入れ替えを機に、「誰がどこに入れるか」を必要な分だけに絞りました。旧サーバーでは、外部のデザイナーも sudo できる共用ユーザーで本番とステージングに SSH できる状態でした。
| 誰 | 本番 | ステージング |
|---|---|---|
| 弊社担当者 | SSH | SSH |
| 外部のデザイナー | なし | テーマのフォルダだけに SFTP |
| お客様 | 管理画面のみ | 管理画面のみ |
- 本番のテーマは、Git の production ブランチから反映スクリプトでしか変えない。スクリプトは反映前に全 PHP の構文を確かめ、反映後に主要ページが 200 を返さなければ自動で戻す
- デザイナーはステージングにアップロードし、担当者が差分を Git に記録してから本番に出す
- SFTP 専用アカウントはシェル不可・chroot で、テーマのフォルダだけが見える。bindfs で見せることで、アップロードされたファイルも実体は運用ユーザーの所有になり、反映スクリプトの所有者チェックもそのまま動く
- SSH と SFTP は自社 VPN からのみ。個人の自宅 IP を許可リストに足していく運用をやめた
移行のとき、旧サーバーの共用ユーザーにあったデザイナーの公開鍵も、一度は新サーバーの管理ユーザーに引き継がれていました。「今までどおり入れるように」と移すと、権限もそのまま持ち込みます。鍵ごとに「この人には何が必要か」を判断し直し、専用アカウントに移しました。
まとめ
今回いちばん効いたのは、「修正版が出たら当てられる構成」に移ったことです。緊急の脆弱性はまた来ます。次は、自動更新と侵入検知の通知を待つだけで済むはずです。
同じように古い WordPress サーバーを運用している方向けに、確かめることを並べます。
- PHP が OS とは別に同梱された構成になっていないか。その場合、OS を更新しても PHP は新しくならない
php -vが各ブランチの修正版(8.2.35 / 8.3.36 / 8.4.27 / 8.5.12)以上か。当てるまではsession.upload_progress.enabled = 0と、不要なら multipart POST の遮断- セキュリティ更新が自動で入るか(
unattended-upgradesと、PHP を入れたリポジトリがその対象になっているか) - DB(3306)や phpMyAdmin が外から見えていないか。MariaDB はソケット起動にも注意
- イメージが作った初期管理者や、使っていない管理者が残っていないか
- 共用ユーザーに、誰のものか分からない SSH 鍵がないか
- 「入られたときに気づけるか」。何もなければ、まず管理者・SSH 鍵・cron・PHP ファイルの差分だけでも見る
弊社では、WordPress サイトの保守やサーバーの移行・堅牢化のご相談をお受けしています。