
Независимый PoC и воспроизводимая лабораторная среда для CVE-2026-97332 — неаутентифицированное раскрытие приватных файлов в User Private Files < 2.2.0 на WordPress Multisite.
[!NOTE] Этот репозиторий представляет собой независимое воспроизведение и Proof of Concept для CVE-2026-97332.
Первоначальное обнаружение уязвимости: Akshat Parikh (SN1PER). CNA / advisory: WPScan. Независимый PoC и воспроизведение: Kolya080808.
Раскрытие приватных файлов других пользователей без аутентификации в WordPress-
плагине User Private Files (slug на wp.org user-private-files) до 2.2.0
на multisite-установках. Плагин хранит приватные документы каждого пользователя
внутри обычного дерева uploads и защищает их единственным RewriteRule,
добавленным в корневой .htaccess, который направляет запросы к файлам через PHP-
проверку доступа. В multisite-сети это правило никогда не срабатывает: один обычный
GET — без cookies, без заголовков, без входа в систему — возвращает приватный файл другого
пользователя как статический контент. Broken Access Control, CWE-284, CVSS 3.1 5.3
(AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N).
Этот репозиторий содержит рабочий эксплойт на Python без зависимостей и двухконтейнерную docker-лабораторию (уязвимая версия 2.1.9 и исправленная 2.2.0, обе — WordPress multisite) для воспроизведения атаки от начала до конца.
[!CAUTION] Этот материал предоставлен исключительно для авторизованных исследований в области безопасности и защитного тестирования (см. дисклеймер внизу). Атака на системы, которыми вы не владеете и не управляете без письменного разрешения, незаконна.
Плагин хранит приватные файлы как обычные вложения в плоском подкаталоге uploads
для каждого сайта — wp-content/uploads/upf-docs/ (основной сайт) и
wp-content/uploads/sites/N/upf-docs/ (подсайт N) — без изоляции
по пользователям. Контроль доступа находится не на уровне каталога; он реализован в PHP
(dl-file.php: 403 для анонимных пользователей, затем проверка автора/списка разрешений) и
достигается только через rewrite-правило, добавляемое при активации плагина в корневой
.htaccess:
# appended by 2.1.9 (upvf_actdeact.php), AFTER the WordPress block
RewriteRule ^wp-content/uploads/upf-docs/(.*)$ https://site/?file=$1 [QSA,L]
При мультисайте это никогда не срабатывает по двум независимым причинам:
Сетевой .htaccess сначала перехватывает существующие файлы. WordPress
multisite записывает свой собственный набор правил в корневой .htaccess:
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
Запрос, сопоставленный с физически существующим файлом, прекращает перезапись прямо на этом месте — Apache отдаёт его как статический контент, и mod_rewrite так и не доходит до блока плагина в конце файла. (В односайтовом WordPress используются отрицательные условия !-f/!-d, поэтому тот же плагин работает там.) Это уже раскрывает файлы главного сайта сети.
Пути подсайтов вообще не соответствуют шаблону. В multisite у каждого сайта свои загрузки — wp-content/uploads/sites/N/…, плюс префикс URL /siteN/ в сетях с подкаталогами — тогда как правило жёстко задано как ^wp-content/uploads/upf-docs/ от базового URL главного сайта. Даже после того, как внутренняя перезапись сети убирает префикс /siteN/, сегмент sites/N/ всё равно не совпадает.
Таким образом, для любой сети multisite (как с подкаталогами, так и с поддоменами) неаутентифицированный атакующий напрямую получает приватные файлы:
GET /wp-content/uploads/upf-docs/victim.pdf HTTP/1.1 (main site)
GET /site2/wp-content/uploads/sites/2/upf-docs/victim.pdf HTTP/1.1 (subsite)
Ответ — 200 с телом файла, отдаваемый Apache напрямую из
uploads/; проверка доступа плагина при этом никогда не выполняется.
В URL нет ничего секретного. Плагин формирует имена из числового идентификатора пользователя-владельца и unix-времени загрузки:
| Генератор (2.1.9) | Имя на диске |
|---|---|
современный UI, inc/functions-file.php:73-77 | usr{ID}_{unixtime}_{original} |
классический шаблон, inc/classic-user-functions.php | user_{ID}_doc_{unixtime}.{ext} |
Идентификаторы пользователей в мультисайтовой сети — это небольшие последовательные целые числа из одной общей таблицы wp_users, поэтому атакующему нужно лишь приблизительное время загрузки (окно ±2 минуты — это ≈240 запросов) и либо исходное имя файла, либо только его расширение. Суффиксы при коллизиях (-1, -2) существуют, но встречаются редко.
Версия 2.2.0 заменила перезапись корневого файла на .htaccess на уровне папки upf-docs/.htaccess — RewriteRule ^(.*)$ {home}/?file=$1 [R=302,QSA,L],
плюс Require all denied, когда mod_rewrite отсутствует, — пересоздаваемый при каждом
admin_init (самовосстанавливающийся, для каждого подсайта). Правило для каталога срабатывает для любого
физического пути внутри upf-docs/ независимо от префикса URL, поэтому прямой
GET теперь превращается в 302 на проверку доступа и, для анонимных пользователей, в
403. Файлы не перемещаются; меняется только механизм перехвата. Это различие
между 302 и статической отдачей также используется эксплойтом как поведенческий
детектор.
.htaccess
и файл был доступен для записи при активации (nginx требует ручной
перезаписи от вендора — см. readme плагина).exploit.py использует только стандартную библиотеку Python 3 (http.client), не следует
за перенаправлениями и не отправляет cookies:
GET /wp-content/plugins/user-private-files/readme.txt, разбор Stable tag:.
< 2.2.0 → продолжить; >= 2.2.0 → красное поле NOT VULNERABLE - plugin 2.2.0+ (patched) и код выхода 6. Если readme.txt недоступен для чтения,
выполняется поведенческая проверка несуществующего имени в upf-docs/ (302 = перезапись
на месте, 404 = статическая отдача), она отображается, и запуск продолжается
(--skip-check отключает весь этот шаг).{base}/wp-content/uploads/upf-docs/{file}
или, с --site site2 --site-id 2,
{base}/site2/wp-content/uploads/sites/2/upf-docs/{file}.GET, без Cookie, без Authorization.200 → * LEAKED с первыми строками файла в качестве доказательства, код выхода 0;302 → "redirected to the access check - not vulnerable / patched", код выхода 1;404 → "file not found (wrong name?)" — попробуйте --guess, код выхода 1.