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 namedefault 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 disableddisabled----/option); } document.write(option valueEnglishEnglish/option); document.write(option valueFrenchFrench/option); document.write(option valueSpanishSpanish/option); document.write(option valueGermanGerman/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)还原成scriptalert(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: ?langEnglish); exit; } } ?服务端在lang参数里搜索script字符串一旦命中就跳转回English。但这段过滤至少有三个绕法。第一个绕法也是最核心的绕过过滤对象是lang参数不是default参数。前端JS读取的是default服务端检查的是lang两者完全错位。直接构造?langEnglishdefault%3Cscript%3Ealert(1)%3C%2Fscript%3E服务端看到的是langEnglish白名单验尸官看都懒得看第二眼正常放行前端JS可不管什么过滤它找到default就直接开写。第二个绕法就算攻击者把Payload放在lang参数里strpos查找的也只是script这一串字符。黑名单的漏网之鱼太多了我用img srcx onerroralert(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?langEnglishdefault%3Cscript%3Ealert(1)%3C%2Fscript%3E服务端拿到langEnglish跟白名单一比通过了正常输出页面。前端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 valueEnglishEnglish/option option valueFrenchFrench/option option valueSpanishSpanish/option option valueGermanGerman/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里写死的。在页面上按下CtrlU查看源代码搜索document.write就能找到我们前面分析过的那段脚本。3.2 Low级别三分钟复现在页面URL后面直接追加Payload完整地址长这样http://127.0.0.1/dvwa/vulnerabilities/xss_d/?langEnglishdefault%3Cscript%3Ealert(1)%3C%2Fscript%3E注意在地址栏输入时如果直接粘贴script浏览器地址栏会自动把尖括号编码成%3C和%3E不需要手动转。回车之后一个弹窗出现说明XSS成功触发。打开开发者工具的Elements面板刷新一下页面定位到语言选择下拉框能看到被注入的option标签已经躺在里面。这个标签的文本内容就是被解码后的scriptalert(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/?langEnglishdefault%3Cscript%3Ealert(1)%3C%2Fscript%3E照样弹窗。服务端看到langEnglish进入流程根本不会注意default带了什么。接着验证黑名单本身的缺陷试这个http://127.0.0.1/dvwa/vulnerabilities/xss_d/?lang%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3Edefaultwhatever呃等等这里如果URL里没有default参数前端JS根本不会执行那段写入脚本所以直接这么传是不弹窗的。要让img方案生效还是得把img放在default参数里http://127.0.0.1/dvwa/vulnerabilities/xss_d/?langEnglishdefault%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3EMedium对lang的黑名单检查碰不到default这条也成立。实战里选择哪一种取决于你想演示哪个层面的绕过参数错位还是黑名单短板。High级别验证更简单切到High后直接用同一个参数错位URLhttp://127.0.0.1/dvwa/vulnerabilities/xss_d/?langEnglishdefault%3Cscript%3Ealert(1)%3C%2Fscript%3E白名单看到langEnglish放行前端看到default执行。整个过程我建议在DevTools的Network里看一眼请求和响应请求URL里带着langEnglish和很长的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到底能挡住什么CSPContent Security Policy是浏览器提供的一层安全策略它决定哪些脚本被允许执行。一个比较严格的策略可以这样配置Content-Security-Policy: default-src self; script-src self这个策略的意思是脚本只允许从当前同源地址加载所有内联的script代码或onclick、onerror这类事件属性里的脚本都被拒绝。前面Low级别打进去的scriptalert(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里按CtrlShiftF可以做全文搜索直接搜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)这种题目基本就是一眼看穿的事。