1. 项目概述:从“反射”到“DOM”的认知跃迁
很多刚接触Web安全的朋友,一提到XSS(跨站脚本攻击),脑子里蹦出来的第一个场景可能就是:在搜索框里输入一段<script>alert(1)</script>,然后点击搜索,页面弹窗了。这确实是XSS,但这是最经典、也最容易被WAF(Web应用防火墙)拦截的反射型XSS。它的攻击载荷(Payload)是作为HTTP请求的一部分(比如查询参数)发送到服务器,服务器“反射”回响应中,最终在浏览器端执行。
但今天我们要聊的,是另一种更“狡猾”、更依赖前端逻辑、常常能绕过传统防护手段的XSS——DOM型XSS。它不经过服务器。是的,你没看错,攻击者的恶意脚本可能压根就没离开过你的浏览器。它的整个“作案过程”都发生在客户端的文档对象模型(DOM)解析与渲染环节。理解这一点,是你从“脚本小子”迈向真正渗透测试员的关键一步。
简单来说,DOM型XSS的漏洞根源在于,前端JavaScript代码不当地信任并操作了来自用户可控源的数据。这些数据源可能是document.location.hash、document.URL、document.referrer,甚至是window.name。当这些数据被innerHTML、document.write、eval等“危险”的DOM API或全局函数处理时,如果缺乏足够的净化,攻击者精心构造的脚本就会被注入并执行。
这个项目,就是一次针对DOM型XSS的深度渗透实战。我们将模拟一个真实的前端应用场景,从漏洞原理分析、攻击向量挖掘、Payload构造,到绕过技巧和防御思路,进行全链条的拆解。无论你是前端开发者想加固自己的代码,还是安全爱好者想提升实战能力,这篇文章都将提供一套清晰的“作战地图”。我会分享很多在真实渗透测试和代码审计中积累的“骚操作”和踩坑经验,这些在标准文档里可找不到。
2. DOM型XSS的核心原理与攻击面剖析
2.1 为什么说DOM型XSS更“高级”?
要理解它的高级之处,我们得先看看它与反射型、存储型XSS的本质区别。传统XSS(反射/存储)的恶意代码,最终是作为服务器HTTP响应体的一部分(HTML内容)发送给浏览器的。因此,安全人员可以在服务器层(如WAF、输入过滤)或网络层进行检测和拦截。
而DOM型XSS的流程是:浏览器收到一个“干净”的HTML文档 -> 解析DOM树 ->JavaScript代码执行-> JavaScript代码从某个URL参数或客户端存储中读取了数据 -> 用不安全的方式将这些数据写入了DOM -> 浏览器渲染这部分新DOM,触发了其中包含的脚本。
整个恶意脚本的“诞生”和“执行”,完全在客户端JavaScript引擎的控制下。服务器自始至终看到的,可能只是一个正常的请求(比如https://victim.com/page#userInput),因为#后面的hash片段默认不会发送到服务器。这就让基于流量检测的防护手段几乎失效。
2.2 关键的攻击源(Source)与危险的接收点(Sink)
这是分析DOM型XSS的黄金法则。你必须像侦探一样,追踪数据从“进入”到“造成危害”的完整路径。
常见的数据源(Source):
document.URL/window.location.href/window.location.search/window.location.hashdocument.referrer(来源页面URL)window.name(跨页面传递的数据)document.cookielocalStorage/sessionStoragepostMessage事件的数据- 通过
URL.createObjectURL()创建的Blob URL(是的,这个也能玩出花)
危险的接收点(Sink):任何能将字符串当作HTML或JavaScript代码来解析、执行的API。
- HTML注入类:
element.innerHTML,element.outerHTML,document.write(),document.writeln() - 脚本执行类:
eval(),setTimeout()/setInterval()的第一个参数是字符串时,Function()构造函数 - 跳转类:
location.href,location.assign(),location.replace()(当赋值为javascript:伪协议时) - 事件处理器类:
element.addEventListener()在某些特定场景下,但更常见的是内联事件处理器如onclick,onload,onerror的属性值被动态设置。 - 现代前端框架的“危险”操作:比如Vue.js的
v-html指令、React的dangerouslySetInnerHTML属性,它们本身就是为直接插入HTML设计的,如果其内容源不可信,就是高危漏洞。
注意:这里有一个非常重要的思维转变。在DOM型XSS中,我们关注的不是“用户输入是否经过了服务器端过滤”,而是“前端JS代码是否对来自Source的数据进行了安全的处理,再送到Sink”。很多开发者的误区是只在后端做一次过滤就高枕无忧,却忽略了前端复杂的、动态的数据流。
2.3 一个经典的漏洞代码模式
让我们看一段极度简化但非常典型的漏洞代码:
<!DOCTYPE html> <html> <body> <script> // 从URL的hash中获取数据(不会发送到服务器) var userData = decodeURIComponent(window.location.hash.substring(1)); // 危险操作:直接将用户数据作为HTML写入DOM document.getElementById("output").innerHTML = "Hello, " + userData; </script> <div id="output"></div> </body> </html>假设这个页面地址是http://example.com/page.html。一个正常的访问可能是http://example.com/page.html#Alice,页面上会显示 “Hello, Alice”。
但攻击者可以构造这样的链接:http://example.com/page.html#<img src=x onerror=alert(document.cookie)>。当受害者点击这个链接时,userData变量的值就是<img src=x onerror=alert(document.cookie)>。它被直接拼接进字符串,并通过innerHTML插入到div#output中。浏览器解析这段新HTML时,会创建一个img标签,其src属性x加载失败,随即触发onerror事件,执行其中的JavaScript代码,窃取用户的Cookie。
关键点在于:服务器收到的请求只是http://example.com/page.html,hash部分#...在HTTP请求中是不可见的。因此,任何服务器端的XSS过滤都对此无能为力。
3. 实战环境搭建与手动漏洞挖掘
3.1 选择合适的“靶场”
纸上谈兵终觉浅。要真正理解DOM型XSS,你必须亲手去挖。对于初学者,我强烈推荐从以下开始:
- DVWA (Damn Vulnerable Web Application):虽然它的XSS关卡主要演示反射型和存储型,但其“DOM型XSS”关卡(如果安装的版本包含)是绝佳的起点。它通常提供一个有缺陷的下拉菜单或输入框,让你直观感受基于
document.write和location.hash的漏洞。 - PortSwigger Web Security Academy (Burp Suite官方靶场):这是目前最好的免费Web安全学习平台之一。它的DOM型XSS实验室由浅入深,涵盖了从基础到各种绕过技巧的完整链条,并且每个实验室都有详细的解决方案和解释。
- 自己编写漏洞代码片段:就像上面的那个简单例子。在本地创建一个HTML文件,故意写一些有问题的JS代码。这是理解原理最快的方式,你可以随意修改Source和Sink,观察不同Payload的效果。
我个人在带新人时,通常会要求他们先自己写3-5个包含不同Source和Sink组合的漏洞页面,然后再去挑战靶场。这样基础才牢固。
3.2 手动挖掘流程与思维导图
当你面对一个真实或仿真的Web应用时,如何系统性地寻找DOM型XSS?我的工作流如下:
第一步:静态分析(看代码)
- 搜索危险Sink:在开发者工具的Sources面板,或者如果有可能拿到前端JS代码,全局搜索
innerHTML、outerHTML、document.write、eval、setTimeout(、Function(等关键词。 - 回溯数据流:找到Sink后,向上回溯,看插入的数据(变量)从哪里来。是否来自
location.search、location.hash、document.referrer等Source?这个回溯过程可能跨越多个函数,需要耐心。 - 分析过滤逻辑:在从Source到Sink的路径上,数据经过了哪些处理?是否有
replace()、encodeURIComponent()、正则表达式过滤、黑名单等?过滤是否彻底?是否存在双写、大小写、编码绕过可能?
第二步:动态分析(浏览器中测试)
- 控制输入,观察流向:在URL参数、Hash、表单输入框等所有可能的输入点,注入特殊的测试Payload,如
'"><svg/onload=alert(1)>或-alert(1)-。然后打开开发者工具的“Debugger”,在疑似Sink的代码行设置断点。 - 监控DOM变化:使用开发者工具的“Elements”面板,观察你的输入最终被插入到了DOM的哪个位置,是否被HTML编码,是否触发了事件属性。
- 测试Source:系统地测试每一个可能的Source。对于
location.hash,直接在地址栏修改。对于postMessage,可以自己写一个恶意页面向目标页面发送消息。对于window.name,可以构造一个中间页面设置window.name后再跳转到目标页。
第三步:构造突破性Payload
- 闭合与逃逸:如果数据被插入到现有的HTML标签属性(如
<div id="[user-input]">)或JavaScript字符串(如var data = '[user-input]';)中,你的Payload首先要做的就是闭合当前的上下文。- 在HTML属性中:你需要先闭合引号,然后引入事件处理器或新标签。如
" onmouseover="alert(1)或"><script>alert(1)</script>。 - 在JS字符串中:你需要闭合字符串引号,插入分号,执行代码,然后再注释掉剩余部分。如
';alert(1);//。
- 在HTML属性中:你需要先闭合引号,然后引入事件处理器或新标签。如
- 利用JavaScript协议:对于
location.href或a标签的href等Sink,可以尝试javascript:alert(1)。但现代浏览器可能会对javascript:协议的内容进行一定编码,需要测试。 - 利用HTML5新特性与解析差异:这是高级绕过技巧的宝库。例如:
- SVG标签:SVG元素内的某些事件处理器可能被更宽松地执行。
<svg onload=alert(1)>。 - 细节标签:
<details ontoggle=alert(1)>,配合open属性或通过其他方式自动触发toggle事件。 - 自定义数据属性:有时过滤脚本会检查
onxxx事件,但忽略其他方式。可以结合DOM Clobbering等技巧。
- SVG标签:SVG元素内的某些事件处理器可能被更宽松地执行。
3.3 实操案例:挖掘一个基于location.search的漏洞
假设我们发现一段这样的代码:
function getSearchParam(key) { const urlParams = new URLSearchParams(window.location.search); return urlParams.get(key); } const username = getSearchParam('u'); document.getElementById('welcome-msg').innerHTML = `Welcome back, <strong>${username}</strong>!`;看起来用了URLSearchParams这个现代API,似乎很安全?它确实能方便地获取查询参数。但问题出在最后一行:它用模板字符串将username直接拼接进了innerHTML。
攻击过程:
- 构造Payload:我们需要让
username的值突破模板字符串的上下文,成为有效的HTML标签或事件。一个简单的测试:http://victim.com/page?u=<img src=x onerror=alert(1)>。 - 结果分析:
URLSearchParams.get('u')会返回%3Cimg%20src=x%20onerror=alert(1)%3E吗?不会,浏览器会自动解码URL编码,所以它拿到的是<img src=x onerror=alert(1)>。这个字符串被直接放入模板字符串,最终innerHTML接收到的字符串是Welcome back, <strong><img src=x onerror=alert(1)></strong>!。 - 漏洞触发:浏览器解析这段HTML,创建了一个
img元素,其src指向一个不存在的x,触发onerror事件,执行alert(1)。
这个案例的教训:即使使用了现代的、安全的API来获取用户输入(URLSearchParams),只要最终使用了不安全的输出方式(innerHTML),漏洞依然存在。安全是一个链条,最薄弱的一环决定了整体强度。
4. 高级绕过技巧与混淆艺术
当目标网站存在一些基础的过滤或WAF时,直接使用简单的<script>标签或onerror事件可能会被拦截。这时就需要一些“骚操作”。
4.1 编码与多重编码
浏览器在解析的不同阶段会对编码进行解码。我们可以利用这种特性。
- HTML实体编码:
<代表<,>代表>,&代表&。如果过滤函数只解码一次,而输出点解码了两次,就可能绕过。例如,Payload<img src=x onerror=alert(1)>,经过一次解码变成<img src=x onerror=alert(1)>,然后被innerHTML解析执行。 - JavaScript Unicode转义:在JS上下文中,
\u003c是<的Unicode转义。如果输入被放入eval()或setTimeout的字符串参数中,且过滤不严,可能有效。如eval('\u0061\u006c\u0065\u0072\u0074\u0028\u0031\u0029')会被执行为alert(1)。 - URL编码:在
location.hash或search中,%3C是<。如果前端JS代码使用了decodeURIComponent来解码,那么传入%3Cimg%20src=x%20onerror=alert(1)%3E就能成功。
4.2 利用非标准的Sink和事件
当常见的Sink被监控时,可以寻找一些“偏门”的利用点。
<iframe>的srcdoc属性:srcdoc属性可以直接内嵌HTML内容。如果我们可以控制srcdoc的值,就可以构造一个完整的恶意页面。例如:<iframe srcdoc="<script>alert(1)</script>"></iframe>。<object>的data属性配合type:某些老式技巧,但现在仍值得一试。- 自动触发的事件:除了
onload,onerror,还有onmouseenter,onfocus等,但需要用户交互。我们可以寻找能自动触发的事件,如<details open ontoggle=alert(1)>,open属性会使它自动展开并触发ontoggle。或者利用<input autofocus onfocus=alert(1)>,autofocus属性会让元素自动获得焦点。
4.3 DOM Clobbering(DOM污染)
这是一种相对高级的技巧,通过向DOM中插入一些具有特殊名称的HTML元素(通常是带有id或name属性的表单元素),来影响全局的JavaScript命名空间,从而改变代码逻辑,最终导向XSS。
简单原理:在浏览器中,如果一个HTML元素(如<a id="x">)具有id或name属性,那么该属性值会作为一个全局变量window.x或document.x的引用。更神奇的是,某些元素的属性也会被提升,例如<a id="config" href="javascript:alert(1)">,可能会产生window.config.href这样的属性。
一个简化案例:假设存在这样的漏洞代码:
if (window.config && window.config.url) { window.location.href = window.config.url; // Sink! }攻击者可以在页面中注入这样的HTML:
<a id="config" name="url" href="javascript:alert(document.domain)"></a>注入后,window.config变成了对这个<a>元素的引用,而window.config.url实际上访问的是该元素的name属性(被提升为了url属性?这里需要精确构造,实际中更复杂,可能利用HTMLCollection)。通过精心构造,可以让window.config.url的值变成javascript:alert(...),从而在location.href赋值时触发。
实操心得:DOM Clobbering 的利用条件比较苛刻,需要代码中存在“先检查某个全局对象/属性是否存在,然后再使用它”的逻辑模式。在实战中不常见,但一旦发现,往往能绕过非常严格的输入过滤,因为攻击载荷看起来只是一些普通的HTML标签和属性,不含任何JavaScript关键字或事件。
5. 自动化工具辅助与漏洞验证
手动挖掘虽然彻底,但效率较低。在实际渗透测试中,我们通常会结合自动化工具进行初筛。
5.1 使用Burp Suite进行扫描和测试
Burp Suite的Scanner和DOM Invader插件是神器。
- 主动扫描:配置好爬虫和扫描范围后,Burp的主动扫描引擎能够检测到一些常见的DOM型XSS漏洞。但它并非万能,对于需要复杂交互或条件触发的漏洞容易漏报。
- DOM Invader (Burp Pro版):这是专门为挖掘DOM型漏洞设计的浏览器内工具。它通过修改浏览器环境,在可控的Source(如URL参数、消息)中注入独特的“Canary”令牌,然后自动监控整个页面生命周期,看这个令牌是否流向了危险的Sink(如
innerHTML,eval),并高亮显示数据流路径。这极大地简化了回溯分析的过程。
我的工作流是:先用Burp的爬虫把目标网站结构摸清楚,然后开启DOM Invader进行手动浏览。在浏览过程中,DOM Invader会自动标记出存在潜在数据流从Source到Sink的页面。我再针对这些标记点进行深入的手动分析和Payload构造。
5.2 使用浏览器开发者工具进行深度调试
无论工具多强大,最终验证和利用都离不开开发者工具。
- Console面板:直接执行JavaScript来测试某些想法,比如查看
document.location.hash的当前值,或者手动调用某个可疑函数。 - Debugger面板:这是核心。在疑似包含Sink的JS文件行号上设置断点,然后触发页面操作(如提交表单、改变hash)。当代码执行到断点时,你可以查看所有变量的当前值,单步执行(F10),步入函数(F11),观察数据是如何一步步流向Sink的。
- Network面板:观察是否有额外的AJAX请求获取了数据,这些数据也可能成为新的Source。特别是关注响应头中的
Content-Type,确保返回的是正确的application/json而不是text/html,后者在JS解析时可能导致问题。
5.3 漏洞验证与概念证明(PoC)构造
找到可疑点后,需要构造一个能稳定触发的PoC。
- 最小化Payload:从最简单的
alert(document.domain)开始。alert(1)虽然通用,但document.domain更能证明你确实在目标域下执行了脚本。 - 考虑触发场景:你的Payload是需要用户点击(如
onclick),还是自动触发(如onload,onerror)?如果是后者,利用成功率更高。 - 制作攻击URL:将完整的Payload进行URL编码,拼接到目标URL上。确保它能在无痕窗口或不同浏览器中稳定复现。
- 编写漏洞报告:一份好的报告应包括:漏洞URL、触发步骤(Step-by-Step)、请求/响应截图、漏洞原理简要说明、修复建议。修复建议可以指向OWASP的DOM型XSS防护指南:“在正确的上下文中对不可信数据进行编码”。对于HTML上下文,使用
textContent代替innerHTML,或使用安全的API如DOMPurify库进行净化;对于JavaScript上下文,永远不要将不可信数据拼接进eval或Function,应使用JSON解析。
6. 防御策略与安全开发建议
作为渗透测试的最后一环,也是最有价值的一环,我们需要知道如何修复它。防御DOM型XSS的核心思想是:严格区分代码和数据。
6.1 前端层面的根本性防御
- 避免使用危险的Sink:这是最有效的方法。问问自己,真的需要用
innerHTML吗?绝大多数时候,textContent或innerText足以安全地显示文本内容。真的需要用eval()吗?99.9%的场景下都有更安全的替代方案。 - 使用安全的API进行净化:
- 对于HTML插入:使用经过严格安全审计的库,如DOMPurify。它会在将字符串插入DOM前,移除所有危险的标签和属性。
cleanHTML = DOMPurify.sanitize(userControlledInput); - 对于URL处理:使用
URL或URLSearchParams对象来解析和构造URL,而不是手动拼接字符串。对于跳转,确保目标URL是白名单内的,或者至少检查协议不是javascript:。 - 对于动态脚本:避免使用
eval和new Function。如果需要加载动态内容,考虑使用JSON.parse()(仅针对JSON数据)或创建script标签并设置其src到可信来源。
- 对于HTML插入:使用经过严格安全审计的库,如DOMPurify。它会在将字符串插入DOM前,移除所有危险的标签和属性。
- 实施严格的上下文相关编码:
- 在HTML上下文中(比如你要把数据放到标签之间
<div>这里</div>),需要对<,>,&,",'等进行HTML实体编码。 - 在HTML属性上下文中(
<div attr="这里">),除了上述字符,空格和引号也需要特别注意。 - 在JavaScript上下文中(
<script>var x = '这里';</script>),你需要进行JavaScript Unicode转义,并处理好引号。 - 在URL上下文中(
<a href="这里">),使用encodeURIComponent进行编码。 - 好消息是,很多现代前端框架(如React, Vue, Angular)的默认模板引擎已经帮你做了大部分上下文编码。但当你使用
dangerouslySetInnerHTML或v-html时,你就自己接管了安全责任。
- 在HTML上下文中(比如你要把数据放到标签之间
6.2 安全开发生命周期(SDL)集成
- 安全培训:让前端开发人员了解什么是DOM型XSS,危险的Source和Sink有哪些。
- 代码审计与组件分析:将DOM型XSS的检测纳入代码审查清单。使用静态应用安全测试(SAST)工具扫描前端JavaScript代码,寻找危险的数据流模式。
- 实施内容安全策略(CSP):CSP是一个强大的深度防御措施。通过HTTP头
Content-Security-Policy,你可以告诉浏览器只允许执行来自特定来源的脚本,禁止内联脚本(包括onclick等事件处理器),从而从根本上杜绝很大一部分XSS攻击。一个严格的CSP头像是给页面套上了一层坚固的盔甲。
这个策略表示:默认只加载同源资源;脚本只能从同源或Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; object-src 'none';https://trusted.cdn.com加载;完全禁止<object>等插件。
6.3 我踩过的坑与心得
- 不要依赖黑名单!早期我曾尝试写一个函数过滤
script、onerror等关键词。结果被大小写混淆(ScRiPt)、双写(scrscriptipt)、编码绕过(script)教做人。安全领域,白名单思维(只允许已知安全的字符或模式)永远比黑名单可靠。 - 注意第三方库和依赖!你写的代码可能很安全,但你引入的某个古老的jQuery插件可能内部使用了
$.html()来操作数据。定期用npm audit或类似工具检查依赖项的安全漏洞。 - DOM型XSS可能“沉睡”很久!我曾遇到一个漏洞,触发条件是:用户从搜索引擎(如Google)的特定链接点击过来(
document.referrer包含特定参数),并且页面上的一个广告区块加载失败(触发onerror事件)。这种组合条件在常规测试中极难发现。这提醒我们,安全测试需要覆盖各种边缘场景和用户行为路径。 - 自动化工具只是辅助。Burp Scanner报告了一个潜在的DOM XSS,但你可能需要手动调整Payload十几次才能成功触发。工具告诉你“这里可能有问题”,而你的经验和手动分析告诉你“如何把可能变成肯定”。这份“手动验证”的能力,正是资深安全工程师的价值所在。