XSS-labs靶场通关实战:反射型与DOM型XSS绕过技巧全解析
1. 为什么一个靶场能让人反复折腾XSS-labs 这个靶场圈内人基本都听过。它不像那些大型漏洞演练平台动辄几十个G的镜像和复杂的编排就是一个轻量级的 PHP 项目二十关从最基础的反射型一路推到各种编码绕过、DOM 型、协议利用。很多人第一次接触 Web 安全就是从它开始的也有不少人卡在第十几关之后就再也没打开过。我自己前前后后刷过三遍。第一遍是照着攻略抄 payload第二遍是关掉攻略自己推第三遍是带着如果我是出题人我会怎么设计防御的思路去反推。三遍下来感受完全不同。这篇就把整个通关过程里真正值得记录的东西整理出来——不是那种第几关输入什么的答案清单而是每一类关卡背后的过滤逻辑、绕过思路的推导过程以及我在反复试错中踩过的坑。适合谁看如果你刚学完 XSS 的基本概念知道反射型和存储型的区别但一到实战就不知道从哪下手那这篇正好。如果你已经能独立通关也可以看看我在编码绕过和 DOM 型那几关的推导思路或许能补上一些之前靠试出来的直觉。需要提前说明的是XSS-labs 是本地搭建的练习环境所有操作都在自己的机器上完成。真实场景中的测试必须获得明确授权这一点没有商量余地。2. 环境搭建与前期准备2.1 运行环境的选择与取舍XSS-labs 是 PHP 写的所以最省事的方案就是找一个集成的 PHP 环境。我试过几种组合最后稳定在 PHPStudy 这类集成面板上原因是它把 Apache、PHP、MySQL 一次性配好省去了单独调 php.ini 和扩展的麻烦。版本上建议用 PHP 7.x太新的 8.x 在某些关卡里会因为函数行为变化导致 payload 表现不一致反而增加干扰。如果你习惯用容器也可以拉一个带 PHP 和 Apache 的基础镜像把项目目录挂进去。这种方式的好处是环境干净、随时重置坏处是要自己处理端口映射和目录权限。我个人的习惯是本地练习用集成面板需要反复重置环境的时候才上容器。部署的时候有个细节容易被忽略项目根目录的权限。在 Linux 环境下如果目录权限不对PHP 写入 session 文件会失败某些关卡的状态就存不下来表现就是明明 payload 对了但结果不对。这个坑我踩过一次排查了快半小时才反应过来是权限问题。2.2 浏览器与调试工具的搭配靶场练习离不开浏览器的开发者工具。我主要用两个东西一个是 Elements 面板看 DOM 结构另一个是 Console 面板直接执行 JS 验证想法。Network 面板在判断请求是 GET 还是 POST、参数怎么传的时候也用得上。这里有个经验不要只盯着一个浏览器。不同浏览器对某些编码的解析、对 URL 中特殊字符的处理是有差异的。我在某一关用某个浏览器怎么都弹不出框换了一个浏览器同样的 payload 直接就过了。所以遇到卡壳的时候换个浏览器试试往往能打开思路。另外建议装一个可以方便改请求的工具用来手动调整参数、改编码方式。很多时候 payload 不是写错了而是传输过程中被浏览器自动编码了手动控制一下就能看出问题在哪。2.3 心态上的准备这一条听起来虚但我觉得比环境还重要。XSS-labs 的关卡设计是循序渐进的前面几关几乎是送分中间开始上强度后面几关会让人怀疑人生。我的建议是前几关快速过建立手感中间关卡一定要自己先想想不出来再看提示后面几关如果卡太久可以先跳过回头再来。还有一点不要背 payload。背下来的 payload 换个环境就废了。真正要掌握的是这一关过滤了什么、我可以用什么方式绕过的推导能力。这个能力才是靶场真正想训练的东西。3. 反射型关卡的核心逻辑拆解3.1 最朴素的反射参数直接回显最开始那几关本质上是同一个模式你传一个参数进去页面原封不动地把它打印出来。这种就是最典型的反射型 XSS。第一关通常连过滤都没有直接构造一个带脚本的标签就过了。但这里我想说的是即使是这么简单的一关也值得你停下来想一个问题为什么它能弹框因为服务端把用户输入当成了 HTML 的一部分返回给浏览器浏览器解析的时候把这段输入当成了可执行的脚本。理解了这个因果链后面所有的绕过本质上都是在解决同一个问题——怎么让我的输入在到达浏览器时仍然保持可被解析为脚本的形态。3.2 属性闭合与标签构造再往后几关输入被放进了某个 HTML 属性里比如 input 的 value 属性或者某个标签的某个属性值中。这时候直接写脚本标签是没用的因为它已经在属性内部了。你需要先闭合当前属性再闭合标签然后插入自己的脚本。这里的关键是观察输入落在哪个位置。用开发者工具看返回的 HTML 源码找到你的输入出现在哪一对引号之间、哪个标签之内。然后根据上下文构造闭合。比如输入落在value...里你就先写一个双引号闭合 value再写闭合 input 标签后面接脚本。我在这类关卡上犯过一个典型错误只顾着闭合忘了看引号类型。有时候是单引号包裹你写双引号闭合不了自然就失败。所以第一步永远是确认包裹你输入的是单引号、双引号还是根本没有引号。3.3 大小写与标签黑名单的对抗有一关开始对标签名做黑名单了比如把script这个词过滤掉。这时候有几种思路一是换标签比如用img的 onerror、svg的 onload 这类事件触发二是大小写混写因为 HTML 标签名不区分大小写但过滤逻辑如果是简单的字符串匹配就可能被绕过三是用其他等价写法。换标签这个思路我觉得最值得掌握因为它不依赖过滤逻辑的漏洞而是利用 HTML 本身丰富的可执行入口。img、svg、body、input、details等等很多标签都有可以触发脚本的事件属性。记住几个常用的比死记 payload 有用得多。大小写绕过则要看具体实现。如果过滤用的是不区分大小写的匹配那这招就没用。所以它不是一个通用解而是一个需要先判断过滤方式再决定是否使用的技巧。4. 编码绕过与特殊字符处理4.1 URL 编码与 HTML 实体编码的区别到了中段过滤开始变严很多关键字被拦。这时候编码就登场了。但编码有好几种用错了地方就没效果这是很多人卡住的原因。URL 编码是在传输层面生效的。浏览器把 URL 里的特殊字符编码后发给服务端服务端解码后再处理。所以如果你的 payload 是在 URL 参数里且服务端会先解码再过滤那 URL 编码可能帮你绕过过滤。但如果服务端是先过滤再解码或者过滤发生在解码之后那这招就无效。HTML 实体编码则是在浏览器解析 HTML 时生效的。比如#x3c;会被浏览器解析成。这种编码能不能用取决于你的输入是被当成 HTML 解析还是被当成纯文本。如果服务端做了 HTML 实体转义那你的实体编码会被再次转义变成字面量显示出来就失效了。我在这两类编码上栽过跟头就是因为没搞清楚编码在哪一层生效。后来我养成了一个习惯先判断输入经过了几层处理每一层做了什么再决定用哪种编码。4.2 双重编码与嵌套绕过有些关卡会对输入做一次解码然后过滤但过滤之后又做了一次解码。这种情况下你可以把 payload 编码两次。第一次解码后得到的是编码一次的形态过滤逻辑看不懂它第二次解码后才还原成真正的 payload。双重编码的构造需要你对编码规则很熟。以 URL 编码为例编码一次是%3C编码两次就是%253C因为%本身也被编码成了%25。这个推导过程要能自己算出来而不是靠工具一键生成否则换个场景就不会了。嵌套绕过是另一个思路把关键字拆开利用服务端拼接或者浏览器解析的特性重新组合。比如把标签名拆成两段中间插入一些会被忽略的字符。这种技巧需要对 HTML 解析规则有比较深的理解属于进阶内容。4.3 编码绕过的判断流程我把判断用哪种编码的流程整理成了一个简单的思路先看输入落在 URL 还是 HTML 里再看服务端做了几次解码、过滤在解码前还是解码后最后看浏览器拿到的是什么形态。这三步走完基本就能确定该用哪种编码。这个流程不是死的实际关卡里可能还有别的干扰因素。但有了这个框架至少不会像无头苍蝇一样乱试。我在后面几关能快速找到方向靠的就是这个判断习惯。5. DOM 型关卡的独特之处5.1 服务端看不到的漏洞DOM 型 XSS 和前面积累的经验有一个本质区别payload 不经过服务端。也就是说你在 URL 里传的东西服务端可能根本没记录过滤也无从谈起。漏洞出在浏览器端的 JavaScript 代码里是前端脚本把 URL 里的内容取出来然后不安全地插入到了 DOM 中。这就导致一个现象你用抓包工具看请求发现服务端返回的 HTML 里根本没有你的 payload但页面上就是弹框了。第一次遇到这种情况会懵以为是自己看错了。其实是因为整个触发过程都发生在浏览器里。理解这一点很重要因为它决定了你的调试方式。DOM 型关卡不能只看服务端返回必须打开开发者工具看前端 JS 是怎么处理 URL 参数的。找到那段代码看它用了什么方法把参数插入 DOM是innerHTML还是document.write还是别的。不同的插入方式能触发的 payload 形态不一样。5.2 锚点参数与前端取值DOM 型关卡里经常用到 URL 的锚点部分也就是#后面的内容。为什么用锚点因为锚点不会发送给服务端天然就绕过了服务端的任何过滤。前端 JS 通过location.hash取到这部分内容然后处理。但锚点有个坑它里面的内容如果包含特殊字符浏览器可能会做处理。而且不同的取值方式拿到的内容形态也不一样。我在这上面卡过明明 payload 写对了但前端取出来的值和我预期的不一样导致插入 DOM 后无法执行。解决办法还是看源码。找到前端取值的那一行看它用的是location.hash还是location.search有没有做decodeURIComponent之类的处理。搞清楚数据流问题就迎刃而解。5.3 前端过滤的绕过思路有些 DOM 型关卡在前端也加了过滤比如用正则替换掉某些关键字。这种过滤的绕过思路和服务端过滤类似但因为是在 JS 里可以利用 JS 的一些特性。比如 JS 里字符串的某些方法、正则的某些写法可能存在绕过空间。又比如前端过滤往往只做一次你可以通过编码或者构造让过滤后的结果在插入 DOM 时又被还原。这些思路需要你对 JS 比较熟但核心逻辑和前面是一样的找到过滤点分析过滤规则找规则的盲区。6. 通关过程中的典型问题与排查6.1 payload 不生效的排查顺序卡关的时候最怕的就是瞎试。我总结了一个排查顺序基本能覆盖大部分情况。排查项检查内容常见问题输入位置用开发者工具看输入落在 HTML 哪个位置落在属性里却直接写标签引号类型确认包裹输入的是单引号、双引号还是无引号闭合符号用错过滤规则看哪些字符或关键字被过滤或转义用了被过滤的标签或事件编码层级判断输入经过几层解码、编码在哪层生效编码方式用错层浏览器差异换浏览器测试某些字符解析行为不同前端处理DOM 型关卡看前端 JS 如何处理参数忽略了前端取值和插入方式这个表我建议在卡关的时候逐项过一遍比漫无目的地改 payload 效率高得多。很多时候问题就出在最前面一两项只是自己没意识到。6.2 几个让我印象深刻的坑第一个坑是引号闭合。有一关我试了半天最后发现包裹输入的是单引号而我一直用双引号去闭合。这种低级错误在疲劳的时候特别容易犯所以卡住的时候先回头确认最基础的东西。第二个坑是编码层级。有一关我用了 URL 编码但服务端是先解码再过滤所以编码根本没起到绕过作用。后来改成 HTML 实体编码才过。这个坑让我彻底记住了编码要在正确的层级生效这个原则。第三个坑是 DOM 型的调试方式。一开始我还在看服务端返回怎么都找不到问题。后来才反应过来要看前端 JS打开 Console 一步步跟才发现是前端取值后做了处理我传的 payload 形态不对。这些坑现在说起来都很简单但当时每一个都卡了不少时间。记录下来是想说卡关不可怕可怕的是不知道往哪个方向排查。6.3 通关之后该做什么通关不是终点。我的建议是通关之后做两件事一是回头把每一关的过滤逻辑和绕过思路整理成自己的笔记用自己的话写不要抄二是尝试自己改一改关卡代码加一层过滤看能不能防住自己之前的 payload。第二件事特别有价值因为它让你从攻击者视角切换到防御者视角。你会发现很多你以为很严的过滤其实漏洞百出而真正有效的防御往往需要在正确的层级做正确的处理。这个认知的转变比多刷几遍靶场有用得多。7. 从靶场到实战的思维迁移靶场里的关卡是理想化的每一关只针对一个或几个知识点。真实环境要复杂得多过滤可能有多层编码可能嵌套还可能有一些你意想不到的处理逻辑。但靶场训练出来的推导能力是可以迁移的。核心的思维模式是先观察再假设后验证。观察输入落在哪里、经过了什么处理假设过滤规则和绕过方式构造 payload 验证假设。这个循环在靶场里练熟了到了真实场景里就是同一套方法只是变量更多、需要更耐心。另外靶场里学到的各种 payload 形态和绕过技巧在真实场景里要灵活组合。没有哪个 payload 是万能的关键是理解每个技巧背后的原理知道它在什么条件下有效、什么条件下失效。这种理解才是靶场真正想给你的东西。我自己在刷完第三遍之后最大的收获不是多会了几个 payload而是面对一个陌生的输入点时知道该从哪里开始看、往哪个方向想。这个能力比记住任何具体的技巧都重要。