1. 先搞清楚靶场的关卡设计逻辑
接触过各类注入靶场的人应该都有这个感受:很多靶场就是给你一个注入点,让你猜payload,猜不出来看WP,看完了下一关还是不会。Sqli-lab不一样的地方在于,它的关卡安排本身就是一条非常清晰的学习曲线,从最简单的数字型注入一路走到HTTP头渗透,中间还穿插了宽字节、二次注入、堆叠注入、order by注入这些实际业务里才会遇到的变体。
我第一次完整刷这个靶场的时候,最大的感受就是:它逼着我把"盲注"这件事真正搞懂了。很多人在真实渗透里遇到盲注就只会丢给工具跑,一旦工具跑不出来或者流量被拦截,整个人就卡住了。但Sqli-lab前面几关恰恰是手工盲注的训练场,从布尔盲注到时间盲注,每一关都在强化同一个思路:判断语句怎么执行、数据怎么逐位拆出来。
这个靶场适合谁来刷?我觉得有三类人:第一类是想系统梳理SQL注入知识点的安全专业学生;第二类是准备护网、渗透测试面试,想把手艺重新拾起来的从业者;第三类是写代码的研发人员——你至少要懂攻击者怎么测你的查询参数,才能写出真正安全的SQL层代码。
先说一个最核心的思路:Sqli-lab的每一关,本质上是同一个SQL语句的不同拼接形态。开发者写了个查询,传参方式不同,闭合方式不同,过滤规则不同,你就得用不同的姿势去闭合它、注释它、绕过它。所以刷靶场的重点不是背payload,而是建立一套"看到什么报错、响应什么特征,就知道语句长什么样"的推断能力。
| 阶段 | 大致关卡范围 | 核心技能目标 |
|---|---|---|
| 参数识别与闭合 | 前10关左右 | 判断注入点类型、闭合方式、注释符使用 |
| 数据获取方式 | 中段关卡 | union查询、报错注入、布尔/时间盲注 |
| 注入位置迁移 | POST / HTTP头 | 从GET参数转到POST正文和请求头 |
| 过滤与绕过 | 宽字节、关键字过滤等 | 理解编码差异与过滤规则的可绕过性 |
| 特殊场景注入 | 堆叠、order by | 语句截断、堆叠执行、排序参数注入 |
这个表格基本就是一个标准的自学路线图。下面我按这个路线逐层拆解,每部分都会讲原理、讲手法、讲我实际踩过的坑。
2. 从参数识别到闭合方式:前期关卡的地基
2.1 第一步永远是判断注入点
很多人刷靶场喜欢直接上sqlmap,这是很吃亏的。Sql-lab前期关卡设计得很"良心",报错全开,你甚至能在页面上直接看到MySQL的语法报错。这时候一定要做一件事:手工测。
先看一个最典型的例子。一个基础的GET查询长这样:
http://example.com/less-1?id=1页面正常显示数据。然后我加一个单引号:
http://example.com/less-1?id=1'如果页面报错,说明这个参数很可能存在SQL注入。但报错不一定是好事,不同数据库、不同语句拼接方式,报错信息会完全不一样。比如MySQL的经典报错里经常出现"SQL语法错误"或者"Unknown column"之类的字样;而在某些配置下,数据库异常会被框架层吞掉,直接返回500,这时候就要靠页面内容差异来判断了。
判断注入点还有一个经典做法:数字运算。把参数改成id=2-1,如果页面显示的是id=1对应的数据,说明参数被代入SQL语句参与了运算,大概率是数字型注入。如果页面依然显示id=2的数据,说明参数被当成字符串处理了,这时候要做的就是字符串闭合。
我自己的习惯是,每次先用三种姿势打底:单引号、2-1、1 and 1=1/1 and 1=2。三组请求一打,基本能把"参数是否可控、是否参与SQL拼接、是数字型还是字符型"这三件事定下来。
2.2 闭合方式:字符型注入的核心
数字型和字符型的区别很多人张嘴就能说,但实际判断起来还是会翻车。数字型注入直接拼接数值,语句长这样:
SELECT * FROM users WHERE id = 1字符型注入则是在SQL里多了一层引号,语句长这样:
SELECT * FROM users WHERE id = '1'如果你用id=1'去测数字型注入,大概率只是报错,因为多了一个单引号没人闭合。但如果是字符型,你就可以通过插入单引号来"破坏"原有的SQL结构。比如:
SELECT * FROM users WHERE id = '1''这时候数据库会认为字符串'1'后面的'是语法错误。怎么办?用一个单引号把前面的引号闭合掉,再注释掉后面的部分:
id=1' --+对应的SQL变成了:
SELECT * FROM users WHERE id = '1' --+'MySQL里--是行注释符,后面的内容全部被忽略,语句就变成了WHERE id = '1'。这样注入字符串就成功落地了。
Sqli-lab早期几关就是反复练这个事:有的是'1'这种单引号闭合,有的是"1"双引号闭合,有的是('1')加括号的闭合。关键不是背,而是要通过报错文本去反推原始语句的结构。页面报错里如果出现了类似''1'' LIMIT 0,1这样的字样,你就能直接看到原语句长什么样,用什么闭合一目了然。
2.3 注释符那些坑
注释符是注入里最容易翻车的一个细节。在MySQL里常用的注释符有三种:#、--(注意后面要跟一个空格)、/* */。但HTTP请求里#会被浏览器当成锚点,直接丢掉,所以URL传参时要把#编码成%23。--+这个写法为什么会流行?因为+在URL解码后会变成空格,正好满足--后面需要空格的要求。
还有一个容易踩的坑是GBK编码下的%00截断,这个在PHP老版本里很常见。我用一个表把常用注释方式列出来:
| 写法 | 实际效果 | 使用场景 |
|---|---|---|
--+ | --加空格 | GET传参时最常用 |
%23 | # | URL编码后传参 |
# | # | POST正文里直接用 |
--%20 | --加空格 | 部分场景下规范写法 |
;%00 | 分号加空字节 | 老版本PHP+MySQL的特殊场景 |
我在刷Sqli-lab时发现,早期关卡用--+基本畅通无阻,到了后面有些关卡把注释符过滤掉了,就需要考虑用#或者闭合后面内容的其他方式。遇到这种情况,要回去看源码层面的过滤逻辑,而不是盲目换符号碰运气。
3. 报错注入、布尔盲注与时间盲注:三种盲区打法
3.1 报错注入:最快的数据提取方式
当页面不回显数据,但会把SQL错误信息打印出来的时候,报错注入就是最高效的方式。Sqli-lab里从第五关开始就进入了这个模式,页面不说人话,不管你输入什么,返回的都是那句经典的You are in,但报错信息却会原原本本地暴露在页面上。
MySQL里最常见的报错注入手法有三种:extractvalue、updatexml、floor(rand(0)*2) group by。前两个本质上都是对XML文档函数传入非法参数,MySQL会在错误信息里把函数参数原样返回。我用extractvalue举个例子:
AND extractvalue(1, concat(0x7e, (SELECT version()), 0x7e))这条语句的意思是,用concat拼出一个包含查询结果的字符串,传给extractvalue的第二参数。因为第一个参数不是合法的XML文档,MySQL就会报错,而且错误信息里会包含这个字符串。0x7e是波浪号~的十六进制表示,用来包住查询结果,防止出现不可见字符干扰阅读。
实际操作里,报错注入因为每个请求只能报出固定长度的信息,所以依然要配合substr和limit来逐段提取。比如一次报错只能拿出32位左右,那就:
AND extractvalue(1, concat(0x7e, substring((SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=database()), 1, 31)))报错注入最大的优点是可以直接出数据,不像盲注那样一点一点猜;最大的缺点是很多WAF都会对extractvalue、updatexml这些函数做精确匹配。刷Sqli-lab的时候,遇到这类函数被过滤的关卡,就要在报错函数的选择和绕过写法上动心思。
3.2 布尔盲注:把注入变成判断题
布尔盲注的思路很简单:构造一个条件分支,让SQL语句的执行结果因条件真假而完全不同,然后根据页面的不同表现来判断真伪。Sqli-lab布尔盲注关卡的页面特征是,条件为真时正常显示一条记录,条件为假时页面内容为空或者不显示。
最常见的构造方式是用IF函数:
AND IF(ASCII(SUBSTR((SELECT DATABASE()), 1, 1)) > 100, 1, 0)如果数据库名的第一个字符ASCII码大于100,IF返回1,整个条件成立,页面正常;否则返回0,条件不成立,页面异常。这样通过真假响应就可以把数据一位一位拆出来。
逐位提取的时候,最关键的是定位逻辑。我常用的定位语句是:
AND ORD(MID((SELECT IFNULL(CAST(DATABASE() AS CHAR), 0x20)), 1, 1)) > 64很多人习惯用substr配合ascii,我更喜欢用mid配合ord,实际效果一样,但代码写起来更顺。盲注最忌讳的是在一个请求里把多个判断条件叠在一起,一旦响应特征模糊了,你根本不知道是条件的问题还是语句的问题。每次只测一个字符,通过二分法逐步逼近,虽然慢,但稳。
在靶场里练布尔盲注的时候,我强烈建议别开sqlmap,纯手工跑。你会在一个字符上反复刷几十次请求,这个过程很枯燥,但正是这种枯燥让我真正理解了"每次请求只能判断一个命题"这个核心逻辑。跑完几个字符,再去打开工具看它怎么工作,很多东西就通了。
3.3 时间盲注:连响应内容都没有时的最后手段
有些场景下,页面不管条件真假,返回的内容一模一样,你什么都看不出来。这时候唯一可用的信息通道就是时间。Sqli-lab时间盲注关卡就是一个典型,页面永远显示一句话,真正的差别藏在响应时间上。
时间盲注的核心是让条件真假导致SQL执行时间不同:
AND IF(ASCII(SUBSTR(DATABASE(), 1, 1)) > 100, SLEEP(3), 0)条件为真时执行SLEEP(3),MySQL睡3秒,HTTP响应时间就会明显增加;条件为假时直接返回,响应时间正常。这样一个请求一个请求地打,根据响应时间差逐步判断数据。
时间盲注在实际网络环境里有一个大问题:网络抖动。你没法保证正常请求就一定是几十毫秒,延迟和丢包都可能产生误判。我的经验是,判断条件要用相对阈值而不是绝对时间,比如把SLEEP(3)和正常响应时间做对比,如果正常响应是100毫秒,那超过2秒就视为真。另外要配合重试,同一个条件至少打两次,两次都超过阈值才记真,否则判假。
3.4 三种盲注的选型判断
| 盲注类型 | 依赖信息 | 请求量 | 速度 | 适用场景 |
|---|---|---|---|---|
| 报错注入 | 页面显示SQL错误 | 少 | 快 | 报错信息可见 |
| 布尔盲注 | 页面内容有差异 | 多 | 中 | 条件开关特征明显 |
| 时间盲注 | 响应时间差异 | 最多 | 慢 | 页面内容完全不变 |
真实场景里,我倾向于能报错就报错、能布尔就不时间,时间盲注永远是最后的选择。因为时间盲注一个字符可能要打七八个请求,一个数据库名几十个字符,再算上表名、列名、数据内容,整个提取过程的请求量会非常恐怖,对目标系统和自身隐蔽性都是一种考验。
4. HTTP头渗透:从请求头里找入口
4.1 为什么HTTP头会成为注入点
到了Sqli-lab后段,你会在某几关突然发现GET和POST参数都测不到注入点了,页面完全正常,这时候很多人就卡住了。其实入口藏在请求头里。
HTTP头注入在实际开发里非常常见。一个典型的场景是:开发者在后台做了一个登录日志功能,把用户的User-Agent、Referer、X-Forwarded-For记录到数据库里,方便排查问题。这个功能本身没问题,但只要开发图省事,把请求头内容直接拼进SQL语句,注入口就出现了。
你想象一下这个场景:一个后台管理系统,管理员查看登录日志列表,日志表里存的是用户浏览器UA。攻击者只需要在请求里把UA改成恶意SQL,管理员下次刷新日志页面的时候,注入就触发了。这就是所谓的存储型注入——你的输入被存进了数据库,在另一个页面被拼进SQL执行。Sqli-lab在HTTP头关卡设计上其实很接近这个真实场景。
4.2 UA、Referer、XFF三大头的注入构造
在Sqli-lab这类靶场里,HTTP头注入集中在三个位置:
User-Agent的注入方式,是在UA字段里直接构造闭合引号后的SQL语句:
User-Agent: 1' AND extractvalue(1, concat(0x7e, (SELECT version()), 0x7e)) --+Referer注入的逻辑一样,只是把注入串放在Referer字段里:
Referer: http://example.com/1' AND extractvalue(1, concat(0x7e, (SELECT version()), 0x7e)) --+X-Forwarded-For的注入则比较特殊,因为这个头一般用来记录客户端真实IP,所以注入点是IP格式的拼接。有些开发者会把请求头里的XFF直接拼接进SQL:
X-Forwarded-For: 127.0.0.1' AND extractvalue(1, concat(0x7e, (SELECT version()), 0x7e)) --+这三个位置的核心逻辑完全一样:找到原始SQL语句对请求头的拼接位置,用闭合符把引号闭合掉,再用注释符把后面的内容吞掉。区别只是你需要在Burp里重新构造请求,而不是在URL或POST参数里改。这也是为什么我建议刷到HTTP头关卡时,一定要从浏览器切换到Burp Suite之类能自由修改请求头的工具,否则你连注入点都摸不到。
4.3 GET参数测不出来?换个思路
很多人测注入有个惯性思维:先在URL参数里加单引号,再在POST参数里加单引号,都测不出来就认为没洞。但实际上,HTTP头注入的触发点往往不在用户直接操作的参数里,而是在服务端主动读取的元信息里。UA、Referer、Cookie、XFF、Host,这些都可能是注入点。
我个人的排查习惯是:一个页面测完GET和POST参数之后,一定把所有请求头都过一遍。尤其是当你看到目标系统有登录日志、操作审计、IP黑白名单这类功能时,请求头注入的概率会直线上升。日志记录类功能喜欢存UA和Referer,IP校验类功能喜欢读XFF和X-Real-IP,这些字段只要进了SQL都是口子。
4.4 头注入的几个常见坑
头注入有几个比较隐秘的坑,我一个个说。
第一个坑是换行与注入语句的截断问题。有些语言和框架在解析HTTP头时会剥离换行符,如果注入payload里带了回车换行,可能导致请求头解析错误或者注入语句被截断。遇到这种情况,要把payload里的换行符去掉,或者改用其他字符替代。
第二个坑是有些头字段存在格式校验。最典型的是XFF,很多代码会先对XFF做IP格式校验,你再怎么注入它也只会把非IP部分过滤掉。这时候可以尝试双写或者在合法IP后面拼接:127.0.0.1' AND ...,有些校验只检查开头几位,不会做完整的IP解析。
第三个坑是请求头的编码问题。在HTTP头里直接放中文或者特殊字符可能会被框架转义,所以报错注入用的0x7e这种十六进制编码在头注入里特别实用,可以避免很多由编码引起的干扰。
5. 过滤与绕过:在规则缝隙里找路
5.1 关键字过滤:大小写、双写与注释拆分
过了基础关之后,Sqli-lab会引入一些模拟真实开发场景的过滤逻辑。最常见的过滤就是针对关键字,比如直接过滤掉select、union、and、or这些词。
碰到这类关卡,第一步是确认过滤规则到底是怎么写的。如果过滤方式是正则替换为空,那就可以用双写绕过:
SELSELECTECT外层的是原词拆一半,内层保留原词,过滤函数把中间的内层词删掉后,剩下的恰好是完整的SELECT,数据库还是能识别。这个思路在关键字过滤的场景里非常经典。
大小写混合也能绕过一部分过滤规则,前提是过滤规则的匹配方式区分大小写。SeLeCt在MySQL里能正常执行,但如果过滤逻辑只匹配小写的select,就直接绕过了。不过现在很多过滤规则都做得很完善,大小写混写不一定好用,需要现场验证。
注释拆分是另一个技巧。MySQL支持/*!50000SELECT*/这种内联注释,注释内容会被当作SQL执行。Sqli-lab有些关卡的过滤规则不会对注释内容做检查,于是你可以把关键字藏进注释里,比如UNI/**/ON SEL/**/ECT,过滤不了注释里的内容,数据库却可以正常拼接执行。
5.2 空格与符号替换
空格在很多过滤规则里是重点监控对象。遇到空格被过滤的情况,可以用注释符模拟空格:
UNION/**/SELECT/**/1,2,3/**/在SQL里等效于一个空格,但很多过滤逻辑只盯着字面空格,对注释符的管理往往更宽松。
另外,MySQL还有几个特殊的空白字符可以替代空格:制表符%09、换行符%0a、回车符%0d。在URL参数里直接写这些编码,有些过滤规则会漏掉。我刷Sqli-lab时印象很深的是,有一关在URL里可以用%0a代替所有空格,完全没被过滤。这种绕过方式在真实场景里同样有效,所以遇到空格过滤不一定是死路,先把能替代空格的字符都试一遍。
5.3 宽字节注入的真原理
宽字节注入是Sqli-lab里一个很有特色的关卡,也是很多人理解得很模糊的一个知识点。它的产生条件有两个:一是数据库编码为GBK这类双字节字符集,二是前端或框架在拼接SQL前对单引号做了转义,把'改成了\'。
问题的关键在于:在GBK编码下,\的反斜杠编码是0x5c,而某些汉字字符的编码是以0x5c结尾的。比如汉字"運"的GBK编码是0xbf5c,当攻击者在输入里放了这个字,再放一个单引号,整个字符串在数据库眼里就变成了这样:
0xbf5c 5c27也就是運加上\'。这时候0xbf5c被解析成一个汉字,而0x5c27里的5c和27分别变成了另一个汉字的两个字节,单引号就从转义状态中解脱出来了。这就是所谓的"宽字节吃掉反斜杠"。
实际刷这关的时候,没必要真的去翻GBK码表。你只需要知道,在目标站点是GBK编码且对单引号做转义处理时,可以试试在payload前面加上%bf%5c这类双字节字符前缀,把转义用的反斜杠"吸走"。如果站点换成了UTF-8编码,这个手法就基本失效了。这也是为什么宽字节注入只针对特定字符集环境,不能当成万金油。
5.4 堆叠注入与order by注入
堆叠注入和order by注入是Sqli-lab后段的两类特殊关卡。
堆叠注入的核心是分号。在SQL中,分号标志着一条语句的结束,你可以在注入点后加上分号再写一条完整的SQL语句,让两条语句依次执行:
SELECT * FROM users WHERE id = 1; DROP TABLE users堆叠注入能执行任意SQL语句,所以危害远高于普通的union注入。但它有一个明显限制:不是所有数据库和所有API都支持在一次查询里执行多条语句。MySQL的很多驱动默认是不支持堆叠查询的,所以这关在实际环境里能不能通,得看目标的技术栈。
order by注入是我个人觉得最绕的一类。因为排序子句的语法限制,你不能用union,也不能随便用引号,很多时候只能通过报错注入或者盲注去提取数据。排序注入的基础形式是在参数后面加判断条件:
ORDER BY IF(1=1, id, name)当条件为真时按一个字段排序,条件为假时按另一个字段排序,排序结果不同导致页面元素顺序变化,这就是布尔盲注通道。遇到过滤严格的场景,还可以在order by后面拼rand(0)配合group by触发报错。我在刷这个模块时最大的体会是:order by注入不是一种独立的注入技术,而是把前面所有注入知识综合用在一个语法受限的位置上。
5.5 手工与工具:什么时候该用什么
很多人问我,刷Sqli-lab到底该用手工还是该用sqlmap。我的判断标准很简单:学习阶段必须手工,实战阶段能上工具就上工具。
Sqli-lab这种靶场设计的目的就是训练手工思路,每一关都对应一种注入类型,你在手工构造每一个payload的过程中,才能真正理解参数拼接、闭合、注释、编码转换这些底层逻辑。直接跑sqlmap当然也能跑出来,每个关卡都出数据,但跑完了你还是只知道--dbs和-D那几个参数,换个真实环境照样抓瞎。
实战中恰恰相反,时间就是生命,手工盲注一个库可能得花几百上千个请求,sqlmap几分钟就能搞定。所以我的建议是:靶场里把手工练到条件反射,实战里再依赖自动化工具做提速。两者不是对立关系,而是先后关系。
6. 卡关时的排查清单
6.1 常见问题速查表
刷到后段关卡,卡关是常态。我把刷靶场过程中最常撞见的几个问题整理成一个速查表,你照着这个表逐项排查,大部分卡点都能解决:
| 卡关现象 | 可能原因 | 排查方向 |
|---|---|---|
| payload在GET里无效 | 参数可能被URL编码变形 | 用Burp直接改包观察原始请求 |
| 报错信息不显示 | 页面程序把错误吞了 | 换布尔盲注或时间盲注 |
| 单引号被转义 | 低版本PHP走addslashes类函数 | 尝试宽字节注入或换注入点 |
| 空格被过滤 | 存在基础WAF或过滤函数 | 用注释符、%0a替代空格 |
| select被过滤 | 关键字黑名单 | 双写、注释拆分、大小写混用 |
| 页面内容完全不变 | 可能是时间盲注或HTTP头注入 | 测响应时间差、排查请求头 |
| get请求测不出注入 | 注入点可能在POST正文 | 把参数从URL挪到请求体 |
| 头字段被校验 | 目标对头做了格式限制 | 试试在合法值后面拼接payload |
6.2 学会从响应差异里读信息
排查问题最核心的能力不是记住payload,而是能从响应里读出有效信息。同一个注入点在报错信息可见和不可见两种状态下,应对策略是完全不同的。所以每次请求都要关注三件事:状态码有没有变、页面长度有没有变、响应时间有没有变。这三个特征就是你在盲区里判断条件的依据。
我刷Sqli-lab时有一个习惯,凡是页面内容发生变化的关卡,我会先用curl把正常请求和异常请求的页面大小抓出来对比。如果正常请求是2350字节,异常请求是1980字节,那这个差的370字节就是你判断布尔条件的信号。把这个信号稳定下来,盲注的准确率会大幅提升,也不会因为偶尔一次的响应抖动而误判。
6.3 一个老手才会注意到的小细节
最后分享一个我在刷HTTP头渗透关卡时发现的细节。很多人在Burp里构造完请求头注入后,会忘记处理Content-Length。如果你改了请求体或者加了请求头,但Content-Length没跟着更新,目标服务器会认为请求不完整,直接断开连接,你的注入就莫名其妙地失败了。不少人在这个坑里卡了很久。所以每次改完包,先看一眼请求头里的长度字段,再点发送,能省很多排查时间。
7. 刷完整个靶场之后,我的实际感受
Sqli-lab整体刷下来,最大的收获不是记住了几十种payload,而是建立了一套完整的注入排查思维。遇到一个目标,先看参数怎么传,再判断闭合方式,接着尝试数据提取,最后考虑过滤绕过的可能性。这个思维链路是我在真实渗透测试里反复用到的。
我个人建议你刷靶场时准备一个笔记文档,每一关都记录三样东西:原始SQL语句长什么样、注入构造的关键一步是什么、如果过滤规则变了该用什么替代方案。这样刷完一遍,你手里就相当于有了一本自己的注入手册,比你收藏任何网上的wp都有价值。
后面你还可以往两个方向延伸:一个是把Sqli-lab里的注入手法迁移到其他类型的数据库上,比如PostgreSQL和MSSQL的报错函数、注释符和堆叠支持都很不一样;另一个是尝试绕过真实WAF产品,把靶场里学到的绕过思路放到更严格的规则体系里验证。这两个方向都能让这套基本功转化成真正的实战能力。