Query Parameter Pollution with Over-Long Input: XSS Root Cause and Defenses

免责声明:本文为个人技术学习与工程实践笔记,所涉操作仅应在获得授权的环境中进行。因不当使用造成的后果由使用者自行承担。

这是几个月前国外研究者发布的一个挑战;标题本想用中文,但中文标题的 SEO 效果不佳,因此仍使用了英文标题。本文从防御视角梳理该案例的成因与治理思路,涉及的验证环境为挑战方提供的示例程序。


挑战背景

挑战源代码如下,可在本地搭建验证环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const express = require('express');
const app = express();

app.set('query parser', 'extended');

app.get('/', (req, res) => {
const redirectUri = req.query.redirect_uri;

if (!redirectUri) {
return res.send("redirect_uri is required");
}

if (redirectUri !== "https://pwnbox.xyz/docs") {
return res.send("Invalid redirect_uri");
}

return res.send(`
<script>
location = new URLSearchParams(window.location.search).get("redirect_uri");
</script>
`);
});

app.listen(3000, () => console.log('Listening on port 3000'));

这段示例中同时存在两类问题:其一,跳转目标没有做白名单校验,属于任意链接跳转;其二,页面在浏览器端重新从 URL 中取出参数值,并直接用于 location 赋值,一旦取值结果带有伪协议,就会转为脚本执行。代码虽然对跳转地址做了一次等值判断,但这个判断发生在服务端,而真正的取值与使用发生在浏览器端,两者并不共享同一套解析结果,因此问题的关键并不在于如何构造输入,而在于同一段查询字符串被两条解析路径理解成了不同的内容。

解析器差异的成因

浏览器端取值依赖的是 new URLSearchParams(window.location.search).get("redirect_uri")。该 API 的语义非常有限:它只做扁平化的键值切分,get() 会返回与参数名关联的第一个值,既不了解嵌套结构,也不认识方括号。

https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/get

image-20260415192614429

image-20260415192638067

服务端则不同。示例通过 app.set('query parser', 'extended') 引入了 qs 的完整解析能力,例如 foo[x]=bar 会被解析成对象结构;同时 qs 对参与解析的参数个数设有上限(arrayLimit,默认值为 1000),一旦超出该上限,带方括号的键名不再按数组或对象语法展开,而是作为普通字面量键名被保留下来。

两个解析器叠加之后,同一段查询字符串就得到了互不相同的语义:

  • 服务端看到的是一个带特殊字符的字面量键名,等值判断因此不通过,校验被”绕过”;
  • 浏览器不认识方括号语法,只把它当成一个无关的键名,随后精确命中末尾那个真正名为 redirect_uri 的参数,得到伪协议取值并执行。

这正是**解析器差异(parser differential)**的典型形态:校验发生在一条解析路径上,而数据的使用发生在另一条解析路径上,两者对同一输入的理解存在分歧。本例的后果是 XSS,而在其他场景中,同样的分歧也可能造成越权判定、缓存投毒,或请求边界错位一类的问题。因此这类风险的治理重点,不在于让某一条正则表达式覆盖更多字符形态,而在于消除”同一输入、两套解析”的结构。

https://www.npmjs.com/package/qs

image-20260415193450182

检测方法

此类问题在流量与日志层面有一定的可观测特征,适合作为常态化监控项:

  • 参数数量与长度异常:单次请求的 query 参数个数明显超出业务预期(例如接近或超过千级)、同名参数大量重复、query 总长度远超正常业务值,这类请求本身就很可疑,通常不是正常用户浏览器产生的。
  • 编码后的方括号与嵌套语法:请求中出现 %5B、%5D 或未编码的 [、],以及形如 a[b]=c 的嵌套键名,应结合业务判断是否属于预期用法。
  • 伪协议与跳转行为:在网关或反向代理日志中检索 javascript:、data:、vbscript: 等 scheme 出现在查询串或跳转响应中的记录;对存在 Location 响应头或前端跳转逻辑的端点,记录跳转目标域名分布,出现站外或非白名单域名即为高风险。
  • 前端侧信号:启用 CSP 报告(report-uri / report-to)后,内联脚本或伪协议导航被拦截时会产生告警,可作为此类 XSS 的最后一道发现手段。

检测的前提是能定位到”服务端校验 + 前端二次取值”的代码模式,因此在代码审计与静态扫描中,应把 URLSearchParams、location.search、URLSearchParams.get 与跳转、location 赋值、innerHTML 组合出现的位置列为重点。

修复方案

治理的核心是让校验与使用基于同一份解析结果,而不是去加强单点过滤:

  1. 不要让前端重新从 URL 取值。服务端完成校验后,应把规范化的结果直接渲染给前端,例如以服务端生成的常量、白名单映射表或签名值的形式下发,前端只做消费,不再解析。
  2. 跳转目标改为集合枚举而非字符串比对。把允许的跳转地址登记为服务端白名单(键到地址的映射),入参只传递一个标识符,这样任何伪协议或站外地址都无法进入跳转路径。
  3. 对跳转目标做 scheme 与 host 双重白名单,仅允许 https(必要时 http),并在解析后基于规范化后的 host 判断,避免依赖字符串前缀或后缀匹配。
  4. 统一解析器并收紧解析上限。同一份请求在整条链路中应使用同一种解析方式,避免”网关用一套、应用用另一套”;同时显式设置参数个数与长度上限(qs 的 parameterLimit、arrayLimit,以及网关层的 URL 长度限制),让异常输入在入口即被拒绝,而不是进入业务判断。
  5. 对渲染位置做上下文相关的输出编码。若确实需要在脚本上下文中使用动态值,应使用 JSON 序列化等方式安全地嵌入,而不是直接拼接字符串。

分层防护

上述修复针对的是应用本身的缺陷,工程上还需要若干层相互独立的控制,避免任何单点失效后直接演变为可利用的 XSS:

  • 网关与 WAF 层:URL 长度、参数个数、同名参数数量的硬限制,以及对方括号嵌套语法和常见 scheme 关键字的识别。需要明确的是,这类规则只能拦截已知形态,本例恰恰说明单点过滤的局限——只要解析语义存在分歧,绕过就会以新形态反复出现,因此网关规则应定位为降低噪声与提高攻击成本,而不是安全边界。
  • 浏览器侧:启用 CSP,限制 script-src 并避免 unsafe-inline,可显著降低伪协议导航被用于执行内联脚本的可行性;同时开启 CSP 报告收集,用于发现遗漏的注入点。
  • 依赖与组件治理:query parser、参数解析库的行为会随版本演进(例如默认上限、嵌套语法处理方式的变化),应把这类基础库纳入依赖清单并定期比对版本,避免长期停留在行为不明确的老版本上。
  • 最小权限:跳转、渲染类端点通常不需要高权限上下文,应按最小必要原则授权;跳转白名单的配置应由服务端持有并具备变更审计,避免被业务代码随意放宽。

排查清单

结合以上分析,可从以下几步确认系统是否存在同类风险:

  1. 检索代码中 URLSearchParams、location.search、location.hash 的取值位置,确认其是否用于跳转、location 赋值或 HTML 拼接。
  2. 检查服务端是否存在针对同一参数的校验逻辑,并核对该校验所依据的解析方式与前端取值方式是否一致。
  3. 确认请求解析链路:框架的 query parser 配置、网关是否对 query 做过重写或二次编码,是否存在两套解析器。
  4. 检查是否显式设置了参数个数与长度上限,以及超限时的处理方式(拒绝还是继续解析)。
  5. 检查跳转类端点是否使用白名单枚举;如仍为字符串比对,按高风险项整改。
  6. 确认 CSP 与 CSP 报告是否启用,并核对报告接口是否有人跟踪。

Support via Solana

Solana

Solana

Solana Pay

Solana Pay

WeChat

WeChat