资讯动态

DVWA DOM型XSS源码级分析:从原理到绕过与防御

发布时间:2026/10/17 12:13:14 来源:尧图企业网站定制
DOM型XSS是我在带人入门Web安全时最喜欢拿来讲的漏洞类型因为它的触发链路和反射型、存储型XSS完全不一样payload根本没有经过服务端渲染从头到尾都是浏览器在前端自己把自己玩死了。DVWA里的XSS(DOM)模块正好把这种漏洞在不同安全级别下的代码写了个遍很适合用来做源码级分析。这篇文章我会把四个安全级别的源码逐个拆开结合实际的payload构造和利用过程把DOM XSS的成因、绕过思路和防御方式一次说清楚。想系统学XSS的Web安全初学者或者做前端安全评估的测试人员都可以拿这篇文章当参考。阅读前提是你已经搭好了DVWA环境能正常登录靶场并进入XSS(DOM)页面。1. 先搞清楚DOM型XSS的运行机制1.1 DOM XSS与反射型、存储型XSS的本质区别很多人在学XSS时先接触反射型和存储型这两类漏洞的特点都是服务端把用户输入直接拼进了响应HTML。比如一个搜索框你输入scriptalert(1)/script服务端把它放进搜索结果页的HTML里返回浏览器解析后执行这就是反射型。存储型则是把payload存进数据库每次访问都返回。这两类漏洞的检测相对简单看服务端代码里有没有对用户输入做过滤和编码就行。DOM型XSS却完全不同。它的服务端响应是一个固定不变的静态页面用户输入根本不会作为参数参与服务端HTML的生成。漏洞发生在客户端的JavaScript代码里前端脚本从location.href、document.URL或者location.search这类地方读取数据然后不经任何检查直接写入document.write、innerHTML、eval这类能被浏览器当成HTML或代码执行的位置。整个链路从读取用户可控数据到写入危险函数都在浏览器端完成服务端完全蒙在鼓里。这也是为什么很多传统的WAF和代码扫描工具对DOM XSS无能为力。这里有一个很关键的认知DOM XSS的检测重点不在服务端代码而在前端的source到sink链路。source就是数据来源比如URL、referrer、window.namesink就是会被浏览器解释执行的位置比如document.write、innerHTML、eval。只要source可控sink危险中间又没有有效的校验和编码漏洞就存在。1.2 DVWA的XSS(DOM)页面为什么是练手好靶场DVWA里的XSS(DOM)模块模拟的是一个语言选择下拉框。正常访问时URL里带defaultEnglish这样的参数页面里的JavaScript会把参数值动态写成一个下拉选项。页面功能很简单但四个安全级别对应的源码刚好覆盖了从完全无防护到服务端过滤服务端白名单双端白名单的完整演进过程每一个级别都对应了真实项目里常见的编码漏洞。我在梳理这个靶场时把四个级别的核心差异整理成了下面这个表后面每一章都会围绕这张表展开安全级别服务端处理客户端处理可利用性LowPHP直接接收参数无过滤JS直接从URL截取数据写入DOM直接打穿MediumPHP正则删除scriptJS逻辑不变换标签或双写绕过HighPHP白名单非法值重定向JS逻辑不变#锚点绕过服务端ImpossiblePHP白名单默认值兜底JS同样白名单校验无法利用带着这张表再去看后面的源码你会更容易理解每个安全级别到底补上了哪个环节的缺口。2. Low级别源码分析数据裸奔才是漏洞的起点2.1 逐行读代码前后端各干了什么Low级别的PHP代码如下?php $default English; if (array_key_exists(default, $_GET)) { $default $_GET[default]; } ?这段PHP逻辑很简单先给$default一个默认值然后如果URL的查询参数里存在default直接用GET参数覆盖它。服务端在这里没有对$default做任何过滤或者输出编码但这并不是漏洞的关键。真正关键的是HTML里的JavaScriptselect 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 lang /option); } /script option valueEnglishEnglish/option option valueSpanishSpanish/option option valueFrenchFrench/option option valueGermanGerman/option /select这段JS的意图很直观如果当前URL里包含字符串default就把default后面的内容截取出来用document.write动态生成一个option放到下拉框里。用户选择语言后表单再次提交URL就变成?defaultEnglish这种形式下拉框里多出一个English选项看起来没什么问题。但问题大了。document.write接收的lang是直接从URL里截取出来的完全没有校验。攻击者只要在URL里把default后面的内容换成一段恶意HTML这段HTML就会被原样写进DOM。而且由于服务端响应本来就是静态的payload根本没有经过服务端代码的任何处理服务端的无过滤状态只是让服务器的返回保持了原样真正的执行在浏览器端。2.2 漏洞根因source到sink的链路上没有任何检查我们把这段代码抽象一下就能看到典型的DOM XSS结构source数据源document.location.href数据提取substring(indexOf(default) 8)sink危险写入点document.write攻击者可控的数据从URL进入经过一个简单的字符串截取直接落到sink里。中间没有任何校验、编码或白名单。这条链路里的任意一个环节有防御漏洞都可能被阻断但Low级别一个都没有。从开发角度说问题不只是document.write这个API危险更在于开发者对URL数据过于信任觉得URL里的东西只是在地址栏显示的字符串没有意识到它可以包含HTML标签和脚本。真实项目里这类问题常出现在页面埋点、活动页跳转参数、统计渠道标识等场景里开发图方便直接拼HTML等出了事才追悔莫及。2.3 构造第一个能弹窗的payload先试一个最直观的payloadhttp://127.0.0.1/DVWA/vulnerabilities/xss_d/?defaultscriptalert(1)/script不过这里有一个新手必踩的坑document.write生成的option是位于select内部的。浏览器在解析HTML时select元素的内容模型只允许option、optgroup等特定标签脚本标签在select内部并不会被执行。也就是说直接把script拼进去页面往往不会弹窗。要让payload生效需要先闭合前面的option和select标签让后面插入的内容脱离select这个上下文。经典的payload是这样http://127.0.0.1/DVWA/vulnerabilities/xss_d/?default/option/selectscriptalert(document.cookie)/scriptdocument.write实际写入的内容是option value/option/selectscriptalert(document.cookie)/script/option/selectscriptalert(document.cookie)/script/option浏览器解析时第二个lang也就是option元素内容里的那段中的/option和/select会正常闭合标签随后出现的script标签在select外部就能被执行弹出浏览器当前域名的cookie。也可以用不需要闭合标签的事件属性payloadhttp://127.0.0.1/DVWA/vulnerabilities/xss_d/?defaultimg srcx onerroralert(1)这个payload虽然没有闭合select但img在option元素内容中也会被浏览器的容错解析器识别为一个实际元素src加载失败后触发onerror事件同样可以弹窗。我自己测试下来现代浏览器里这两种方式都能触发个人更推荐第一种因为含义更清晰闭合上下文再注入新节点。注意直接往浏览器地址栏粘贴这些payload时浏览器可能会自动把和编码成%3C和%3E导致JavaScript截取到的不是原始尖括号。建议用Burp Suite拦截请求后修改URL或者构造一个本地HTML页面在里面放一个指向DVWA URL的链接模拟受害者点击这样能确保payload以原始字符进入页面。后面第6章我会专门讲这个问题。3. Medium级别源码分析过滤script标签的几种绕过方式3.1 源码解读正则替换了什么Medium级别的PHP代码在Low级别的基础上加了一个过滤动作?php if (array_key_exists(default, $_GET)) { $default $_GET[default]; $default preg_replace(/\s*script/i, , $default); } ?这个正则的意思是把字符串中所有匹配script不区分大小写且和script之间可以有空字符的内容替换成空字符串。开发者显然是想把script标签直接消灭掉思路是对的但实现得太粗暴只盯着script这一个标签忽略了XSS的载体远不止script标签这一种。而且这个过滤还有一个很容易想到的问题preg_replace默认是替换所有匹配所以会删掉字符串里所有符合模式的子串。但删除之后剩下的字符可能会拼出新的标签这就是双写绕过的基本原理。换句话说这种删掉敏感子串的防御方式本质上是在给拼接留机会。3.2 绕过思路一换成img的onerror事件因为正则是基于内容的匹配只对script感兴趣那我们干脆不用script标签。直接沿用第2章的img事件payloadhttp://127.0.0.1/DVWA/vulnerabilities/xss_d/?default/option/selectimg srcx onerroralert(1)这个payload里完全没有script这个子串滤镜里根本没有匹配项payload原样通过。DOM XSS的sink在客户端服务端过滤只能处理服务端可见的数据如果数据从#锚点进来服务端甚至看不到但在这个级别我们走?default参数过滤确实会生效不过它拦不住img标签的事件属性所以绕过很直接。这也说明一个防御层面的问题黑名单过滤永远是不完整的。今天过滤了script明天还有img的onerror、svg的onload、a标签的href跳转、iframe的srcdoc……攻击者总能在HTML的庞大元素和事件属性清单里找到新的突破口。真正可靠的做法是白名单校验和输出编码而不是试图穷举所有恶意标签。3.3 绕过思路二双写和大小写混淆有人可能会说如果目标系统把img、svg、iframe这类标签也一起过滤了那怎么办这时候双写绕过就有用了。回到这个只过滤script的正则构造这样的payloadhttp://127.0.0.1/DVWA/vulnerabilities/xss_d/?default/option/selectscrscriptiptalert(1)/script过滤过程是怎么工作的正则匹配到scrscript中的第二个script把它删掉剩下的字符拼起来刚好是script。完整的过滤结果变成了/option/selectscriptalert(1)/script浏览器拿到这个合法的script标签脚本正常执行。这就是双写绕过本质上是利用过滤删除后拼接的副作用把被删部分前后的字符当成胶水重组出被过滤的标签。那大小写混淆呢这个级别的正则带了/i修饰符所以SCRIPT一样会被删掉大小写混淆在这里对script标签无效。但如果在某些只匹配小写字符串的系统里SCRIPT就能直接绕过。类似的思路还有scr\nipt、scr\ript这类在标签名中间插入空白字符或换行的变体是否能生效取决于正则怎么写。在实战测试中我习惯把常见绕过手法都快速过一遍而不是去猜过滤逻辑这样更省时间。提示判断一个XSS过滤能不能绕过先看它是黑名单还是白名单再看它匹配时是否区分大小写、是否处理了空白字符、删除后是否可能拼接出新标签。这三个问题问完绕过思路基本就出来了。4. High级别源码分析白名单校验为什么拦不住锚点4.1 源码解读服务端白名单的工作机制到了High级别开发者的防御意识明显提高了不再用黑名单碰运气而是直接上白名单?php if (array_key_exists(default, $_GET)) { $default $_GET[default]; switch ($default) { case English: case Spanish: case French: case German: break; default: header(location: ?defaultEnglish); exit; } } ?这段代码的逻辑是只允许default参数等于English、Spanish、French、German这四个固定值否则直接把浏览器重定向到?defaultEnglish。如果攻击者走?defaultscriptalert(1)/script这条常规路径PHP发现参数不在白名单里一条302响应把浏览器带走payload根本没机会进入页面。看到这里很多人会觉得这个级别已经很安全了。其实不然关键问题在于服务端能校验的只有HTTP请求的GET参数也就是URL中?后面的部分。而这段JavaScript读取数据的来源是document.location.href这个值不仅包含GET参数还包含URL中#后面的锚点部分。锚点内容纯粹是浏览器端的东西根本不会被发送到服务端服务端看不到自然也就校验不到。4.2 用#锚点完全绕过服务端校验所以High级别的利用思路就变得很清晰把payload放到#锚点里而不是?default参数里。构造这样的URLhttp://127.0.0.1/DVWA/vulnerabilities/xss_d/#default/option/selectscriptalert(document.cookie)/script这个URL发送到服务端时HTTP请求行里只有/DVWA/vulnerabilities/xss_d/没有?default...PHP代码里的array_key_exists(default, $_GET)判断为false整个if分支不会执行更不会触发重定向。服务端返回一个完全正常的静态页面。但浏览器端的JS呢document.location.href拿到的完整URL包含#default/option/selectscript...这一整段indexOf(default)能找到#后面的defaultsubstring(..., indexOf8)截取出来的就是完整的payloaddocument.write照样把它写进DOM并执行。此时服务端白名单形同虚设。我最初测试这个级别时第一反应也是直接在?default后面放payload结果总是被重定向一度以为High级别没法利用。后来把payload挪到#后面才恍然大悟——这个靶场教的东西比预期多了一层服务器能看见的和前端能处理的数据不一致本身就是绕过服务端防护的关键。有个细节需要提醒如果URL里同时存在?defaultEnglish#defaultpayload那么indexOf(default)匹配到的是第一个default截取出来的是English#defaultpayload这一整串payload并不能独立成为option。所以要干净地利用URL里就不要出现?default参数直接让#后面成为唯一的default来源。4.3 这个案例给开发者的启示High级别的代码给所有前后端开发提了个醒DOM XSS是客户端问题只靠服务端校验根本无法根治。尤其是当JavaScript从location.href这类包含完整URL的source读取数据时服务端只能看到URL的一部分#锚点、甚至部分浏览器对URL的标准化处理都会让服务端校验出现盲区。正确的做法要么是在客户端JavaScript里同样做白名单校验要么干脆不要让前端直接解析URL字符串去决定DOM内容。很多开发者以为服务端做了白名单就万事大吉结果用户用一个#就绕过了全部防护这类案例在真实漏洞报告里屡见不鲜。5. Impossible级别源码与正确的DOM XSS防御姿势5.1 源码解读双层白名单如何封锁payloadImpossible级别的代码看起来和High级别差不多但多了一层关键防护。PHP部分依然做服务端白名单?php if (array_key_exists(default, $_GET)) { $default $_GET[default]; switch ($default) { case English: case Spanish: case French: case German: break; default: $default English; } } ?注意这里与High级别的一个区别非法参数不再用header(location: ...)重定向而是把$default直接重置为English。这会减少额外的一次请求但更关键的变化在JavaScript部分。Impossible级别的JS不再盲目信任URL而是同样做了白名单校验if (document.location.href.indexOf(default) 0) { var lang document.location.href.substring(document.location.href.indexOf(default) 8); var parsedLang lang.split()[0]; var validLangs [English, Spanish, French, German]; if (validLangs.indexOf(parsedLang) 0) { document.write(option value parsedLang parsedLang /option); } }这段JS先把default后面的内容截取出来再通过split()[0]把后续其他参数剥掉然后检查这个值是否在四个合法语言选项里。如果不在什么都不写入。这样即使攻击者把payload放在#锚点里JS也能检测到值不合法直接放弃写入payload在到达sink之前就被拦截了。从这里能看到一个完整的防御闭环服务端白名单拦常规参数客户端白名单拦锚点绕过输出的内容本身又是白名单内的固定值即使到了sink也无法构造恶意HTML。每一层各司其职漏洞自然打不进来。5.2 前端开发中防御DOM XSS的清单从这个靶场能提炼出几条很实际的防御经验第一减少危险sink的使用。document.write、innerHTML、outerHTML、insertAdjacentHTML、eval、setTimeout字符串参数、Function构造器这些都是DOM XSS的高发点。能用textContent、createElement、appendChild这类安全API就别用HTML拼接。第二source数据必须校验。location.href、location.search、document.referrer、postMessage消息、window.name都可能是攻击者可控的使用前一定要做合法性判断。能白名单就走白名单不能白名单就用encodeURIComponent、HTML实体编码等方式做输出编码。第三别只依赖服务端。DOM XSS发生在浏览器端服务端过滤和CSP都可以增加攻击成本但真正能阻断DOM XSS的是客户端代码对source和sink的管理。前端监控、代码评审时都应该把source-sink链路作为重点而不是只扫服务端模板。6. 测试过程中的常见问题与踩坑实录6.1 为什么地址栏输入payload经常不弹窗这是新手问得最多的问题。在浏览器地址栏直接粘贴?defaultscriptalert(1)/scriptChrome等浏览器会自动把尖括号编码成%3C、%3EJS截取到的是编码后的字符串写入DOM时尖括号无法被解析成标签自然不弹窗。这不是漏洞不存在而是payload没有以原始字符进入URL。我测试DOM XSS时常用两种方式一种是Burp Suite拦截请求在Repeater里直接修改URL中的payload再让浏览器打开确保URL行里的尖括号不会被浏览器二次编码另一种是构造一个本地恶意链接页面把payload URL放在a标签的href里诱导测试浏览器点击。点击跳转时浏览器对链接的编码行为往往比地址栏手动输入宽容得多payload能保持原始形态。还有一个可控性更强的办法在浏览器控制台执行下面这句代码来跳转location.href http://127.0.0.1/DVWA/vulnerabilities/xss_d/#default/option/selectscriptalert(1)/script;JS赋值的URL不会像地址栏输入那样被强制编码测试效率会高不少。6.2 document.write的上下文陷阱前面提到过直接把script放在select内部不会执行必须用/option/select先闭合。这个上下文陷阱在真实代码审计里也很常见同样的payload在不同的DOM位置利用难度和效果完全不同。比如写入点是a href...你就得先逃逸出引号再构造javascript:伪协议或者事件属性写入点在注释里要先逃逸出注释边界。实战中我习惯先手动注入一个无害的探测字符串比如zzzimg srcx onerroralert(1)打开浏览器开发者工具观察渲染后的DOM结构确定数据落在哪个标签、哪个属性里再针对性构造payload。盲打会浪费大量时间我早期测试DOM XSS时经常因为没注意上下文白白折腾半小时。6.3 为什么扫描器容易漏报DOM型XSS传统漏洞扫描器大多基于服务端响应来判断XSS也就是看响应HTML里是否原样拼接了payload。但DOM XSS的payload根本不会出现在服务端返回的HTML里它是在浏览器执行JavaScript的过程中动态写入DOM的。如果扫描器不做真实浏览器的DOM渲染和JavaScript执行就没法发现这类漏洞。所以人工审计还是很重要的。我审查前端JavaScript时会先画出source到sink的路径再逐个判断路径上有哪些校验。比如这段代码var name location.hash.slice(1); document.getElementById(box).innerHTML b name /b;一眼就能看出location.hash到innerHTML的链路没有过滤是DOM XSS。除了代码审计配合浏览器的调试器打断点、在控制台手动验证可以在真实项目中快速定位这类问题这比等扫描器报告靠谱得多。我在实际项目里也习惯用代理抓包确认payload有没有被URL编码再用浏览器控制台验证一次能快速确认真实可利用性整个过程可能只需要几分钟。最后再分享一个小经验跑完DVWA这一整个模块后不要急着进入下一个靶场尝试自己改一改源码里的过滤逻辑或者给它加一个必须用CSP才能解决的新场景会有额外的收获。安全测试的核心不是背payload而是理解数据和执行环境的边界在哪里。DOM XSS只是这条理解路径上一个非常经典的切面。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑