Modular DS plugin
Unauthenticated Privilege Escalation
This blog post is about an Unauthenticated Privilege Escalation vulnerability in the Modular DS plugin. Patchstack has issued a mitigation rule to protect against exploitation of this vulnerability. If you're a Modular DS user, please update to at least version 2.5.2.
This vulnerability was discovered and reported to Patchstack by Teemu Saarentaus from group.one.
✌️ Our users are protected from this vulnerability. Are yours?
Identify vulnerabilities in your plugins and get recommendations for fixes.
Request auditProtect your users, improve server health and earn additional revenue.
Patchstack for hostsAbout the Modular DS plugin
Modular DS is a plugin developed by modulards.com, which has over 40,000 active installs. Its goal is to help managing multiple WordPress websites, including monitoring, updating, as well as performing various tasks remotely.

The security vulnerability
In versions 2.5.1 and below, the plugin is vulnerable to privilege escalation, due to a combination of factors including direct route selection, bypassing of authentication mechanisms, and auto-login as admin.
To provide some context, the plugin exposes its routes through a Laravel-like router under the /api/modular-connector/ prefix in src/app/Providers/RouteServiceProvider.php, boot().
The sensitive routes are grouped under a Route::middleware('auth') group, which is supposed to enforce authentication. However, it turned out to be possible to bypass it because the mechanism ultimately relies on a flawed isDirectRequest() method (vendor/ares/framework/src/Foundation/Http/HttpUtils.php) that bypasses authentication when the "direct request" mode is activated.
This mode can be enabled simply by supplying an origin parameter set to "mo" and a type parameter set to any value.
public static function isDirectRequest(): bool
{
$request = \Modular\ConnectorDependencies\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');
// When is wp-load.php request
if ($isFromQuery) {
return \true;
}
// TODO Now we use Laravel routes but we can't directly use the routes
$isFromSegment = \false && $request->segment(1) === 'api' && $request->segment(2) === 'modular-connector';
if ($isFromSegment) {
return \true;
}
return \false;
}
There is no verification of a signature, secret, IP, or mandatory User-Agent: the simple pair origin=mo&type=xxx is enough for the request to be considered as a Modular direct request.
As well, when the request is considered "direct", the auth middleware in vendor/ares/framework/src/Foundation/Auth/ModularGuard.php only checks if the site is connected to Modular via the validateOrRenewAccessToken() function.
Therefore, as soon as the site has already been connected to Modular (tokens present/renewable), anyone can pass the auth middleware: there is no cryptographic link between the incoming request and Modular itself. This exposes several routes, including /login/, /server-information/, /manager/ and /backup/, which allow various actions to be performed, ranging from remote login to obtaining sensitive system or user data.
Route::middleware('auth')
->group(function () {
Route::get('/login/{modular_request}', [AuthController::class, 'getLogin'])
->name('login');
Route::get('/users/{modular_request}', [AuthController::class, 'getUsers'])
->name('manager.users.index');
Route::get('/server-information', [ServerController::class, 'getInformation'])
->name('manager.server.information');
[...]
#region Cache
Route::get('/cache/clear', [CacheController::class, 'clear'])
->name('manager.cache.clear');
#endregion
#region Manager
Route::get('/manager/{modular_request}', [ManagerController::class, 'index'])
->name('manager.update');
Route::get('/manager/{modular_request}/install', [ManagerController::class, 'store'])
->name('manager.install');
[...]
Route::get('/manager/{modular_request}/delete', [ManagerController::class, 'update'])
->name('manager.delete');
#endregion
# region Safe Upgrade
Route::get('/manager/{modular_request}/safe-upgrade/backup', [SafeUpgradeController::class, 'getSafeUpgradeBackup'])
->name('manager.safe-upgrade.backup');
[...]
Route::get('/manager/{modular_request}/safe-upgrade/rollback', [SafeUpgradeController::class, 'getSafeUpgradeRollback'])
->name('manager.safe-upgrade.rollback');
# endregion
#region Backup
Route::get('/tree/directory/{modular_request}', [BackupController::class, 'getDirectoryTree'])
->name('manager.directory.tree');
[...]
Route::get('/woocommerce/{modular_request}', WooCommerceController::class)
->name('manager.woocommerce.stats');
});
Focus on the /login/{modular_request} route giving an unauthenticated access to wp-admin
In the controller src/app/Http/Controllers/AuthController.php, methodgetLogin(SiteRequest $modularRequest), the code attempts to read a user ID from the body of $modularRequest. If this is empty, it falls back to getting existing admin or super admin users via getAdminUser(), then logs in as that user and returns an admin redirect.
Since this code can be accessed by unauthenticated users because of the flaw previously explained, it allows an immediate privilege escalation.
public function getLogin(SiteRequest $modularRequest)
{
$user = data_get($modularRequest->body, 'id');
if (!empty($user)) {
$user = get_user_by('id', $user);
}
if (empty($user)) {
Cache::driver('wordpress')->forget('user.login');
$user = ServerSetup::getAdminUser();
} else {
Cache::driver('wordpress')->forever('user.login', $user->ID);
}
if (empty($user)) {
// TODO Make a custom exception
throw new \Exception('No admin user detected.');
}
$cookies = ServerSetup::loginAs($user, true);
return Response::redirectTo(admin_url('index.php'))
->withCookies($cookies);
}
This vulnerability has been patched in version 2.5.2 and is tracked with CVE-2026-23550.
The patch
In version 2.5.1, the route was first matched based on the attacker-controlled URL. So if an attacker called
/api/modular-connector/login/anything?..., the router would naturally resolve the login/{modular_request} route and pass it to the filter. Then, in RouteServiceProvider::bindOldRoutes(), if type was not recognized (e.g. foo), the code would not fall back to any alternative route and would simply return the original route (/login/...).
In version 2.5.2, URL-based route matching has been removed. The router no longer matches routes for this subsystem based on the requested path and route selection is now entirely driven by the filter logic.

As well, a default route pointing to an error 404 was added in src/routes/api.php. In src/app/Providers/RouteServiceProvider.php, bindOldRoutes() was refactored: its signature changed (it no longer receives $route), it now retrieves the available routes, and binds it to the current request with a default fallback.


Only afterwards, if the request is a "direct request" and if type is recognized (request, oauth, lb), it replaces $route with an allowed route (by name) and binds it. Otherwise, the code stays on the default route, which results in a 404.
Indicators of Attack & Compromise (IOA/IOC)
According to WP.one Support Engineer's team, first attacks were detected on January 13th around 2AM UTC0. The pattern is a GET call to /api/modular-connector/login/, with origin parameter set to "mo" and type set to "foo".
Following Patchstack mitigation rule deployment, we immediately noticed exploitation attempts matching the same patterns on our client's sites.
When successfully logged in through the flaw, the attacker then attempts to create a new "PoC Admin" WordPress administrator user, using a username containing "admin" and a bogus email address.
So far, we have identified the following attacking IP addresses:
- 45.11.89.19
- 162.158.123.41
- 172.70.176.95
- 172.70.176.52
Conclusion
This vulnerability highlights how dangerous implicit trust in internal request paths can be when exposed to the public internet. In this case, the issue was not caused by a single bug, but by several design choices combined together: URL-based route matching, a permissive "direct request" mode, authentication based only on the site connection state, and a login flow that automatically falls back to an administrator account.
When these elements are chained, an unauthenticated attacker can reach a sensitive internal route and trigger an admin login, leading to a full site compromise. The fact that exploitation was seen in the wild shortly after mitigation rules were deployed confirms that this flaw is actively abused.
This case shows that internal or inter-service features should never be reachable without strong validation of where the request comes from. Authentication should verify the requester itself, not just whether the site is connected, and routing decisions should not depend on attacker-controlled parameters.
Want to learn more about finding and fixing vulnerabilities?
Timeline
🤝 You can help us make the Internet a safer place
Streamline your disclosure process to fix vulnerabilities faster and comply with CRA.
Get started for freeProtect your users too! Improve server health and earn added revenue with proactive security.
Patchstack for hostsReport vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.
Learn more




