sqli-labs是我接触过最适合入门SQL注入的练习靶场,没有之一。它把SQL注入拆成了几十个关卡,从最简单的GET数字型注入,到后面绕WAF、宽字节、堆叠注入,全部按难度递进排好。对刚入门Web安全的人来说,它比直接看CTF writeup更友好,因为每一关只聚焦一种注入形态,你能清楚知道自己究竟卡在哪一环。这篇文章我不打算把每一关的payload从头贴到尾,那没什么营养。我更想讲清楚判断逻辑:怎么识别闭合方式、报错信息如何利用、盲注为什么需要写脚本、过滤规则怎么绕过。内容适合有一定Web基础、想系统补一遍注入知识的朋友,本地部署起来跟着走一遍,收获会很大。
1. 部署细节与关卡地图:先把靶场跑起来再谈通关
1.1 本地环境搭建的常见坑
sqli-labs本质是一个PHP项目,跑在Apache或者Nginx上都行,核心依赖是PHP环境和一套MySQL数据库。最省事的方案是直接用现成的集成环境,把源码丢进站点目录,改一下数据库连接配置就能访问。听起来很简单,但我见过太多人卡在部署这一步,这里把最容易出问题的几个点单独拎出来说。
第一,PHP版本兼容性。sqli-labs是老牌项目,官方仓库的代码在PHP 7以下版本跑得很顺,但如果你用的是PHP 8.x环境,某些旧函数会被移除或者触发弃用警告,严重的时候直接白屏。遇到这种情况不要急着怀疑源码,先打开PHP错误日志,看一下是不是某个函数不存在。常见处理方式是换一个PHP版本,或者找社区维护版本。
第二,数据库账号权限。靶场源码里默认的数据库连接账号密码往往是root/root,如果你本地的MySQL用的是别的密码,记得把配置文件里所有涉及连接的地方都改掉。这个步奏漏掉的话,页面会一直提示数据库连接失败,但很多人会误以为是端口或者路径的问题,白白浪费时间。
第三,初始化数据库。正常情况下,打开靶场首页会有一个初始化按钮,点一下它自动建库建表。如果你的环境没法自动初始化,那就手动创建数据库,再把sql文件导入。这个文件在sql目录下,导入之后记得确认表是否齐全,尤其是users表,后面的堆叠注入和二次注入关卡都要用到它。
第四,端口占用。集成环境默认的80端口经常被其他服务占掉,我习惯改成8080或者自定义端口。改完之后访问地址也要跟着变,这个细节很不起眼,但确实卡过我一会儿。
1.2 关卡布局速览
部署完成之后,先别急着第一关就开打,花十分钟把靶场首页的关卡列表过一遍,理清整体节奏。sqli-labs的关卡大致可以分成几段:
- Less 1到4:GET型基础手注,练闭合方式和联合查询。
- Less 5到10:没有常规回显,转入报错注入、布尔盲注和时间盲注。
- Less 11到17:注入点从URL参数转到POST表单参数。
- Less 18到22:注入点出现在HTTP头,User-Agent、Referer、Cookie都有。
- Less 23以后:大量过滤与绕过关卡,直到宽字节、堆叠、order by 注入等进阶形态。
每打完一个阶段,我会强烈建议你停下来做一个阶段小结,把"判断注入点 → 确认闭合 → 确定类型 → 选手法 → 构造payload → 验证输出"这条流程在心里过一遍。靶场设计得很有层次,它不是在为难你,而是在逼你建立自己的注入思维链。如果只是照着网上的答案一个个抄,那你打完五十三关依然面对一个真实输入点毫无头绪。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前四关的手注基本功:从报错试错到联合查询
2.1 在第一关建立手注的完整手感
第一关打开是一个带id参数的GET请求,页面会显示一个登录用户的信息。这一关是经典的字符型注入,闭合方式是单引号。很多教程直接告诉你payload是?id=1' --+,但我想说说自己拿到一个陌生输入点时的完整试错流程,这才是第一关真正要练的东西。
第一步,先在参数后面随手加一个单引号。页面报错,报错信息里能直接看到SQL语句片段,这是最典型的基于报错的探测。报错信息会把后端拼SQL的方式暴露得一清二楚,我在这一关第一次体会到"错误信息是漏洞利用的地图"是什么意思。
第二步,加注释符--+,页面恢复正常。--+其实是URL编码写法,因为后面需要一个空格或者换行来让注释符生效,但直接在URL里写空格会被浏览器处理掉,所以用+代替。新手经常在这里栽跟头,觉得自己明明写了注释符还是报错,其实是没有理解注释符后面的空格规则。这一个细节搞明白,后面所有关卡都顺了。
第三步,用order by判断列数。从1开始逐列加,页面正常就继续加,直到某一列报错,那这个数字再减一就是列数。比如第一关执行order by 3正常,order by 4报错,表示查询返回3列。
第四步,构造联合查询。联合查询玩的技巧是让前面的查询结果为空,把后面查询的结果顶到回显位:
code复制?id=-1' union select 1,2,3 --+
这里把id改成-1或者一个不存在的值,会让原始查询返回空集,这样union后面的结果就能占据回显位置。页面上一旦出现2和3,说明这两个位置是回显位,接下来就把需要的信息填进去:
code复制?id=-1' union select 1,database(),version() --+
数据库名和版本号直接显示出来,第一关就算通了。整个过程走一遍,你对"闭合、注释、列数、回显位"这几个词会有非常具体的体感。
2.2 数字型与字符型的分辨
第二关同样带id参数,但它是数字型注入。所谓数字型,就是后端没有给参数加引号,直接把数字拼进了SQL。判断数字型还是字符型,有一个我自己很习惯的快速方法:先试1 and 1=1,再试1 and 1=2,对比页面内容。
如果1 and 1=1正常显示、1 and 1=2显示为空,那基本可以断定是数字型注入,因为SQL语句把整个1 and 1=1当成一个条件参与了逻辑运算。如果页面完全没反应,或者两种情况的页面没有任何差异,那大概率是字符型,需要先闭合引号再注入。
数字型注入的好处在于payload不需要闭合引号,直接拼逻辑:?id=-1 union select 1,2,3。第二关的回显位置跟第一关不一样,但是联合查询的思路完全一致。这一关真正的学习点不是payload本身,而是建立"先判断类型,再选注入方式"的肌肉记忆。很多人在真实环境中碰到一个注入点,上来就直接union select,结果因为闭合方式不对,怎么都出不了数据,问题就出在跳过了基础判断。
2.3 闭合变形的试错套路
第三关开始出现变化,直接在id后面加单引号报错,但你试着闭合会发现,一个单引号加注释符不一定能让页面恢复正常。这一关的SQL语句用了('$id')的写法,也就是说参数外面除了单引号还有一个左括号。闭合时需要把括号也补上:
code复制?id=1') --+
第四关则是双引号加括号的变形:("$id"),对应的payload是?id=1") --+。
闭合方式不确定时,我习惯的做法非常简单粗暴:把'、"、')、")、'))、"))这些常见闭合符挨个试一遍,每次都配合注释符观察页面是否恢复正常。哪个组合让页面回到正常显示,就说明闭合猜对了。这个试错过程看起来低级,但就是最可靠的方法。不要想着一眼看穿后端代码,在真实场景里你连源码的影子都看不到,能做的就是通过页面的反馈去推断它背后的SQL写法。
这一阶段的联合查询payload统一遵守"构造一个查不出结果的原始条件,让union之后的查询输出到回显位"的原则。数据库名、用户名、版本号这些常用函数可以组合着测,一次请求多拿几个信息,效率会高很多。
3. 第五关到第十关:报错注入与盲注的分水岭
3.1 第五关的报错注入为什么能出数据
第五关打开之后你会发现,联合查询的套路不灵了。页面只显示"数据库查询出错"或者"你在数据库查询的时候输出的错误信息"这类提示,并没有回显位。这就是典型的不显示查询结果的注入场景。这种情况下,提取数据靠的是报错信息本身。
第五关官方叫法是基于双查询的报错注入,也就是Double Injection。原理说起来有点绕,但用起来非常直接。最常用的方式是MySQL的extractvalue或者updatexml函数,它们的参数里包含一个XPath表达式,如果表达式格式不对,MySQL会把错误信息连同你写入的内容一起返回:
code复制?id=1' and extractvalue(1,concat(0x7e,database())) --+
0x7e是波浪号~的十六进制表示,把它拼在数据前面是为了让XPath格式错误,从而触发报错。执行之后,页面上会显示类似XPATH syntax error: '~security'的信息,数据库名称security就这样被带了出来。
如果想一次性提取多张表名,可以用:
code复制?id=1' and extractvalue(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()))) --+
注意extractvalue报错信息默认只显示32位字符,所以group_concat的结果太长会被截断,这是报错注入的一个天然限制。遇到长数据的时候,我会用substr分批取。这一点在实战中非常常见,提前知道能省不少排查时间。
除了extractvalue,还有一处经典用法是count(*)+floor(rand(0)*2)+group by的报错组合。这个payload看起来像天书,但它本质上利用了MySQL在做group by聚合时对随机键的Duplicate entry报错,把子查询的结果顶到报错信息里。这个组合在旧版MySQL上很稳定,但有些新版本修复了部分行为,不如extractvalue通用。所以我的建议是:把extractvalue和updatexml这两个函数练熟,它们的适用范围更广,报错信息也更清晰。
3.2 第八关的布尔盲注与脚本化
第八关是布尔盲注的经典关卡,页面行为只有两种:有数据显示正常内容,没数据或者条件为假就不显示。没有报错、没有回显位,你只能通过"能否显示正常内容"这一个维度来判断SQL条件是否为真。
手测布尔盲注极其考验耐心,比如判断数据库名的第一个字符:
code复制?id=1' and left(database(),1)='s' --+
如果页面正常,说明库名第一个字符是s。再试第二个字符、第三个字符,逐位下去。这种方式一次请求只能确认一个字符的一个判断,效率低到让人怀疑人生。但你只要试过几个字符,就会自然而然想到写脚本。
脚本的思路非常简单:发一个请求,检测页面中某个特征关键字是否存在,存在则说明当前条件为真。正常情况下会写一个二分循环,从字符表中间值开始猜,逐步缩小范围。我当时的做法是用Python的requests库,先测页面正常状态下的固定字符串,比如Welcome或者某个用户名,将其作为判断基准,再跑一个两层循环:外层遍历位置,内层遍历ASCII码,命中条件就记录字符。
布尔盲注脚本需要注意两个小坑:一是请求要加一个很小的延时,避免压得太快,靶场本地还好,网络环境慢的话不加延时也容易误判;二是特征关键字不要选得太短,否则页面HTML里其他位置可能撞上同样的字符导致误判,最好选一段完整的标识性文本。
有了脚本之后,第八关的数据提取就会快很多。这种"单字节拼出完整数据"的过程,会让你对数据库的信息结构有非常深的印象,information_schema库、表名、列名、数据内容,一层层剥出来的感觉相当直观。
3.3 第九关时间盲注的耐心战
第九关比布尔盲注更让人崩溃,因为它不管你输入什么,页面都返回同样的正常内容,连"条件为真和条件为假页面不同"这个判断维度都没有。唯一能用的反馈是时间延迟。
判断注入点是否存在,做法是直接尝试让数据库睡觉:
code复制?id=1' and sleep(5) --+
如果页面卡了5秒才返回,说明数据库真的执行了sleep,注入点确认无疑。之后数据提取的逻辑就是靠if判断把条件绑在sleep上:
code复制?id=1' and if(ascii(substr(database(),1,1))>115,1,sleep(5)) --+
这条语句的意思是:如果数据库名第一个字符的ASCII码大于115,就立即返回;否则就睡5秒。我们通过响应时间的长短来判断条件真假。由于字符范围最多是128个ASCII码,用二分法的话,每个字符最多7次请求就能确定。
手测时间盲注是一场灾难,一条数据可能要等好几分钟。所以时间盲注几乎是必写脚本的场景。脚本里除了要检测响应时间,还要设置一个合理的timeout阈值。比如正常响应是几十毫秒,sleep(5)的响应大约是5秒,阈值可以设在2秒左右。网络不稳定时建议做一次重试,避免把网络波动误判成条件为假。
另外要说一句,时间盲注的实际危害性很大,因为即使目标把报错关掉、把页面差异抹平,时间差依然存在。这也是为什么时间盲注在真实环境的渗透测试中很常见。练好这一关,本质上是在练"从近乎无反馈的环境里提取有效信号"的能力。
4. 从GET转到POST:表单参数与HTTP头里的注入
4.1 POST注入点判断与登录场景的真相
从第十一关开始,靶场切换到登录表单模式,注入点变成了POST参数。对很多人来说,这是一个认知切换点:不是只有URL里的参数才能注入,整个HTTP请求里的每一条信息都可能被后端拿去拼SQL。
POST注入的判断手法和GET一模一样,只是把payload从URL挪到了请求体。比如第十一关,在用户名输入框里加一个单引号,如果页面报错,说明用户名被直接拼进了SQL:
code复制uname=admin' and 1=1 --+ &passwd=123456
这里有个常见误区,新手总是想着"登录嘛,那就用万能密码绕过试试",其实登录框的重点不是绕过认证,而是判断哪个参数参与了SQL语句拼接、以及SQL语句长什么样。大多数情况下输入的用户名会出现在查询的WHERE子句里,所以在用户名处注入,可以利用报错注入把数据带出来:
code复制uname=admin' and extractvalue(1,concat(0x7e,database())) --+ &passwd=xx
第八关到第十关已经练过报错注入,到这里只是换了个传输位置,原理完全没变。这种"换汤不换药"的感觉,恰恰说明注入的本质在于后端拼接逻辑,而不在于请求位置。
4.2 从User-Agent到Referer的头注入
到了第十八关,普通表单参数已经没有注入点了,真正的注入点在User-Agent头。后端会把浏览器UA信息记入数据库,这个UA字符串被直接拼进了SQL的INSERT语句。你需要手动修改请求头来注入。
我最常用的工具是Burp Suite,开启代理拦截请求,把头里的User-Agent改成payload:
code复制User-Agent: ' and extractvalue(1,concat(0x7e,database())) --+
如果页面返回了报错信息,说明UA确实参与进了SQL语句。第十九关类似,注入点在Referer头。第二十关则更进一步,注入点在Cookie里的uname字段。
这一阶段一定要习惯用Burp Suite的Repeater功能,它可以方便地修改任意请求头、重放请求、查看响应。浏览器自带的开发者工具虽然也能编辑请求头,但在改Cookie、做base64编码、反复重放这些操作上,Burp的效率是碾压级的。任何时候拿到一个抓包工具,先学会确认注入点在哪个位置,再构造对应的payload。
4.3 Cookie的base64编码注入
第二十一关很有意思,后端的SQL语句本身似乎没有直接过滤,但Cookie中的值在进入SQL前经过了base64编码。你直接把payload写在Cookie里是无效的,因为后端拿到的是admin' and ...的原始字符,它把整个字符串当成一个普通值。要注入成功,需要先把完整的payload做base64编码,再填入Cookie:
code复制Cookie: uname=YWRtaW4nIGFuZCBleHRyYWN0dmFsdWUoMSxjb25jYXQoMHg3ZSxkYXRhYmFzZSgpKSAtLSAr
这里看起来是一串乱码,但后端解码后就等于admin' and extractvalue(1,concat(0x7e,database())) --+,于是SQL注入就发生了。这个关卡让我意识到,很多所谓的复杂度并不是安全机制,只是在数据流转过程中引入了额外的编码层。你只要顺着编码规则把payload翻译成目标期望的格式,其它一切照旧。
练习这个关卡时要特别注意编码的准确性,base64编码之后的字符串不能有空格、换行或者多余字符,否则数据库解码之后拼出来的SQL就不符合预期。我建议先在本地用命令或者在线工具编码,然后把结果粘贴到Cookie里,跑通了之后再手工写一遍编码过程,加深印象。
5. 过滤与绕过:从二十三关开始的进阶玩法
5.1 注释符被过滤后学会补全语法
到第二十三关,后端开始过滤注释符,#不能用了,--也发挥不了作用。很多习惯依赖注释符收尾的人一下子懵住,不知道payload该怎么构造。其实有一个非常实用的替代思路:不再尝试注释掉SQL语句后面的尾巴,而是主动补全整个SQL语法,让原语句在语法层面自行闭合。
举个例子,查询语句可能是:
sql复制SELECT * FROM users WHERE id='$id' LIMIT 1;
当注释符被过滤后,我们不再想办法砍掉后面的' LIMIT 1,而是直接构造:
code复制?id=1' and '1'='1
这样拼出来的SQL是:
sql复制SELECT * FROM users WHERE id='1' and '1'='1' LIMIT 1;
语法完全正确,查询正常执行。如果要联合查询,思路也一样,把最后一个字段改成带引号的字符串去对齐:
code复制?id=-1' union select 1,2,'3
这个补全语法的能力,在真实场景里比背任何payload都重要。因为现实中的SQL语句大概率不止一个字符串拼接点,你永远不知道自己会不会遇到必须手动拼完整SQL的情况。所以这一关虽然看起来只是"过滤了注释符",但它是所有绕过思路的一个总纲:要站在数据库解释器的角度看问题,而不是站在payload的格式上看问题。
5.2 宽字节注入的编码逃逸原理
三十二关到三十七关是宽字节注入的重灾区。先说现象:后端对输入的单引号做了转义,比如输入1'之后,数据库里实际拿到的是1\',也就是说反斜杠把单引号转义成了普通字符,你就没法闭合了。看起来毫无突破口,但其中隐藏了一个编码层面的漏洞。
当数据库连接使用GBK编码,而页面又是另一个字符集时,%df'这个组合会把%df和反斜杠\拼成一个合法的GBK多字节字符(比如"運"),于是单引号前面的转义符消失了,单引号成功逃逸出来:
code复制?id=1%df' union select 1,2,3 --+
我第一次接触这个payload的时候,完全看不懂%df是干什么的。后来在本地用同样的编码组合反复测试,才彻底明白:这本质上是"半吊子转义"造成的漏洞。安全防护最怕的就是只防住了一面,却忽略了字符编码转换过程中可能产生的语义变化。
宽字节注入不能在所有数据库连接上都复现,它依赖特定的字符集设置。如果靶场环境里的数据库连接已经是UTF-8,那宽字节注入大概率不生效,所以练习这一关时先把环境确认好,跑通了再仔细琢磨编码计算,不要一上来就怀疑自己是不是payload写错了。
5.3 堆叠注入与order by注入的边界
堆叠注入出现在三十八关之后。它的核心特点是可以在一条请求里执行多条SQL语句,用分号隔开:
code复制?id=1'; insert into users values(88,'test','pass') --+
执行完这条请求,不仅查询被正常执行,你还往users表里插了一条新数据。堆叠注入和union的差别非常关键:union不能改数据,最多是改变查询结果;堆叠注入则可以执行任意后续SQL,插入、更新、删除都行,危害等级完全不一样。但堆叠注入有一个现实限制:很多后端查询API只允许执行一条语句,分号后面的语句会被拒绝。所以堆叠注入不是万能的,能不能用取决于后端怎么控制查询。
order by注入是后面四十六到五十三关的练习重点。排序参数出现在order by后面时,你没法用union,因为union和order by的语法位置决定了它们无法拼接。此时常规思路变成两种:一是利用排序字段的值参与表达式判断,比如:
code复制?sort=if(1=1,1,2)
通过排序结果的变化来传递条件真假;二是用报错函数把数据带到错误信息里:
code复制?sort=1' and extractvalue(1,concat(0x7e,database())) --+
这一阶段会彻底打开思路,让你意识到注入不止联合查询这一条路。只要参数被拼进了SQL,哪怕它只出现在排序、分组、limit这些边角位置,都有可能找到数据外带的方式。练到这里,基本就脱离了"背payload"的层次,开始真正用SQL语言本身来做绕过。
6. 通关之后的几点体会与自查清单
6.1 打靶过程中的高频翻车位
有几个坑,我在陪别人打靶时反复看到,这里集中说一遍。
第一个是注释符问题。不同的数据库解析器对注释符结尾的要求不一样,MySQL旧版本里--后面必须有空格或者控制字符,很多人直接写--没有任何结尾符,页面必然报错。标准写法是--+或者--%20,在SQL注入场景里几乎成了默认配置。#号也是MySQL注释符,但URL里直接写#会被浏览器当成锚点不发送,需要URL编码成%23。
第二个是请求方式不匹配。明明第二关可以用GET直接注入,到了第十一关就得把payload移到POST参数里,有人习惯性在URL后面拼接,发现完全无效。还有头注入关卡,不抓包就不可能成功,因为浏览器地址栏不会给你机会输入任意请求头。
第三个是盲注脚本的稳定性。写脚本检测关键字时,不要只检测单个字母或者很短的单词,因为你可能在页面里其他的位置撞上相同的字符。选择一段唯一的标志性文本更可靠。时间盲注脚本要设置合理的响应阈值和重试策略。
第四个是报错信息长度限制。extractvalue和updatexml的报错内容最多只能带出约32位字符,这在提取表名、字段列表这种超长数据时会截断。解决办法就是substr分段截取,一次取30个字符左右。
6.2 从靶场到真实环境的思维迁移
sqli-labs打完之后,最大的收获不是会背几十个payload,而是面对一个陌生输入点,能条件反射地走完一条完整链路:先探测闭合方式,再确认注入类型,然后选择报错、联合、盲注还是堆叠,最后构造payload并验证输出。这条思维链在真实场景里一样适用。
还要记住,靶场终究是刻意设计出来的环境,真实的业务系统不会那么规整地告诉你"这一关是字符型"或"这一关可以报错注入"。所以打靶时不要只满足于通关,每打完一关,在笔记里写三句话:这个漏洞是什么类型、后端闭合为什么这样、如果过滤了这个字符我会用什么替代。这三句话写下来,这一关才算真正消化。
最后分享一个我个人的习惯。sqli-labs这种靶场,最忌讳的就是把自己完全交给现成的payload字典。拿到一关,先别急着搜答案,先猜一下出题人想让你练什么,然后自己动手试:加引号看反应、改闭合方式看变化、思考为什么这个请求能影响数据库行为。猜错了再打开源码对照,记住自己错在哪一环。这样打完一遍靶场,你得到的不是一张payload清单,而是一套完整的注入分析框架。以后再去面对稍微复杂一些的Web应用,至少不会一上来就一头雾水。
