从 Less-38 开始,sqli-lab 给我的感觉一下子就变了。前 37 关基本是在一条单查询里折腾:联合注入、报错、盲注、宽字节,方法没变,变的是过滤和闭合。到了 38 关,注入点后面第一次出现了分号。这个分号,让注入从“一句话里做文章”升级成了“一口气执行多条 SQL”。这篇接着记录 38 关到收尾的 Writeup,重点拆解堆叠注入、ORDER BY 注入,以及最后 54~65 关的挑战模式。如果你刚啃完前面 37 关,准备往后刷,这篇可以直接当路线图参考。
1. 从38关开始第一个分水岭:堆叠注入在堆什么
1.1 普通注入和堆叠注入的本质区别
普通注入无论怎么绕,最终只能影响当前这条 SQL 语句。比如查询里写的是select * from users where id='$id',就算用union select把后面撑开,后端执行的还是这一条 select。堆叠注入不一样,它利用的是“分号结尾后还能再跟一条语句”的语义。你在参数里输入1';select sleep(3)--+,拼到 SQL 里就成了两条语句:
select * from users where id='1';select sleep(3)--+'如果后端用的是支持多语句的接口,比如 PHP 的mysqli_multi_query(),MySQL 就会把分号前后的两条语句都执行掉。这跟前台看到的查询结果没关系,select sleep(3)没有结果集,但页面会明显卡三秒。从这一关开始,题目考的不再是“怎么把查询结果拼到页面里”,而是“后端到底允不允许你一次执行多件事”。
1.2 为什么前面 37 关都不教堆叠
并不是前面不存在这个漏洞,而是 sqli-lab 的前 37 关使用的是普通查询接口,源码里写的是mysqli_query(),这种接口一次只能执行一条语句。你往参数里加分号,MySQL 要么直接报错,要么把分号后的内容忽略掉,页面不会给任何反应。到了 38 关,源码换成了mysqli_multi_query(),教学意图很明确:告诉你同一个 SQL 拼接漏洞,在“单语句连接”和“多语句连接”两种模式下,危害完全不同。
我在实际测试里习惯把这种差距类比成电梯和楼梯:普通查询像电梯,你按一个楼层就只到那一层;堆叠注入像是整栋楼梯,你在某层打开门后还能继续往上走,只要体力够,走到哪一层取决于你输入多长。这个差异也是很多真实系统容易忽略的地方:开发平时写 SQL 用的是普通接口,上线后某个报表模块用了多语句接口,一旦参数拼接点存在注入,攻击面立刻变大。
1.3 确认能不能堆叠,只需要一个 sleep
拿到一个注入点,我最先做的是测闭合,然后马上试;select sleep(3)。如果页面卡了 3 秒再出结果,说明分号后面的第二条语句被真正执行了;如果页面秒回或者直接报语法错误,再考虑是不是闭合不对、是不是接口不支持多语句。这里有个容易踩的坑:很多新手看到sleep()返回一个结果集,会习惯性地去等页面回显那一行数字,但堆叠注入的第二条查询结果一般不会显示,最可靠的判断标准就是网络请求耗时。
举个 Less-38 的例子。Less-38 是 GET 型,单引号闭合,我先请求?id=1',页面报错,说明存在字符型注入点,再请求:
GET /sqli-labs/Less-38/?id=1';select sleep(3)--+ HTTP/1.1响应时间比正常请求多了大约三秒,堆叠确认。这套“测闭合 + 看延迟”的组合,在整个 38~45 关里可以复用。
2. 38~45 关:堆叠注入的逐关拆解
2.1 先整理一套闭合方式再打
这组关卡一共有 8 关,第 38~41 在 GET 参数里,42~45 在 POST 参数里,闭合方式基本就是前面 1~37 关重复过的那几种:单引号、无引号、单引号加括号。我自己的习惯是先不急着堆叠,而是用一条无害and 1=1和and 1=2确认闭合,再开始堆叠。
这里放一张我边刷边整理的对照表,不同改版可能会有小差异,但闭合和请求位置的规律是通用的:
| 关卡 | 请求位置 | 闭合方式 | 探测示例 |
|---|---|---|---|
| Less-38 | GET id | 单引号 | 1'--+ |
| Less-39 | GET id | 数字型 | 1--+ |
| Less-40 | GET id | 单引号加括号 | 1')--+ |
| Less-41 | GET id | 数字型 | 1--+ |
| Less-42 | POST passwd | 单引号 | 1'--+ |
| Less-43 | POST passwd | 单引号加括号 | 1')--+ |
| Less-44 | POST passwd | 数字型 | 1--+ |
| Less-45 | POST passwd | 单引号加括号 | 1')--+ |
如果你继续往后刷就会发现,这个矩阵其实非常符合实战经验:后端代码写习惯了,同一个项目里很可能同时存在数字拼接和字符串拼接,而且喜欢给查询条件套括号,形成where (id='$id')这样的结构。
2.2 GET 型堆叠:Less-38 到 Less-41
Less-38 是整组题的样本。闭合方式确认后,我在参数里拼一条 insert:
GET /sqli-labs/Less-38/?id=1';insert into users(id,username,password) values(88,'sqlinote','pass')--+页面还是显示 id=1 的正常数据,但我用靶场自带的登录功能,用sqlinote/pass登录成功,说明这条 insert 真的写进了数据库。Less-39 和 Less-41 是数字型,不需要单引号闭合,直接从1;insert...--+就能执行;Less-40 是1')闭合,payload 就变成1');insert...--+。
这三个变体值得记住的原因不是记忆 payload,而是理解“怎么把 SQL 语义闭合完整”。单引号负责让你跳出字符串,括号负责跳出子句,后面的--+负责把源码里本来会拼接的尾巴注释掉。跳得不够远,后面语法就是错的;注释不够干净,后面内容会影响你执行的第二条语句。
2.3 POST 型堆叠:Less-42 到 Less-45
POST 型堆叠和 GET 型在原理上完全一样,区别在于参数位置从 URL 挪到了请求体,而且通常出现在登录框。我习惯用抓包工具把passwd的值从1改成:
passwd=1';insert into users(id,username,password) values(89,'postnote','pass')--+需要注意 POST 表单提交时的+会被当作空格处理,所以--+到了后端其实是--,正好满足 MySQL 对注释符后面必须留空的语法要求。很多新手在这里纠结“为什么写--+而不是--%20”,实际只要提交的是表单数据,--+就能正常工作;如果数据是放 JSON 里传输,反而优先用--或#。
POST 型堆叠的应用比 GET 型更接近真实漏洞:登录接口、更新资料接口、搜索框,一旦使用多语句接口且参数可拼接,攻击者不一定需要看到查询结果,只要能执行一条 update 改掉管理员密码,就足以造成危害。我在 Less-42 里就试过直接用 update 把所有用户密码改成同一个值,这种“不关心回显只关心执行”的思路,是堆叠注入最危险的地方。
2.4 再进一步:堆叠注入里的文件写入
如果数据库账号有 FILE 权限,并且 MySQL 的secure_file_priv允许写入指定目录,堆叠注入还可以把第二条语句变成写文件操作。本地靶场里我为了验证整条链路,会把 MySQL 的secure_file_priv临时设为空,然后执行:
select '<?php phpinfo();?>' into outfile '/var/www/html/sqli-labs/shell.php'写完后访问shell.php,能看到 phpinfo 页面。这个例子我只建议在本地靶场里验证,不要拿去测任何线上系统。它带来的思考是:堆叠注入加高权限账号,不只是拖库,还有可能直接控制服务器,这也是数据库账号权限最小化为什么那么重要。
2.5 堆叠注入不能怎么玩
堆叠不是万能的。第一,如果后端使用 PDO 但关闭了多语句支持,分号后的语句不会执行;第二,即使分号后的语句执行了,如果第一条 select 没有结果集,页面可能空白,容易被误判为失败;第三,第二句如果是 insert 或 update,对前端页面可能毫无影响,需要靠其他功能验证。所以刷到后面,我的验证习惯是:先用 sleep 确认有没有执行,再决定要不要来一条有影响的操作。
3. 46~53 关:ORDER BY 注入和堆叠的组合
3.1 ORDER BY 为什么不能用 union
从 Less-46 开始,注入点变成了排序参数sort,SQL 大概长这样:
select * from users order by $sortORDER BY 之后跟的不是查询出来的值,而是一个“排序规则”,所以union select那套在这里根本不成立。你可以试?sort=1 union select 1,2,3,但大多数情况下要么语法错误,要么被当成排序列名。真正能用的只有三类:报错注入、布尔盲注、堆叠注入,而这三类在 46~53 关里全被过了一遍。
3.2 报错注入在 ORDER BY 里怎么活
数字型 ORDER BY 注入最省事的 payload 是:
?sort=1 and extractvalue(1,concat(0x7e,database(),0x7e))因为order by后面可以跟表达式,extractvalue报错内容会被页面带出来。如果排序参数有引号,先闭合单引号再写and extractvalue(...):
?sort=1' and extractvalue(1,concat(0x7e,database(),0x7e))--+我在刷 Less-47 时就是先用单引号探测出闭合,再切换成报错注入。这类题的关键不是 payload 本身,而是意识到“能排序的参数也是 SQL 片段的一部分”,很多开发人员都默认排序参数是固定的列名,结果直接拼进了查询。
3.3 布尔盲注在 ORDER BY 里怎么判断
如果报错内容被隐藏,只能看到列表顺序,那就用if表达式控制排序结果。比如判断数据库名的第一个字符是不是大于某个值:
?sort=if(ascii(substr(database(),1,1))>115,1,2)条件为真时,数据按1排;条件为假时,按2排。通过观察第一行数据的用户名变化,就能一位一位把数据库名抠出来。我习惯把这个过程和做选择题类比:每条请求只回答一次“大于还是小于”,然后拿二分法把答案逼近出来。Less-48、Less-49 就是这种盲注场景,区别在有没有引号。
3.4 在 ORDER BY 里继续堆叠
Less-50 到 Less-53 又把堆叠带了回来,只是在 ORDER BY 参数上。数字型可以直接:
?sort=1;insert into users(id,username,password) values(90,'orderby','pass')--+有引号就先闭合:
?sort=1';insert into users(id,username,password) values(91,'orderby2','pass')--+这里我体会到一件事:靶场把“普通注入”和“堆叠注入”分开讲,但真实系统中这两件事会叠加出现。一个排序参数如果既允许拼接又允许多语句,那它就不再只是“拖库”或“写文件”,而是可以直接往数据库里塞任何东西。
4. 54~65 关:挑战关里怎么用最少请求拿结果
4.1 挑战关改动的地方
到了 54 关,题目会随机生成一张带 flag 的表,并且每次请求次数被限制在个位数或十几位数。sqli-lab 这么设计,练的是信息搜集效率:你不再能靠一条条盲注慢慢猜,必须在有限次数内把表名、列名、数据全拿出来。
我刷挑战关之前先把前面 46~53 的报错注入方式熟悉了一遍,因为报错注入可以在一次请求里带回一大段字符串,比逐字符盲注效率高太多。当然挑战关也会换闭合方式,所以第一步仍是拿1'或1)` 快速判断闭合。
4.2 一次请求拿全表名
以一次常见的挑战关为例,闭合是单引号,我直接用extractvalue报错分组函数结果:
?id=1' and extractvalue(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e))--+页面的报错信息里会出现类似~users,emails,referers,uagents,secret_xxx~的一段内容,目标表就在里面。extractvalue的报错回显通常有长度限制,如果表名太多,就补一个limit 0,1或者分批group_concat。
4.3 再用一次请求拿列名和 flag
拿到目标表名如secret_xxx后,下一步用一个请求查列名:
?id=1' and extractvalue(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_name='secret_xxx'),0x7e))--+返回内容里能看到id,something,flag之类的字段列表。接着把flag的值拿出来:
?id=1' and extractvalue(1,concat(0x7e,(select flag from secret_xxx limit 0,1),0x7e))--+一次请求一段,一般三次到四次请求就能通关。如果字段值太长被截断,可以用substr把值分成两段取。我遇到过有人在这里用group_concat把整张表数据都拼在报错里,结果超过报错回显长度,反而不如limit 0,1一条一条拿。
4.4 报错被过滤,就用二分法盲注
如果挑战关把extractvalue、updatexml这类函数过滤了,就只能回到布尔盲注。顺序仍然是先拿表名长度、再逐字符拿名字,但挑战关次数有限,手测几乎不可能,得写脚本二分。我会在本地写个小脚本,每次请求判断布尔条件,用if(ascii(substr(...))>x,1,0)控制排序或回显,次数控制在字符集范围内的二分级别。这里最重要的不是脚本本身,而是把“探测目标”拆成公式化的两步:先定目标,再定判断条件。
4.5 挑战关对堆叠的态度
挑战关一般不会要求你用堆叠,因为多语句接口在真实环境里比较容易触发告警,靶场更想考你单条语句内的信息提取能力。我自己刷的时候仍然会习惯性检查一次;select sleep(2),如果延迟生效,说明这关也支持多语句,那就可以用堆叠快速更新数据绕关;如果不生效,就老老实实报错或盲注。能用堆叠能节省好几条请求,但不该把堆叠当默认选项。
5. 常见问题与排查技巧
5.1 闭合方式试探顺序
我在整个 sqli-lab 后半段反复用到的试探顺序是:1'报错 →1"报错 →1')报错 →1无报错。这个过程不是机械的,而是看报错信息的样貌。如果单引号报错里带出了整段 SQL,说明闭合就是单引号;如果带出的是('...'),说明外面套了括号;如果怎么加引号都没变化,大概率是数字型拼接。把这个顺序记牢,后面所有关卡都能少走弯路。
5.2 注释符被过滤或被解码的情况
很多新手经常问“为什么--+没用”。在 URL 里,+在某些服务端会被解析为空格,所以--+变成--,这本来就是对的;但如果 URL 经过编码层,+可能没转成空格,MySQL 又把--后面跟上别的内容,注释就不成立。更稳的做法是改用%23也就是#,或者直接写--%20。POST 表单里用--+比较顺,因为表单解码会把加号还原为空格。注释符这块没有绝对万能答案,建议在本地把三种都试一遍,再记住当前环境下的偏好。
5.3 堆叠注入没有延迟,别急着怀疑 sleep
我最初刷 38 关时遇到过sleep(3)不生效的情况,后来发现是闭合没对,语句语法本来就是错的,自然谈不上延迟。如果闭合对了但还不生效,再看接口是不是多语句。sqli-lab 在 38~53 的源码固定用多语句接口,但如果你把同样的 payload 放到前面 1~37 关,就不会延迟。只需要分清“语法错误”“不支持多语句”“语句执行但无回显”这三种状态,排查会快很多。
5.4 group_concat 和报错函数的长截断
extractvalue和updatexml报错输出的长度有限,大概只有几十个字符,group_concat默认长度也可能只有 1024。数据一长就会被截断,页面看到的一堆查询结果里只有一半。解决方法是给目标数据分段:select substr(group_concat(...),1,64)、select substr(group_concat(...),65,64)。另一个经验是报错注入拿数据时,尽量让字符串短一点,用concat_ws控制分隔符,而不是把所有字段都堆在一起。
5.5 堆叠更新完数据,页面没变化
用 insert 或 update 验证堆叠时,很多新手会说“明明执行了,但页面没有任何变化”。这是因为第二条语句操作的是数据库,不是当前查询结果。你可以去调用一个读取该数据的功能,或者再看一次数据库表,别指望当前页面会帮你打印提示。如果在靶场里只是验证,最直观的还是;select sleep(3),延迟本身就是证据。
6. 把后半段这些套路翻译成防御手段
6.1 多语句接口是堆叠注入的土壤
sqli-lab 从 38 关开始能堆叠,不是因为 MySQL 本身有问题,而是 PHP 选择了mysqli_multi_query()。真实项目里,如果不需要一次执行多条语句,就不应该开这个口子。开发用 PDO 的时候以为自带防护,其实某些 PDO 配置也会允许多语句,稍不留神就会踩坑。防御上最直接的写法是:查询一律用预处理语句,连接配置里显式关闭多语句,排序参数走白名单,不接受用户自由拼接。
6.2 账号最小权限能挡掉一半写操作
堆叠注入最疼的不是多查一条数据,而是多执行一条 update 或者写一个文件。数据库账号只要不持有 FILE 权限,into outfile这招基本失效;只要没有 update 和 insert 权限,堆叠造成的破坏面就小很多。靶场里你可能会觉得堆叠很好用,但真实数据库一旦让业务账号拥有所有权限,一次注入连备份都可能被删掉。最小权限原则不是上线规范里的一句口号,而是渗透测试里最实在的止损手段。
6.3 监控和告警比 WAF 更靠谱
挑战关里的报错注入和布尔盲注,特征其实很明显:大量请求里反复出现information_schema、extractvalue、updatexml、ascii(substr(,而且同一参数的值逐字符变化。与其依赖 WAF 的规则库,不如在数据库审计日志里对这类关键词做统计,请求频率异常时直接告警。我自己刷完这二十几关最大的感受是:漏洞利用的变形能力很强,但“在数据库层面对异常 SQL 进行审计”这条基线从来没变过。
这套后半段刷下来的经验,我自己最想留下的不是某一条 payload,而是那个排查顺序:先判断注入点是什么拼接形态,再判断能不能堆叠,最后才决定用报错还是盲注。sqli-lab 把同一个知识点翻来覆去换了二十遍包装,就是在训练这种肌肉记忆。如果你也在刷某一个系列,别急着背答案,建议每关在笔记里画一张参数、闭合、接口模式的表格,刷到后面会发现很多东西都是排列组合。