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.6.0.
This vulnerability was discovered and reported to Patchstack by Teemu Saarentaus from group.one.
Update 16 Jan 2026
An additional exploit path was discovered that is being actively exploited, this occurred due to another piece of code that sets the authentication of the current user to that of an administrator allowing the execution of any WordPress REST route under the admin privilege. The exploitation attempts that we have seen consists of the creation of an administrator user on the website, typically under the username backup with email backup@wordpress.com and backup1@wordpress.com.
Patchstack has deployed a mitigation rule for this as well as a vulnerability entry under CVE 2026-23800, more IOC/IOA's have been added further down in this blog post.
Again, much credit to the Modular DS team for quickly tackling this and their great communication by releasing a version 2.6.0 that resolves this new vulnerability.
✌️ 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
- 185.196.0.11
- 64.188.91.37
New IOA/IOC for the second vulnerability:
Request path looking like /?rest_route=/wp/v2/users&origin=mo&type=x where the user agent is often firefox and a body payload is provided with a parameter username containing backup and email parameter containing backup@wordpress.com or backup1@wordpress.com.
We have identified the following attacking IP addresses:
- 62.60.131.161
- 185.102.115.27
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




