
Root-cause analysis, passive version checker, and lab PoC for CVE-2026-18322, an unauthenticated privilege escalation in the Smart Popup by Supsystic WordPress plugin.
Security research on CVE-2026-18322, an unauthenticated privilege-escalation
vulnerability in the WordPress plugin Smart Popup by Supsystic
(popup-by-supsystic). This repository contains a root-cause analysis derived from the
upstream source diff, the extracted patches, a detection tool (passive version fingerprinter) for identifying affected installations and a lab for exploitation PoC.
The vulnerability is public and patched. This work is published for defensive use: helping operators find and remediate affected hosts.
| Version range | Status |
|---|---|
< 1.13.0 | Vulnerable |
>= 1.13.0 | Fixed |
1.13.0 (released 31.07.2026) is the first patched release; it repairs all three links of the chain described below. See §1 of the analysis.
Three independent weaknesses compose into unauthenticated administrator creation:
1. A permission-map collision — havePermissions() combined two permission maps with
array_merge(). Both use the string key PPS_USERLEVELS ('userlevels'), and
array_merge() overwrites string keys, so the base controller's short default list
replaced the popup module's list wholesale — silently stripping the administrator
restriction from save and nine other methods:
$permissions = $mod->getController()->getPermissions(); // [... 'save' ...]
$permissionsBase = $mod->getController()->getBasePermissions(); // ['getListForTbl','removeGroup','clear']
$permissions = array_merge($permissions, $permissionsBase); // base wins — 'save' is gone
The check fails open: an action absent from the map is never denied.
2. A reusable nonce mailed to strangers — save still required a pps_nonce, but the
subscription confirmation email embedded that exact nonce action. For logged-out users a
WordPress nonce is keyed on uid=0, so the token minted for an anonymous subscriber
verifies for any anonymous attacker.
3. No server-side role allowlist — createWpSubscriber() passed the configured role
straight to WP_User::set_role(). The plugin's safe role list (which excludes
administrator) was applied only when rendering the admin dropdown — a UI-only control.
Chained: subscribe to get a nonce → replay it against popup::save to set
sub_wp_create_user_role=administrator → trigger the subscribe flow → persistent admin
account.
Full walkthrough with file/line references: docs/ANALYSIS.md.
poc/cve_2026_18322_check.py determines whether a site runs an affected version. It never
attempts exploitation: it issues plain HTTP GETs and reads the version the install
publishes about itself.
git clone <this-repo> && cd CVE-2026-18322
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
requests is recommended but optional — the tool falls back to the standard library.
# Single target
python3 poc/cve_2026_18322_check.py https://example.com
# With supporting evidence
python3 poc/cve_2026_18322_check.py https://example.com -v
# Many targets, concurrently, exporting results
python3 poc/cve_2026_18322_check.py -f targets.txt -t 20 --json results.json
python3 poc/cve_2026_18322_check.py -f targets.txt --csv results.csv --only-vulnerable
# Through a proxy, ignoring TLS errors (lab use)
python3 poc/cve_2026_18322_check.py https://staging.internal -k --proxy http://127.0.0.1:8080
Targets may be given as arguments or via -f (- reads stdin). A bare hostname is
assumed to be https://.
$ python3 poc/cve_2026_18322_check.py https://example.com -v
[VULNERABLE] https://example.com/ (Smart Popup by Supsystic 1.11.2) < 1.13.0 affected by CVE-2026-18322; upgrade to 1.13.0+
- asset-version: plugin asset enqueued with ?ver=1.11.2 (https://example.com/)
- homepage-reference: references /plugins/popup-by-supsystic/ (https://example.com/)
- readme: plugin readme.txt is publicly readable (https://example.com/wp-content/plugins/popup-by-supsystic/readme.txt)
- readme-stable-tag: Stable tag: 1.11.2 (.../readme.txt)
Exit codes: 0 = no vulnerable targets, 1 = at least one vulnerable, 2 = usage error.
Two passive signals, both plain HTTP GETs of public resources:
?ver=PPS_VERSION
(classes/frame.php:410,473), so the homepage often leaks the exact version.readme.txt — Stable tag: is authoritative and takes precedence over a
potentially stale cached asset; the changelog serves as a fallback.The homepage scan also discovers renamed wp-content directories (e.g. Bedrock's /app/)
so the readme lookup follows the site's real layout.
The detection tool does not attempt exploitation. No weaponized exploit for this CVE is published here: the only code that runs the full chain is hard-gated to the local lab (see below).
The passive checker performs version fingerprinting only. Its HttpClient exposes no
HTTP verb other than GET — enforced by construction and asserted in the test suite, so
it cannot issue a state-changing request even by mistake. It never calls popup::save,
sends a sub_wp_create_user_role parameter, submits a subscription form, triggers a
confirmation email, or creates or modifies any user, popup, or setting. It reads two public
resources — the homepage and readme.txt — and nothing else.
That safety comes with a cost, stated plainly: the verdict is only as good as the version metadata the host publishes. Tradeoffs and failure modes: §9.
lab/exploit_full_chain.py is the one piece of code here that performs the actual attack,
and it creates a WordPress administrator. It is published so the analysis can be
reproduced rather than taken on trust — a claim about an authorization bug that cannot be
demonstrated is an assertion, not a finding. It is written for the disposable stack in
lab/ and for nothing else:
--lab-confirm is mandatory. Both checks run in assert_lab_target() before the
first HTTP request, so a rejected invocation touches the target not at all.pps_nonce from the
subscription confirmation email through the lab's Mailpit API
(http://localhost:8025/api/v1/…). There is no code path for retrieving that email from
anywhere else, so the chain has no first step against a host outside the lab.lab/docker-compose.yml binds to 127.0.0.1.As shipped it therefore cannot be pointed at a real site — it stops at
REFUSING TO RUN before issuing a request:
$ python3 exploit_full_chain.py https://example.com --lab-confirm
REFUSING TO RUN: example.com resolves to non-local address(es) 104.20.23.154, ...
This script creates a WordPress administrator and is for the local lab only.
To test a real site, use the non-destructive checker in ../poc/.
These guards are load-bearing rather than decorative: the script is the real chain, and
what keeps it a demonstration is that it will not run outside a throwaway local stack.
Please do not remove them, and see SECURITY.md — changes that
weaken them, or a standalone exploit meant to run outside the lab, are out of scope for
this repository. To find out whether a real site is affected, use poc/ — the
checker answers the same question without touching anything.
For an authoritative answer on a host you control:
grep -n "array_merge(\$permissions, \$permissionsBase)" \
wp-content/plugins/popup-by-supsystic/classes/frame.php
Any output means the host is vulnerable — that line is precisely what 1.13.0 replaced.
Use only against systems you own or are explicitly authorized to test. See SECURITY.md.
lab/ contains a disposable Docker stack that runs the vulnerability end-to-end, so
the analysis can be verified rather than taken on trust:
cd lab
./setup.sh # WordPress + plugin 1.12.0
../.venv/bin/python exploit_full_chain.py http://localhost:8080 --lab-confirm
./verify.sh # confirm from the database
./teardown.sh
Its most useful output is the version comparison — identical requests against three releases:
./setup.sh --plugin-version 1.11.2 # exploit succeeds
./setup.sh --plugin-version 1.12.0 # exploit succeeds
./setup.sh --plugin-version 1.13.0 # exploit fails at phase 2
The lab is intentionally vulnerable and its exploit script creates a WordPress administrator. Everything binds to
127.0.0.1, and the script will not run against anything but a local lab — see the lab exploit runs in the lab only. It is not a scanner; to check a real site, usepoc/.
sub_wp_create_user_role set to a privileged role, and POST requests to
admin-ajax.php carrying pl=pps&mod=popup&action=save.Queries and log greps: §10.
.
├── README.md
├── SECURITY.md Scope, authorized-use policy, disclosure
├── LICENSE MIT
├── requirements.txt
├── docs/
│ └── ANALYSIS.md Full root-cause analysis and fix rationale
├── patches/
│ ├── 01-frame-permission-merge.diff array_merge() → _mergePermissions()
│ ├── 02-subscribe-role-allowlist-and-nonce.diff Role allowlist + dedicated nonce
│ └── 03-v1.12.0-confirmation-page-hardening.diff Unrelated hardening added in 1.12.0
├── poc/
│ └── cve_2026_18322_check.py Passive version fingerprint (GET only)
├── lab/ Disposable Docker lab — INTENTIONALLY VULNERABLE
│ ├── docker-compose.yml MariaDB + WordPress 1.12.0 + Mailpit (loopback only)
│ ├── setup.sh / teardown.sh Bring the lab up / destroy it
│ ├── exploit_full_chain.py Full attack chain — CREATES AN ADMIN; lab-gated
│ └── verify.sh Independent confirmation from the database
├── scripts/
│ └── fetch_versions.sh Export plugin versions from SVN and regenerate diffs
└── tests/
└── test_check.py Offline unit tests for the passive checker
pip install -r requirements.txt pytest
python3 -m pytest tests/ -v
The suite is fully offline — it uses recorded fixtures, never live targets. It covers version triage across the 1.13.0 boundary, target normalization, and the evidence rules that decide each status.
Safety properties are asserted, not just documented: that a full scan issues no
state-changing requests, and that HttpClient exposes no HTTP verb other than GET.
Two regressions found during development are locked in: a CRLF readme.txt defeating
line-anchored parsing, and an HTTP error page being miscounted as plugin evidence.
To reproduce the analysis from upstream sources:
./scripts/fetch_versions.sh # exports 1.11.2 / 1.12.0 / 1.13.0 and regenerates patches/
MIT — see LICENSE. The analysed plugin is GPLv2-or-later and is not
redistributed here; scripts/fetch_versions.sh fetches it from the official WordPress SVN
repository.
| Status | Meaning |
|---|
VULNERABLE | Plugin detected at < 1.13.0 |
NOT_VULNERABLE | Plugin detected at >= 1.13.0 |
PLUGIN_DETECTED_VERSION_UNKNOWN | Plugin present, version not determinable — verify manually |
PLUGIN_NOT_DETECTED | No evidence of the plugin (not proof of absence) |
ERROR | Target unreachable or malformed |