DVWA系列写到第十篇,终于轮到XSS(DOM)这关了。XSS(DOM)也就是基于DOM的跨站脚本漏洞,很多人在靶场里把Low、Medium、High、Impossible四个等级全打通就算完事,但对两件事还是含糊:一是“为什么DOM XSS不需要经过服务器处理”,二是“为什么服务端明明做了过滤还是被打穿”。这两点没想透,离开靶场到了真实代码里,遇到一个从location.search里取参数再塞进innerHTML的页面,照样认不出漏洞长什么样。这篇就把DVWA的XSS(DOM)源码逐行拆掉,把Low到Impossible四个级别的利用思路、绕过逻辑、修复要点一次说清楚。适合正在刷靶场的同学,也适合那些被前端代码里一堆document.write和innerHTML搞得头皮发麻的开发者。
1. 先弄明白:DOM型XSS和普通反射型XSS的根本区别
1.1 判断标准其实就一条:Payload有没有经过服务端输出
很多人把XSS分成“反射型”“存储型”“DOM型”,却只记了个大概。反射型是服务端把用户提交的参数直接拼进了响应HTML,浏览器解析响应之后执行;存储型是服务端把Payload存进数据库,下次访问页面再输出;DOM型则完全不一样——服务端返回的HTML里根本没有Payload,是浏览器端的JavaScript自己读取了URL中的参数或者window.name这类数据源,然后修改DOM,导致恶意脚本在本地被解析执行。也就是说,DOM XSS的攻击链路是从浏览器到浏览器,服务端从头到尾不知道发生了什么。
举一个生活化的类比。反射型XSS像餐厅把客人点的菜名直接写进菜单又端回来,厨师照着菜单做了一盘问题菜;DOM型XSS则是服务员把一张空白贴纸递给客人,让客人自己填写内容并贴到厨房的看板上,厨房看到什么就做什么。问题不在于餐厅的原料检查机制,而在于那张贴纸上的内容从来没有被任何人审核过。
实战里判断一个XSS到底是不是DOM型,有个很粗暴的办法:在浏览器里打开一个带Payload的链接,同时打开开发者工具的Network面板,看这次请求的响应HTML。如果响应里能找到刚才提交的Payload,那是反射型或者存储型;如果响应里干干净净,但页面还是弹窗了,那就是DOM型。这个判断方法的原理就是看Payload是否“经过”服务端脚本输出。
1.2 为什么前端框架泛滥之后这类漏洞反而更多见
DOM型XSS并不是新东西,但今天的前端工程让它的出现概率大大增加了。单页应用大量使用hash路由或者history路由,很多页面状态直接从URL读取;同时为了交互体验,前端代码免不了要把参数、状态、配置项动态插入页面。问题就出在这些动态插入的位置。
现代框架默认是转义的,比如Vue里的{{ }}插值、React里的{}文本节点,默认不会把用户输入当HTML执行。但开发者一旦用了v-html、dangerouslySetInnerHTML、insertAdjacentHTML这类“逃逸舱口”,或者直接在老代码里用document.write、innerHTML,就等于主动关闭了框架的安全机制。再加上很多人喜欢从URL里读取参数做各种逻辑,比如从location.search里取回调名、从location.hash里取路由参数、从window.name里传数据,这些数据一旦流入危险接口,就是DOM XSS。
这也是为什么很多自动化扫描器对DOM XSS束手无策:它没有实际执行浏览器的JavaScript,自然不知道Payload最终会流到哪里。人工测试反而是发现这类漏洞的主力。
2. DVWA的XSS(DOM)靶场源码逐级拆解
DVWA的XSS(DOM)页面模拟的是一个“语言选择器”场景,打开页面会看到一个语言下拉框,默认是英语,可以切换法语、西班牙语、德语。页面加载后,前端JavaScript从URL里找default=这个参数,如果找到了,就把参数值直接写进下拉框的选项里。整页代码本身不大,但设计得相当巧妙。下面按DVWA 1.9/1.10版本的页面结构为参考,逐级拆解。
2.1 Low级别:一段教科书式的document.write陷阱
先看Low级别的PHP代码:
<?php // 这段PHP只是接收参数,并没有把参数输出到页面上 $default = "English"; if (isset($_GET['lang'])) { $default = $_GET['lang']; } ?>注意一个关键点:$_GET['lang']虽然被赋值给了$default,但这个变量从未被echo到HTML里。也就是说,服务端完全不在乎参数里写了什么,更没有把它返回给浏览器。这是理解DOM XSS的第一步:漏洞发生的位置不在服务端模板,而在浏览器端。
页面里真正“作恶”的是下面的内联JavaScript。
<select name="default"> <script> if (document.location.href.indexOf("default=") >= 0) { var lang = document.location.href.substring(document.location.href.indexOf("default=") + 8); document.write("<option value='" + lang + "'>" + decodeURI(lang) + "</option>"); document.write("<option value='' disabled='disabled'>----</option>"); } document.write("<option value='English'>English</option>"); document.write("<option value='French'>French</option>"); document.write("<option value='Spanish'>Spanish</option>"); document.write("<option value='German'>German</option>"); </script> </select>这段脚本的数据流是这样的:先判断当前URL里有没有default=字符串,有的话就用indexOf定位、用substring截取,把default=后面的整段内容放进lang变量。然后document.write把内容写进页面。第一次document.write生成了一个<option value='...'>标签,属性里放的是URL编码状态的lang,紧接着的文本区域放的是decodeURI(lang)——也就是把百分号编码还原后的内容。
如果攻击者构造了default=%3Cscript%3Ealert(1)%3C%2Fscript%3E,过程是这样的:
lang变量拿到的是%3Cscript%3Ealert(1)%3C%2Fscript%3E,因为document.location.href返回的URL对特殊字符做了百分号编码。decodeURI(lang)还原成<script>alert(1)</script>。document.write把整个选项写进文档流,浏览器HTML解析器看到文本位置出现了<script>标签,立即按脚本解析,弹窗出现。
这里有一个新手经常绕不过来的点:为什么<script>标签放在<option>后面的文本区也能执行?因为<script>标签在HTML语法里是特殊的存在,解析器只要遇到它就会自动切进脚本数据状态,不在根元素下也一样执行。注入成功的关键恰恰是decodeURI(lang)被写进了标签的文本内容区,而不是属性值区。所以大家做实验的时候,不要急着把Payload往lang参数里塞,Low级别前端读的是default=,不是lang=。
2.2 Medium级别:只过滤<script>等于没过滤
Medium级别的PHP加了一道看似安全检查的逻辑:
<?php $default = "English"; if (isset($_GET['lang'])) { $default = $_GET['lang']; if (strpos($default, "<script") !== false) { header("location: ?lang=English"); exit; } } ?>服务端在lang参数里搜索<script字符串,一旦命中就跳转回English。但这段过滤至少有三个绕法。
第一个绕法也是最核心的绕过:过滤对象是lang参数,不是default参数。前端JS读取的是default=,服务端检查的是lang=,两者完全错位。直接构造?lang=English&default=%3Cscript%3Ealert(1)%3C%2Fscript%3E,服务端看到的是lang=English,白名单验尸官看都懒得看第二眼,正常放行;前端JS可不管什么过滤,它找到default=就直接开写。
第二个绕法:就算攻击者把Payload放在lang参数里,strpos查找的也只是<script这一串字符。黑名单的漏网之鱼太多了,我用<img src=x onerror=alert(1)>,里面没有任何<script字样,直接绕过。而且注意这里的注入点始终是decodeURI(lang)输出的文本区,<img>标签会被浏览器当成真实HTML解析,加载失败时触发onerror事件执行脚本。
第三个绕法跟strpos的默认行为有关,它是大小写敏感的。就算不加其他标签,直接用<SCRIPT>或者<ScRiPt>也能把这个检查蒙混过去,因为HTML标签本身大小写不敏感,浏览器依然会当作脚本标签执行。这类黑名单漏洞不是DVWA独有的,任何把“过滤字符串”当安全方案的代码都有同样的病根。
Medium级别的前端JavaScript和Low级别一模一样,没有做任何加固,所以这个关卡实际的防护等于零。分析到这里,你应该能感觉到这道关卡的用意:不要以为加了个过滤函数就安全,校验对象和真正执行数据流的入口对不上号,再多的规则都只是自我安慰。
2.3 High级别:白名单没锁到真正的数据通道
High级别把黑名单升级成了白名单,看起来终于有“安全开发”的样子了:
<?php $default = "English"; $allow = array("English", "French", "Spanish", "German"); if (isset($_GET['lang'])) { $default = $_GET['lang']; if (!in_array($default, $allow)) { $default = "English"; } } ?>白名单里只有English、French、Spanish、German四种语言。如果直接传?lang=%3Cscript%3Ealert(1)%3C%2Fscript%3E,因为不在白名单里,服务端会把$default强制设回English,然后继续渲染页面。表面上滴水不漏。
但问题依旧极其尴尬:白名单校验的是lang参数,前端JS读取的还是default参数。构造一个这样的URL:
?lang=English&default=%3Cscript%3Ealert(1)%3C%2Fscript%3E服务端拿到lang=English,跟白名单一比,通过了,正常输出页面。前端JS在document.location.href里搜索default=,找到后面那串内容,直接写入DOM。弹窗,收工。
这个靶场设计的精髓就在这一关:安全控制与数据通道的错位。真实世界的XSS绕过有很多正是这种状态——开发者在参数A上做了层层校验,但前端实际信任的参数B完全裸露在攻击者面前。审计代码时如果只盯着校验函数看,很容易被带偏;必须把“用户数据从哪里来、最终流向哪里”的完整链路画出来,才能找到真正的问题。
2.4 Impossible级别:正确的防御姿势是什么样
Impossible级别的修复思路不再是在某个边界加过滤,而是从根上拿掉“前端不安全输出用户数据”这个行为。DVWA 1.10版本里,PHP端依然是白名单加输出编码:
<?php $default = "English"; $allow = array("English", "French", "Spanish", "German"); if (isset($_GET['lang'])) { $default = $_GET['lang']; if (!in_array($default, $allow)) { $default = "English"; } } ?>真正的变化在前端。原先那段从URL里截取default=参数再document.write到页面的脚本被整个删掉了。下拉框选项全部来自服务端白名单数组,直接生成:
<option value="English">English</option> <option value="French">French</option> <option value="Spanish">Spanish</option> <option value="German">German</option>这里没有任何一个位置把用户可控数据当作HTML去拼接。攻击者即使构造了花式Payload,它在URL里也只是一个不会被执行字符串。修复DOM XSS的黄金法则就是一句话:用户可控数据永远不要放进HTML解析器。守住这一条,之前那些花哨的绕过思路全部失效。
3. 实操复现:一步步把漏洞打出来
3.1 本地靶场准备
在动手之前强调一句:DVWA是设计给安全教学用的本地漏洞靶场,所有测试请在授权的私有环境里进行,不要拿这套思路去探测没有任何授权的公网站点。本地部署最简单的方式是用Docker拉一个现成镜像:
docker run -d -p 80:80 vulnerables/web-dvwa也可以手动用XAMPP或者LAMP环境部署DVWA源码。启动之后,浏览器访问http://127.0.0.1/,用默认账号登录,先把DVWA Security的级别切到Low。
进去之后找到“XSS (DOM)”页面,你会看到语言选择器正常显示。先用眼睛确认一件事:下拉框里出现的选项是前端JavaScript写入的,而不是服务端HTML里写死的。在页面上按下Ctrl+U查看源代码,搜索document.write,就能找到我们前面分析过的那段脚本。
3.2 Low级别三分钟复现
在页面URL后面直接追加Payload,完整地址长这样:
http://127.0.0.1/dvwa/vulnerabilities/xss_d/?lang=English&default=%3Cscript%3Ealert(1)%3C%2Fscript%3E注意在地址栏输入时,如果直接粘贴<script>,浏览器地址栏会自动把尖括号编码成%3C和%3E,不需要手动转。回车之后,一个弹窗出现,说明XSS成功触发。
打开开发者工具的Elements面板,刷新一下页面,定位到语言选择下拉框,能看到被注入的<option>标签已经躺在里面。这个标签的文本内容就是被解码后的<script>alert(1)</script>,而value属性里还是编码态。看到这个结构就彻底明白为什么漏洞能成立:解码后的Payload出现在文本区,浏览器把它当成HTML标签解析执行。
3.3 Medium和High的绕过验证
把DVWA Security切换到Medium,再用刚才的Payload打一次。如果还用?lang=%3Cscript%3E...这种把Payload放在lang里的方式,会被服务端拦下来重定向。换成参数错位攻击:
http://127.0.0.1/dvwa/vulnerabilities/xss_d/?lang=English&default=%3Cscript%3Ealert(1)%3C%2Fscript%3E照样弹窗。服务端看到lang=English进入流程,根本不会注意default带了什么。接着验证黑名单本身的缺陷,试这个:
http://127.0.0.1/dvwa/vulnerabilities/xss_d/?lang=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E&default=whatever呃,等等,这里如果URL里没有default=参数,前端JS根本不会执行那段写入脚本,所以直接这么传是不弹窗的。要让<img>方案生效,还是得把<img>放在default参数里:
http://127.0.0.1/dvwa/vulnerabilities/xss_d/?lang=English&default=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3EMedium对lang的黑名单检查碰不到default,这条也成立。实战里选择哪一种取决于你想演示哪个层面的绕过:参数错位还是黑名单短板。
High级别验证更简单,切到High后直接用同一个参数错位URL:
http://127.0.0.1/dvwa/vulnerabilities/xss_d/?lang=English&default=%3Cscript%3Ealert(1)%3C%2Fscript%3E白名单看到lang=English,放行;前端看到default=,执行。整个过程我建议在DevTools的Network里看一眼请求和响应:请求URL里带着lang=English和很长的default参数,响应HTML里却没有任何一处回显Payload。这个“服务端无感知”的现场证据,比任何理论都更能说明DOM XSS的本质。
3.4 攻击链和影响范围拆解
很多人觉得DOM XSS的危害不如存储型大,因为它看起来只是“本地执行”。实际不是这样。DOM XSS的Payload一旦执行,脚本运行的上下文是当前页面的源、协议和端口,也就是说它拥有和站点自身脚本一样的权限。它完全可以做如下事情:
- 读取Cookie(只要Cookie没标记HttpOnly)和localStorage里的Token,造成账号接管。
- 修改页面内容,注入钓鱼表单,诱导用户输入密码。
- 监听键盘事件,记录用户在这个页面敲的每一个字。
- 向同源接口发送请求,配合CSRF完成转账、改密等敏感操作。
- 读取并外传当前页面上渲染的其他敏感数据。
攻击者只需要让受害者点击一个构造好的链接,不需要在受害者与站点之间做中间人攻击,不需要提前入侵服务器。链接可以藏在论坛帖子、私信、邮件、二维码、短链接里。正因为Payload不经过服务端,WAF扫不到、日志看不到、流量审计也不容易发现,这类漏洞的隐蔽性相当强。
4. 真实业务里DOM XSS的危害与防御
4.1 两条真实攻击路径
路径一:钓鱼链接加session劫持。攻击者收集到目标站点某个页面存在DOM XSS,于是构造一条恶意链接,伪装成“查看你的好友动态”发送给受害者。受害者点击后,恶意脚本窃取Cookie并发送到攻击者控制的服务器。攻击者拿到Cookie后直接用浏览器插件改Cookie登录受害者账号。这一条链路里受害者没有输入任何密码,却失去了对整个账号的控制权。
路径二:与存储型XSS组合放大危害。如果某系统还有一个允许用户提交富文本内容的地方,攻击者把一段包含恶意脚本的文本保存到服务器,服务器在渲染时做了过滤没出问题,但攻击者借DOM XSS在打开某个页面的瞬间把这段文本内容再次写入DOM。此时页面本身是安全的,数据却被跨上下文“绑架”成了执行代码。这种组合拳在分析真实漏洞时见到过不止一次,单看任何一段代码都像没问题,串起来就是完整攻击链。
从影响范围的角度看,最容易中招的是这几类页面:社交平台的分享页、电商的商品详情页、管理后台的日志查看页、后台报表系统。共性在于这些页面都会大量拼接URL参数、路由参数、用户昵称等外部数据,而且因为业务迭代快,前端安全审查往往跟不上。
4.2 开发者侧修复清单
开发修复DOM XSS不能只靠“过滤关键字”,那是治标不治本。可落地的做法按照优先级排列:
禁止不安全DOM接口。代码审查时重点关注
document.write、innerHTML、outerHTML、insertAdjacentHTML,凡是能用textContent、innerText、createTextNode实现的场景,一律不用HTML拼接。例如原本是el.innerHTML = userInput,改成el.textContent = userInput即可切断HTML解析。使用安全的参数校验。URL参数里如果只有固定枚举值,直接做白名单校验;如果必须接收任意字符串,在输出到DOM前做HTML实体编码。JavaScript里可以写一个编码函数,把
< > & " '全部转义。禁止把第三方数据直接拼进
eval、new Function、setTimeout的字符串形式、location.href赋值等执行接口。这些接口比DOM注入更危险,一旦被控制就是直接任意脚本执行。对输出到HTML属性中的值做上下文编码。同一个数据放在HTML文本中、属性中、script中和URL中需要的编码规则完全不同,不能一个函数走天下。属性值里尤其要注意双引号或单引号闭合。
使用CSP做纵深防御。即使代码层面有遗漏,内容安全策略也能让内联脚本和外联脚本都执行失败,把漏洞危害从“可执行任意代码”降级成“页面显示异常”。
4.3 CSP到底能挡住什么
CSP,Content Security Policy,是浏览器提供的一层安全策略,它决定哪些脚本被允许执行。一个比较严格的策略可以这样配置:
Content-Security-Policy: default-src 'self'; script-src 'self'这个策略的意思是:脚本只允许从当前同源地址加载,所有内联的<script>代码或onclick、onerror这类事件属性里的脚本都被拒绝。前面Low级别打进去的<script>alert(1)</script>就是内联脚本,在CSP生效的情况下会被浏览器直接阻断,即使Payload已经写进了DOM也执行不了。
不过CSP不是万能药。它需要全局运维配合部署,而且老版本浏览器和新版本之间的支持情况有差异;更关键的是它只能缓解脚本执行,不能修复漏洞本身。最稳妥的方案依然是“不把用户数据交给HTML解析器”,CSP只作为最后一道保险。
5. 常见问题排查与实操经验
5.1 弹窗不出来?先查这三件事
第一件:确认你攻击的是default参数而不是lang参数。DVWA的XSS(DOM)前端读取的就是default=,很多人习惯性往lang=里塞Payload,结果自然看不到任何反应。第二件:确认当前安全级别。如果是High,白名单会拦截非法lang参数;如果是Impossible,整个sink已经被移除,必须切回低级别。第三件:确认浏览器没有把编码后的Payload再加工一次。正常情况下地址栏输入<script>会自动编码,document.location.href返回的也是编码态,decodeURI负责还原。如果你用某些浏览器插件或开发者工具自动解码了URL,可能导致截取到的内容形态和你预期的不一致,建议直接复制标准编码格式的链接。
另外提醒一下:不要把Cookie标记为HttpOnly作为解决XSS的手段。HttpOnly只能防住脚本读取Cookie这一条路径,脚本还可以改页面内容、发请求、记录键盘,危害并未消失。
5.2 用DevTools快速定位DOM XSS
在真实代码审计里找DOM XSS,最有效的方法不是一行行读代码,而是从危险接口反查数据流。具体有两步操作。
第一步,打开开发者工具,用全局搜索功能搜索危险关键字。Chrome的DevTools里按Ctrl+Shift+F可以做全文搜索,直接搜document.write、innerHTML、insertAdjacentHTML、outerHTML、eval、location.href。搜到的每一处,都看一下它的赋值来源是不是外部可控数据。
第二步,在疑似危险的sink处打一个断点,刷新页面,观察调用栈。比如在document.write处打断点,当URL带了Payload时,能看到一次调用从哪一行JavaScript发起、这个函数从哪里拿到参数。顺着调用栈往上层找,就能追到source,可能是location.search、location.hash、window.name、document.referrer等等。只要source和sink之间有任意一段路径没有经过转义或验证,DOM XSS就存在。
5.3 项目实战里的DOM XSS排查心得
我在实际项目里排查DOM XSS时,最大的体会是:不要把精力浪费在“猜测过滤规则”上,而是先把前端JavaScript的数据流画出来。很多系统的前端代码压缩过、混淆过,直接读原文效率很低。我的做法是先定位所有sink,再针对每一个sink回溯它的直接参数来源。一个sink如果是写死的字符串,那就没有风险;只有“sink + 外部可控来源”才构成漏洞。
另一个经验是不要忽略postMessage和window.name这两个数据源。DOM XSS的source远不止URL参数,跨窗口消息postMessage带进来的数据、window.name跨页面传递的数据,都可能成为注入源头。DVWA靶场只演示了URL这个最常见的场景,真实世界的数据源要丰富得多。
还有些经验是踩坑踩出来的。有一类DOM XSS虽然能弹窗,但需要用户交互才能触发,比如点击某个按钮才把数据插入DOM。这类占位在自动化扫描里很容易被漏掉。测试时一定要模拟完整的用户操作流程。另外,现代浏览器已经在部分场景下对某些XSS行为做了内置防御,比如恶意脚本加载失败时控制台会有拦截提示,但这不代表漏洞不存在,只代表浏览器帮你挡了一枪。换一个没有内置防御的浏览器,或者绕过防御机制的Payload,该执行还是会执行。
最后分享一个从靶场带进工作里的习惯:遇到任何前端从URL或存储数据里取内容并往页面里塞的场景,第一反应永远是问一句“如果这里出现一段HTML字符串,会发生什么”。把这个问题问多了,面对DVWA XSS(DOM)这种题目,基本就是一眼看穿的事。