
Análisis de causa raíz, verificador de versión pasivo y PoC de laboratorio para CVE-2026-18322, una escalada de privilegios no autenticada en el plugin de WordPress Smart Popup by Supsystic.
Investigación de seguridad sobre CVE-2026-18322, una vulnerabilidad de escalada de
privilegios no autenticada en el plugin de WordPress Smart Popup by Supsystic
(popup-by-supsystic). Este repositorio contiene un análisis de causa raíz derivado del
diff del código fuente upstream, los parches extraídos, una herramienta de detección
(fingerprinter pasivo de versiones) para identificar instalaciones afectadas y un laboratorio para el PoC de explotación.
La vulnerabilidad es pública y está parcheada. Este trabajo se publica para uso defensivo: ayudar a los operadores a encontrar y remediar hosts afectados.
| Rango de versiones | Estado |
|---|---|
< 1.13.0 | Vulnerable |
>= 1.13.0 | Corregida |
1.13.0 (publicada el 31.07.2026) es la primera versión parcheada; repara los tres eslabones de la cadena descrita a continuación. Véase §1 del análisis.
Tres debilidades independientes se combinan para permitir la creación de un administrador sin autenticación:
1. Una colisión en el mapa de permisos — havePermissions() combinaba dos mapas de permisos con
array_merge(). Ambos usan la clave de tipo string PPS_USERLEVELS ('userlevels'), y
array_merge() sobrescribe las claves de tipo string, por lo que la lista corta por defecto del
controlador base reemplazó por completo la lista del módulo popup — eliminando silenciosamente la
restricción de administrador de save y otros nueve métodos:
$permissions = $mod->getController()->getPermissions(); // [... 'save' ...]
$permissionsBase = $mod->getController()->getBasePermissions(); // ['getListForTbl','removeGroup','clear']
$permissions = array_merge($permissions, $permissionsBase); // base wins — 'save' is gone
La comprobación falla abierta: una acción ausente del mapa nunca se deniega.
2. Un nonce reutilizable enviado por correo a desconocidos — save seguía requiriendo un
pps_nonce, pero el correo de confirmación de suscripción incluía esa misma acción de nonce. Para
usuarios no autenticados, un nonce de WordPress se basa en uid=0, por lo que el token generado para
un suscriptor anónimo es válido para cualquier atacante anónimo.
3. Sin lista de roles permitidos del lado del servidor — createWpSubscriber() pasaba el rol
configurado directamente a WP_User::set_role(). La lista de roles seguros del plugin (que excluye
administrator) solo se aplicaba al renderizar el desplegable de administración — un control
únicamente de UI.
Encadenado: suscribirse para obtener un nonce → reutilizarlo contra popup::save para establecer
sub_wp_create_user_role=administrator → activar el flujo de suscripción → cuenta de administrador
persistente.
Recorrido completo con referencias a archivo/línea: docs/ANALYSIS.md.
poc/cve_2026_18322_check.py determina si un sitio ejecuta una versión afectada. Nunca
intenta la explotación: emite GETs HTTP simples y lee la versión que la instalación
publica sobre sí misma.
git clone <this-repo> && cd CVE-2026-18322
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
requests es recomendado pero opcional — la herramienta recurre a la biblioteca estándar.
# 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
Los objetivos pueden indicarse como argumentos o mediante -f (- lee de stdin). Un nombre de host
sin esquema se asume como 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)
Códigos de salida: 0 = sin objetivos vulnerables, 1 = al menos uno vulnerable, 2 = error de uso.
Dos señales pasivas, ambas GETs HTTP simples de recursos públicos:
?ver=PPS_VERSION
(classes/frame.php:410,473), por lo que la página de inicio a menudo filtra la versión exacta.readme.txt — Stable tag: es autoritativo y tiene precedencia sobre un asset en caché
potencialmente obsoleto; el changelog sirve como respaldo.El escaneo de la página de inicio también descubre directorios wp-content renombrados (p. ej. /app/
de Bedrock) para que la búsqueda del readme siga la estructura real del sitio.
La herramienta de detección no intenta la explotación. Aquí no se publica ningún exploit armamentizado para este CVE: el único código que ejecuta la cadena completa está estrictamente restringido al laboratorio local (véase más abajo).
El comprobador pasivo solo realiza fingerprinting de versiones. Su HttpClient no expone ningún
verbo HTTP distinto de GET — impuesto por construcción y verificado en la suite de tests, por lo
que no puede emitir una petición que cambie el estado ni siquiera por error. Nunca llama a
popup::save, envía un parámetro sub_wp_create_user_role, envía un formulario de suscripción,
dispara un correo de confirmación, ni crea o modifica ningún usuario, popup o ajuste. Lee dos recursos
públicos — la página de inicio y readme.txt — y nada más.
Esa seguridad tiene un coste, dicho claramente: el veredicto es tan bueno como los metadatos de versión que publique el host. Compromisos y modos de fallo: §9.
lab/exploit_full_chain.py es la única pieza de código aquí que realiza el ataque real,
y crea un administrador de WordPress. Se publica para que el análisis pueda reproducirse
en lugar de darse por sentado — una afirmación sobre un fallo de autorización que no puede
demostrarse es una aserción, no un hallazgo. Está escrito para el stack desechable en
lab/ y para nada más:
--lab-confirm es obligatorio. Ambas comprobaciones se ejecutan en assert_lab_target() antes
de la primera petición HTTP, por lo que una invocación rechazada no toca el objetivo en absoluto.pps_nonce reutilizable del
correo de confirmación de suscripción a través de la API de Mailpit del laboratorio
(http://localhost:8025/api/v1/…). No hay ninguna ruta de código para recuperar ese correo desde
ningún otro lugar, por lo que la cadena no tiene primer paso contra un host fuera del laboratorio.lab/docker-compose.yml se enlaza a 127.0.0.1.Tal como se distribuye, por lo tanto, no puede apuntarse a un sitio real — se detiene en
REFUSING TO RUN antes de emitir una petición:
$ 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/.
Estas protecciones son estructurales y no decorativas: el script es la cadena real, y lo que lo
mantiene como demostración es que no se ejecutará fuera de un stack local desechable.
Por favor, no las elimines, y véase SECURITY.md — los cambios que las
debiliten, o un exploit independiente destinado a ejecutarse fuera del laboratorio, quedan fuera del
alcance de este repositorio. Para averiguar si un sitio real está afectado, usa poc/ — el
comprobador responde a la misma pregunta sin tocar nada.
Para una respuesta autoritativa en un host que controlas:
grep -n "array_merge(\$permissions, \$permissionsBase)" \
wp-content/plugins/popup-by-supsystic/classes/frame.php
Cualquier salida significa que el host es vulnerable — esa línea es precisamente lo que 1.13.0 reemplazó.
Úsalo solo contra sistemas que poseas o para los que tengas autorización explícita de probar. Véase SECURITY.md.
lab/ contiene un stack Docker desechable que ejecuta la vulnerabilidad de extremo a extremo, para que
el análisis pueda verificarse en lugar de darse por sentado:
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
Su salida más útil es la comparación de versiones — peticiones idénticas contra tres versiones:
./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
El laboratorio es intencionadamente vulnerable y su script de exploit crea un administrador de WordPress. Todo se enlaza a
127.0.0.1, y el script no se ejecutará contra nada que no sea un laboratorio local — véase el exploit del laboratorio se ejecuta solo en el laboratorio. No es un escáner; para comprobar un sitio real, usapoc/.
sub_wp_create_user_role establecido a un rol privilegiado, y peticiones POST a
admin-ajax.php que lleven pl=pps&mod=popup&action=save.Consultas y greps de logs: §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
La suite es totalmente offline — usa fixtures grabadas, nunca objetivos en vivo. Cubre la clasificación de versiones en torno al límite 1.13.0, la normalización de objetivos y las reglas de evidencia que deciden cada estado.
Las propiedades de seguridad se verifican, no solo se documentan: que un escaneo completo no emite
peticiones que cambien el estado, y que HttpClient no expone ningún verbo HTTP distinto de GET.
Dos regresiones encontradas durante el desarrollo quedan fijadas: un readme.txt con CRLF que
derrotaba el parseo anclado a líneas, y una página de error HTTP contada erróneamente como evidencia
del plugin.
Para reproducir el análisis desde las fuentes upstream:
./scripts/fetch_versions.sh # exports 1.11.2 / 1.12.0 / 1.13.0 and regenerates patches/
MIT — véase LICENSE. El plugin analizado es GPLv2-or-later y no se
redistribuye aquí; scripts/fetch_versions.sh lo obtiene del repositorio SVN oficial de WordPress.
| Estado | Significado |
|---|
VULNERABLE | Plugin detectado en < 1.13.0 |
NOT_VULNERABLE | Plugin detectado en >= 1.13.0 |
PLUGIN_DETECTED_VERSION_UNKNOWN | Plugin presente, versión no determinable — verificar manualmente |
PLUGIN_NOT_DETECTED | Sin evidencia del plugin (no es prueba de ausencia) |
ERROR | Objetivo inalcanzable o malformado |