目次

WordPressコアRCE「wp2shell」を徹底解剖|CVE-2026-63030・CVE-2026-60137のコード差分、隔離ラボ、検知、復旧まで

2026年7月17日、WordPressは緊急セキュリティリリースとして、WordPress 7.0.2、6.9.5、6.8.6を公開しました。

修正対象となったのは、単独でも危険なSQLインジェクションと、WordPress REST APIのBatch処理におけるルート混同です。

特にWordPress 6.9系と7.0系では、この二つの問題を連鎖させることで、ログインしていない匿名の攻撃者が、プラグインを一つも入れていない標準的なWordPressに対して、最終的にリモートコード実行へ到達できる状態にあったとされています。

この攻撃チェーンは、研究者によってwp2shellと名付けられました。

今回が異例なのは、脆弱なプラグインやテーマではなく、WordPress本体のコアコードに問題が存在したことです。

攻撃者に必要なのは、管理者アカウントでも、購読者アカウントでも、特殊なプラグインでもありません。

脆弱なバージョンのWordPressがインターネットへ公開されていれば、攻撃対象になり得ます。

本記事では、単に「最新版へ更新してください」と書いて終わりません。

WordPressコアの実コードと修正差分を読み、次の内容まで徹底的に確認します。

  • wp2shellが二つの脆弱性を連鎖させた攻撃である理由
  • どのWordPressバージョンが影響を受けるのか
  • WP_Queryのどの実装がSQLインジェクションを生んだのか
  • REST APIのBatch処理で、どの配列管理が壊れていたのか
  • 7.0.2で追加された再入防止処理の意味
  • アップデートだけでは不十分な理由
  • Kali Linuxを含む隔離Dockerラボの作り方
  • 攻撃コードを使わず、同じ設計不備を安全に体験する方法
  • Webサーバー、ファイル、データベース、WordPressの調査方法
  • 侵害が疑われる場合の封じ込めと復旧手順
  • WAF、権限分離、ファイル監視、EDRによる多層防御

重要:本記事は防御・コード監査・インシデント対応を目的としています。実在する脆弱なWordPressへ侵入するペイロード、Webシェル、任意コード実行コマンドは掲載しません。ラボでは、本物の脆弱性を攻撃するのではなく、同じ設計上の問題を安全な固定処理へ置き換えて学習します。

防御学習ラボを無料ダウンロード

WordPress 7.0.2、MariaDB、Kali Linux、WP-CLI、安全な学習用プラグイン、緊急ブロック、調査スクリプトをまとめたDockerラボです。

本物の攻撃コード、SQLインジェクション実行、Webシェル、任意コマンド実行は含みません。


wp2shell Defender Labをダウンロード

ファイル名:wp2shell-defender-lab.zip


最初に結論:今すぐ確認すべきバージョン

WordPressバージョン CVE-2026-60137 CVE-2026-63030 完全なRCEチェーン 修正版
6.8未満 対象外 対象外 対象外 最新安定版を推奨
6.8.0~6.8.5 影響あり 対象外 公式上は対象外 6.8.6以上
6.9.0~6.9.4 影響あり 影響あり 影響あり 6.9.5以上
7.0.0~7.0.1 影響あり 影響あり 影響あり 7.0.2以上
7.1 Beta 1 影響あり 影響あり 影響あり 7.1 Beta 2以上

管理画面の「ダッシュボード」→「更新」だけでなく、SSHへ入れる場合はWP-CLIでも確認します。

cd /var/www/html

wp core version

WordPressのインストール先が異なる場合:

wp core version \
  --path=/実際のWordPressディレクトリ

Docker環境の場合:

docker compose exec wordpress \
  wp core version \
  --allow-root

WordPressの管理画面に表示されているからといって、自動更新が完了していると決めつけてはいけません。

ファイル所有者、ディスク容量、無効化された自動更新、書き込み権限、ホスティング会社独自の管理方式などにより、更新が失敗することがあります。

必ず実際のバージョン番号を確認してください。


wp2shellとは何か

wp2shellは、一つのCVEだけを指す名称ではありません。

二つの異なる脆弱性を組み合わせた攻撃チェーンです。

CVE-2026-60137

WP_Queryauthor__not_inパラメーターにおけるSQLインジェクションです。

本来、投稿者IDの一覧として扱われる値が、入力形式によっては整数へ正規化されず、SQL文の一部へ直接連結される可能性がありました。

CVE-2026-63030

WordPress REST APIの/batch/v1エンドポイントにおける、Batchルート混同とREST処理状態の管理不備です。

Batch内部のサブリクエストと、それぞれに対応するハンドラー、検証結果の対応関係が崩れる可能性がありました。

二つが連鎖した結果

匿名のHTTPリクエスト
    ↓
REST API Batch処理へ到達
    ↓
サブリクエストと検証・ハンドラーの対応が混乱
    ↓
本来安全に到達できない入力経路が成立
    ↓
WP_Queryのauthor__not_inへ不正な入力形式が渡る
    ↓
SQLインジェクション
    ↓
WordPress内部状態を攻撃者に有利な形へ変更
    ↓
管理権限相当の操作へ到達
    ↓
プラグイン・テーマ・WordPress機能を通したコード実行

重要なのは、「SQLインジェクションが直接シェルを起動する」という単純な構造ではないことです。

WordPressには、投稿、ユーザー、オプション、キャッシュ、プラグイン、テーマ、Cron、ファイル更新など、多数の状態と機能があります。

攻撃者はSQLインジェクションで得た能力を、WordPressが本来持つ正規機能と組み合わせ、最終的なコード実行へ進みます。


wp2shellの攻撃チェーン。匿名REST要求からBatch処理、WP_Query SQLインジェクション、管理者権限、コード実行へ至る流れ
wp2shellは、REST APIとSQL入力処理という二つの境界崩壊を連鎖させる。

なぜ「プラグインなし」でも危険なのか

WordPressの重大事故というと、多くの管理者は次を想像します。

  • 古いフォームプラグイン
  • 更新されていないバックアッププラグイン
  • 海賊版テーマ
  • 脆弱なファイルマネージャー
  • 不正な管理者アカウント

wp2shellは、これらを必要としません。

問題が存在する場所はWordPressコアです。

したがって、次のサイトでも対象になります。

  • プラグインを一つも入れていない
  • 標準テーマしか利用していない
  • 管理画面のパスワードが十分に長い
  • 管理者がログインしていない
  • XML-RPCを無効化している
  • ログインURLを変更している

ログインページを隠す、管理者パスワードを強くする、XML-RPCを停止するといった対策は重要です。

しかし、未認証のREST API処理から始まるコア脆弱性を止める対策にはなりません。


脆弱性1:WP_Queryの入力正規化不備

最初に、SQLインジェクション側の実装を確認します。

問題があったのは、WordPressコアの次のファイルです。

wp-includes/class-wp-query.php

WordPress 7.0.1相当のコードは、概略として次の構造でした。

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()が使われています。

しかし、整数化されるのは、author__not_inが配列だった場合だけです。

if ( is_array( $query_vars['author__not_in'] ) ) {
    array_map( 'absint', ... );
}

文字列など、配列以外の形式で値が入った場合は、整数化処理を通りません。

その後、単に配列へキャストされ、implode()されます。

implode(
    ',',
    (array) $query_vars['author__not_in']
);

最後に、作成された文字列がSQLのNOT IN (...)へ連結されます。

$where .=
    " AND wp_posts.post_author NOT IN ($author__not_in) ";

つまり、コードは暗黙に次の前提を置いていました。

author__not_inは配列として渡される。配列なら整数化できるので安全である。

しかし、外部入力を扱うコードでは、「通常は配列で来る」という期待はセキュリティ境界になりません。

文字列、配列、数値、空値など、受け入れ得るすべての入力形式を、SQLへ渡す前に同じ型へ正規化する必要があります。


7.0.2でWP_Queryはどう直されたか

WordPress 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;
    }
}

wp_parse_id_list()は、受け取った値をID一覧として解析し、有効な整数へ変換します。

修正後の重要な点は、配列か文字列かという入力形式に依存しないことです。

入力
  ↓
wp_parse_id_list()
  ↓
整数IDの配列
  ↓
implode()
  ↓
SQLへ利用

この修正から学べること

サニタイズは、「危険な文字を消す作業」だけではありません。

より重要なのは、入力をアプリケーションが期待する型へ強制することです。

投稿者IDなら、最終的な値は正の整数一覧でなければなりません。

安全な処理は次の順番です。

  1. 入力を受け取る
  2. 期待する型へ正規化する
  3. 有効な値だけを残す
  4. 空になった場合の処理を決める
  5. SQLへ渡す

危険な処理は次です。

  1. 入力を受け取る
  2. 一部の入力形式だけサニタイズする
  3. 文字列へ変換する
  4. SQLへ連結する

脆弱性2:REST API Batch処理の対応配列がずれる

次に、完全なRCEチェーンを成立させたREST API側を確認します。

対象ファイルは次です。

wp-includes/rest-api/class-wp-rest-server.php

WordPressの/batch/v1は、複数のREST API要求を一つのHTTPリクエストへまとめる機能です。

概念的には、次のような要求を一度に処理します。

{
  "requests": [
    {
      "method": "POST",
      "path": "/example/v1/task-a"
    },
    {
      "method": "POST",
      "path": "/example/v1/task-b"
    }
  ]
}

Batch処理では、主に次の三つの配列が利用されます。

  • $requests:作成したサブリクエスト
  • $matches:各サブリクエストに対応するルートとハンドラー
  • $validation:各サブリクエストの検証結果

これらは、同じ添字が同じ要求を表す必要があります。

$requests[0]
$matches[0]
$validation[0]

$requests
$matches
$validation

ところが、修正前のコードでは、サブリクエストがWP_Errorだった場合に、$validationへは値を追加する一方、$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;
}

その後の実行処理では、元の$requestsの添字を使って$matches[$i]を参照します。

foreach ( $requests as $i => $single_request ) {
    $match = $matches[ $i ];
}

途中にエラーとなったサブリクエストが含まれると、配列の対応がずれる可能性があります。

requests[0]   → エラー
requests   → 正常ルートA
requests   → 正常ルートB

matches[0]    → 正常ルートA
matches    → 正常ルートB

この状態でrequestsmatchesを対応させると、本来のルートAではなく、ルートBのハンドラーや検証状態が参照される可能性があります。

これが「Batchルート混同」の中心です。

入力、検証結果、実行ハンドラーの対応関係は、認証・認可と同じセキュリティ境界です。

ここがずれると、「ある要求で通過した検証結果を、別の要求へ使う」という状態が発生します。


Batch処理の修正は、わずか一行だった

WordPress 7.0.2では、エラーとなったサブリクエストも$matchesへ追加する変更が入りました。

if ( is_wp_error( $single_request ) ) {
    $has_error    = true;
    $matches[]    = $single_request;
    $validation[] = $single_request;

    continue;
}

追加された中心的な処理は、次の一行です。

$matches[] = $single_request;

これにより、三つの配列の要素数と添字が揃います。

requests[0]    → エラー
matches[0]     → エラー
validation[0]  → エラー

requests    → ルートA
matches     → ルートAのハンドラー
validation  → ルートAの検証結果

セキュリティ脆弱性の修正は、必ずしも大量のコード変更になるとは限りません。

一行の不足が、認可境界全体を壊すことがあります。


REST処理中に、別のトップレベルREST処理を開始できた

7.0.2では、配列の対応修正だけでなく、REST処理の再入を防ぐガードも追加されました。

修正対象は次の二か所です。

wp-includes/rest-api.php
wp-includes/rest-api/class-wp-rest-server.php

rest_api_loaded()には、既にREST dispatch中であれば処理を終了する条件が追加されました。

if (
    isset( $GLOBALS['wp_rest_server'] )
    &&
    $GLOBALS['wp_rest_server']
        instanceof WP_REST_Server
    &&
    $GLOBALS['wp_rest_server']
        ->is_dispatching()
) {
    return;
}

WP_REST_Server::serve_request()にも同様の確認が追加されています。

public function serve_request( $path = null ) {
    if ( $this->is_dispatching() ) {
        return false;
    }

    // 通常のREST処理
}

WordPressの内部サブリクエストは、トップレベルのHTTP要求を処理するserve_request()ではなく、内部dispatch用の処理を利用する必要があります。

修正後は、既にREST dispatchが進行している間に、新しいトップレベルRESTライフサイクルを開始できません。

外部HTTP要求
    ↓
serve_request()
    ↓
dispatch開始
    ↓
内部サブリクエスト
    ↓
dispatch()を利用

禁止:
dispatch中
    ↓
serve_request()をもう一度開始

この修正は、「今どの処理状態にいるか」を明示的に確認する防御です。

入力値の検証だけでなく、アプリケーションの状態遷移もセキュリティ境界になります。


WordPress 7.0.1と7.0.2のwp2shell修正差分
7.0.2は、入力の型を正規化し、REST処理の再入を拒否した。

なぜ二つの問題がRCEへ発展したのか

個別に見ると、次のように見えるかもしれません。

  • SQLインジェクション:プラグインやテーマが不正入力を渡さなければ限定的
  • Batchルート混同:REST処理の検証状態がずれるロジックバグ

しかし、攻撃者は一つの脆弱性だけで目的を達成する必要はありません。

複数の弱点をつなぎ、最終的な能力を拡大します。

段階 攻撃者が得る能力
REST Batchへ匿名到達 複数サブリクエストを一つの要求で処理させる
ルート混同 入力、検証、ハンドラーの対応を崩す
SQLインジェクション データベース状態へ影響を与える
WordPress状態の悪用 ユーザー、投稿、設定、キャッシュ等を攻撃へ利用
管理機能への到達 プラグインやテーマ等の機能を利用可能にする
コード実行 Webサーバーユーザーの権限で処理を実行する

安全設計では、最初の防御が破られても、次の境界で止まる必要があります。

wp2shellでは、複数の境界が同時に成立しなかったため、最終的なコード実行へ進みました。


実際に「未認証からサーバー側処理まで」を体験する

ここまでコード差分を読んできましたが、コードだけではwp2shellの怖さを実感しにくいかもしれません。

そこで、実在するwp2shellの攻撃コードを使わず、同じ種類の設計ミスを持つ意図的脆弱プラグインを作ります。

この実験では、Kali Linuxからログイン情報を一切送らず、次の流れを実際に確認します。

未認証のHTTPリクエスト
    ↓
複数処理をまとめたBatch風API
    ↓
要求と検証結果の対応がずれる
    ↓
文字列入力が正規化されず危険な処理へ渡る
    ↓
本来は管理者専用の処理が選択される
    ↓
サーバー側で固定されたPHP処理が実行される
    ↓
Web公開領域外へ実行証跡が作成される

この実験の制限:任意コード実行、OSコマンド実行、SQL実行、管理者作成、プラグイン設置、Webシェル作成は行いません。攻撃者が指定できるコマンド、ファイル名、PHPコード、SQL文は一切受け付けません。

実験で再現する四つの問題

実際のwp2shellで問題となった境界 このラボでの安全な置き換え
匿名でREST Batchへ到達 未認証で呼べる学習用Batchエンドポイント
要求とハンドラーの対応混乱 配列の添字がずれる小さなルーター
author__not_inの型正規化不備 旧式文字列と整数化後の値を比較
最終的なコード実行 固定された計算と固定JSON証跡の作成

完全なwp2shellを再現するものではありません。

しかし、匿名リクエストが複数の不備を通過し、本来は選択されるべきでないサーバー側処理へ到達する流れを、自分のPC内で観察できます。


意図的脆弱プラグインを作る

次のディレクトリを作成します。

wp-content/plugins/wp2shell-chain-lab/

その中へ、次のファイルを作成します。

wp2shell-chain-lab.php
<?php
/**
 * Plugin Name: wp2shell Chain Lab
 * Description: 未認証入力、配列対応ずれ、型未正規化を安全に体験する学習専用プラグイン。
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

add_action(
    'rest_api_init',
    static function (): void {
        register_rest_route(
            'wp2shell-chain-lab/v1',
            '/batch',
            array(
                'methods'             => WP_REST_Server::CREATABLE,

                /*
                 * 意図的な脆弱設定。
                 * 未認証でも実行可能。
                 */
                'permission_callback' => '__return_true',

                'callback'            => 'wp2shell_chain_lab_batch',

                'args'                => array(
                    'requests' => array(
                        'required' => true,
                        'type'     => 'array',
                    ),
                ),
            )
        );
    }
);

/**
 * Batchルート混同を安全に模擬する。
 *
 * OSコマンド、SQL、任意PHP、任意ファイル名は受け付けない。
 */
function wp2shell_chain_lab_batch(
    WP_REST_Request $request
): WP_REST_Response|WP_Error {
    $requests = $request->get_param( 'requests' );

    if ( ! is_array( $requests ) ) {
        return new WP_Error(
            'invalid_requests',
            'requests must be an array.',
            array( 'status' => 400 )
        );
    }

    $parsed_requests = array();
    $matches         = array();
    $validation      = array();

    /*
     * 利用可能な処理は、固定された二つだけ。
     */
    $handlers = array(
        'preview' => 'wp2shell_chain_lab_preview',
        'proof'   => 'wp2shell_chain_lab_create_proof',
    );

    foreach ( $requests as $item ) {
        /*
         * pathが存在しない要素をエラー扱いにする。
         */
        if (
            ! is_array( $item )
            || empty( $item['path'] )
            || ! is_string( $item['path'] )
        ) {
            $parsed_requests[] = new WP_Error(
                'invalid_path',
                'A request path is missing.'
            );

            continue;
        }

        $parsed_requests[] = $item;
    }

    /*
     * 意図的脆弱部分。
     *
     * エラー項目はvalidationへ追加するが、
     * matchesへ対応要素を追加しない。
     *
     * このため配列の添字がずれる。
     */
    foreach ( $parsed_requests as $item ) {
        if ( is_wp_error( $item ) ) {
            $validation[] = $item;

            /*
             * 本来は次も必要。
             *
             * $matches[] = $item;
             */
            continue;
        }

        $path = $item['path'];

        if ( ! isset( $handlers[ $path ] ) ) {
            $match = new WP_Error(
                'unknown_path',
                'Unknown fixed lab path.'
            );
        } else {
            $match = $handlers[ $path ];
        }

        $matches[]    = $match;
        $validation[] = true;
    }

    $responses = array();

    foreach (
        $parsed_requests as $index => $item
    ) {
        if ( is_wp_error( $item ) ) {
            $responses[] = array(
                'index'  => $index,
                'status' => 'input-error',
            );

            continue;
        }

        /*
         * ここで元のindexをmatchesへ利用する。
         *
         * 前にエラー要素があると、
         * 別リクエストのハンドラーが選択される。
         */
        $handler = $matches[ $index ] ?? null;

        if (
            ! is_string( $handler )
            || ! is_callable( $handler )
        ) {
            $responses[] = array(
                'index'  => $index,
                'status' => 'no-handler',
            );

            continue;
        }

        $responses[] = call_user_func(
            $handler,
            $item
        );
    }

    return new WP_REST_Response(
        array(
            'authenticated'     => is_user_logged_in(),
            'wordpress_user_id' => get_current_user_id(),
            'responses'         => $responses,
            'warning'           => 'Educational vulnerable router. No SQL or shell command was executed.',
        ),
        200
    );
}

/**
 * 入力正規化の違いを表示するだけの処理。
 *
 * SQLは実行しない。
 */
function wp2shell_chain_lab_preview(
    array $item
): array {
    $value = $item['author__not_in'] ?? '';

    $legacy_value = implode(
        ',',
        (array) $value
    );

    $safe_ids = wp_parse_id_list( $value );

    return array(
        'handler' => 'preview',
        'input'   => $value,

        /*
         * SQLとして実行せず、表示だけ行う。
         */
        'legacy_fragment' =>
            'NOT IN (' . $legacy_value . ')',

        'safe_fragment' =>
            count( $safe_ids ) > 0
                ? 'NOT IN (' .
                    implode( ',', $safe_ids ) .
                    ')'
                : '(no valid IDs)',

        'sql_executed' => false,
    );
}

/**
 * 本来は管理者専用と仮定する固定処理。
 *
 * 任意コマンドは受け付けず、
 * 1から100までの合計を計算して
 * 固定JSONへ記録するだけ。
 */
function wp2shell_chain_lab_create_proof(
    array $item
): array {
    $directory = '/var/www/wp2shell-chain-proof';
    $filename  = 'chain-execution-proof.json';
    $path      = $directory . '/' . $filename;

    if (
        ! is_dir( $directory )
        &&
        ! wp_mkdir_p( $directory )
    ) {
        return array(
            'handler' => 'proof',
            'status'  => 'directory-error',
        );
    }

    $result = array_sum(
        range( 1, 100 )
    );

    $proof = array(
        'lab' => 'wp2shell-chain-lab',

        'executed_at_utc' =>
            gmdate( 'c' ),

        'authenticated' =>
            is_user_logged_in(),

        'wordpress_user_id' =>
            get_current_user_id(),

        'server_hostname' =>
            php_uname( 'n' ),

        'php_version' =>
            PHP_VERSION,

        'fixed_operation' =>
            'sum(1..100)',

        'result' =>
            $result,

        'arbitrary_code_executed' =>
            false,

        'shell_command_executed' =>
            false,

        'sql_executed' =>
            false,
    );

    $written = file_put_contents(
        $path,
        wp_json_encode(
            $proof,
            JSON_PRETTY_PRINT
            | JSON_UNESCAPED_SLASHES
        ),
        LOCK_EX
    );

    return array(
        'handler' => 'proof',
        'status'  =>
            false === $written
                ? 'write-error'
                : 'proof-created',
        'result'  => $result,
        'file'    => $filename,
    );
}

なぜ、このコードでルートが混同するのか

実験用コードは、入力された要求を次の三つの配列で管理しています。

$parsed_requests
$matches
$validation

しかし、エラーとなった要求では、$validationへ値を追加しても、$matchesへ値を追加していません。

if ( is_wp_error( $item ) ) {
    $validation[] = $item;
    continue;
}

例えば三つの入力が次の状態だったとします。

requests[0] = pathなしのエラー
requests = preview
requests = proof

$matchesへはエラー要素が入らないため、次のようになります。

matches[0] = preview
matches = proof

その後、元の要求番号を使ってハンドラーを取得しています。

$handler = $matches[ $index ];

このため、requestsは本来のpreviewではなく、matchesに入っているproof処理へ接続されます。

requests = preview
       ↓ 添字1でmatchesを見る
matches = proof
       ↓
proof処理を実行

利用者が直接proofを選択したのではありません。

要求とハンドラーの対応関係がずれたことで、本来とは異なる処理が呼ばれます。


Dockerへ固定証跡領域を追加する

docker-compose.ymlのWordPressサービスへ、次を追加します。

services:
  wordpress:
    volumes:
      - ./chain-proof:/var/www/wp2shell-chain-proof

ホスト側へフォルダを作ります。

mkdir -p chain-proof

WordPressコンテナを再作成します。

docker compose up \
  -d \
  --force-recreate \
  wordpress

プラグインを有効化します。

docker compose run \
  --rm \
  wpcli \
  plugin activate wp2shell-chain-lab

Kaliから匿名リクエストを送る

次の要求には、WordPressのCookie、Nonce、ユーザー名、パスワードを付けません。

docker compose exec kali bash -lc '
cat > /tmp/wp2shell-chain-demo.json << "JSON"
{
  "requests": [
    {
      "invalid": true
    },
    {
      "path": "preview",
      "author__not_in": "1,abc,3"
    },
    {
      "path": "preview",
      "author__not_in": [4, 5]
    }
  ]
}
JSON

curl -sS \
  -X POST \
  http://wordpress/wp-json/wp2shell-chain-lab/v1/batch \
  -H "Content-Type: application/json" \
  --data-binary @/tmp/wp2shell-chain-demo.json |
jq
'

何が起こるのか

最初の要求にはpathがないため、入力エラーになります。

二番目の要求はpreviewを指定しています。

しかし、最初のエラーによって$matchesの添字が一つずれ、二番目の要求にproofハンドラーが対応する状態になります。

レスポンスでは、ログインしていないことを確認できます。

{
  "authenticated": false,
  "wordpress_user_id": 0,
  "responses": [
    {
      "index": 0,
      "status": "input-error"
    },
    {
      "handler": "proof",
      "status": "proof-created",
      "result": 5050,
      "file": "chain-execution-proof.json"
    }
  ]
}

注目する部分は次です。

"authenticated": false
"wordpress_user_id": 0

二番目の要求はpreviewを指定していたにもかかわらず、実行結果は次です。

"handler": "proof"

要求とハンドラーの対応がずれたことを確認できます。


サーバー側の実行証跡を確認する

cat chain-proof/chain-execution-proof.json

出力例:

{
  "lab": "wp2shell-chain-lab",
  "executed_at_utc": "2026-07-28T03:00:00+00:00",
  "authenticated": false,
  "wordpress_user_id": 0,
  "server_hostname": "1a2b3c4d5e6f",
  "php_version": "8.3.23",
  "fixed_operation": "sum(1..100)",
  "result": 5050,
  "arbitrary_code_executed": false,
  "shell_command_executed": false,
  "sql_executed": false
}

ここまでで、次の事実を確認できました。

  • WordPressへログインしていない
  • NonceもCookieも送っていない
  • 匿名RESTリクエストが受理された
  • 要求と異なる固定ハンドラーが選択された
  • WordPressサーバー側でPHP処理が実行された
  • サーバー側へ固定された証跡が作成された

ただし、これは任意コード実行ではありません。

攻撃者がPHPコードやOSコマンドを指定できる機能は存在せず、実行されるのはプラグイン内へ事前定義した固定処理だけです。


脆弱部分を修正する

エラー要求の場合も、$matchesへ同じ位置の要素を追加します。

修正前:

if ( is_wp_error( $item ) ) {
    $validation[] = $item;
    continue;
}

修正後:

if ( is_wp_error( $item ) ) {
    $matches[]    = $item;
    $validation[] = $item;
    continue;
}

この一行を追加します。

$matches[] = $item;

再度、同じKali側コマンドを実行します。

今度は、二番目の要求へ二番目のハンドラーが正しく対応します。

{
  "authenticated": false,
  "wordpress_user_id": 0,
  "responses": [
    {
      "index": 0,
      "status": "input-error"
    },
    {
      "handler": "preview",
      "input": "1,abc,3",
      "legacy_fragment": "NOT IN (1,abc,3)",
      "safe_fragment": "NOT IN (1,3)",
      "sql_executed": false
    }
  ]
}

chain-execution-proof.jsonも新しく作成されません。

一行の配列追加によって、要求とハンドラーの対応関係が復元されました。


安全な防御ラボを作る

ここからは、実際に手を動かして学習します。

公開済みの攻撃PoCや、脆弱なWordPressコアを使う必要はありません。

本記事のラボでは、WordPress 7.0.2の修正版を使い、次の二点だけを安全に再現します。

  1. 入力形式によってサニタイズ結果が変わる設計の危険性
  2. 認可なしで状態変更できるAPIと、認可ありAPIの違い

ラボが行わないこと

  • 本物のwp2shellペイロードを送信しない
  • SQLを不正実行しない
  • 管理者を不正作成しない
  • Webシェルを配置しない
  • 任意コマンドを実行しない
  • 任意のファイル名・内容を書き込まない
  • 外部ネットワークへ攻撃を送らない

ラボで行うこと

  • WordPress 7.0.2をDockerで起動
  • MariaDBを内部ネットワークで起動
  • Kali Linuxコンテナから安全なAPIを呼ぶ
  • 旧式SQL文字列と安全な正規化結果を比較
  • 未認証状態変更の危険性を固定JSONファイルで体験
  • 権限確認を追加した安全版と比較
  • 緊急Batchブロックを有効化
  • ファイル、設定、ユーザーを調査

ラボ一式

次のボタンからZIPをダウンロードし、Windows、macOS、LinuxのDocker環境へ展開してください。


wp2shell-defender-lab.zipをダウンロード

このZIPには、Docker Compose、学習用プラグイン、MUプラグイン、Nginx・Apache緊急ルール、調査用スクリプトを収録しています。


ラボの構成

ホストPC
  │
  ├─ 127.0.0.1:8088
  │      ↓
  │   WordPress 7.0.2
  │
  └─ Docker内部ネットワーク
         ├─ MariaDB
         ├─ Kali Linux
         └─ WP-CLI

Dockerネットワークはinternal: trueで作成します。

WordPressの公開ポートも127.0.0.1だけへバインドします。

127.0.0.1:8088:80

同じLANの別PCや、インターネットからはアクセスできません。


Step 1:ラボを展開する

Linux・macOS

unzip wp2shell-defender-lab.zip

cd wp2shell-defender-lab

Windows PowerShell

Expand-Archive `
  .\wp2shell-defender-lab.zip `
  .\wp2shell-defender-lab

Set-Location .\wp2shell-defender-lab

構成は次のとおりです。

wp2shell-defender-lab/
├── docker-compose.yml
├── README.md
├── plugin/
│   └── wp2shell-defender-lab/
├── mu-plugins/
├── nginx/
├── apache/
├── scripts/
└── lab-events/

Step 2:WordPressを起動する

docker compose up -d db wordpress

状態を確認します。

docker compose ps

ログを確認します。

docker compose logs -f wordpress

WordPressの起動を確認したら、初期設定を行います。

docker compose run --rm wpcli core install \
  --url=http://localhost:8088 \
  --title="wp2shell Defender Lab" \
  --admin_user=labadmin \
  --admin_password='Change-This-Lab-Password-Now' \
  --admin_email=labadmin@example.invalid \
  --skip-email

学習用プラグインを有効にします。

docker compose run --rm wpcli \
  plugin activate wp2shell-defender-lab

ブラウザで開きます。

http://127.0.0.1:8088

Step 3:Kali Linuxコンテナを起動する

docker compose up -d kali

学習に必要なcurljqだけを入れます。

docker compose exec kali bash -lc '
apt-get update &&
apt-get install -y curl jq
'

Kaliを使用していますが、侵入ツールは使いません。

このラボにおけるKaliは、WordPressとは別のクライアントからHTTP要求を送るための隔離端末です。


実験1:入力の型が違うだけで結果が変わる

学習用エンドポイントへ、文字列形式の投稿者ID一覧を送ります。

docker compose exec kali bash -lc '
curl -sS -X POST \
  http://wordpress/wp-json/wp2shell-lab/v1/query-preview \
  -H "Content-Type: application/json" \
  -d "{\"author__not_in\":\"1,abc,3\"}" |
jq
'

このAPIはSQLを実行しません。

旧式の文字列連結結果と、wp_parse_id_list()を通した安全な結果を表示するだけです。

出力例:

{
  "warning": "Educational preview only. No SQL was executed.",
  "input": "1,abc,3",
  "legacy_preview":
    "AND wp_posts.post_author NOT IN (1,abc,3)",
  "safe_id_list": [
    1,
    3
  ],
  "safe_preview":
    "AND wp_posts.post_author NOT IN (1,3)",
  "lesson":
    "Normalize all accepted input shapes before building SQL."
}

確認ポイント

旧式処理では、文字列がほぼそのままSQL断片へ入ります。

1,abc,3

安全版では、IDとして有効な整数だけが残ります。

1,3

ここで学ぶべきなのは、特定の攻撃文字列ではありません。

外部入力の型を信じず、SQLへ到達する前に型と許容値を固定することです。


実験2:未認証で状態変更できるAPIの危険性

次に、認可なしのREST APIへ要求を送ります。

docker compose exec kali bash -lc '
curl -sS -X POST \
  http://wordpress/wp-json/wp2shell-lab/v1/unsafe-marker |
jq
'

このAPIが行うのは、固定された名前と内容のJSONマーカーを、Web公開領域外の固定フォルダへ作ることだけです。

任意のファイル名や内容は受け付けません。

ホスト側で確認します。

cat lab-events/unauthenticated-marker.json

出力例:

{
  "lab": "wp2shell-defender-lab",
  "actor": "unauthenticated",
  "timestamp": "2026-07-28T00:00:00+00:00",
  "message": "Fixed educational marker."
}

攻撃者がログインしていないにもかかわらず、サーバー側の状態が変わりました。

実際のWebアプリで、この処理が次のような機能だった場合は重大です。

  • ユーザー作成
  • 設定変更
  • ファイルアップロード
  • テンプレート保存
  • プラグインインストール
  • コマンド実行
  • 外部URLへの送信

実験3:権限確認を追加すると拒否される

安全版のエンドポイントへ、同じ未認証状態で要求します。

docker compose exec kali bash -lc '
curl -sS -X POST \
  http://wordpress/wp-json/wp2shell-lab/v1/safe-marker |
jq
'

安全版はpermission_callbackで次を確認します。

current_user_can( 'manage_options' )

未認証の要求は拒否されます。

WordPress REST APIで状態変更を行う独自ルートを作る場合、少なくとも次を確認します。

  • permission_callbackが存在する
  • __return_trueを安易に使っていない
  • ログイン確認だけでなくCapabilityを確認する
  • 読み取りと書き込みで権限を分ける
  • 対象リソースの所有権も確認する

ラボで緊急ブロックを有効化する

配布ラボには、未認証の/batch/v1を一時的に拒否するMUプラグインを含めています。

ファイル名を変更します。

mv \
  mu-plugins/wp2shell-emergency-block.php.disabled \
  mu-plugins/wp2shell-emergency-block.php

WordPressを再起動します。

docker compose restart wordpress

プラグインの中心部分は次です。

add_filter(
    'rest_pre_dispatch',
    static function (
        $result,
        WP_REST_Server $server,
        WP_REST_Request $request
    ) {
        if ( '/batch/v1' !== $request->get_route() ) {
            return $result;
        }

        if ( is_user_logged_in() ) {
            return $result;
        }

        return new WP_Error(
            'rest_batch_temporarily_blocked',
            'Anonymous batch REST access is temporarily disabled.',
            array( 'status' => 403 )
        );
    },
    1,
    3
);

これは緊急緩和策です。

恒久対策ではありません。

Batch APIを正規利用しているテーマ、プラグイン、外部連携へ影響する可能性があります。

可能な限り早くWordPressコアを更新し、更新後は緊急ブロックの必要性を再評価します。


アップデートできない場合の緊急対策

メンテナンス時間、互換性検証、ホスティング制約などにより、直ちに更新できない場合があります。

その場合は、少なくとも/batch/v1への未認証アクセスを一時遮断します。

URL形式は一つではありません。

次の両方を対象にします。

/wp-json/batch/v1

?rest_route=/batch/v1

Nginxの例

location = /wp-json/batch/v1 {
    return 403;
}

if ($arg_rest_route = "/batch/v1") {
    return 403;
}

Apacheの例

RewriteEngine On

RewriteCond %{REQUEST_URI} \
  ^/wp-json/batch/v1/?$ [OR]

RewriteCond %{QUERY_STRING} \
  (^|&)rest_route=%2Fbatch%2Fv1(&|$) [NC]

RewriteRule ^ - [F,L]

Cloudflare等のWAF

次の条件を組み合わせます。

  • URI Pathが/wp-json/batch/v1
  • Query Stringにrest_route=/batch/v1
  • HTTP MethodがPOST
  • 未認証ユーザー

WAFは、WordPressが修正されるまでの時間を稼ぐ防御です。

WAFルールが設定されていても、オリジンサーバーへの直接アクセス、別URL表現、ルールの誤りによって回避される可能性があります。


更新しただけでは、侵害の有無は分からない

パッチは、今後の攻撃経路を閉じます。

更新前に侵入されていなかったことを証明するものではありません。

脆弱な期間に外部公開されていた場合は、少なくとも次を確認します。

  • 不審な管理者アカウント
  • 見覚えのないプラグイン
  • 最近変更されたPHPファイル
  • uploads配下のPHP系ファイル
  • WordPress Cronの追加
  • 設定値の変更
  • ログインセッション
  • Application Passwords
  • Webサーバーから起動された外部プロセス
  • 不審な外向き通信

WordPressコアの改ざんを確認する

cd /var/www/html

wp core verify-checksums

日本語版などロケールを指定する場合:

wp core verify-checksums \
  --locale=ja

Dockerの場合:

docker compose run --rm wpcli \
  core verify-checksums

コアのチェックサムが一致しても、次の領域は別途確認が必要です。

  • wp-content/plugins
  • wp-content/themes
  • wp-content/mu-plugins
  • wp-content/uploads
  • wp-config.php
  • Webサーバー設定
  • OSのCronやsystemd

最近変更されたPHPファイルを探す

直近14日以内に変更されたPHPファイルを一覧化します。

find /var/www/html \
  -type f \
  -name '*.php' \
  -mtime -14 \
  -printf '%TY-%Tm-%Td %TH:%TM %p\n' |
sort

特に確認する場所:

wp-content/plugins/
wp-content/themes/
wp-content/mu-plugins/
wp-content/uploads/
wp-includes/
wp-admin/

更新直後は正規のWordPressファイルも新しい時刻になります。

更新日時だけで不正と断定せず、正規パッケージとの差分、ファイル所有者、ハッシュ、内容を確認します。


uploads配下の実行可能ファイルを探す

find /var/www/html/wp-content/uploads \
  -type f \
  \( \
    -iname '*.php' \
    -o -iname '*.phtml' \
    -o -iname '*.phar' \
    -o -iname '*.php5' \
  \) \
  -print

通常、画像アップロード領域にPHPファイルは必要ありません。

ただし、一部のプラグインが独自処理でファイルを配置する可能性があるため、発見しただけで削除せず、作成日時、所有者、参照元、内容を保全します。


不審な管理者アカウントを確認する

wp user list \
  --role=administrator \
  --fields=ID,user_login,user_email,user_registered

確認項目:

  • 覚えのないユーザー名
  • 外部ドメインのメールアドレス
  • 深夜や休日に作られたユーザー
  • 無効化したはずの旧担当者
  • 通常と異なる命名規則

データベースから確認する場合:

SELECT
    u.ID,
    u.user_login,
    u.user_email,
    u.user_registered
FROM wp_users AS u
JOIN wp_usermeta AS m
  ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%';

テーブル接頭辞がwp_以外の場合は読み替えます。


有効なプラグインを確認する

wp plugin list

有効なものだけ:

wp plugin list \
  --status=active

確認するポイント:

  • 管理画面で見覚えのないプラグイン
  • 一文字だけ違う有名プラグイン名
  • 説明や作者が空欄
  • プラグイン一覧へ表示されないMUプラグイン
  • 最近作られたディレクトリ

MUプラグインも確認します。

find /var/www/html/wp-content/mu-plugins \
  -maxdepth 2 \
  -type f \
  -print

WordPress Cronを確認する

wp cron event list

不審なイベント名、異常に短い実行間隔、見覚えのないコールバックを確認します。

WordPress Cronだけでなく、OS側も確認します。

crontab -l

sudo crontab -l

sudo find /etc/cron.d \
  /etc/cron.daily \
  /etc/cron.hourly \
  -type f \
  -maxdepth 2 \
  -print

systemdタイマー:

systemctl list-timers --all

REST Batchへのアクセスログを調べる

Nginx

grep -E \
  'wp-json/batch/v1|rest_route=.*batch.*v1' \
  /var/log/nginx/access.log*

Apache

grep -E \
  'wp-json/batch/v1|rest_route=.*batch.*v1' \
  /var/log/apache2/access.log*

圧縮済みログも含める場合:

zgrep -E \
  'wp-json/batch/v1|rest_route=.*batch.*v1' \
  /var/log/nginx/access.log*.gz

確認する内容

  • 2026年7月17日前後のPOST要求
  • 短時間に集中したアクセス
  • 通常利用していない送信元
  • 異常に大きいリクエストボディ
  • 400、403、500、207等の応答
  • 同じ送信元から管理画面やプラグインへの後続アクセス

/batch/v1へのアクセスがあっただけで侵害とは限りません。

正規のテーマ、プラグイン、管理ツールがBatch APIを使っている可能性があります。

後続の管理者作成、ファイル変更、ログイン、外向き通信と時系列で関連付けます。


Webサーバーから起動されたプロセスを確認する

RCEが成立すると、PHP-FPMやApacheの子プロセスから、通常は不要なコマンドが起動される可能性があります。

EDR、auditd、SIEMで次の親子関係を監視します。

php-fpm → sh
php-fpm → bash
php-fpm → curl
php-fpm → wget
php-fpm → python
php-fpm → perl
php-fpm → nc
apache2 → sh
httpd → bash

現在のプロセスだけを確認する場合:

ps auxf

ただし、攻撃プロセスは短時間で終了する可能性があります。

事後調査には、EDR、auditd、プロセス監査ログが必要です。


auditdでWebサーバーの実行を監視する

例として、PHP-FPMユーザーが実行したプロセスを監査します。

環境によってユーザーIDを確認します。

id www-data

実行監視ルールの例:

sudo auditctl \
  -a always,exit \
  -F arch=b64 \
  -S execve \
  -F euid=33 \
  -k webserver_exec

検索:

sudo ausearch \
  -k webserver_exec

本番環境へ導入する前に、ログ量と性能影響を確認してください。


uploadsでPHPを実行させない

WordPressが侵害された場合でも、アップロード領域でPHPを実行できなければ、攻撃経路の一部を切断できます。

Nginx

location ~* ^/wp-content/uploads/.*\.(php|phtml|phar|php[0-9]*)$ {
    deny all;
    return 403;
}

より確実にするには、通常のPHP locationより前に評価される設定を確認します。

Apache

wp-content/uploads/.htaccessの例:

<FilesMatch "\.(php|phtml|phar|php[0-9]*)$">
    Require all denied
</FilesMatch>

Apacheの構成やPHPハンドラーによって設定方法が異なります。

必ずテストファイルで403になることを確認してください。


管理画面のファイル編集を無効化する

wp-config.phpへ追加します。

define( 'DISALLOW_FILE_EDIT', true );

これにより、管理画面のテーマファイルエディターとプラグインファイルエディターを無効化できます。

さらに、管理画面からプラグインやテーマの追加・更新を完全に禁止する場合:

define( 'DISALLOW_FILE_MODS', true );

DISALLOW_FILE_MODSは、自動更新や管理画面からのセキュリティ更新も妨げます。

Git、CI/CD、イメージ再構築など、別の安全な更新経路がある環境でのみ使用してください。


Webサーバーユーザーの書き込み範囲を減らす

多くのWordPress環境では、WebサーバーユーザーがWordPress全体へ書き込めます。

これは更新を簡単にする一方、RCE成立後の改ざん範囲を広げます。

理想的な構成は次です。

WordPressコア      → 読み取り専用
プラグイン         → 読み取り専用
テーマ             → 読み取り専用
wp-config.php      → 読み取り専用
uploads            → 書き込み可能
cache              → 必要な場合のみ書き込み可能
upgrade一時領域    → 更新時だけ書き込み可能

コンテナ環境では、アプリイメージを読み取り専用にし、必要なディレクトリだけVolumeへ分離できます。


データベース権限を最小化する

WordPressのDBユーザーへ、不要なグローバル権限やFILE権限を与えてはいけません。

確認:

SHOW GRANTS
FOR 'wordpress'@'%';

少なくとも避けるべき権限:

  • FILE
  • PROCESS
  • SUPER
  • GRANT OPTION
  • 他データベースへの権限

同じDBユーザーを複数サイトで共有しないでください。

一つのWordPressが侵害された際に、別サイトのデータベースまで読まれる原因になります。


外向き通信を制限する

コード実行が成立しても、Webサーバーから自由に外部通信できなければ、次の行動を妨げられます。

  • 追加マルウェアの取得
  • 認証情報の外部送信
  • C2サーバーへの接続
  • 外部スキャン
  • 暗号資産マイナーの取得

許可が必要な通信先を整理します。

  • WordPress.org更新サーバー
  • 正規プラグインのAPI
  • メールサーバー
  • 決済・外部連携API
  • 監視サービス

それ以外の外向き通信を、Firewall、Proxy、Kubernetes NetworkPolicy、クラウドSecurity Groupなどで制限します。


ファイル整合性監視を導入する

更新時だけでなく、通常運用中の変更を監視します。

監視対象

  • wp-admin
  • wp-includes
  • wp-content/plugins
  • wp-content/themes
  • wp-content/mu-plugins
  • wp-config.php
  • Webサーバー設定

選択肢

  • AIDE
  • Wazuh
  • OSSEC
  • Tripwire
  • EDRのFIM機能
  • Git差分監視
  • コンテナイメージ差分

変更を検出した際は、単に「ファイルが変わった」と通知するだけでなく、変更者、時刻、親プロセス、ハッシュ、変更内容を残します。


管理者ログインを強化する

wp2shellの入口は未認証でしたが、侵害後の永続化と再侵入を防ぐため、管理者側の防御も必要です。

  • MFAを必須化
  • 管理者人数を最小化
  • 共有アカウントを廃止
  • 管理画面をVPNまたはIP制限
  • Application Passwordsを棚卸し
  • 長期間未使用のアカウントを無効化
  • ログイン通知
  • 権限変更通知

侵害が疑われる場合、最初にしてはいけないこと

  • 不審ファイルをすぐ削除する
  • ログを消す
  • サーバーを初期化する
  • 原因不明のままサービスを再公開する
  • バックアップを現在の環境へ上書きする
  • 同じパスワードを使い続ける

不審ファイルを削除すると、一時的に表示が戻る場合があります。

しかし、侵入経路、永続化、窃取された情報を調査できなくなります。


推奨するインシデント対応手順

1.外部から隔離する

  • ロードバランサーから外す
  • Firewallでアクセスを制限する
  • メンテナンスページへ切り替える
  • 管理用接続だけを許可する

2.証拠を保全する

  • ディスクスナップショット
  • VMスナップショット
  • コンテナイメージとVolume
  • アクセスログ
  • PHP・Webサーバーログ
  • データベースダンプ
  • プロセス一覧
  • ネットワーク接続

3.影響範囲を確認する

  • WordPressだけか
  • 同一サーバーの別サイトへ到達したか
  • DBサーバーへ横展開したか
  • SSH鍵やクラウド認証情報が読まれたか
  • バックアップへ到達したか
  • CI/CDの秘密情報が漏れたか

4.認証情報をローテーションする

  • WordPress管理者パスワード
  • データベースパスワード
  • WordPress salts
  • Application Passwords
  • FTP・SFTP
  • SSH鍵
  • クラウドAPIキー
  • SMTP認証
  • 決済・外部サービスAPIキー

5.既知正常環境から再構築する

侵害が確認された場合、現在のファイルから不審部分だけを削除して使い続けるより、既知正常なイメージから再構築する方が安全です。

新しいOS・コンテナ
    ↓
修正版WordPress
    ↓
正規配布元からプラグイン・テーマを再取得
    ↓
検査済みuploadsを復元
    ↓
検査済みデータベースを復元
    ↓
新しい認証情報で接続
    ↓
監視を有効化
    ↓
再公開

バックアップがあっても安心できない理由

バックアップが侵害後に取得されたものであれば、不正ユーザー、改ざんDB、悪意あるファイルを含んでいます。

また、攻撃者がバックアップ先へ到達していれば、バックアップ自体が改ざんされている可能性があります。

必要なバックアップ設計

  • 複数世代
  • 別アカウント・別ホスト
  • WordPressから削除できない
  • イミュータブル保存
  • 復元テスト
  • 取得日時とハッシュの記録

更新以外に必要な防御レイヤー


wp2shell対策として更新、WAF、実行阻止、検知、封じ込め、復旧を重ねる多層防御
パッチは入口を閉じる。多層防御は、実行、永続化、横展開、復旧までを守る。
防御層 対策 目的
露出削減 Batch API一時遮断、管理画面IP制限 攻撃面を減らす
入口防御 WAF、レート制限、Bot制御 既知攻撃を入口で止める
実行阻止 uploadsでPHP禁止、読み取り専用配置 侵入後のコード実行を難しくする
権限制限 DB・OS・WordPressの最小権限 被害範囲を限定する
検知 FIM、EDR、ログ監視 侵害を早期発見する
封じ込め 隔離、秘密情報ローテーション 横展開と再侵入を止める
復旧 既知正常イメージ、イミュータブルバックアップ 安全な状態へ戻す

WordPress開発者が学ぶべきコードレビュー項目

wp2shellはWordPressコアの問題ですが、同じ設計不備は独自プラグインや業務用WordPressにも発生します。

WP_Queryへの外部入力

次を検索します。

author__not_in
author__in
post__in
post__not_in
meta_query
tax_query
orderby
order

$_GET$_POST、RESTパラメーターを直接渡していないか確認します。

REST API

register_rest_route(
    'example/v1',
    '/update',
    array(
        'methods' => 'POST',
        'callback' => 'update_data',
        'permission_callback' => '__return_true',
    )
);

状態変更処理で__return_trueを使用している場合は、優先的に調査します。

AJAX

add_action(
    'wp_ajax_nopriv_example_action',
    'example_action'
);

wp_ajax_nopriv_は未認証ユーザーから呼び出せます。

ファイル操作、DB更新、ユーザー作成、メール送信が含まれる場合は特に危険です。

ファイル書き込み

file_put_contents
fopen
fwrite
move_uploaded_file
wp_handle_upload
ZipArchive
unzip_file

確認する項目:

  • 保存先を利用者が指定できるか
  • 拡張子を利用者が指定できるか
  • PHP実行可能な場所へ保存されるか
  • パストラバーサルを防いでいるか
  • MIMEと実内容を確認しているか
  • 認可を確認しているか

動的実行

eval
assert
include
require
call_user_func
shell_exec
exec
system
passthru
proc_open

これらが存在するだけで脆弱とは限りません。

外部入力からデータフローが到達するかを調べます。


安全なRESTルートの基本形

register_rest_route(
    'company/v1',
    '/report',
    array(
        'methods' => WP_REST_Server::CREATABLE,

        'permission_callback' =>
            static function () {
                return current_user_can(
                    'manage_options'
                );
            },

        'args' => array(
            'author_ids' => array(
                'required' => true,
                'type' => 'array',
                'items' => array(
                    'type' => 'integer',
                    'minimum' => 1,
                ),
                'sanitize_callback' =>
                    'wp_parse_id_list',
            ),
        ),

        'callback' =>
            'company_create_report',
    )
);

防御は一か所だけに依存させません。

  • JSON Schemaで型を限定
  • sanitize_callbackで正規化
  • permission_callbackでCapability確認
  • Callback内でも対象データを確認
  • SQLは$wpdb->prepare()または安全なAPIを利用
  • 監査ログを残す

よくある質問

WordPress 6.8もRCEの対象ですか

WordPress公式の整理では、6.8.0~6.8.5はauthor__not_inのSQLインジェクション側の影響を受けます。

6.9以降で追加されたREST Batch側の問題は対象外であるため、公式に説明されている完全なwp2shell RCEチェーンの対象ではありません。

ただし、SQLインジェクション自体が安全という意味ではありません。6.8.6以上へ更新してください。

プラグインを全て停止すれば安全ですか

完全なwp2shellチェーンはWordPressコアで成立するため、プラグイン停止だけでは対策になりません。

自動更新が有効なら確認不要ですか

不要ではありません。

WordPress.orgは影響版への強制自動更新を有効化しましたが、環境によって更新が失敗する可能性があります。

実際のバージョンを確認してください。

WAFを入れていれば更新しなくてよいですか

WAFは暫定対策です。

設定漏れ、直接オリジンアクセス、別表現、暗号化内部通信などにより回避される可能性があります。

恒久対策は修正版への更新です。

REST APIを完全停止すべきですか

WordPressのブロックエディターやプラグイン、外部連携がREST APIを利用しています。

完全停止は機能障害を起こす可能性があります。

緊急時は/batch/v1だけを限定的に遮断し、影響を確認します。

7.0.2ではBatch APIが削除されたのですか

削除されていません。

Batch機能は残され、サブリクエストとハンドラーの対応、REST処理中の再入、入力正規化が修正されています。

パッチ後に不審ファイルがなければ安全ですか

不審ファイルが見つからなくても、管理者アカウント、DB設定、Cron、Application Passwords、外部サービスの秘密情報が変更・窃取されている可能性があります。

ファイルだけで判断しないでください。

実際のExploitをラボで試す必要はありますか

防御を理解するために、実際のRCEペイロードを実行する必要はありません。

入力正規化、配列対応、認可、監査ログ、ファイル実行制御を安全な模擬処理で確認できます。


緊急対応チェックリスト

最初の30分

  • WordPressバージョンを確認
  • 6.8.6、6.9.5、7.0.2以上へ更新
  • 更新できない場合はBatch APIを一時遮断
  • アクセスログとサーバースナップショットを保全
  • 不審な管理者とプラグインを確認

当日中

  • コアチェックサム確認
  • 最近変更されたPHPファイルを調査
  • uploads配下の実行可能ファイルを確認
  • CronとMUプラグインを確認
  • Webサーバーの子プロセスを確認
  • 外向き通信ログを確認

侵害が疑われる場合

  • サーバーを隔離
  • 証拠を保全
  • 既知正常環境から再構築
  • 全認証情報をローテーション
  • 同一ホスト・同一DBの別サイトを調査
  • 関係者と顧客への報告要否を判断

恒久対策

  • uploadsでPHP実行禁止
  • コア・テーマ・プラグインを読み取り専用化
  • MFAと管理画面アクセス制限
  • FIM・EDR・SIEM導入
  • DB最小権限
  • 外向き通信制限
  • イミュータブルバックアップ
  • インシデント対応手順の整備

まとめ

wp2shellは、単純な「WordPressにSQLインジェクションがあった」という話ではありません。

今回の本質は、次の二つのセキュリティ境界が同時に壊れたことです。

  • 外部入力を期待する型へ正規化する境界
  • RESTサブリクエストと検証・ハンドラーを正しく対応させる境界

WP_Queryでは、配列の場合だけ整数化し、文字列形式の入力がそのままSQL断片へ入る可能性がありました。

REST Batch処理では、エラーとなったサブリクエストに対応する$matches要素が追加されず、要求、検証結果、ハンドラーの添字がずれる可能性がありました。

さらに、REST dispatch中に新しいトップレベルREST処理を開始できる状態も修正されています。

これらが連鎖し、匿名ユーザーから標準的なWordPressへのコード実行へ発展しました。

WordPress 7.0.2、6.9.5、6.8.6は、単なる不具合修正版ではありません。

サイトの管理権限とサーバー上のコード実行を守るための緊急セキュリティ更新です。

しかし、更新だけで対応を終えてはいけません。

更新前に攻撃を受けていなかったことは、パッチ適用だけでは確認できません。

バージョン確認、ログ調査、管理者棚卸し、ファイル整合性確認、秘密情報のローテーション、多層防御まで行う必要があります。

パッチは脆弱性を閉じます。監視と多層防御は、侵入された事実を見つけ、侵入後の被害を止め、安全に復旧するために必要です。

記事を読み終えたら、安全な隔離ラボで確認する

入力正規化、認可なしの状態変更、緊急Batchブロック、WordPress調査コマンドを、自分のPC内だけで確認できます。


防御学習ラボをダウンロード


公式・一次資料

本記事は2026年7月28日時点で公開されているWordPress公式リリース、GitHub Security Advisory、WordPressコア差分、Searchlight Cyberの公開情報を基に作成しています。インシデント調査では、公開情報だけで侵害の有無を断定せず、実際のログ、ファイル、データベース、ネットワーク記録を保全して専門家へ相談してください。

ABOUT ME
Yuusuke
業務システムの要件定義・設計・実装を担当。Python、PHP、JavaScript、VBA、Oracle、MySQLなど、実務と個人開発で検証した内容を発信しています。