CVE-2026-18322: Analysis and Exploitation

By Peter Gabaldon (X / LinkedIn)

TL;DR

CVE-2026-18322 is a vulnerability in Smart Popup by Supsystic plugin for WordPress. This vulnerability allows to create arbitrarily a WordPress administrator by chaining three bugs. The vulnerability is under active exploitation. Recent incidents leveraging this vulnerability has been detected.

The repository regarding this analysis can be found here:

All versions < 1.13.0 are vulnerable, the version with the fix, 1.13.0, was released 31.07.2026.

VersionStatus
< 1.13.0Vulnerable
>= 1.13.0Fixed

The three bugs consists on:

  • First of all, permissions are not well designed and implemented. Unauthenticated users can call methods of the plugin which should be restricted to admin only.
  • Second, even permissions are not well implemented, a nonce is necessary. But this nonce is included in the link sent to the user in then email when it subscribes.
  • Third, the role of the user being created when someone subscribes is enforced only in the UI, but not server-side. This makes possible to specify administrator role.

Chaining the three bugs allows to create a WordPress administrator without authentication and gain full access over the WordPress site.

Let’s get into the details.

How the plugin routes requests

First of all, it is important to understand the architecture that the plugin follows.

The plugin implements its own MVC-style router on top of WordPress. Every request is inspected by framePps::parseRoute() (classes/frame.php:47).

public function parseRoute() {
$pl = reqPps::getVar('pl');
if ($pl == PPS_CODE) { // PPS_CODE === 'pps' (config.php:65)
$mod = reqPps::getMode(); // 'mod' request var
if ($mod) { $this->_mod = $mod; }
$action = reqPps::getVar('action');
if ($action) { $this->_action = $action; }
}
}

So a request carrying pl=pps&mod=<module>&action=<method> selects a module and a controller method. Dispatch then happens in _doExec() (classes/frame.php:301).

protected function _doExec() {
$mod = $this->getModule($this->_mod);
if ($mod && $this->checkPermissions($this->_mod, $this->_action)) {
switch (reqPps::getVar('reqType')) {
case 'ajax':
add_action('wp_ajax_' . $this->_action, [$mod->getController(), $this->_action]);
add_action('wp_ajax_nopriv_' . $this->_action, [$mod->getController(), $this->_action]);
break;
...
}
}
}

The two most import things regarding this architecture are the following:

  • Every dispatched action is reachable by logged-out users, the only gate is checkPermissions() because wp_ajax_nopriv_ is registered unconditionally.
  • Registration happens after the permission check, so checkPermissions() is the single point of authorization for the entire AJAX surface.

In summary, checkPermissions() is the Master Key as it is the only authorization mechanism for the whole plugin’s surface.

Root cause #1 – the permission map collision

In PHP, array_merge() allows to join two arrays. But, and this is the core of the bug, if they are maps (keyed arrays or whatever you call it), entries with the same name are NOT merged but overwritten. Here is a simple example.

Two 1D arrays merged each other. The first one contains the values “00” and “01” and the second one the values “aa” and “ab”. After merging them, the final array contains “00”, “01”, “aa” and “ab”.

But, when using two maps where each of them define an array as their value and they share the SAME name as the key the entry get overwritten. In this example “samename” is used as the key name, the final array contains only one “samename” entry, corresponding to the most “right” array to be merged that has the same name in a key. Thus, overwriting it and the result array only having one entry.

In Smart Popup plugin havePermissions() (classes/frame.php:172) combines two permission maps.

$permissions = $mod->getController()->getPermissions(); // module-specific
$permissionsBase = $mod->getController()->getBasePermissions(); // base-class defaults
$permissions = array_merge($permissions, $permissionsBase); // <-- the bug

The two maps are keyed by the same string constants, defined in config.php:60-61.

define('PPS_METHODS', 'methods');
define('PPS_USERLEVELS', 'userlevels');

The popup controller declares a broad admin-only list (modules/popup/controller.php:383).

public function getPermissions() {
return [
PPS_USERLEVELS => [
PPS_ADMIN => ['createFromTpl', 'getListForTbl', 'remove', 'removeGroup',
'clear', 'save', 'getPreviewHtml', 'exportForDb', 'changeTpl',
'saveAsCopy', 'switchActive', 'outPreviewHtml', 'updateLabel'],
],
];
}

But, the base controller declares a much smaller default (classes/controller.php:16).

$this->_permissions = [
PPS_USERLEVELS => [
PPS_ADMIN => ['getListForTbl', 'removeGroup', 'clear'],
],
];

When they get combined in havePermissions() the result is that PPS_USERLEVELS becomes the smaller default one.

Intended / RealityAdmin-restricted methods
Popup controller intendscreateFromTpl, getListForTbl, remove, removeGroup, clear, save, getPreviewHtml, exportForDb, changeTpl, saveAsCopy, switchActive, outPreviewHtml, updateLabel
Actually enforcedgetListForTbl, removeGroup, clear

Ten methods, including save, silently lose their administrator restriction.

The failure is silent because of how the check is written (classes/frame.php:202-223). The enforcement loop only ever sets $res = false when the requested action is found in a userlevel list:

foreach ($permissions[PPS_USERLEVELS] as $userlevel => $methods) {
$lowerMethods = array_map('strtolower', $methods);
if (in_array($action, $lowerMethods)) { // 'save' is no longer in here
if ($currentUserPosition != $userlevel) { $res = false; }
break;
}
}

An action that is absent from the map is not denied, it simply falls through with $res still true. Dropping a method from the list is therefore equivalent to marking it public. This is a fail-open design: the map is an explicit deny list, so losing an entry removes a restriction rather than triggering an error.

The plugin did not follow a deny-by-default design model.

Root cause #2 – the reusable nonce mailed to strangers

Losing the role check is not yet sufficient. havePermissions() applies a second gate (classes/frame.php:225-245).

$noncedMethods = $mod->getController()->getNoncedMethods();
if (in_array($action, $noncedMethods)) {
$nonce = isset($_REQUEST['_wpnonce']) ? $_REQUEST['_wpnonce'] : reqPps::getVar('_wpnonce');
if (is_admin()) {
if (!wp_verify_nonce($nonce, 'pps_nonce')) { $res = false; }
} else {
if (!wp_verify_nonce($nonce, 'pps_nonce_frontend')
&& !wp_verify_nonce($nonce, 'pps_nonce_export')) { $res = false; }
}
}

Plugin method save is in getNoncedMethods() (modules/popup/controller.php:391), so a valid nonce is still required. And because admin-ajax.php lives under wp-admin/, is_admin() returns true for AJAX requests, so the required nonce action is the generic 'pps_nonce'. Public pages carry pps_nonce_frontend, which admin-ajax.php won’t accept.

That nonce is supposed to be an admin-only secret; it is minted in the admin UI (classes/view.php:215, classes/html.php:896). But it is also embedded in the subscription confirmation email (modules/subscribe/models/subscribe.php:440).

$confirmLinkData = [
'email' => $email,
'hash' => $confirmHash,
'_wpnonce' => wp_create_nonce('pps_nonce'), // <-- same generic action
];

Anyone who submits a subscription form receives, by email, a link containing a valid pps_nonce.

WordPress nonces are not random tokens, they are keyed HMACs over (tick, action, user ID, session token). For a logged-out visitor, the user ID is 0 and the session token is empty.

For unauthenticated users, wp_create_nonce('pps_nonce') is effectively a shared secret common to all logged-out visitors, valid for up to 24 hours (two 12-hour ticks). Even using this nonce as a CSRF token will not be effective because all unauthenticated users share it. The nonce is only valid for the plugin to know that the link was actually generated by the corresponding WordPress instance.

In summary, the nonce is not a barrier actually because it is in the email.

So, at this point it is possible to call without authentication the method save of the plugin after having a confirmation email (received of the one of another user as the nonce will be the same).

The last part is modifying the plugin configuration, specifying the administrator role for new subscribers. After that, subscribing a user will mean creating an administrator.

Root cause #3 – no server-side role allowlist

The final component is that createWpSubscriber() (modules/subscribe/models/subscribe.php:335) creates the WordPress user and then applies a role taken straight from stored popup configuration.

$userId = wp_create_user($username, $password, $email);
if ($userId && !is_wp_error($userId)) {
...
if (isset($popup['params']['tpl'][$pref . '_wp_create_user_role'])
&& !empty($popup['params']['tpl'][$pref . '_wp_create_user_role'])
&& $popup['params']['tpl'][$pref . '_wp_create_user_role'] != 'subscriber') {
$user = new WP_User($userId);
$user->set_role($popup['params']['tpl'][$pref . '_wp_create_user_role']); // unvalidated
}

There is no allowlist and no capability check. Any role string that survives to this point is passed directly to WP_User::set_role().

The plugin does have a safe role list, but it is applied only when rendering the admin dropdown (modules/subscribe/mod.php:152), and it deliberately excludes the dangerous roles.

public function getAvailableUserRolesForSelect() {
...
foreach ($editableRoles as $role => $data) {
if (in_array($role, ['administrator', 'editor'])) { continue; }
...
}
}

So the restriction “you may not pick administrator” existed only in the UI. The server never re-validated the submitted value, a textbook client-side-only control. Even without the permission-map bug, any user who could reach popup::save could assign roles the interface refused to offer.

EXPLOITation

Chaining these three bugs allows to arbitrarily modify the configuration of an existing subscription form/Popup of the plugin. Then, using that form a new administrator will be created. It is not necessary that a form already exists and is configured in the site, the plugin already contains default subscriptions. In case a subscription form is not found in the source the exploit fails back to using ID 1.

KeyWhy it must be in the payload
enb_subscribe=1the form must still accept subscriptions
sub_dest=wordpressroutes the submission to WP user creation; any other value and there’s no createWpSubscriber() call at all
sub_fields[email]the submitted address has to be an accepted field
sub_fields[name]supplies the username
sub_fields[pass]added, not restored — makes the password attacker-chosen rather than random
sub_wp_create_user_role=administratorthe payload
sub_ignore_confirm=1the payload — skips double opt-in so phase 3 creates the user outright

We have created a test WordPress lab to test the vulnerability.

The repository also contains the analysis of vulnerability, the diffing of the path, a passive checker using version fingerprinting, the script to fetch the versions and test suite, among the lab.

It install version 1.12.0 of the plugin and configures it.

Then, using exploit_full_chain.py allows to create an arbitrary administrator chaining the three bugs described. In order to receive the email to get the pps_nonce a mailpit docker container is spinned up also.

The WordPress site contains the plugin installed and the form configured.

As you can see there are any forms/PopUps configured.

Launching the exploit allows to create the administrator (pwned_admin / Pwned!Passw0rd123 in this case).

The exploit sets the password the newly created administrator.

The hijacked save action rewrites the popup config so that, alongside the malicious sub_wp_create_user_role=administrator and sub_ignore_confirm=1, the subscribe form now declares a pass field.

"params[tpl][sub_fields][pass][label]": "Password",
"params[tpl][sub_fields][pass][html]": "password",
"params[tpl][sub_fields][pass][enb]": "1",

Enabling this pass field is what makes the plugin’s createWpSubscriber() read a caller-supplied password instead of generating a random one.

Even there was not any form configured, the exploit defaulted to ID 1 after not finding any reading the source code.

It is possible to see how it was modified, as extracted from the database stored configuration of the plugin.

This is the email that was received in order to obtain the pps_nonce.

The final result is the new administrator being created.

MITIGATION

The fix in 1.13.0 repairs all three links in the chain.

  • Replace array_merge() with a real union
  • Enforce the role allowlist server-side
  • Stop reusing pps_nonce in emails

The three bugs are fixed in version 1.13.0.

Replace array_merge() with a real union

A new method, _mergePermissions() has been defined in classes/frame.php. This walks each permission key and unions the method lists per userlevel instead of letting one map replace the other.

private function _mergePermissions($permissions, $permissionsBase) {
foreach ([PPS_METHODS, PPS_USERLEVELS] as $permKey) {
if (empty($permissionsBase[$permKey])) { continue; }
if (!isset($permissions[$permKey])) { $permissions[$permKey] = []; }
foreach ($permissionsBase[$permKey] as $userlevel => $methods) {
$incoming = is_array($methods) ? $methods : [$methods];
if (isset($permissions[$permKey][$userlevel])) {
$existing = is_array($permissions[$permKey][$userlevel])
? $permissions[$permKey][$userlevel]
: [$permissions[$permKey][$userlevel]];
$permissions[$permKey][$userlevel] = array_unique(array_merge($existing, $incoming));
} else {
$permissions[$permKey][$userlevel] = $incoming;
}
}
}
return $permissions;
}
- $permissions = array_merge($permissions, $permissionsBase);
+ $permissions = $this->_mergePermissions($permissions, $permissionsBase);

Enforce the role allowlist server-side

Method createWpSubscriber() now validates the requested role against the same list the admin UI offers, which already excludes administrator and editor.

- $user = new WP_User($userId);
- $user->set_role($popup['params']['tpl'][$pref . '_wp_create_user_role']);
+ $requestedRole = $popup['params']['tpl'][$pref . '_wp_create_user_role'];
+ // Never trust a role coming from stored popup config - only allow roles
+ // that are also offered in the admin UI's own role picker, which already
+ // excludes 'administrator' and 'editor'.
+ $subscribeMod = framePps::_()->getModule('subscribe');
+ $allowedRoles = $subscribeMod ? $subscribeMod->getAvailableUserRolesForSelect() : [];
+ if (isset($allowedRoles[$requestedRole])) {
+ $user = new WP_User($userId);
+ $user->set_role($requestedRole);
+ }

Note the shape of this check: it is an allowlist keyed lookup, not a denylist of ['administrator', 'editor']. Any role not explicitly offered is rejected, so custom high-privilege roles are covered too. It also fails closed — if the subscribe module is unavailable, $allowedRoles is empty and no role is assigned.

Stop reusing pps_nonce in emails

The confirmation link now uses a dedicated, per-subscriber nonce action.

-$confirmLinkData = ['email' => $email, 'hash' => $confirmHash,
- '_wpnonce' => wp_create_nonce('pps_nonce')];
+// Use a dedicated nonce action tied to this specific subscriber/hash rather
+// than the generic 'pps_nonce' action used to gate admin-only AJAX actions -
+// this link is emailed to the (unauthenticated) subscriber, so it must never
+// double as a valid nonce for anything else.
+$confirmLinkData = ['email' => $email, 'hash' => $confirmHash,
+ '_wpnonce' => wp_create_nonce('pps_subscribe_confirm_' . $confirmHash)];

Binding the nonce action to $confirmHash scopes each emailed token to one subscriber and one purpose, so it can no longer authorize anything else.

CONCLUSION

If you are using this plugin update it as soon as possible, as this is being currently exploited. The severity of this one is critical, as any WordPress with the plugin installed in a vulnerable version is totally vulnerable to full compromise.

Regarding authorization controls, design should always focus following a deny-by-default policy. So a user is not able to perform an operation unless specifically allowed regarding its role, attributes, group or similar (RBAC / ABAC / PBAC). Controls must be always enforced server side, enforcing things in UI is just a facade.

Theses bugs chained together will allow an attacker to fully take over your site.