
Анализ первопричины, пассивный чекер версий и лабораторный PoC для CVE-2026-18322 — неаутентифицированного повышения привилегий в WordPress-плагине Smart Popup от Supsystic.
Исследование безопасности CVE-2026-18322 — уязвимости повышения привилегий без аутентификации в WordPress-плагине Smart Popup by Supsystic (popup-by-supsystic). Этот репозиторий содержит анализ первопричины, основанный на diff исходного кода upstream, извлечённые патчи, инструмент обнаружения (пассивный фингерпринтер версий) для выявления уязвимых установок и лабораторию для PoC эксплуатации.
Уязвимость публична и исправлена. Эта работа опубликована для защитного использования: чтобы помочь администраторам находить и устранять уязвимые хосты.
| Диапазон версий | Статус |
|---|---|
< 1.13.0 | Уязвима |
>= 1.13.0 | Исправлена |
1.13.0 (выпущена 31.07.2026) — первый исправленный релиз; он устраняет все три звена описанной ниже цепочки. См. §1 анализа.
Три независимые слабости складываются в создание администратора без аутентификации:
1. Коллизия карт разрешений — havePermissions() объединяла две карты разрешений с помощью array_merge(). Обе используют строковый ключ PPS_USERLEVELS ('userlevels'), а array_merge() перезаписывает строковые ключи, поэтому короткий список по умолчанию базового контроллера полностью заменял список модуля popup — молча лишая ограничения администратора метод save и девять других методов:
$permissions = $mod->getController()->getPermissions(); // [... 'save' ...]
$permissionsBase = $mod->getController()->getBasePermissions(); // ['getListForTbl','removeGroup','clear']
$permissions = array_merge($permissions, $permissionsBase); // base wins — 'save' is gone
Проверка отказывает открыто: действие, отсутствующее в карте, никогда не запрещается.
2. Многоразовый nonce, отправляемый по почте посторонним — save всё ещё требовал pps_nonce, но письмо с подтверждением подписки содержало именно это действие nonce. Для неавторизованных пользователей WordPress nonce привязан к uid=0, поэтому токен, созданный для анонимного подписчика, проходит проверку для любого анонимного атакующего.
3. Отсутствие серверного списка разрешённых ролей — createWpSubscriber() передавал настроенную роль напрямую в WP_User::set_role(). Безопасный список ролей плагина (который исключает administrator) применялся только при отрисовке выпадающего списка в админке — контроль только на уровне UI.
В цепочке: подписаться, чтобы получить nonce → воспроизвести его против popup::save, чтобы установить sub_wp_create_user_role=administrator → запустить поток подписки → постоянная учётная запись администратора.
Полный разбор со ссылками на файлы/строки: docs/ANALYSIS.md.
poc/cve_2026_18322_check.py определяет, работает ли сайт на уязвимой версии. Он никогда не пытается эксплуатировать уязвимость: он отправляет обычные HTTP GET-запросы и читает версию, которую установка сообщает о себе.
git clone <this-repo> && cd CVE-2026-18322
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
requests рекомендуется, но необязателен — инструмент переключается на стандартную библиотеку.
# 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
Цели можно указывать как аргументы или через -f (- читает stdin). Голое имя хоста предполагается как 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)
Коды выхода: 0 = уязвимых целей нет, 1 = как минимум одна уязвима, 2 = ошибка использования.
Два пассивных сигнала, оба — обычные HTTP GET-запросы к публичным ресурсам:
?ver=PPS_VERSION (classes/frame.php:410,473), поэтому главная страница часто раскрывает точную версию.readme.txt — Stable tag: авторитетен и имеет приоритет над потенциально устаревшим кэшированным ресурсом; changelog служит запасным вариантом.Сканирование главной страницы также обнаруживает переименованные каталоги wp-content (например, /app/ в Bedrock), поэтому поиск readme следует реальной структуре сайта.
Инструмент обнаружения не пытается эксплуатировать уязвимость. Никакого боевого эксплойта для этой CVE здесь не публикуется: единственный код, выполняющий полную цепочку, жёстко ограничен локальной лабораторией (см. ниже).
Пассивный чекер выполняет только фингерпринтинг версий. Его HttpClient не предоставляет никакого HTTP-метода, кроме GET — это обеспечено конструкцией и проверяется в наборе тестов, поэтому он не может отправить изменяющий состояние запрос даже по ошибке. Он никогда не вызывает popup::save, не отправляет параметр sub_wp_create_user_role, не отправляет форму подписки, не инициирует письмо с подтверждением и не создаёт и не изменяет ни пользователя, ни popup, ни настройку. Он читает два публичных ресурса — главную страницу и readme.txt — и ничего больше.
У этой безопасности есть цена, и о ней сказано прямо: вердикт настолько хорош, насколько хороши метаданные версии, публикуемые хостом. Компромиссы и режимы отказа: §9.
lab/exploit_full_chain.py — единственный фрагмент кода здесь, выполняющий реальную атаку, и он создаёт администратора WordPress. Он опубликован, чтобы анализ можно было воспроизвести, а не принимать на веру — утверждение об ошибке авторизации, которое нельзя продемонстрировать, является заявлением, а не находкой. Он написан для одноразового стека в lab/ и ни для чего другого:
--lab-confirm обязателен. Обе проверки выполняются в assert_lab_target() до первого HTTP-запроса, поэтому отклонённый вызов вообще не касается цели.pps_nonce из письма с подтверждением подписки через API Mailpit лаборатории (http://localhost:8025/api/v1/…). Нет никакого пути в коде для получения этого письма откуда-либо ещё, поэтому у цепочки нет первого шага против хоста вне лаборатории.lab/docker-compose.yml привязан к 127.0.0.1.В поставляемом виде его поэтому нельзя нацелить на реальный сайт — он останавливается на REFUSING TO RUN до отправки запроса:
$ 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/.
Эти защиты несут нагрузку, а не служат украшением: скрипт и есть реальная цепочка, и именно то, что он не запустится вне одноразового локального стека, делает его демонстрацией. Пожалуйста, не удаляйте их, и см. SECURITY.md — изменения, ослабляющие их, или автономный эксплойт, предназначенный для запуска вне лаборатории, выходят за рамки этого репозитория. Чтобы узнать, уязвим ли реальный сайт, используйте poc/ — чекер отвечает на тот же вопрос, ничего не затрагивая.
Для авторитетного ответа на хосте, который вы контролируете:
grep -n "array_merge(\$permissions, \$permissionsBase)" \
wp-content/plugins/popup-by-supsystic/classes/frame.php
Любой вывод означает, что хост уязвим — именно эту строку заменила 1.13.0.
Используйте только против систем, которыми вы владеете или на тестирование которых у вас есть явное разрешение. См. SECURITY.md.
lab/ содержит одноразовый стек Docker, который прогоняет уязвимость от начала до конца, чтобы анализ можно было проверить, а не принимать на веру:
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
Его самый полезный вывод — сравнение версий: идентичные запросы против трёх релизов:
./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
Лаборатория намеренно уязвима, и её скрипт эксплойта создаёт администратора WordPress. Всё привязано к
127.0.0.1, и скрипт не запустится ни против чего, кроме локальной лаборатории — см. эксплойт лаборатории запускается только в лаборатории. Это не сканер; чтобы проверить реальный сайт, используйтеpoc/.
sub_wp_create_user_role, установленным в привилегированную роль, и POST-запросы к admin-ajax.php с pl=pps&mod=popup&action=save.Запросы и grep по логам: §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
Набор тестов полностью офлайн — он использует записанные фикстуры, никогда не живые цели. Он покрывает триаж версий через границу 1.13.0, нормализацию целей и правила доказательств, определяющие каждый статус.
Свойства безопасности утверждаются, а не просто документируются: что полное сканирование не отправляет изменяющих состояние запросов и что HttpClient не предоставляет никакого HTTP-метода, кроме GET.
Зафиксированы две регрессии, найденные во время разработки: readme.txt с CRLF, ломающий парсинг с привязкой к строкам, и страница ошибки HTTP, ошибочно засчитанная как свидетельство плагина.
Чтобы воспроизвести анализ из источников upstream:
./scripts/fetch_versions.sh # exports 1.11.2 / 1.12.0 / 1.13.0 and regenerates patches/
MIT — см. LICENSE. Анализируемый плагин распространяется под GPLv2-or-later и не распространяется здесь; scripts/fetch_versions.sh получает его из официального SVN-репозитория WordPress.
| Статус | Значение |
|---|
VULNERABLE | Плагин обнаружен версии < 1.13.0 |
NOT_VULNERABLE | Плагин обнаружен версии >= 1.13.0 |
PLUGIN_DETECTED_VERSION_UNKNOWN | Плагин присутствует, версию определить не удалось — проверьте вручную |
PLUGIN_NOT_DETECTED | Нет свидетельств наличия плагина (не доказательство отсутствия) |
ERROR | Цель недоступна или некорректна |