XSS攻击是Web安全学习者绕不开的第一道门槛。最近群里总有人问我:“到底该怎么系统地练XSS?直接看文章看完就忘。”我给的建议基本一致:先把Xss-Labs靶场的1-10关老老实实打一遍。这是一个非常经典的PHP靶场,覆盖了反射型XSS里最常见的基础绕过手法,从裸奔到逐步加过滤,每一关都能让你对“输入输出”这两个词有新的理解。这篇文章我来分享一下自己通关前10关的完整思路、payload和踩坑记录。不是为了让你背答案,而是把每一关的“考点”和“为什么这么绕”讲清楚,配合源码一起看,你能真正建立XSS绕过的体系。
1. XSS攻击基础与靶场概述
1.1 从一句script说起,反射型XSS到底发生了什么
先别急着打靶,先把XSS的最基本模型捋顺。XSS的全称是跨站脚本攻击,核心就是攻击者把恶意JavaScript脚本“注入”到页面里,然后让浏览器当成正常代码执行。平时最常见的反射型XSS,其实就是一个“数据从不安全的地方来,然后被放在了不该放的HTML上下文里”的故事。
举个例子。一个搜索页面,把用户输入的keyword参数直接拼进HTML,代码大概是:
php复制<p>你搜索的关键词是:<?php echo $_GET['keyword']; ?></p>
如果我输入的不是关键词,而是:
code复制<script>alert(1)</script>
那么后端输出的HTML就变成了:
html复制<p>你搜索的关键词是:<script>alert(1)</script></p>
浏览器在解析HTML时,看到<script>标签就当成脚本执行,于是弹窗了。这就是最原始的反射型XSS。它不会存在服务器上,而是通过URL把payload发给受害者,受害者点击这个链接后,在自己的浏览器里执行脚本。之所以dangerous,是因为脚本可以在受害者的会话上下文里做任何事:读cookie、发请求、改页面内容……这也是为什么现代Web开发里,任何用户输入进入HTML上下文前都必须做输出编码。
1.2 Xss-Labs靶场的关卡设计逻辑
Xss-Labs是一个本地PHP靶场,一共20关,前10关集中在反射型XSS的各种过滤绕过场景。它的设计思路很实在:每一关在源代码里都有一段不完整的防护逻辑,比如把<script>替换掉、把onerror删掉、把关键字变成小写……你要做的就是观察这些过滤规则,然后想办法构造一个能逃过过滤且能在浏览器里执行的payload。
不少朋友打靶场只看答案,payload复制进去弹窗了就算通关,这样意义不大。我建议每关至少做两件事:第一,打开源码,找到处理输入的那段PHP,看清楚它到底过滤了什么;第二,把payload拆开看,想明白哪部分是为了闭合上下文,哪部分是为了触发事件,哪部分是为了绕过关键字匹配。两件事做完,你再看自己的payload,会发现每一段字符都是有用的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建与工具准备
2.1 本地运行环境:用PHP内置服务器就够了
Xss-Labs是在PHP环境下跑的,很多教程让你装全套LAMP环境,其实没必要。如果你只是想打这20关,用PHP内置的开发服务器就够了。前提是你已经装好了PHP,然后把分好区的靶场源码解压到任意目录,在终端里进入该目录,执行:
bash复制php -S 127.0.0.1:8080
然后浏览器访问http://127.0.0.1:8080就能看到靶场首页。如果遇到页面乱码或者显示不全,多半是PHP配置文件里short_open_tag没开,或者字符集问题。可以检查一下php.ini里的相关配置,或者用高版本PHP运行时注意一下兼容性。
还有一点,靶场需要依赖数据库吗?不需要,它全部用GET参数和文件本身存储,所以不用MySQL,跑起来非常轻量。部署完以后,你会在目录里看到level1.php到level20.php之类的文件,第1关对应level1.php,以此类推。
2.2 调试工具箱:开发者工具和手动构造payload
工欲善其事,必先利其器。我实际打靶时用的工具很简单:
- 浏览器自带开发者工具,用来查看网页源码、看网络请求、验证弹窗。按F12就能呼出,重点看“元素”面板和“控制台”。
- Burp Suite,用来抓包改包,当你需要看POST参数或者想修改请求头时有用。但前10关全是GET传参,直接用浏览器的地址栏就能完成大部分操作。
- HackBar之类的插件,方便快速修改URL参数,但不依赖它也没关系,地址栏手动改参数其实更锻炼思路。
我个人的习惯是,先直接在URL参数里塞一个很基础的payload试试水,比如?name=test,然后查看页面源代码,找到输入被插入的位置,再根据上下文构造闭合符。浏览器地址栏就是最快的手动测试工具,别一上来就开Burp,反而慢。
3. Xss-Labs 1-5关通关实录
3.1 第一关:裸奔的反射型注入,把script直接塞进去
第一关的URL是/level1.php?name=test,页面把你传入的name参数直接显示在页面上。打开源码会发现没有做任何过滤,直接把$_GET['name']交给echo输出。所以最简单的payload就是:
code复制<script>alert(1)</script>
直接在地址栏里把test替换掉,访问后就会弹窗。第一关没有考点,就是让你熟悉流程:参数如何传递、脚本如何被执行。通关后建议做一个小实验:输入<img src=x onerror=alert(1)>看看是否也能弹窗。这能帮你建立“标签不一定非要用script”的直觉,后面很多绕过都用事件属性。
3.2 第二关:双引号闭合属性,不是单纯闭合标签
第二关打开后,发现有一个搜索框,输入内容会显示在<input value="这里">的value属性里。源码大概是这样:
php复制echo '<input name="keyword" value="'.htmlspecialchars($_GET['keyword']).'">';
看起来好像用了htmlspecialchars?其实没有,第二关只是把输入放进了双引号包裹的value里。这时候直接塞<script>不会生效,因为它在引号内部,属于属性值,不是标签上下文。你要做的是先闭合前面的双引号,然后构造自己的属性或标签。
我的payload是:
code复制"><script>alert(1)</script>
前面的">先把input标签的value属性闭合掉,并结束整个input标签,然后<script>作为新的标签接在后面。浏览器解析时,遇到闭合后的>就知道input标签结束了,接着看到<script>就会执行。这一关的核心是“上下文闭合”四个字,先把输入从合法数据位置“挤”出来,再让你自己的代码成为HTML结构的一部分。
3.3 第三关:单引号逃逸,用事件属性来触发
第三关源码把输入嵌在了单引号包裹的属性里:
php复制echo '<input value=\''.$_GET['keyword'].'\'>';
这里用的是单引号闭合,如果你还按照第二关的双引号payload打,闭合不了。构造payload的关键是先用一个单引号闭合前面的value=',然后用事件属性代替闭合标签。
我使用:
code复制' onfocus='alert(1)
这里单引号闭合value属性,空格引入onfocus事件,再闭合onfocus的属性值。但光有onfocus不会自动触发,需要让输入框自动获得焦点。于是可以缩写成:
code复制' autofocus onfocus='alert(1)
autofocus让input标签自己获得焦点,onfocus在焦点到达时执行。如果你不想依赖自动聚焦,也可以用oninput:
code复制' oninput='alert(1)
这个只要在输入框里敲一个字符就会触发。事件属性是XSS绕过的万能钥匙,因为大多数过滤黑名单都只盯着script和javascript,很少会把所有事件过滤干净。
3.4 第四关:双引号加数字,事件继续用
第四关的输入点看起来和第三关很像,但实际源码不同:
php复制$str = $_GET['keyword'];
$str2 = str_replace(">", "", $str);
echo '<input type="text" value="'.$str2.'">';
它会把>全部删除。这意味着你没法用">这种闭合标签的操作,因为>直接没了。但不能用标签不代表没有XSS,你仍然可以先闭合双引号,然后构造一个完整的事件属性,比如:
code复制" onclick="alert(1)
前面的双引号闭合value属性,onclick作为input标签的事件属性,后面的双引号闭合事件值。这样即使没有>,我们依然可以在input标签内部构造触发条件。点击输入框时弹窗。当然这里需要用户点击,实际攻击中可能会配合页面其他交互,但靶场只要能弹出即可。
第四关让我印象很深:过滤单个字符往往能堵死一种常见思路,但堵不死所有思路。只要上下文还是属性值,事件属性永远有效。
3.5 第五关:href里的伪协议,标签名被过滤
第五关考察的是链接型XSS。源码大概是:
php复制$str = $_GET['keyword'];
$str2 = strtolower($str);
echo '<a href="'.$str2.'">测试链接</a>';
它会先把输入转成小写,然后直接放进href属性。小写过滤堵住了<SCRIPT>之类的大小写混淆。你可能会想试<img src=x onerror=alert(1)>,但是标签里的onerror属于事件,这个应该可以?其实第五关的关键在于它把输入放在了<a>标签的href属性里,你构造的payload必须能闭合href,并让链接变成本可执行的伪协议。
最经典的payload是:
code复制" onclick="alert(1)
但这样要点击。更简洁的是利用javascript:伪协议,比如:
code复制"><a href="javascript:alert(1)">x</a>
先闭合value,再用javascript伪协议。注意javascript:字符串不受小写影响,因为本身就是小写。实际源码如果是strtolower,那么用大写字母的payload会被转成小写,但这里不受影响。
第五关结束后,你基本理解了XSS的两种大方向:一种是直接注入新标签,另一种是修改已有属性并用手事件或伪协议触发。
4. Xss-Labs 6-10关通关实录
4.1 第六关:大小写绕过,过滤正则忘了忽略大小写
进入第六关,源码对输入做了一串过滤:
php复制$str2 = preg_replace('/<script>/i', '', $str2);
把<script>(忽略大小写)替换成空,然后直接输出。这里你以为大小写已经没用了,但实际上是用了正则的i修饰符,只能匹配<script>这一个小写字符串?“忽略大小写”就是说<SCRIPT>、<Script>都会被替换。那怎么绕?
关键点是它只过滤了script标签,没过滤img标签。所以直接用<img>加事件属性:
code复制<img src=1 onerror=alert(1)>
这个payload里没有任何<script>关键字,不会被替换。第六关告诉你:不要死磕一个标签,功能相似的元素多得是。img是天然的XSS载具,onerror又是最常用的事件,组合起来几乎在所有“只过滤script标签”的场景下都能用。
4.2 第七关:双写绕过,只替换一次就万事大吉?
第七关源码里有一个替换操作,把<script字符串替换成空,替换顺序是直接从左边扫到右边,而且只替换一次:
php复制$str2 = str_replace('<script', '', $str);
如果把输入写成:
code复制<scr<script>ipt>
会发生什么?虽然你写的是<scr<script>ipt>,但过滤程序会从左往右看,找到第一个<script(从<scr后面开始?实际上字符串是<scr<script>ipt>,其中包含<script>吗?仔细看:<scr + <script> + ipt> = 实际上是<scr<script>ipt>,注入后变成<scr<script>ipt>,替换掉中间的那个<script,剩余的是<script>?不对,我们来算。
如果原字符串是<scr<script>ipt>,其中匹配<script的是从第5个字符开始的<script?不对,<scr后面是<script>,也就是说原payload是<scr + <script> + ipt>,拼接为<scr<script>ipt>。过滤程序查找<script,在<scr后面找到了<script,于是将它删掉,剩下<scr + ipt>,即<script>?等等,我手误。
标准双写payload是<scr<script>ipt>,其中包含两个“script”片段:前一个是<scr,后一个<script>?我理一下:<scr + ipt> = <script>,中间插入<script>就变成<scr<script>ipt>。过滤时,程序从左到右扫描,会匹配到第一个<script,它是从中间的<script开始的,删掉后剩余<scr和ipt>拼在一起变成<script>。所以最终输出是完整的<script>标签,恶意代码照样执行。
第七关的payload:
code复制<scr<script>ipt>alert(1)</scr</script>ipt>
关闭标签对应也要双写,但有时候关闭不写也行,只要打开标签里的脚本能执行。浏览器解析时,<script>标签内容就是alert(1),后面关闭标签残缺也不影响执行。双写绕过适用于“过滤只替换一次”的场景,非常经典。
4.3 第八关:Unicode编码和HTML实体,骗过关键字过滤
第八关开始要动点脑筋了。源码过滤了javascript这个字符串,而且是小写匹配,还过滤了一些标签。网上常见的做法是利用HTML实体编码绕过字符串匹配。比如在href中,浏览器会先解码HTML实体再处理伪协议,因此你可以把javascript:写成javascript:。
实际payload:
code复制javascript:alert(1)
当浏览器解析这段href属性时,HTML实体j会被解码为字母j,于是浏览器看到的是javascript:alert(1),但源码过滤时只匹配纯文本javascript,它看到的是一串实体,匹配不到,于是放行。第八关的核心就是:过滤是基于“字符串的字面值”,而浏览器解析是基于“解码后的语义”——利用解码差异绕过滤。
除了HTML实体,还可以试试Unicode编码、URL编码等,但不同的上下文解码方式不同。在href里,实体解码和URL解码都可能发生,需要根据页面实际输出来调整。我当时用Burp抓包看了响应里的编码状态,才确认实体被解码成什么。
4.4 第九关:过滤alert关键字,用编码再绕一次
第九关过滤了几个关键字,包括alert。直接写alert(1)会被替换。但函数只是替换了字符串字面量,我们可以把alert编码后再丢进去。在JavaScript代码里,可以用Unicode转义序列来表示字符,比如:
code复制eval('al' + 'ert(1)')
不过更直接的是利用HTML实体:
code复制<img src=x onerror="alert(1)">
这里的a是字母a,浏览器解析HTML实体后,事件属性里的值变成alert(1),JavaScript正常执行。如果你发现onerror也被过滤,可以换其他事件或标签,总之思路是让关键字以“分离”的形式出现在源码里,而浏览器解析时又重新组合起来。
第九关还存在一个细节:源码里正则可能没有开启忽略大小写,所以用ALERT也能绕过,但实际调试时我发现某些版本里过滤函数是strtolower先转小写再匹配,所以大小写就不管用了。这种情况下,编码是最稳的。
4.5 第十关:综合关卡,无伤大雅的“JS代码注入”
第十关的源码变得复杂,它会检查某些关键字,比如<script、javascript、onerror等,而且会做多次替换。但真正巧妙的点是它允许你往页面的一个隐藏input标签的value属性里写内容,而这个input标签的value会被JavaScript取出来用在某个地方。
我的通关思路是:既然直接注入脚本关键字被过滤,就找一个不受影响的属性,比如style。先看源码,发现过滤规则把你输入的<、>进行了处理,但允许单引号存在。于是payload可以构造为:
code复制' style='background:url(javascript:alert(1))
利用CSS中的url()也可以触发JavaScript?其实CSS的url(javascript:...)在现代浏览器里一般不执行。真正有效的还是事件。我给的思路是寻找源码里echo的上下文,如果有一个值是拼接在<input value='...' onclick='...'>之类的位置,那么闭合单引号后构造事件。具体payload因版本略有不同,我这里提供一个可用的通用型:
code复制' autofocus onfocus='alert(1)
如果没有自动聚焦,则借助页面已有的JavaScript代码来拼接变量。第十关难在多层过滤,但只要抓住“闭合上下文 + 事件触发 + 编码混淆”的组合,就能解决。
我在打第十关时试了不下二十种payload,最后发现被骗了:过滤函数里有一个黑名单,但它只针对标签,不限制属性,直接敲事件就完事。这就是看源码的重要性。
5. 通关经验总结与防护启示
5.1 通关技巧速查:把每次绕过都变成“经验卡”
打完1-10关,我总结了几个高频技巧,列成了一张速查表,打其它靶场也适用:
| 场景 | 过滤点 | 绕过思路 |
|---|---|---|
| 标签被过滤 | <script>、<img>等 |
换用其它标签,或使用现有属性 |
| 关键字被替换 | javascript、alert |
HTML实体编码、Unicode转义、字符串拼接 |
| 引号被过滤 | '、" |
使用反引号或直接不闭合,触发事件时可用/代替空格 |
| 大小写被转小写 | strtolower |
代码中大小写无意义,改用事件或编码 |
| 只过滤一次 | str_replace |
双写绕过<scr<script>ipt> |
>被删除 |
str_replace(">","") |
不闭合标签,改构造事件属性 |
| 多次替换 | 多重黑名单 | 观察替换顺序,利用替换后拼出关键字 |
具体到反射型XSS,还有一条黄金法则:先找输出上下文,再想绕过方式。你是被放到<div>之间,还是被放到value="..."里,还是被放到href里,决定了你必须用什么闭合符来“越狱”。我见过很多人一上来就试<script>alert(1)</script>,然后发现没反应就慌了,其实只要用开发者工具看一眼源码,一切都明白了。
5.2 XSS防护真没你想的那么复杂,但也没那么简单
站在防护的角度再看这10关,你会发现一个残酷的事实:传统黑名单过滤几乎永远有漏洞。Xss-Labs每一关的过滤逻辑对应现实世界里常见的“拍脑袋”防护,比如只过滤script标签、只过滤alert、只过滤javascript关键字……这些全部能被绕过。
现代XSS防护的根基是“输出编码”和“上下文分离”。具体来说:
- 在HTML标签内容区域输出时,对
<、>、&、'、"做实体编码; - 在标签属性值区域输出时,对引号和特殊字符做属性编码;
- 在JavaScript代码区域输出时,使用
JSON.stringify等正确的转义; - 在URL区域输出时,只允许白名单协议,例如http和https,禁止javascript协议;
- 全局加一层Content-Security-Policy(CSP),即使XSS注入成功,也限制脚本来源。
这才是真正能挡住XSS的体系。黑名单只能应对已知攻击,白名单和上下文编码才能保证安全底线。
写在最后:一份额外的实战建议
打完这10关,我最大的感受是:XSS练习不是比谁payload记得多,而是比谁对“解析机制”理解得深。做每一关前,我都会先问自己三个问题:我的输入被放进了哪个上下文?这个上下文两侧的闭合符是什么?过滤器是基于字面量还是基于解析后内容?想清楚这三个问题,payload基本是水到渠成的事。
最后分享一个小技巧:每通关一关,都去翻一眼源码,把过滤函数依次列出来,然后在笔记本上写一句“这个过滤可以被XX绕过”。这样积累10次,你就拥有一张属于自己的XSS绕过速查卡。后续遇到真实漏洞挖掘时,这张卡比任何教程都管用。别忘了,靶场只是练习场,任何技术都要用在合规的地方。
