2026年7月17日、WordPress 7.0.2 が緊急セキュリティリリースとして公開された。修正されたのは2件。WordPress.org は影響を受けるバージョンに対し、自動更新システム経由の強制更新を有効化した。筆者のサイトで確認したところ、すでに 7.0.2 が当たっていた。
目次
直された2件と、その関係
| CVE | 内容 | 深刻度 |
|---|---|---|
| CVE-2026-60137 | WP_Query の author__not_in で SQL インジェクションを誘発する問題 |
Moderate |
| CVE-2026-63030 要注意 | REST API の batch-route 混同と SQL インジェクションがリモートコード実行に至る | Critical |
この2件は、組み合わせて初めて危険になる。VulnCheck の解析によれば、SQL インジェクション単体は認証済みユーザーでなければ到達できない。ところが batch エンドポイント側の不具合が、その「認証済みユーザー限定」という制限を回避する経路を提供してしまう。
Moderate の SQL インジェクションが Critical のリモートコード実行に昇格するのは、この連鎖のためだ。報告者は CVE-2026-60137 が TF1T、dtro、haongo の3氏、CVE-2026-63030 が Searchlight Cyber(Assetnote)の Adam Kues 氏となっている。
認証要件について、どう書くか迷った
WordPress.org の発表文にも、GitHub の Security Advisory 2件にも、「認証なしで成立する」という記述はない。筆者は当初、書かれていない以上その語を使うべきではないと判断した。
調べ直して、その判断を変えた。
報告者である Searchlight Cyber は「プラグインを入れていない素の WordPress に対し、認証なしの攻撃者が悪用できる」「前提条件はなく、匿名ユーザーが悪用可能」と説明している。この脆弱性チェーンには wp2shell という通称が付き、主要なセキュリティメディアが pre-auth RCE として報じた。さらに watchTowr が実際の悪用を観測し、Hexastrike はハニーポットでの検知を経て複数のインシデント対応をすでに実施している。公開された PoC も複数ある。
公式発表が触れていないからと伏せてしまうと、読者は「管理者権限が必要なら急がなくていい」と受け取る。実際には認証不要で、悪用は進行中だ。出典を分けて書くのが正しいと考えた。発表文には書かれておらず、報告者が明言している。情報の重みが違うので、どちらの出所かを示したうえで両方を載せる。
添字が1つズレる、という原因
batch-route confusion の中身は、配列インデックスのズレだ。Hadrian の解析が分かりやすい。
// 修正前のコード
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request; // $validation には積む
continue; // が $matches には積まずに continue
}
エラーになったリクエストを $validation にだけ積んで continue するので、$matches の要素数が $requests より1つ少なくなる。その後のディスパッチループで、リクエストとハンドラの対応が1つズレる。
requests: [ 不正リクエスト, リクエストA, リクエストB ]
matches: [ ハンドラA, ハンドラB ]
→ リクエストA が ハンドラB のコンテキストで実行される
ハンドラがズレるということは、そのリクエストに紐づくはずの permission_callback とパラメータ検証もズレる。攻撃者は「認証不要で通るルート」のリクエストを、特権が要るルートのハンドラに食わせられる。
身近な例で言うと、受付で整理券を配りながら、途中で1枚無効になった券を記録簿から抜き忘れたようなものだ。以降の番号がすべて1つずつずれて、待合室で呼ばれた人が別の人の予約枠に案内される。呼び出す側は正しく処理しているつもりでいる。
/wp-json/batch/v1 はルート自体に権限チェックを持たず、各サブリクエストの permission_callback に安全性を委ねる設計になっている。委ねた先との対応が崩れたので、防御が丸ごと空振りした。修正では、エラー時も $matches[] に積んで長さを揃え、再入防止のガードを追加している。
6.8.6 だけ修正が1件なのはなぜか
バックポート版の対応範囲が版によって違う。ここは他の記事があまり触れていない。
| 版 | CVE-2026-60137 | CVE-2026-63030 |
|---|---|---|
| 6.8.6 | 修正 | 非該当 |
| 6.9.5 | 修正 | 修正 |
| 7.0.2 | 修正 | 修正 |
| 7.1 beta2 | 修正 | 修正 |
batch エンドポイントの route confusion は WordPress 6.9 で入った不具合なので、6.8 系にはそもそも存在しない。6.8.x は SQL インジェクション単体の影響だけで、リモートコード実行の連鎖は成立しない。6.8.6 の修正が1件なのはこれが理由だ。
整理するとこうなる。6.8.0〜6.8.5 は SQL インジェクションのみ。6.9.0〜6.9.4 と 7.0.0〜7.0.1 が連鎖の成立する範囲。6.8 より前は影響を受けない。
古い 6.8 系で止めているサイトのほうが、結果的に軽傷だったことになる。更新を追いかけていた側が重い影響を受けるのは気分の良い話ではないが、こういうことは起きる。
自分のサイトを確認した
当サイトは WordPress を SSH + WP-CLI で運用している。まず現状を見た。
wp core version
# 7.0.2
wp core check-update
# Success: WordPress is at the latest version.
強制自動更新が効いていた。何もしていないのに当たっていたわけで、この仕組みは実際に機能している。
ただ、悪用が観測されている以上、バージョンを見て終わりにはしたくなかった。コアファイルが書き換えられていないかを確認する。
wp core verify-checksums
# Success: WordPress installation verifies against checksums.
# Warning: File should not exist: wp-admin/.htaccess
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
チェックサムは一致した。wp-admin/.htaccess の警告は、以前ハードニング目的で自分が置いたファイルで、監視ツールの許可リストにも入れてある既知の項目だ。管理者アカウントは3件で、登録日はいずれも6月。脆弱性が公開された7月17日以降に増えたものはなかった。
verify-checksums の守備範囲
安心しきる前に、このコマンドが何を見ていないかを押さえておきたい。照合の対象はコア配下だけで、wp-content/ は含まれない。プラグインやテーマ、アップロードディレクトリに書き込まれたファイルは検出できない。
SQL インジェクション経由で PHP ファイルを書き込むタイプの攻撃を検出する手がかりにはなるが、これ1本で「侵入されていない」証明にはならない。wp-content/uploads/ に .php が落ちていないかは、別途見る必要がある。
脆弱性の話になると、まず「危ないファイルが存在するかどうか」を見たくなる。筆者は mu-plugins についても同じことを考えたことがある。バックドアの温床として名前が挙がる仕組みだが、当サイトでも Authorization ヘッダーの復元と Tor ブラックリストの更新という正規の用途で2つ使っている。存在するかどうかを見ても意味はなく、判断すべきは各ファイルが何をしているかだ。今回も同じで、バージョン番号を確認した次に、中身が変わっていないかを見た。
batch エンドポイントを実際に叩いてみた
バージョンは 7.0.2 で修正済みだが、問題になったルート自体がどう振る舞うのかは見ていなかった。匿名で当サイトの batch/v1 を叩いた。
curl -s -X POST -H "Content-Type: application/json" \
-d '{"requests":[]}' https://example.com/wp-json/batch/v1
# {"responses":[]}
ログインしていない状態で応答が返る。エンドポイント自体に入口の認証は無い。認証はサブリクエストごとに効く形になっている。
サブリクエストに GET を入れると弾かれた。
requests[0][method] は POST、PUT、PATCH、DELETE の1つではありません。 (rest_not_in_enum)
読み取りは対象外で、状態を変える4つのメソッドだけを束ねる仕組みになっている。件数にも上限があった。26件入れて投げると rest_too_many_items で「requests には最大25個の項目が含まれている必要があります」と返る。1リクエストで束ねられるのは25件まで。
認証がサブリクエスト側にあることを確かめるため、存在しない投稿IDへの削除を匿名で投げた。
{"responses":[{"body":{"code":"rest_post_invalid_id",
"message":"無効な投稿 ID です。","data":{"status":404}},"status":404}]}
401 ではなく 404 が返った。DELETE /wp/v2/posts/<id> の権限コールバックは、権限を見る前に対象の投稿を取りに行く。存在しないIDでは、権限の判定に届く前に「無効な投稿 ID」で終わる。
ここが今回の型と地続きになる。権限チェックが「どの対象について」判定しているかは、添字ひとつでズレる。返ってきたのが 401 か 404 かで、処理がどこまで進んだかが読める。自作のルートを点検するときも、匿名でひととおり叩いて、返るステータスが想定どおりかを見ておきたい。
自動更新が当たらない設定になっていないか
ここは、まだ被害が出うる読者に向けて書いておきたい。
WordPress は wp_version_check という WP-Cron イベントで api.wordpress.org に問い合わせ、応答に含まれる更新オファーに自動更新のフラグが立っていれば適用する。「強制更新の有効化」というのは、WordPress.org 側がこの API 応答にフラグを立てる運用のことだ。マイナー更新の自動適用は 3.7 以降デフォルトで有効なので、既定構成のサイトは何もしなくても当たる。
サーバー側のフラグは、サイト側の無効化設定を上書きしない。 次のいずれかを入れているサイトは自動更新されず、今も未修正のまま動いている可能性がある。
AUTOMATIC_UPDATER_DISABLED— 全自動更新を停止WP_AUTO_UPDATE_COREをfalse— コア自動更新を停止DISALLOW_FILE_MODS— ファイル改変全般を禁止auto_update_core_minorフィルタでfalseを返している- WP-Cron を無効化していて外部 cron も未設定(バージョンチェック自体が走らない)
最後の項目は見落としやすい。パフォーマンス対策で DISALLOW_WP_CRON を入れたまま、外部 cron の設定を忘れているサイトは珍しくない。この状態だと更新チェックそのものが走らないので、フラグが立っていても届かない。
wp config get AUTOMATIC_UPDATER_DISABLED
wp config get WP_AUTO_UPDATE_CORE
wp config get DISALLOW_FILE_MODS
wp cron event list | grep wp_version_check
# 自動が効いていなかった場合
wp core update --minor
wp core update-db
互換性の問題については、7.0.2 と 6.9.5 に固有の回帰報告を探したが見つからなかった。セキュリティ限定の小さなパッチであることとも整合する。ただし「報告が見つからない」は「問題がない」の証明ではないので、本番に当てる前にステージングで確認できるならそのほうがいい。
侵害を疑ったときに何を見るか
ここは書くか迷った。煽りたくないからだ。ただ悪用が観測されている以上、「もし踏まれていたら」の手順を持っておくほうが落ち着いて対処できる。
難しいのは、具体的な IoC が公開されていないことだ。VulnCheck も SecurityWeek も、検知に使えるログのパターンやリクエストのシグネチャは出していない。Hexastrike が調査手順を共有しているという言及はあるが、内容は確認できなかった。
手がかりが無い中で見られるものを挙げると、こうなる。
# コアの改ざん(wp-content は対象外)
wp core verify-checksums
# 管理者の増減
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
# アップロード先に PHP が落ちていないか
find wp-content/uploads -name "*.php" -o -name "*.phtml"
# 最近更新されたファイル(心当たりのない日付が無いか)
find wp-content -type f -mtime -14 -name "*.php" | head -50
# 予約実行に見覚えのないイベントが刺さっていないか
wp cron event list
アクセスログが取れるなら、/wp-json/batch/v1 と ?rest_route=/batch/v1 への POST を見る。正規の運用でこのエンドポイントを叩いていないなら、そこに記録があること自体が確認の対象になる。
更新の適用日も見ておきたい。悪用の開始は脆弱性公開の当日から週末にかけてとされているので、自動更新が当たったのがいつかによって、露出していた時間の長さが変わる。ホスティングのログや wp-content/upgrade/ の痕跡から辿れることがある。
ステージングで先に当てる
今回のように強制更新が走る場面では手遅れなことも多いが、通常の運用としては書いておきたい。
本番と同じプラグイン構成のステージングを持っておくと、更新の影響を先に見られる。当サイトは SSH と WP-CLI で運用しているので、DB のエクスポートとファイルの同期で複製を作れる。7.0.2 と 6.9.5 に固有の回帰報告は探した範囲では見つからなかったが、自作プラグインとの相性は自分の環境でしか分からない。
同じ形のバグは他でも起きる
今回の原因を一般化すると、権限を判断する場所と、実際に処理する場所がずれたという話になる。セキュリティの分野では confused deputy と呼ばれる形だ。
入口で「この人は通していい」と判断し、その判断を持ったまま奥へ渡す設計は、あらゆるところにある。バッチ処理、キュー、ジョブワーカー、非同期のコールバック。渡す途中で対応がずれると、判断だけが正しくて対象が入れ替わる。
自分のコードでこの形を探すなら、「認可を判断した対象」と「実際に処理する対象」が別々の変数に入っている箇所を見るといい。配列の添字、ID のリスト、ループのカウンタで結び付けているところが候補になる。
自作プラグインに同じ型の穴がないか
ここからが本題だと思っている。コアは直った。同じ設計ミスを自分のコードでやっていないかのほうが、これから効いてくる。
サニタイズを if の中に入れない
今回のコアの SQL インジェクションは、型条件付きのサニタイズが原因だった。配列で来たときだけ absint をかける書き方をしていると、文字列で来たときに素通りする。
// 危険: 想定した型で来なければサニタイズを丸ごと飛ばす
if ( is_array( $ids ) ) {
$ids = array_map( 'absint', $ids );
}
$query = new WP_Query( array( 'author__not_in' => $ids ) );
// 安全: 先に型を正規化してから、無条件でサニタイズする
$ids = array_map( 'absint', (array) $ids );
$ids = array_filter( $ids );
$query = new WP_Query( array( 'author__not_in' => $ids ) );
分岐に使うのは「どう正規化するか」だけにする。サニタイズ自体を条件の中に入れなければ、同じ穴は開かない。
なお CVE の記述にある「facilitated(誘発型)」という語が示すとおり、コア単体で外部入力が直結するわけではない。NVD 系の記述は「プラグインやテーマが信頼できない入力を author__not_in に渡した場合に SQL インジェクションを許しうる」となっている。渡しているのが自分のコードなら、直すのも自分だ。
直接 SQL の IN 句
$wpdb を直接使っている箇所もついでに見ておきたい。IN 句は書き方に迷いやすい。
// 危険
$wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE ID IN ({$ids})" );
// 安全: 個数分の %d を組み立ててから prepare に渡す
$ids = array_map( 'absint', (array) $ids );
if ( $ids ) {
$ph = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE ID IN ($ph)", $ids
) );
}
REST ルートの権限チェックを棚卸しする
// 危険: permission_callback を素通しにする
register_rest_route( 'myplugin/v1', '/items', array(
'methods' => 'POST',
'callback' => 'my_handler',
'permission_callback' => '__return_true',
) );
// 安全: 権限判断を permission_callback 内で完結させ、args にスキーマを書く
register_rest_route( 'myplugin/v1', '/items', array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'my_handler',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'exclude' => array(
'type' => 'array',
'items' => array( 'type' => 'integer' ),
'sanitize_callback' => function ( $v ) {
return array_filter( array_map( 'absint', (array) $v ) );
},
),
),
) );
今回の件で分かるのは、ハンドラ本体で「どうせ権限チェックは済んでいる」と前提するのが危ういということだ。その前提が崩れたのが今回のバグそのものだった。ハンドラ内でも capability と nonce を再確認する多層防御にしておけば、上流がズレても一段は残る。
args にスキーマを書いておく効果も同じ理屈で、仮にハンドラがズレても不正な型は素通りしにくくなる。
副作用のあるルートに 'allow_batch' => array( 'v1' => true ) を安易に付けないことも覚えておきたい。宣言していないルートは batch の対象外になる。
棚卸しはこれで足りる。
grep -rn "__return_true" wp-content/plugins/ wp-content/themes/
grep -rn "author__not_in\|post__not_in\|author__in" wp-content/plugins/ wp-content/themes/
# 登録済みルートを一覧にして、身に覚えのないものが無いか見る
wp eval 'foreach ( array_keys( rest_get_server()->get_routes() ) as $r ) { echo $r . PHP_EOL; }'
筆者ならこう動く
強制自動更新は効く。実際に自分のサイトで当たっていた。だから「今すぐ更新を」と急かす記事にはしない。
やるべきは4つだと考えている。自動更新を止める定数が入っていないかを見る。verify-checksums と管理者一覧で改ざんの痕跡を確認する。wp-content/uploads/ に .php が落ちていないか見る。そして自作プラグインの __return_true と型条件付きサニタイズを grep する。バージョン番号を見て安心するより、この4つのほうが実になる。
WordPress の運用やセキュリティについてはmu-plugins のバックドア点検についての記事でも扱っている。まずは wp core verify-checksums を1回走らせるところから始めてほしい。