很多人第一次接触SQL注入的时候,都会产生一个疑惑:同样是注入了,为什么有的页面直接把你想要的数据明晃晃地列出来,有的页面只是"有反应/没反应"两种状态切换,还有的页面看起来什么都没发生,但数据库却在背后默默执行了你的命令?
这三种现象,对应的就是SQL注入的三大类型:联合查询注入、布尔盲注、时间盲注(报错注入在实战中通常作为补充手段使用,我会在合适的位置单独提它)。这篇是《SQL注入篇》的第3篇,重点就是把这三大类型拆开揉碎,讲清楚它们各自的适用前提、判断方法、完整测试步骤,以及我在授权测试中常用的避坑手法。如果你前两篇还没看,建议先补一下注入点的基础识别和闭合方式,这篇会默认你已经能判断"这里能注入",现在需要的是"该怎么拿数据"。
这篇内容适合有初步SQL注入基础、想做系统化渗透测试学习的人,也适合已经打过一些靶场但总觉得"全靠工具一把梭"、想搞清楚原理的同行。我会尽量把实际操作链路写完整,你可以直接照着思路在自己的授权测试环境里复现。
1. 为什么同一处SQL注入,页面表现却千差万别——三大类型的判定起点
先从一个最基础但是特别容易被忽略的问题说起:SQL注入的"类型"其实不是注入本身决定的,而是你所在的应用环境决定的。换句话说,同一个SQL注入漏洞,如果前端页面把查询结果显示出来,它天然就适合联合查询;如果前端只是告诉你"查到了一条记录",它就只适合布尔盲注;如果前端什么反馈都不给,你只能用时间盲注。所以选类型本质上是在回答一个问题:我这条查询的结果,能从哪个通道拿回来?
我把这个"通道"叫作回显位置。所有判断流程都是围绕它展开的。
1.1 回显位置决定打法
我们拿一个典型场景举例。某授权测试系统中,一个商品详情页的URL长这样:
https://target.example/product?id=102我在后面加了一个单引号,页面直接报错;去掉引号正常访问。这说明参数大概率被拼接进了SQL语句。
现在我要做的第一件事不是急着拿数据,而是观察"当我构造条件让SQL返回不同结果时,页面到底哪部分在变"。
- 如果正常访问返回商品名称和一串价格,而我把条件改成一个必然为真的内容,页面把另一个商品的信息也渲染了出来,那说明查询结果有可见的数据位。这种情况直接走联合查询注入。
- 如果我把条件改成
id=102 and 1=1页面正常,改成id=102 and 1=2页面变成了"无数据"的提示,但除此之外页面上没有任何拼接进来的具体字段,那就走布尔盲注。 - 如果最极端,无论我怎么改变查询条件,页面输出完全一致,只有时间有轻微差异,甚至时间差异也不明显,那就只能试着做时间盲注。
很多文章爱把SQL注入类型按"回显注入/盲注"来分,但在实际操作中我更愿意用上面的场景化判断:先找可见通道,没有通道再找状态通道,连状态通道都没有再找时间通道。这套逻辑从根基上避免了"拿着联合查询的语句去打一个布尔盲注点"这种尴尬。
1.2 一张表理清三种类型的适用边界
为了让你在实际测试时能快速给注入点定性,我把三种类型的关键特征整理成了一张对照表,这是我自己每次做授权测试时心里默认挂着的版本:
| 类型 | 适用前提 | 页面特征 | 核心语句 | 效率 |
|---|---|---|---|---|
| 联合查询注入 | 查询结果有可见回显位 | 页面会渲染查询结果字段 | union select | 最高,几秒出数据 |
| 布尔盲注 | 页面存在"真/假"两种可区分状态 | 条件真假导致页面不同 | and 1=1 / and 1=2+substr+ascii | 中等,配合二分法效率可接受 |
| 时间盲注 | 页面无任何回显、无状态差异 | 所有条件页面都一样 | if()+sleep() | 最低,需要耐心和自动化 |
这里有个重要提醒:不要把"报错注入"和上面三种并列。报错注入依赖数据库的报错信息直接回显,严格来说它也是一种可见回显,只是利用方式不同。实战中如果联合查询走不通(比如被过滤了union),而页面对报错信息又不做隐藏,报错注入(比如extractvalue、updatexml)往往能快速救场。我会在第5节展开讲讲怎么把三种类型和报错注入组合起来。
有了这张对照表,接下来要做的就是一件事:沿着"从简单到困难"的顺序,把每个类型完整走一遍。
2. 联合查询注入:信息直接摆在页面上的高效率打法
联合查询注入是三大类型里最优先尝试的,因为只要环境允许,它的数据获取效率是断崖式领先的。核心原理一句话:通过union select把自定义的查询结果和原始查询结果合并,让数据库把你想查的内容当成"新行"返回给页面渲染。
2.1 探测步骤:从提交点确认到列数破解
拿到一个候选注入点后,我的标准流程分成三步。
第一步是探测提交点是否存在拼接型注入。比如在URL参数后加',观察是否报错;再用id=102 and 1=1和id=102 and 1=2做对比,确认条件能否影响SQL执行。这一步前面已经说过了。
第二步是确认原始查询返回了多少列。为什么必须知道列数?因为union select要求前后两个查询的列数完全一致,否则数据库会直接报错。判定列数最常用的手段是order by:
id=102 order by 1 id=102 order by 2 id=102 order by 3这里order by后面的数字表示按第几列排序。如果目标查询只有2列,你让它order by 3,数据库就会报"order by number not found"之类的错误;如果列数存在,页面就正常,不过排序结果可能有变化。通过不断递增去探测,找到"最后一个正常,下一个报错"的临界点,就是真实列数。
举个例子,我测一个参数:
id=102 order by 1 -- 正常 id=102 order by 2 -- 正常 id=102 order by 3 -- 报错那这个查询返回2列,联合查询里就必须是union select 1,2这样两个字段。
2.2 真正的"最后一公里":占位对齐与回显位定位
知道了列数,不等于能直接拿数据。因为你还得解决两个问题:一是让原始查询返回空结果,这样联合查询的"新行"才能顶上来;二是搞清楚页面上哪个位置渲染了第几列的数据。
解决第一个问题,常见手法是给原始查询构造一个不可能成立的条件。比如原参数是id=102,我改成id=-1或者id=102 and 1=2,让原始查询查不到东西。为什么不直接用id=999999这种不存在的值?可以,但很多时候应用对"id不存在"有特殊处理,比如直接跳转到错误页面。用id=-1通常更稳,因为很多系统不检测负数的合法性。
解决第二个问题,就是union select里放占位数字,把每一列都"点亮"一遍:
id=-1 union select 1,2如果页面显示出了数字,你就能看到页面上哪个位置对应第几列。比如页面某处显示了2,那这一列就是可控回显列。之后把对应位置的占位符换成你想查的内容就行了。
2.3 联合查询最常见的翻车情况和处理办法
走联合查询注入,我遇到过不少看似玄学的问题,其实背后原因都很具体。
第一个翻车情况:union select被过滤了。有些系统会对参数里的union、select做简单拦截,这时候可以大小写混写、中间加注释符分割,比如UnIoN/**/SeLeCt。这招对正则过滤不严的系统有效,如果是WAF级别的拦截,就要考虑编码或换接口了。
第二个翻车情况:页面只显示了其中一列。这个情况其实不叫翻车,只是你需要把要查询的数据放在被点亮的那一列上。比如刚才的union select 1,2只显示了2,那就把查询内容放在第二个位置:
id=-1 union select 1,database()第三个翻车情况:回显位有,但查询结果被截断。多见于页面有字数限制,这时候不要一次查太多内容,拆着查,用substr()分段取,或者直接查关键信息而不是整段数据。
联合查询注入的完整SQL样例长这样:
id=-1 union select 1,group_concat(table_name) from information_schema.tables where table_schema=database()这条语句能一次查出当前库的所有表名,经过group_concat直接拼成一行,是实战里最常用的高效语句之一。
3. 布尔盲注:页面只有"是/否"也要把数据问出来
联合查询注入固然爽,但现实是很多接口根本不回显数据。它们只给你一个二元结果:条件为真时页面返回A,条件为假时页面返回B。这种环境就是布尔盲注。
布尔盲注的本质是把"猜测数据库内容"这一个问题,拆解成大量"是/否"判断题,逐个请求问出来。你每发一个包,数据库就会根据条件执行不同的SQL分支,页面随之不同,你再从页面的差异里读出答案。
3.1 怎样确认当前注入点确实适合布尔盲注
判断布尔盲注的可用性,基本就看一点:你能不能构造出两个结果不同的请求,并且这种差异是稳定可复现的。
我通常在参数后面加上下面两个payload:
id=102 and 1=1 id=102 and 1=2如果第一个请求页面显示正常数据,第二个请求显示"无记录"或者干脆空页面,那就说明条件真假能被页面识别。这里有个细节要特别注意:这两个请求之间不要有任何参数变动,最好连着发两次,排除缓存因素。有些系统开了页面缓存,第一次请求和第二次请求结果一样,会误判成"没有布尔状态"。
如果and关键字被过滤了,可以用or替代,或者换一种写法,比如:
id=102 xor 1=1不过xor在真实环境中用得少,判断布尔点的标准动作还是and 1=1/1=2。如果两种状态能稳定区分,再进入下一步。
3.2 二分猜解的SQL构造与效率思路
布尔盲注最忌讳的就是一个字符一个字符用=去猜。比如猜库名第一个字符是不是a,不是再猜b,最多26次起步,加上大小写和数字,次数会非常难看。正确的做法是用二分法:先判断目标字符的ASCII码是大于某个中间值还是小于它,把每次请求的信息量从"是不是这个字符"变成"字符在哪一半区间",查询次数从线性变成对数级。
举个例子,查数据库名的第一个字符。我用:
id=102 and ascii(substr(database(),1,1)) > 100如果页面为真,说明第一个字符的ASCII码大于100,即d之后;如果为假,说明在d及之前。然后继续缩小区间,用>110、>120、>115这样逐步逼近,最后锁定精确值,再通过ASCII表一对照,就知道字符是什么了。整个猜解过程大概就是一个字符发4到6个请求,比逐字符遍历26个字母要快得多。
猜解的内容上,也要讲究优先级。先拿库名长度:
id=102 and length(database()) > 5再拿库名、表名、字段名,最后才是数据。拿到表名后,猜解字段名,再查数据,顺序不能乱。这里我用一个示例说明从表名到数据的完整SQL片段:
-- 猜第一个表名的第1个字符ASCII是否大于100 id=102 and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1)) > 1003.3 自动化辅助标记与干扰排除
布尔盲注打到后期,手动发请求是不现实的,必须依赖工具辅助。以Burp Suite为例,我习惯用Intruder配合资源池把请求并发打出去,再用匹配规则把"条件为真"的响应打上标记。具体做法是:把要猜的ASCII值或比较值设为变量,放进Payload位置;然后在Options里设置"Grep - Match",匹配页面为真时出现的特征字符串,比如"正常商品详情"几个字或者某个特定状态码。跑完之后,凡是响应里带有这个特征的结果,就是条件为真的值。
这一步里最关键的坑是噪点干扰。很多业务页面里,同一个参数不同请求返回的HTML长度本来就上下浮动,比如页面里有个随机推荐位。这时候你不能只看"页面内容是否相同",而要找唯一稳定特征,比如商品标题、状态码、固定的空态文案。我见过有人因为没排除噪点,把随机推荐位当成真/假状态差异,导致猜出来的数据全是错的,浪费了一整晚。
还有一点:布尔盲注的请求量很大,一定要控制节奏。并发太高容易被防护设备封IP,建议并发数在5到10之间,并且随机加一点延迟,模拟正常用户行为。
4. 时间盲注:完全无回显时最后的拿数据办法
最麻烦的情况是页面既没有数据回显,也没有真假状态差异。你发and 1=1页面正常,发and 1=2页面也正常,你能看到的输出一模一样。这种情况下,唯一能利用的通道就是时间——让数据库执行一个耗时操作,然后用"响应时间变慢"来传递信息。
时间盲注有两个前提条件:一是目标数据库支持延时类函数,比如MySQL的sleep()、benchmark(),PostgreSQL的pg_sleep();二是应用不会因为SQL执行时间变长而主动断开连接。常见的超时阈值在5到10秒之间,超过太久连接可能被中断,所以延时值不是越大越好。
4.1 延时判断的基线建立:先回答"到底能不能延时"
很多人一上来就写id=102 and sleep(5),然后发现页面没变慢,就判断"不能时间盲注"。这个结论可能下得太早了。原因在于:and sleep(5)只有在原条件为真的情况下才会执行延时,如果原条件本来为假,或者出现了短路的逻辑,sleep根本不会被调用。
正确做法是先打一个无条件延时的语句:
id=102 and sleep(5)同时观察响应时间是否稳定增加了5秒左右。如果没变化,再试id=102 or sleep(5),防止原条件短路。这里有个细节:如果注入点是数字型,直接拼and sleep(5)没问题;如果是字符型,要注意闭合引号。这也是我一直强调要在前两篇里打好基础的环节,闭合方式错了,后面全白搭。
基线判断的具体操作是:先记录正常请求的响应时间,比如200毫秒;再发一次带sleep(5)的请求,看是不是稳定在5.2秒左右。我一般会重复三次,确认时间差稳定再继续。因为网络抖动可能造成几十毫秒甚至几百毫秒的差异,但不可能稳定产生5秒级差异。只要是明确的大幅延时,就说明这个点可以做时间盲注。
4.2 if()+sleep()的猜解逻辑与参数细节
时间盲注的猜解逻辑和布尔盲注完全一致,只是把"页面差异"替换成"响应时间差异"。判断条件为真时执行延时,为假时不延时:
id=102 and if(ascii(substr(database(),1,1))>100, sleep(5), 0)这条语句的意思是:如果当前数据库名的第一个字符ASCII码大于100,就延时5秒,否则立刻返回。从响应时间上,你就能读出条件的真/假。
这里有几个特别值得注意的参数细节。
第一,延时值建议控制在5秒,少数情况下用10秒。太短了容易跟网络延迟混淆,太长了请求容易超时。5秒是一个在实际环境中比较可靠的中间值。
第二,if()函数在MySQL里是通用的,但如果目标不是MySQL,写法要变。比如PostgreSQL没有if(),要改用case when ... then pg_sleep(5) else 1 end。所以我做时间盲注前,一定会先通过报错信息或特征语句确认数据库类型,再选择对应的函数。
第三,条件里最好优先判断长度,而不是直接猜字符。先通过length(database())确定答案会更高效——比如先用if(length(database())>10,sleep(5),0)把长度范围锁定,再去逐字符猜内容。否则你猜了一堆字符,最后发现猜的是一位数长度,白费了很多时间请求。
4.3 时间盲注的流量与效率控制
时间盲注最令人头疼的就是慢。一个字符平均要5到6个请求,每个请求还要等5秒延时结束,如果不控制好效率,体力感会非常明显。我自己在授权测试里,纯手动时间盲注是很少的,基本都会上工具。
用sqlmap打时间盲注时,我会显式指定参数:
sqlmap -u "https://target.example/product?id=102" --technique=T --time-sec=5 --batch--technique=T强制只用时间盲注,防止它自动走到其他类型;--time-sec=5设定延时阈值。工具跑起来之后,我会让它保持比较低的并发和延迟设置,因为时间盲注的请求本身就是"等待"型的,并发太高容易误判延迟基线。
另外要提醒一句:时间盲注对目标服务器的负载影响比前两种方式都要大。每次延时都是真实占用数据库连接资源,所以即使是在授权范围内,也要控制请求量,做好测试计划,避免把业务系统拖死。这个问题在授权测试里是底线问题,技术能力可以不强,但绝对不能给客户系统造成额外事故。
5. 三种类型在真实授权测试里的组合用法与过滤对抗
把三种类型单拎出来都讲清楚了,最后再回到实战:真实系统不会那么听话地贴好标签等你用对应类型,你往往要先试联合查询、发现被某些因素卡住,再退回布尔盲注,甚至最后转时间盲注。这套"降级组合拳"怎么打,是我最想在结尾和你分享的。
5.1 选择顺序:能直接看就不猜,能猜就不等
我在测试一个新注入点时,平时会默认先跑一遍联合查询的流程:union select列数探测、回显位定位、拿库名。如果页面反馈正常,我就直接进入数据获取环节。
被拦了之后,退一步走布尔盲注:判断页面是否有真/假状态。有,就上二分猜解。
只有前两条路都不通,页面既无回显也无状态差异时,我才会走时间盲注。
这个顺序背后其实是一个效率原则:每次请求的"信息密度"不同。联合查询一个请求能拿到一整个字段;布尔盲注一个请求只能确定1比特信息;时间盲注连1比特信息都要花好几秒。在有选择的情况下,永远优先信息密度高的方式。大部分情况下,一个看似没希望的注入点,实际上只是因为联合查询的语句没写对,而不是真的没有回显位。所以遇到困难,先回去检查自己的SQL构造,再考虑降级。
5.2 常见过滤对三大类型的干扰与绕过思路
真实系统很少让你舒舒服服地执行标准语句,或多或少都会有一些过滤。面对过滤,我的处理原则是"先分析过滤的粒度,再想绕过,最后才考虑换类型"。
常见过滤项和处理思路:
| 过滤类型 | 常见拦截目标 | 处理思路 |
|---|---|---|
| 关键字过滤 | union、select、and、or | 大小写混写、注释符分割(un/**/ion)、等价替换(&&代替and、or替换xor) |
| 符号过滤 | '、"、#、-- | 数字型注入点优先(不需要引号);字符型则考虑十六进制编码写字符串,如0x61646d696e |
| 函数过滤 | sleep、substr、ascii | 用等价函数替换,mid()替代substr()、ord()替代ascii()、benchmark()替代sleep() |
| 单次请求长度限制 | 长SQL语句 | 拆分成多个小请求,分步查询 |
我最常遇到的是关键字过滤。比如union被单次关键字直接拦截,我会先试UnIoN混写,再试union/**/select注释分割,再试union%0a/**/select编码换行。如果都不行,就说明过滤器的逻辑比较严谨,这时候不建议硬刚联合查询,直接转布尔盲注会更省时间——布尔盲注的单词相对简单,and和1=1经常不在拦截名单里。
还有一类情况是引号被转义。应用可能把所有单引号都替换成了\'或者加了一层转义,导致字符型注入很难闭合。这时候,如果参数本身是数字型,完全绕开引号去操作,是最舒服的解。如果你非要传入字符串值,可以考虑用数据库内置的字符编码函数(比如MySQL的0x十六进制写法),把'admin'转换成0x61646d696e再拼进去,不需要出现引号。
WAF拦截是我处理得最谨慎的情况。遇到WAF,我一般不会在原有注入点上反复试探,因为高频率的异常请求会直接触发封IP,反而把测试环境搞坏。更合理的做法是换个请求方式(POST换成GET、增加Content-Type变动)、换个注入点,或者干脆换一个更安静的证明方式(比如只证明存在注入而不实际取数),把风险降下来。
5.3 一个虚构案例演示完整串联流程
为了更直观地说明上面的组合逻辑,我给你讲一个脱敏后的虚构案例。这是我在做某授权测试时遇到的模拟项目X的场景:目标是一个后台登录记录查询接口,参数是log_id,页面只显示"查询成功"或者"查询失败"四个字,没有任何数据回显。
第一步走联合查询。我构造:
log_id=-1 union select 1,2结果页面返回空白。用order by探测列数时报错信息被隐藏了,没法判断列数——说明这条路堵死了。
第二步判断布尔盲注可行性:
log_id=1 and 1=1 -- 页面显示"查询成功" log_id=1 and 1=2 -- 页面显示"查询失败"状态差异非常明确,于是进入布尔盲注流程。我按照二分法先拿库名,发现库名是db_log,又拿表名,定位到了tb_admin,接着拿字段名username,password。到这里,已经证明漏洞可以取出敏感数据,我在测试报告里附上了部分截断数据作为证据就停手了,没有再继续拖库,因为授权范围只需要证明风险。
这个案例其实很典型:联合查询不通,不代表注入没法利用;只要页面有状态差异,布尔盲注就能完成任务。很多初学者一看到联合查询失败就下结论"这里没有SQL注入",其实是大错特错。
还有个小技巧我常跟同伴分享:如果布尔盲注也做了,但页面状态差异很小(比如只有某个响应头不同),或者状态经常漂移,那可以试试给参数加一个#注释符把后面多余的条件闭合掉。有时候页面上看起来"无状态差异",其实只是因为SQL的后续条件干扰了你的判断,加注释符之后,状态差异立刻就出来了。这招在GET型POST型都适用,尤其是拼接了多个条件参数的复杂接口,非常管用。
写在后面:手动打的思路,自动化才能打得准
最后想聊一点题外话。现在很多新手做SQL注入测试,习惯直接掏出工具跑一遍,看到Parameter 'id' is vulnerable就收工。工具确实能帮忙,但它能告诉你的只是"这个点能注入",你真正要写进报告里的"注入类型是什么、能获取哪些数据、利用条件是什么、该怎么修复",还是得靠你自己对三大类型的手工判断来给出结论。
我个人有个习惯:每次拿到一个新注入点,无论工具跑出来什么结果,都会手动走一遍联合查询和布尔盲注的探测流程,最多再用时间盲注兜底。这样做表面上确实慢一些,但时间久了,你对"什么情况下哪条路能走到黑"会形成一种直觉,以后遇到奇怪的目标,这种直觉比任何工具都可靠。
这篇聊的是SQL注入三大类型的判定思路和操作细节。下一篇文章我会继续延展SQL注入的绕WAF思路和拿库拿表的具体手法整理。有授权测试机会的话,多在自己搭的靶场和模拟环境里刷几轮,SQL注入的手感,一定是靠一遍一遍重复练出来的。