Critical Privilege Escalation Vulnerability in Modular DS plugin affecting 40k+ Sites exploited in the wild

Published 14 January 2026
Table of Contents

Modular DS plugin

Unauthenticated Privilege Escalation

40K
CVSS 10.0

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?

Web developers

Mitigate vulnerabilities in real-time without changing code.

See pricing
Plugin developers

Identify vulnerabilities in your plugins and get recommendations for fixes.

Request audit
Hosting companies

Protect your users, improve server health and earn additional revenue.

Patchstack for hosts

About 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, method
getLogin(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

14 January 2026, 7:50AM UTC0We received a possible exploit notification on instances having the Modular DS WordPress plugin from group.one's engineering team.
14 January 2026, 9AM UTC0We confirmed the presence of the vulnerability, reached out to Modular DS to disclose details, and assigned CVE ID 2026-23550. Vulnerability entry was published, and a mitigation rule was created for it.
14 January 2026, 9:30AM UTC0Vendor released the patch.
14 January 2026, 11:30AM UTC0Security advisory article publicly released.

🤝 You can help us make the Internet a safer place

Plugin developer?

Streamline your disclosure process to fix vulnerabilities faster and comply with CRA.

Get started for free
Hosting company?

Protect your users too! Improve server health and earn added revenue with proactive security.

Patchstack for hosts
Security researcher?

Report vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.

Learn more

The latest in Security Advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu