1. 从“选择”到“注入”:DOM型XSS的独特攻击面
在Web安全测试的入门路上,DVWA靶场几乎是所有人的第一站。当我们闯过反射型、存储型XSS的关卡后,往往会遇到一个看似简单却容易让人困惑的挑战:DOM型XSS。很多新手朋友会在这里卡壳,明明输入了经典的<script>alert(1)</script>,页面却毫无反应,浏览器控制台也没有报错,仿佛攻击语句石沉大海。这恰恰是DOM型XSS的“魅力”所在——它的攻击链路与传统XSS截然不同,不依赖于服务器端的响应内容,而是完全在客户端的浏览器中,通过JavaScript对文档对象模型(DOM)的操作来触发。
简单来说,反射型和存储型XSS的恶意脚本是“镶嵌”在服务器返回的HTML页面里的,像是夹带在货物中的违禁品。而DOM型XSS的恶意脚本,则是货物(干净的HTML页面)送达后,在仓库(浏览器)内部分拣处理时,由分拣工人(前端JavaScript代码)自己“写”上去的。靶场中那个经典的下拉选择框,就是触发这个过程的开关。理解这一点,是通关的关键。
所以,本篇教程我们不谈宽泛的理论,直接聚焦DVWA靶场中DOM型XSS的实战通关。我会带你一步步拆解Low、Medium、High、Impossible四个安全等级下的代码逻辑,还原攻击者的完整思考路径,并分享我在实际测试和教学中总结出的、绕过各种前端过滤的技巧与心法。无论你是刚接触安全测试的新手,还是想巩固DOM XSS知识的老兵,这篇近万字的深度解析都能让你有所收获。
2. Low级别:直击源码,理解DOM操作的原始风险
我们将DVWA的安全级别设置为Low,然后进入“XSS (DOM)”模块。页面呈现一个下拉框,让我们选择一个默认语言(English, French, Italian...),选择后页面会显示“You chose: [语言]”的提示,并且浏览器的URL地址栏中会多出一个?default=参数。
2.1 攻击入口与初步测试
一个安全测试者的直觉是:任何用户可控的输入点都可能是入口。这里,URL中的default参数值显然随我们的选择而改变。我们首先尝试直接修改URL。将?default=English改为?default=Test并回车。页面显示“You chose: Test”。很好,这证实了default参数的值被直接输出到了页面中。
接下来,尝试注入HTML标签。输入?default=<h1>Test</h1>,页面显示一个巨大的“Test”标题。这说明服务器没有对输入进行任何HTML编码过滤,标签被成功解析。那么,脚本注入就是顺理成章的下一步:?default=<script>alert(document.domain)</script>。
然而,这次没有弹窗。为什么?因为我们的输入被放进了下拉框的<option>标签里。查看页面源代码(注意,是右键“查看页面源代码”,而不是F12开发者工具看到的动态DOM),你会发现类似这样的结构:
<select name="default"> <option value='English'>English</option> ... <option value='<script>alert(document.domain)</script>'>Test</option> </select><script>标签被放在了<option>标签的value属性里和标签体内。在HTML规范中,<script>标签只有在作为顶级文档节点或通过document.write()等方式动态插入时才会执行。放在<option>标签内部是无效的,浏览器不会将其解析为可执行脚本。
2.2 关键源码分析与正确Payload构造
攻击在此处似乎受阻。但DOM型XSS的精髓在于,漏洞点往往不在你直接看到的地方。我们打开浏览器开发者工具(F12),切换到“Sources”或“调试器”标签页,找到并查看加载的vulnerabilities/xss_d/目录下的前端JavaScript源码(例如source/low.js)。
核心代码通常如下所示:
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>"); document.write("<option value='' disabled='disabled'>----</option>"); }这段代码逻辑清晰:
- 检查当前URL中是否包含字符串
“default=”。 - 如果有,就使用
substring方法截取“default=”之后的所有字符,赋值给变量lang。这里没有任何过滤或编码! - 使用
document.write()方法,直接将lang变量的内容拼接进HTML字符串,并写入文档流。
document.write()是关键!它会将字符串作为HTML解析并立即写入文档。如果字符串中包含<script>标签,它将被创建并执行。但为什么我们之前的<script>没成功?因为document.write()写入的位置和内容决定了最终效果。上面的代码写入了新的<option>标签。
我们需要构造一个Payload,来“闭合”掉当前正在写入的HTML上下文,并“开启”一个新的、可执行的脚本上下文。观察document.write的语句:
document.write("<option value='" + lang + "'>" + lang + "</option>");它被拼接成这样一个字符串:<option value='[我们的输入]'>[我们的输入]</option>。要注入脚本,我们必须先跳出这个<option>标签。我们可以让lang变量的值为:'></option></select><script>alert(document.domain)</script>。
拼接后的HTML将变成:
<option value=''></option></select><script>alert(document.domain)</script>'>'></option></select><script>alert(document.domain)</script></option>我们来分解一下:
value='遇到了我们Payload的第一个字符',于是value属性被闭合。- 紧接着的
>闭合了<option>标签。 </option></select>是为了清理掉可能干扰后续页面结构的原有标签(虽然不总是必要,但是个好习惯)。- 然后,我们成功插入了全新的
<script>alert(document.domain)</script>标签。由于此时document.write()仍在输出,这个<script>标签会被当作新的HTML节点写入文档,并被浏览器执行。 - 后面的
'>...部分会成为<script>标签内容的一部分(但不会影响其执行),或者成为无法解析的垃圾HTML。
将构造好的Payload填入URL:?default='></option></select><script>alert(document.domain)</script>。回车,成功弹窗,显示当前域(如“dvwa”)。
实操心得:在Low级别,最大的障碍是意识到漏洞利用点不在下拉框的显示值,而在背后
document.write()的拼接逻辑。通过查看前端JS源码来理解数据处理流程,是挖掘DOM型XSS的必备技能。另外,闭合HTML属性(')和标签(>)是构造此类Payload的基础操作。
3. Medium级别:绕过服务端的简单过滤
将安全级别调至Medium。重复Low级别的Payload:?default='></option></select><script>alert(document.domain)</script>。发现页面显示“You chose: ”后面是空的,或者是一段被处理过的文本,弹窗没有出现。这说明服务器端开始进行过滤了。
3.1 源码分析与过滤逻辑
我们需要查看服务端(PHP)源码。在DVWA的vulnerabilities/xss_d/source/medium.php中,我们可能会看到如下代码:
<?php // Is there any input? if ( array_key_exists( "default", $_GET ) && !is_null ($_GET[ 'default' ]) ) { $default = $_GET['default']; // Do not allow script tags if (stripos($default, "<script") !== false) { header ("location: ?default=English"); exit; } } ?>过滤逻辑非常直接:使用stripos函数(不区分大小写查找字符串位置)检查输入中是否包含“<script”这个子串。如果包含,服务器直接使用header函数将页面重定向到?default=English,并终止脚本执行。这就是我们的Payload失效的原因。
3.2 绕过策略:不使用<script>标签
既然<script>标签被明令禁止,我们就需要寻找其他可以执行JavaScript代码的HTML标签或属性,即“备选攻击向量”。
策略一:使用带有事件处理程序的HTML标签许多HTML标签支持事件属性,如onclick,onmouseover,onload,onerror等。当事件触发时,其中的JavaScript代码便会执行。我们可以尝试注入一个带有onload事件的<img>标签。
构造Payload:?default='><img src=1 onerror=alert(document.domain)>
'和>用于闭合前面的<option>标签。<img src=1>尝试加载一个不存在的图片(src=1)。onerror=alert(document.domain)是事件处理器,当图片加载失败(必定失败)时,其中的JS代码就会执行。
然而,在Medium级别的源码中,可能还存在对<script>的过滤,但对我们这个Payload,服务端检查stripos($default, "<script")会返回false,因此不会重定向。Payload被原样传递给前端JS。前端JS通过document.write()将其写入页面,浏览器解析出<img>标签,加载失败,触发onerror,成功弹窗。
策略二:使用<svg>或<iframe>标签<svg>标签内可以包含<script>元素,但有时能绕过对普通<script>的过滤。不过在本例的简单过滤下,用事件处理器更直接。<iframe>的srcdoc属性也可以执行脚本,但可能受到同源策略限制。
策略三:利用JavaScript伪协议有些DOM型XSS的漏洞点在于将用户输入直接赋值给如location.href、iframe.src等属性。虽然本例不适用,但思路值得扩展。例如,如果代码是document.location.href = userInput;,那么输入javascript:alert(1)就可能触发。
踩坑记录:在实际测试中,我曾遇到过一个变体,服务端不仅过滤
<script>,还过滤了onerror、onload等常见事件名。这时就需要更隐蔽的方法,比如使用大小写混淆(OnErRor)、插入空字符或换行符(在某些解析器中可能被忽略)、或者利用HTML实体编码在特定上下文中的解码差异。但在DVWA Medium级别,使用<img>的onerror事件通常足够。
3.3 深入测试与确认
使用<img>标签Payload成功弹窗后,我们还应测试其他事件,如onmouseover(鼠标悬停触发),这可以证明漏洞的多样性和危害性。Payload:?default='><img src=1 onmouseover=alert(1)>,然后将鼠标移动到被注入的图片位置,同样会触发弹窗。
注意事项:
onmouseover这类需要用户交互的事件,在漏洞报告中的评级可能低于onload或onerror这种自动触发的事件。但在实际攻击中,攻击者可能会将恶意元素做得极具诱惑性(如一个闪烁的“点击有奖”按钮),诱导用户交互。
4. High级别:挑战客户端的严格白名单
将级别调至High。尝试Medium级别成功的Payload:?default='><img src=1 onerror=alert(1)>。发现页面可能显示为默认的English,或者default参数被重置,弹窗没有出现。过滤明显加强了。
4.1 源码分析与白名单机制
查看high.php源码:
<?php // Is there any input? if ( array_key_exists( "default", $_GET ) && !is_null ($_GET[ 'default' ]) ) { $default = $_GET['default']; // White list the allowable languages switch ($default) { case "French": case "English": case "German": case "Spanish": // ok break; default: header ("location: ?default=English"); exit; } } ?>服务端采用了白名单机制。switch语句只允许$default的值为“French”,“English”,“German”,“Spanish”其中之一。如果用户输入的不是这四个字符串中的任何一个,服务器会直接重定向到?default=English。这意味着,我们试图通过default参数直接传递恶意Payload的路,在服务端被彻底封死了。
4.2 前端代码审查与新的攻击向量
服务端走不通,我们再次将目光转向前端。查看high.js(或对应安全级别的JS文件)。代码可能变成了这样:
if (document.location.href.indexOf("default=") >= 0) { var lang = document.location.href.substring(document.location.href.indexOf("default=")+8); // 这里可能增加了解码操作 lang = decodeURI(lang); // 仍然使用document.write document.write("<option value='" + lang + "'>" + lang + "</option>"); document.write("<option value='' disabled='disabled'>----</option>"); }关键点在于lang = decodeURI(lang);这一行。它对我们从URL中获取的lang变量进行了URL解码。URL编码是为了在HTTP请求中安全传输特殊字符。例如,空格被编码为%20,单引号'被编码为%27,尖括号<被编码为%3C。
服务端的白名单检查,是在URL解码之前还是之后?这是一个至关重要的细节。通常,PHP的$_GET数组会自动对传入的参数进行URL解码。也就是说,当我们访问?default=French时,$_GET['default']的值已经是解码后的“French”。白名单检查的也是这个解码后的值。
但是,如果攻击者传入一个双重编码的值呢?例如,我们想传入一个单引号'。它的URL编码是%27。如果我们把%27本身再进行一次URL编码:%编码为%25,2和7保持不变,那么%27的双重编码结果就是%2527。
攻击流程推演:
- 我们请求:
?default=%253Cscript%253Ealert(1)%253C/script%253E(这是<script>alert(1)</script>的双重URL编码)。 - 服务器收到请求,PHP自动进行第一次URL解码,将
%25解码为%,得到%3Cscript%3Ealert(1)%3C/script%3E。此时,$_GET['default']的值是字符串“%3Cscript%3Ealert(1)%3C/script%3E”。 - 服务端白名单检查:这个字符串显然不在
[“French”, “English”, “German”, “Spanish”]之中,因此触发default分支,重定向到?default=English。 - 攻击失败了吗?不一定。这里存在一个潜在的逻辑漏洞:重定向发生前,原始的
$default变量(即解码一次后的字符串“%3Cscript%3Ealert(1)%3C/script%3E”)是否已经被写入了某个会被前端JS读取的地方?例如,服务器可能将这个值直接输出到某个HTML标签的>// 它可能不是从 document.location.href 中 indexOf("default="), // 而是从 document.location.hash 中获取 if (document.location.hash) { var lang = document.location.hash.substring(1); // 去掉开头的# lang = decodeURI(lang); document.write("<option value='" + lang + "'>" + lang + "</option>"); document.write("<option value='' disabled='disabled'>----</option>"); }如果代码逻辑如此,那么攻击方式就完全不同了。我们不再使用
?default=,而是使用#。构造Payload:直接访问页面,然后在URL末尾添加
#’></option></select><script>alert(document.domain)</script>。 完整的URL类似:http://your-dvwa-address/vulnerabilities/xss_d/#’></option></select><script>alert(document.domain)</script>流程分析:
- 浏览器向服务器请求
http://your-dvwa-address/vulnerabilities/xss_d/。服务器执行High级别的PHP代码,由于没有default参数,白名单检查可能跳过,正常返回页面。 - 页面加载完成后,浏览器解析URL片段
#’></option></select><script>alert(document.domain)</script>。 - 前端JS代码
document.location.hash获取到该片段(不含#),赋值给lang。 lang经过decodeURI解码(这里我们的Payload没有URL编码,所以不变)。document.write()将解码后的lang拼接入HTML,成功闭合标签并注入<script>,触发XSS。
核心技巧:High级别的绕过,关键在于识别输入源从查询字符串(受服务端控制)转移到了URL片段(仅客户端可控)。这要求测试者必须仔细阅读前后端所有相关代码,理解完整的数据流。在真实世界漏洞挖掘中,这种“服务端严格校验,客户端却信任另一处不可信输入”的情况,是逻辑漏洞的典型来源。
5. Impossible级别:从根源上消除漏洞
将级别调至Impossible。查看
impossible.php源码,我们通常能看到一种根本性的解决方案:<?php // Is there any input? if (array_key_exists("default", $_GET)) { $default = $_GET['default']; // 严格白名单,且使用switch的default进行重定向已在high级别体现 // 但impossible级别可能采用更彻底的方式 } ?>而前端JS代码可能被彻底重写,不再使用不安全的
document.write()和字符串拼接,而是采用安全的DOM操作方法:// 假设从服务器获取了安全的默认语言值,或者通过白名单验证后的值 var allowedLangs = ["English", "French", "German", "Spanish"]; var defaultLang = "English"; // 默认值 // 安全地创建和添加option元素 var selectElem = document.querySelector("select[name='default']"); allowedLangs.forEach(function(lang) { var option = document.createElement("option"); option.value = lang; option.textContent = lang; // 使用textContent,而非innerHTML if (lang === defaultLang) { option.selected = true; } selectElem.appendChild(option); });安全要点分析:
- 数据与代码分离:语言选项来自代码内部定义的数组
allowedLangs,而不是从用户控制的URL参数中动态获取。这从根本上切断了注入通道。 - 安全的DOM API:使用
document.createElement、element.textContent、element.appendChild这一套API来构建DOM树。textContent属性会将内容作为纯文本处理,即使其中包含HTML标签字符,也不会被解析为标签。这避免了HTML注入。 - 避免不安全的拼接:完全摒弃了
document.write()和innerHTML属性赋值时与用户输入的字符串拼接,这是导致XSS的罪魁祸首。
开发启示:对于Impossible级别,防御的核心原则是“不信任任何用户输入”和“安全地处理输出”。具体到前端:
- 输入校验:在服务端和客户端都进行严格的校验(类型、长度、格式、白名单)。
- 输出编码:根据输出点的上下文(HTML内容、HTML属性、JavaScript、CSS、URL)进行正确的编码。例如,输出到HTML内容用
htmlspecialchars,输出到HTML属性用htmlentities并引号包裹,输出到JavaScript变量需进行JSON_encode。 - 使用安全API:优先使用
textContent替代innerHTML,使用addEventListener替代onclick属性赋值,使用document.createElement和appendChild进行DOM操作。 - 利用现代框架的安全特性:如React默认对插值进行转义,Vue的
v-bind、v-text等指令在默认情况下也是安全的。
6. 实战扩展:更复杂的DOM XSS挖掘与利用
DVWA的关卡是标准化的,但真实世界的DOM XSS要复杂得多。以下是一些进阶的挖掘思路和技巧:
6.1 溯源JavaScript代码中的“源”(Source)与“汇”(Sink)DOM XSS的本质是,攻击者可控的数据(源,Source)流入了能够动态改变DOM、执行代码的函数(汇,Sink)。常见的“源”包括:
document.location(.href,.hash,.search,.pathname)document.referrerwindow.namelocalStorage/sessionStoragepostMessage消息内容- URL参数(通过
URLSearchParamsAPI获取) - 表单输入(通过
document.getElementById().value获取)
常见的危险“汇”(Sink)包括:
document.write()/document.writeln()element.innerHTML/element.outerHTMLelement.setAttribute()(某些属性如src,href,特别是结合javascript:伪协议时)eval()setTimeout()/setInterval()(第一个参数为字符串时)Function()构造函数location.href/location.assign()/location.replace()(配合javascript:伪协议)<iframe>的srcdoc属性
安全测试时,可以手动搜索源码中的这些危险函数,或使用自动化工具(如浏览器扩展
DOM Invader、Burp Suite的DOM XSS扫描器)来辅助发现源与汇之间的数据流。6.2 利用哈希(Hash)与
postMessage的存储型DOM XSS有时,恶意Payload并非一次性执行。例如,一个页面将location.hash的内容未经处理就存入localStorage,下次页面加载时又从localStorage中读取并放入innerHTML。这就构成了一个存储型DOM XSS,危害更大。postMessageAPI如果消息监听器处理不当,也可能将来自其他窗口的恶意数据注入DOM。6.3 绕过高级过滤与WAF面对更复杂的过滤(如正则表达式过滤特定标签和事件),需要创造性思维:
- 编码混淆:除了URL编码,还有HTML实体编码、JS Unicode转义(
\u0061表示a)、Base64编码(结合data:协议)等。需要观察数据在哪个环节被解码。 - 利用浏览器解析差异:某些Payload在旧版浏览器或特定浏览器渲染模式下可能成功。
- 利用JavaScript框架的特性:研究AngularJS的客户端模板注入(CSTI)或Vue.js在特定不安全用法下的XSS。
- 标签属性绕过:如果
<script>和事件处理器被过滤,可以尝试<svg><script>...</script></svg>、<iframe srcdoc=“...”>、<link rel=“import”>、<math><mi><script>...</script>等冷门标签或利用autofocus、onfocus等属性配合input标签。
经验之谈:自动化扫描工具能发现大量潜在问题,但最关键的漏洞往往需要手动验证和深入理解应用逻辑。DOM XSS的测试,一半靠工具枚举,一半靠人工审代码、理数据流。养成查看网络请求、调试JavaScript、跟踪变量值的习惯,比盲目扔Payload有效得多。
通关DVWA的DOM型XSS关卡,不仅仅是记住几个Payload,更是建立起一套分析前端代码、追踪用户输入、理解浏览器解析逻辑的方法论。从Low级别的直接注入,到Medium的标签事件绕过,再到High级别的输入源转移,最后到Impossible级别的安全编程实践,这条路径清晰地展示了漏洞的成因、利用手法以及最有效的防御策略。在实际工作中,面对一个黑盒或灰盒系统,这套“定位输入源 -> 分析处理逻辑 -> 寻找危险函数 -> 构造上下文相关Payload”的流程,将是你挖掘DOM XSS漏洞的利器。记住,永远不要信任客户端,无论是来自URL、存储还是消息的数据,在放入Sink之前,都必须经过严格的校验和安全的编码。
- 浏览器向服务器请求