sqli-labs这套靶场玩到Less-48这一关,很多人的感受是:终于要把“联合注入”和“报错注入”的惯性思维放下,开始直面ORDER BY子句的注入问题了。Less-48在关卡列表里的定位是“GET - Blind based on Order By Clause - Numeric - Sort Display”,翻译过来就是基于排序子句的数值型盲注,并且页面会把排序后的结果展示出来。也就是说,这一关在考你一件事:在没有报错回显、没有联合查询余地的情况下,怎么靠排序顺序的变化把数据库里的“家底”一点点翻出来。
对于刚刷完前面几十关的初学者来说,Less-48最别扭的地方在于:前面学的UNION SELECT、extractvalue报错等招数,在这里都不太好使。这一关也是从Less-46开始的ORDER BY注入系列十连关中的一环,和后面的Less-49、Less-50、Less-51、Less-52、Less-53紧密相关。把这个系列吃透,你对MySQL中ORDER BY子句能插进去什么、不能插进去什么,会有一个非常清醒的认识。我也建议大家把Less-46到Less-53放在一起刷,因为它们的核心矛盾其实是同一个,只是每次换了闭合方式和过滤条件而已。
1. 关卡背景与核心思路拆解
1.1 Less-48在sqli-labs关卡链条中的定位
sqli-labs从Less-1到Less-65划分了好几个主题段。前面1到10关是基础联合查询,11到22关是POST注入,23关开始专门折腾被注释符和过滤绕过的场景,而Less-46到53这一整段,主题是ORDER BY子句注入。为什么单列一段?因为ORDER BY在SQL语句里的位置太特殊——它在一个查询的最后,UNION只能出现在它的前面,这就把前面练得最熟的“联合查询注入”直接废掉了,必须换一套打法。
Less-48在系列里的具体设定是:参数名为sort,通过GET传递,数值型注入,闭合方式为无引号闭合,页面不会输出SQL报错信息,但会把排序后的用户列表展示出来。和Less-46相比,Less-46同样是数值型ORDER BY注入,但没有排序结果展示,判断条件真伪只能靠页面是否显示数据;而Less-48加上了“排序显示”这个特性,相当于给了你一个侧信道——通过观察记录排列顺序来判断条件真假。和Less-47、Less-49这类单引号闭合的关卡相比,Less-48又省掉了闭合判断的步骤,可以直接拼数字。
我整理了一张这个系列的小对照表,方便你理解Less-48所处的位置:
| 关卡 | 闭合方式 | 是否报错回显 | 排序结果是否展示 |
|---|---|---|---|
| Less-46 | 数值型 | 否 | 否 |
| Less-47 | 单引号 | 否 | 否 |
| Less-48 | 数值型 | 否 | 是 |
| Less-49 | 单引号 | 否 | 是 |
| Less-50 | 数值型 | 是 | 否 |
| Less-51 | 单引号 | 是 | 否 |
| Less-52 | 数值型 | 否 | 否 |
| Less-53 | 单引号 | 否 | 否 |
从这个表能看出来,Less-48其实是“排序可见的盲注”这个分支里最简单的一个:数字型、无过滤、有排序展示。它比Less-46多了一个观测窗口,又比Less-49少了单引号的干扰。所以我的建议是:Less-46和Less-48作为ORDER BY注入的入门关,先把这里的盲注逻辑跑通,再去折腾带引号的变体。
1.2 为什么ORDER BY注入值得单独研究
很多人刚开始学SQL注入的时候,注意力都集中在SELECT、WHERE、JOIN这些位置,ORDER BY往往被当成“排序规则”一笔带过。但实际业务里,排序几乎无处不在:商品列表按价格排序、排行榜按得分排序、后台数据表格按时间排序。只要排序字段来自用户输入且没校验,就存在注入可能。
ORDER BY注入的麻烦点在于:注入点在一个查询的最后,UNION的语法决定了你没法在ORDER BY后面拼一个SELECT。举个例子,假设后台查询是SELECT * FROM users ORDER BY id,如果我在id后面直接拼UNION SELECT 1,2,3,最终变成SELECT * FROM users ORDER BY id UNION SELECT 1,2,3,这在MySQL里是语法错误,因为UNION必须出现在ORDER BY之前。这就是为什么很多人前面刷得顺手,到这个系列突然卡住——过去的经验在这条语法规则面前完全失效。
那ORDER BY后面究竟能做什么?深入看MySQL的语法,ORDER BY后面其实可以接:列名、列别名、列位置序号(比如ORDER BY 1表示按第一列排序)、任意表达式(比如ORDER BY IF(condition, 1, 2))、函数调用(比如ORDER BY SLEEP(5))。这给了注入者两个方向:一是用表达式改变排序键,实现布尔盲注;二是用延迟函数,实现时间盲注。这也正是Less-48这关要练的核心能力。
2. 核心原理与判断方法
2.1 排序键是怎么被用户输入控制的
在Less-48的源码里,关键查询大致长这样:
$sql = "SELECT * FROM users ORDER BY $id";其中$id就是接收自GET参数sort的值。由于没有加引号、没有做过滤,我的输入会直接拼接到ORDER BY之后的SQL语句中。
要理解后续的盲注方式,得先明白ORDER BY后参数的三种身份。
第一种,列位置序号。ORDER BY 1按查询结果的第一列排序,ORDER BY 2按第二列排序,以此类推。这个特性常用来探测“当前查询有几列”,不过在这个关卡里意义不大。
第二种,列名。ORDER BY username会按username列排序。如果列名不存在,MySQL会报错;但在Less-48中,报错不回显,所以你看到的只是页面数据消失或不变。
第三种,表达式。ORDER BY IF(条件, 1, 2)会先计算IF函数,结果为1就按第一列排序,为2就按第二列排序。这就把“SQL里的逻辑真假”翻译成了“页面里数据的排列顺序”。这是Less-48破解的核心。
实操测试的时候,第一步永远是给sort传1、2、3三个值,观察页面数据排列有没有变化。如果sort=1和sort=2结果不同,说明参数确实作用于排序;如果sort=1和sort=1没有任何区别,再尝试sort=1%27(单引号)看是否变成字符型闭合。Less-48是数值型,所以sort直接传数字即可。
2.2 布尔盲注:靠排序差异说话
布尔盲注的核心,是把一个条件表达式塞进IF里面,让条件真假体现为不同的排序键。常见的payload模板是:
sort=IF(条件, 1, 2)当条件为真时,排序键为1,数据会按照第一列的字母序或数字序排列;当条件为假时,排序键为2,数据会换成第二列的排列顺序。你只需要记录两三次请求后页面首条数据的差异,就能反推出条件真假。
举个最简单的验证:访问sort=IF(1=1,1,2),因为1=1恒真,等价于按第一列排序;再访问sort=IF(1=2,1,2),1=2恒假,等价于按第二列排序。如果两次页面返回的数据顺序不同,说明这个payload结构有效。此后,把“条件”替换成任何你想判断的SQL子查询即可。
比较条件有很多种写法:
- 判断长度:sort=IF(length(database())>N,1,2)
- 判断字符范围:sort=IF(ascii(substr(database(),1,1))>100,1,2)
- 判断表内容:sort=IF(ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))>100,1,2)
这些都是可以一笔一划把数据库信息“描”出来的布尔盲注标准姿势。
2.3 时间盲注:当排序差异判断不了时
理论上,布尔盲注在Less-48中已经够了。但在某些场景下,比如排序结果展示关闭、或者排序键变化不明显,你还可以走时间盲注这条路。ORDER BY后面直接接SLEEP函数是完全合法的。典型的payload是:
sort=IF(条件, SLEEP(3), 1)条件为真时,MySQL会停顿3秒再返回页面;条件为假时,立即返回。用页面响应时间差来判断条件真假。如果数据库用户对sleep函数没有权限限制,这会非常稳定。我做测试的时候,一般会把延迟设成3到5秒,太短可能被网络波动干扰,太长则测试效率低。
Less-48这一关,我实测过布尔盲注和时间盲注都能打通。布尔盲注效率更高,因为排序是比较出来的;时间盲注适合写自动化脚本时统一处理,因为脚本只需要记录响应时间,不需要解析页面内容的语义。
2.4 报错注入在这个关卡能不能用
先给结论:Less-48的报错注入大多数情况下不可用,原因在源码里对SQL错误的处理是静默的,也就是mysqli查询失败后不会把错误信息打印到页面。extractvalue和updatexml这类报错注入依赖SQL错误信息回显到客户端,既然页面不吃报错,那这条路自然走不通。
不过有个细节值得注意:Less-48的排序结果是正常的查询输出。也就是说,只要查询不报错,哪怕你用了extractvalue(1,concat(0x7e,database()))这种写法,数据库返回的还是排序后的记录,报错本身不会让你看到任何额外信息。所以在这个关卡里,遇到报错注入“失灵”别怀疑自己路子错了,这是关卡设计给的限制,老老实实用盲注才是正解。
3. 实操过程与关键payload详解
3.1 环境准备与初始探测
实操之前先把环境准备好。sqli-labs是基于PHP+MySQL的靶场,常用搭建方式就是本地跑一个Apache或Nginx,配合MySQL 5.x。我这边用的小皮面板(phpstudy)带的Apache和MySQL 5.7,PHP版本用的7.x,跑sqli-labs没有任何问题。把源码放进网站根目录,导入sql-lab.sql建好库表,访问sqli-labs目录就能看到关卡列表。
进入Less-48后,先看URL结构。默认页面大概是:
http://localhost/sqli-labs/Less-48/?sort=1sort就是注入点参数。第一步我先做三件事:给sort分别传1、2、3,确认数据排序顺序会变;传一个非常大的数字比如sort=999看是否报错或返回空;传sort=1%27看是否出现字符型闭合的痕迹。
实测下来,sort=1、2、3分别对应按users表第一列、第二列、第三列排序,说明这里的排序键确实由参数控制。sort=999会返回空数据集,因为第999列不存在但错误被静默处理了。sort=1%27不会触发任何报错,也不影响排序,说明闭合方式就是数值型,不需要考虑引号。
3.2 利用排序差异做布尔盲注
确认了基础情况后,我直接进入核心环节:构造IF表达式验证排序差异。先访问:
http://localhost/sqli-labs/Less-48/?sort=IF(1=1,1,2)页面按第一列排序;再访问:
http://localhost/sqli-labs/Less-48/?sort=IF(1=2,1,2)页面按第二列排序。两次返回的第一条用户名明显不同,证明布尔盲注的通道已经建立。
接下来爆破数据库名长度。假设当前数据库名长度为8,访问:
sort=IF(length(database())>5,1,2)如果排序键为1,说明length(database())确实大于5;继续用二分法调整阈值,最终能确定长度。我当时测试时用的命令是:
sort=IF(length(database())=8,1,2)页面和sort=1一致,说明长度确实是8。
然后逐字符爆破数据库名。以第一个字符为例:
sort=IF(ascii(substr(database(),1,1))>113,1,2)如果返回排序键1对应的顺序,说明第一个字符的ASCII大于113,也就是在字母q之后;再通过二分收敛,最终确定第一个字符。然后用substr(database(),2,1)、substr(database(),3,1)这些把整个库名拼出来。
表名的获取同样思路。以“当前数据库下的第一张表”为例:
sort=IF(ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))>100,1,2)这里information_schema.tables是所有数据库的元数据表,table_schema=database()过滤出当前库的表,limit 0,1取第一张,再逐字符爆破表名。sqli-labs的当前库里通常有users表,爆破出来之后,进一步获取它的列名和记录值。
3.3 利用时间盲注做备选验证
如果不想靠肉眼分辨排序差异,可以用时间盲注交叉验证。访问:
sort=IF((select ascii(substr(database(),1,1)))=115,SLEEP(3),1)当子查询结果为115(也就是字母s的ASCII值)时,页面延迟3秒返回;否则立即返回。通过调节ASCII阈值,同样能拼出库名。
我个人更喜欢用时间盲注来验证几次布尔盲注的结论,因为排序差异有时受数据库默认排序规则影响,可能首条记录不明显;时间盲注的结果则非常确定——延迟就是延迟,不延迟就是不延迟。Burp Suite的响应时间标签页可以直接看到毫秒级差异,比肉眼翻页面快得多。
3.4 获取关键数据的完整payload参考
这里把我通关过程中实际使用的几组payload整理成一个速查表,供你参考。前提是当前库为默认的安全测试库,里有张users表:
| 目标 | 构造条件 | payload片段(只写IF里的条件部分) |
|---|---|---|
| 判断库名长度 | 长度为8 | length(database())=8 |
| 判断库名第1个字符 | ASCII=115 | ascii(substr(database(),1,1))=115 |
| 判断库名第2个字符 | ASCII=101 | ascii(substr(database(),2,1))=101 |
| 取第一张表名 | 第1个字符ASCII=117 | ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))=117 |
| 取users表第一列名 | 第1个字符ASCII=105 | ascii(substr((select column_name from information_schema.columns where table_schema=database() and table_name='users' limit 0,1),1,1))=105 |
| 查询users表第一行username | 第1个字符ASCII=68 | ascii(substr((select username from users limit 0,1),1,1))=68 |
使用时把条件部分拼到sort=IF(...)的括号里就行。如果你用的是二分法,可以先把条件写成大于某个阈值,再逐步逼近,速度会快不少。
4. 常见问题与排查技巧
4.1 排序结果始终不变怎么办
如果两次请求sort=IF(1=1,1,2)和sort=IF(1=2,1,2)返回的页面数据顺序完全一样,最可能的原因是:你把sort的值写进了数据库,但真正控制排序的字段不是sort。可以去看一下页面源码,确认表单参数名;或者检查URL是不是真的把sort传进去了。
另一种情况是:数据库查询结果集只有一行或几行,排序键再怎么变,视觉上感受不到明显变化。sqli-labs的users表一般有13行以上,不太会撞上这个问题,但如果你在别的页面用这套思路测试,就要注意样本量。这里有个经验:即便数据顺序变化不明显,也可以关注第二个、第三个字段的值变化,而不要只盯着第一条记录。
4.2 报错注入失败是因为没开报错显示
我在前面提过,Less-48对SQL错误处理是静默的。如果你试了sort=extractvalue(1,concat(0x7e,database()))发现啥也没有,这不是你的payload写错了,而是关卡设计本身就不让报错回显。把这个现象记下来:判断一个注入点是否支持报错注入,最快的方法就是故意构造一个语法错误,比如sort=1%27,看页面有没有任何变化;页面毫无波动,就说明报错被吞了,赶紧切到盲注路径。
这里还有一个延伸经验:在实际代码审计里,如果一个查询语句拼接了用户输入,同时PHP端又用了@mysqli_query或mysqli_error不输出,那你的注入思路就得自动切换到盲注或时间盲注,不要把宝押在报错注入上。
4.3 盲注效率太低怎么办
手工逐字符爆破确实枯燥。Less-48作为通关练习,我建议你至少要手工完成一次完整的布尔盲注,把逻辑跑通。但如果你已经明白原理,想快速把数据捞出来,可以用脚本。我这里分享一个思路,不一定需要很长的代码:
用Python的requests库,定义两个函数:一个是“发送请求并返回排序后的第一条记录”,另一个是“根据排序差异判断条件真假”。然后在条件判断函数里写一个二分搜索,从32到126循环获取每个字符的ASCII值。脚本逻辑很直接,几十行就能写完。这样你就能自动跑出库名、表名、字段名,整个过程也加深了对布尔盲注的理解。
我当时写自动化脚本时踩过一个坑:requests每次请求之间默认不保持连接,多线程并发时容易被WAF拦截或触发数据库压力,所以建议用requests.Session()复用连接,并且每次请求之间加一个0.1秒的小延时。如果在真实的授权测试环境中,更要严格控制频率,避免影响业务。
4.4 不要把ORDER BY注入和WHERE注入混为一谈
最后半个提醒:Less-48的注入点在ORDER BY后面,不是在WHERE后面。所以别想着什么' OR 1=1 --+这种套路,那套在WHERE注入里好使,在ORDER BY注入里只会让查询变成一个奇怪的表达式。ORDER BY注入的正确姿势,永远是围绕“排序键表达式”做文章。把这一层想通了,Less-49到Less-53的系列关卡,你就能以不变应万变。
我自己在打通Less-48之后的最大体会是:盲注不是只能靠“页面有无数据”这种二元判断,排序顺序、响应时间、甚至返回数据的具体排列,都是可以被利用的“侧信道”。Less-48虽然没有直接给你一个报错窗口,却给了你一个观察排序的大屏幕,这个设计比纯粹的布尔盲注友好得多,也更容易让人建立起“信息总会有地方投射出来”的思维。后续刷Less-49的时候,你只需要把数值型闭合改成单引号闭合,其他payload几乎可以平移过去。反过来,如果你在实际项目里遇到ORDER BY可控但其他注入位置都堵死的情况,Less-48这套“条件排序 + 二分爆破”的组合,就是稳妥的兜底方案。
最后再分享一个小技巧:通关之后别急着走,把Less-48的源码打开看看,重点观察它处理SQL错误的方式和输出排序结果的方式,你会发现很多实战场景里的“黑盒判断”在源码里都是有迹可循的。理解靶场的代码,比单纯背payload重要得多。