SQL 注入里的 UNION 攻击,听起来像是个高级操作,但拆开来看,第一步永远是在数数——数清楚原始查询到底返回了几列。这个动作没做好,后面所有 UNION payload 都是空中楼阁。我刚在本地靶场里把这条链路完整走了一遍,从 Burp Suite 抓包到最终确定查询返回的列数,整个过程踩了几个不大不小的坑,写下来给同样在练 UNION 注入的朋友做个参考。
这系列操作我只建议在自己搭的靶场或者授权测试环境里玩,别拿去对着线上系统比划。靶场存在的意义就是让人放开手试错,把数据库的脾气摸透,以后真遇到授权测试任务,心里才有底。
1. 项目解读:UNION 注入为什么第一步是数清列数
UNION 注入的核心原理不复杂:我们构造的 SELECT 语句要和原始查询的结果合并,而数据库对 UNION 有个硬性要求——前后两个查询返回的列数必须完全一致。列数不一致时,数据库会直接报错,你注入的语句再漂亮也没用。
可以把它理解成拼积木:UNION 像是要把两块积木上下叠起来,每层的凸起数量必须一模一样,否则就对不上。原始查询返回多少列,我们构造的查询就得给多少个位置,多一个少一个都不行。所以确定查询返回的列数是整个 UNION 注入流程里最先要做、也最不能跳过的步骤。
1.1 从报错读懂数据库的脾气
在靶场里做这个动作,本质上是在跟数据库“对话”。每发一个 payload,数据库都会通过报错信息、响应内容或者页面状态告诉我们一些线索。比如列数不匹配时,数据库会提示“每个 UNION 查询必须具有相同的列数”;ORDER BY 数字超出范围时,会提示“Unknown column”之类的错误。
我见过不少初学者卡在这一步,不是不会发请求,而是不会看响应。你需要关注的不只是页面是否报错,还要留意响应长度、状态码、错误信息类型。Burp Suite 的 Repeater 面板里,每次发送后的响应长度都会明明白白列在顶部,长度突然变化,通常就意味着查询行为发生了改变。
1.2 三种数列数的路子各自解决什么问题
确定列数的方法常见的有三种:ORDER BY 递增试探、UNION SELECT NULL 逐列验证、利用数据库报错信息反推。这三种思路各有适用的场景。
ORDER BY 方法是利用排序子句的特性,不断加大数字让数据库自己去“验收”是否存在这一列,不依赖 UNION 的语法约束,前置条件最少。UNION SELECT NULL 是直接贴着 UNION 的规则来,用 NULL 这种万能类型逐列加位,不存在类型匹配的干扰。报错信息法属于旁门左道,在数据库开启了详细错误回显时效率很高,但可遇不可求。
我个人的习惯是优先用 ORDER BY 扫一遍,再用 UNION SELECT NULL 做精确验证。前者快,后者稳,两者结合起来,基本不会误判。
2. 靶场环境与 Burp Suite 准备工作
动手之前,先把环境和工具捋顺。这里我用的是本地起的一个 SQL 注入靶场,跑在虚拟机里的某 Linux 发行版上,后端数据库是 MySQL。这个组合是练 SQL 注入最经典的搭配,报错信息完整,语法也常见,网上能找到大量对应的参考资料。
Burp Suite 的选择上,社区版就够用。真正干活主要用 Proxy 和 Repeater 两个模块,社区版完全没有限制。
2.1 把 Burp Suite 变成浏览器与服务器之间的“中间人”
Burp Suite 的 Proxy 模块本质上就是一个拦截代理,让所有浏览器发出的请求先经过它,再由它转发给服务器。我们需要在浏览器里配置代理指向 Burp 的监听端口,默认是 127.0.0.1:8080。
配置好代理后,在靶场页面随便点一下链接,Burp 的 Proxy 面板里就会出现一条请求记录。右键点击这条记录,选择 Send to Repeater,或者直接按快捷键 Ctrl+R,请求就进了 Repeater 模块。Repeater 的好处是可以反复修改请求参数然后重新发送,改一次发一次,响应结果直接在右边对比查看,非常适合做注入测试的迭代调试。
这里有个小细节:Burp 内置的浏览器会自动走代理,如果你用系统外部浏览器,得手动确认代理设置生效。不少新手第一步就栽在这,改了半天请求发现根本没经过 Burp,白忙活。
2.2 靶场里那个经典参数长什么样
大部分入门向的 SQL 注入靶场,漏洞都出现在 URL 的查询参数上。比如我们访问一个商品详情或新闻详情页面时,URL 长这样:
GET /sqli-demo/news.php?id=1 HTTP/1.1 Host: 192.168.1.100 User-Agent: Mozilla/5.0这里的id参数直接拼进了后端 SQL 查询,典型的注入点。后端大概执行的是这样的语句:
SELECT title, content, author FROM news WHERE id = 1我们后面所有操作,都是在id=1这个位置后面追加我们构造的 SQL 片段。注意看原始查询返回了三个字段:title、content、author,理论上这个查询返回的列数就是 3。但实际测试时不能靠猜,必须通过注入手段让数据库自己告诉我们。
2.3 怎么判断注入点确实存在
在数列数之前,先确认这个点是能注入的。最简单的做法是在参数后面加一个单引号:
GET /sqli-demo/news.php?id=1' HTTP/1.1如果这个请求丢回去之后页面报错、显示空白,或者返回 500 状态码,说明这个单引号已经被拼进 SQL 语句里,导致语法错误。这基本上就坐实了注入点存在。
更稳妥的方式是构造一组逻辑判断。比如?id=1 AND 1=1页面正常,?id=1 AND 1=2页面空白或报错。前者永真,后者永假,两个结果差异明显,说明我们注入的条件确实影响到了查询逻辑。做完这步确认,再进入列数探测就是水到渠成的事。
3. 实战干货:手把手确定查询返回的列数
环境就绪、注入点也确认了,接下来的重头戏是真正把查询返回的列数给挖出来。我把三种方法都实际跑了一遍,说说每一步具体怎么操作,以及最终怎么判断结果。
本次演示以 GET 请求里的id参数为例,实际场景你遇到 POST 参数也一样,只是把修改位置从 URL 变成请求体。
3.1 方法一:ORDER BY 递增试探
原理上,ORDER BY 后面跟一个整数,数据库会按“查询结果中的第几列”来排序。如果这个数字大于实际列数,数据库就会报错,说找不到这一列。我们从这个特性反推,就能定位到列数的上限。
用 Burp Repeater 打开之前拦截到的请求,把参数值改成:
?id=1' ORDER BY 1--+这个 payload 里的--+是注释符。--是 SQL 注释,后面的空格是必须的(MySQL 对--注释要求后面至少要有一个空格),+在 URL 中会被服务器解析成空格,所以--+等效于--,把后面多余的 SQL 全部注释掉,避免报错。
发送后页面正常显示,说明 SELECT 语句里至少有 1 列可用。接着把数字改成 2、3、4,逐个发。
按照我们刚才对后端 SQL 的推测,这个查询返回 3 列。实际测试表现是:
| ORDER BY 数字 | 页面表现 | 结论 |
|---|---|---|
| 1 | 正常返回数据 | 至少 1 列 |
| 2 | 正常返回数据 | 至少 2 列 |
| 3 | 正常返回数据 | 至少 3 列 |
| 4 | 报错或页面异常 | 列数小于 4 |
当ORDER BY 4报错时,列数就锁定在 3。如果后端开启了 MySQL 错误回显,你还能在响应里看到类似Unknown column '4' in 'order clause'的错误信息,那是数据库在直接告诉你没有第 4 列。
我个人用 ORDER BY 时习惯从 1 开始慢慢加,因为靶场里列数一般不会太多。如果目标是一个复杂的业务查询,列数可能很大,从 1 往上加就太慢了,这时候可以直接跳到较大的数字然后用二分思想缩范围,后面的问题排查部分我会专门讲这个小技巧。
3.2 方法二:UNION SELECT NULL 逐列验证
ORDER BY 试探完,我还会用 UNION SELECT NULL 再验证一遍,双保险。这次直接贴着 UNION 的规则来:不断递增 NULL 的个数,直到数据库不报“列数不匹配”的错误。
先发一个 NULL:
?id=1' UNION SELECT NULL--+防思路是:如果原始查询只有 1 列,这个 payload 就能成功;如果列数不是 1,数据库会报错。我测试时看到的是:
The used SELECT statements have a different number of columns
说明 NULL 的数量和原始查询列数不一致。继续增加 NULL:
?id=1' UNION SELECT NULL,NULL--+ ?id=1' UNION SELECT NULL,NULL,NULL--+加到 3 个 NULL 时,页面恢复了正常展示。这时可以确定,原始查询返回的列数就是 3。
这里必须解释一个关键选择:为什么用 NULL 而不是数字 1、2、3。原因在于 NULL 在 SQL 中是万能类型,可以跟任何列的数据类型兼容。如果直接用数字去试,在某些严格类型检查的数据库里,还没轮到列数检查就先遇到类型不匹配的报错,就会干扰我们对“到底是对还是不对”的判断。NULL 绕开了类型这层麻烦,让错误信息始终聚焦在列数问题上。
3.3 方法三:数据库报错信息反推列数
这个方法属于“趁数据库话多”的时候才能用。靶场里 MySQL 的配置文件经常开着display_errors,SQL 报错会直接渲染在页面里,此时我们就能读到非常详细的错误信息。
在我测试的这个靶场里,当我发送:
?id=1' UNION SELECT NULL--+响应的 HTML 直接给出了完整报错语句。报错信息里除了提示列数不匹配,有时候还会把两个 SELECT 的列数都列出来,比如 Oracle 的报错会写“此查询没有 FROM DUAL”或者附带列数信息。MySQL 的报错会写:
The used SELECT statements have a different number of columns结合报错上下文,你往往不用猜就已经知道原始查询返回几列了。数据库报错信息在这个场景里就像是参考答案一样,帮我们快速锁定方向。
但这个方法有前置条件:数据库必须开启详细错误回显,且页面没有把错误吞掉。真实环境中很多生产系统会把数据库错误包装成通用 500 页面,想看原始报错得靠运气和配置,靶场里倒是很常见。
3.4 三种方法的对比与适用场景
做完三种方法,我把它们放在一起对比了一下,方便你实际选型时心里有个谱:
| 方法 | 核心 trick | 优点 | 不足之处 | 推荐程度 |
|---|---|---|---|---|
| ORDER BY 递增 | 利用排序列序号上限 | 前置条件最少,路径清晰 | 人为逐步试探,比较费请求次数 | 最推荐 |
| UNION SELECT NULL | 紧贴 UNION 规则 | 精确、不混淆类型问题 | 需要知道 UNION 用法,报错依赖 | 推荐配合使用 |
| 报错信息反推 | 读取数据库返回的错误信息 | 一次请求就能获取结果 | 依赖详细错误回显 | 看情况使用 |
三种方法我最后得出的结论一致:列数 = 3。接下来就可以安心构造 UNION 语句进行下一步注入。这里多说一句,很多人拿到列数就直接写UNION SELECT 1,2,3去试,这其实不是一个好的第一选择,下一节说清楚为什么。
4. 列数确认之后的下一步:定位显错位
列数确定下来只是完成了搭桥的第一根桩,想让注入结果真正展示到页面上,你还需要知道原始查询返回的这几列里,哪些列的内容会被渲染到网页上。这个动作在实战中叫定位显示位,或者叫显错位。
4.1 为什么UNION SELECT 1,2,3比全用 NULL 更好用
确定了列数之后,很多人会顺理成章地把 payload 改成:
?id=1' UNION SELECT NULL,NULL,NULL--+这样是能跑通,但页面上啥也看不出来——NULL 本来就不是真实数据,就算渲染了也是一片空白。正确的做法是用数字或者随机字符串去填充,比如:
?id=1' UNION SELECT 1,2,3--+发送这个请求后,页面里会出现数字 1、2、3。哪个数字在页面上出现了,就说明对应的位置是被渲染出来的。比如页面上你能看到2,看不到1和3,那就说明第二列是回显列,后续拿数据就要往第二列的位置放。
这一步是数据提取的关键前置。后面的操作,不管是查库名、查表名还是拖字段内容,都要把信息写到这些被确认的回显位置上。
4.2 显错位、注释符和数据库方言的一次性搞定
实际操作中,显错位的探测往往会和注释符问题、数据库方言问题纠缠在一起。拿 MySQL 举例,注释符常见的有--(后面有空格)、#,以及/* ... */。在 URL 里#会被浏览器当成锚点处理,根本不会发到服务器,所以网上很多教程会写成%23来替代。用--+则更简单,不用管编码,直接写在参数里就行。
如果在 Oracle 靶场里,UNION 后面的 SELECT 语法有严格要求:必须带FROM DUAL。同样是 3 列,payload 应该写成:
?id=1' UNION SELECT 1,2,3 FROM DUAL--+少了FROM DUAL,Oracle 直接报 ORA-00923 语法错误,很多人第一次在 Oracle 靶场里卡住就是因为不知道这个小规则。
所以确定列数后别急着高兴,先确认两件事:当前靶场是什么数据库,以及注释符在该数据库里怎么表达。这两个问题处理好了,显错位探测一次就能过。
4.3 不同数据库之间的细微差别
我在几个不同数据库的靶场里都跑过这套流程,把差异整理成了一张速查表,方便你以后切换环境时快速参考:
| 数据库 | 注释符 | UNION 特殊要求 | 报错特征 |
|---|---|---|---|
| MySQL | --或# | 无特殊要求 | The used SELECT statements have a different number of columns |
| Oracle | -- | SELECT 必须带 FROM DUAL | ORA-00923 / ORA-01789 |
| SQL Server | -- | 无特殊要求 | 消息 205,级别 16,状态 1 |
| PostgreSQL | -- | 无特殊要求 | each UNION query must have the same number of columns |
虽然报错文案不同,但核心信息一致:列数对不上。用我们前面那套方法,在不同数据库里只是 payload 写法上有一点点差异,流程完全可以复用。
5. 实操中的高频问题与排查思路
整个过程实操下来,有几个问题几乎每次分享给别人都会遇到,有的是环境问题,有的是思路问题。我把它们整理成常见的排查方向,省得你走冤枉路。
5.1 加了 ORDER BY 还是正常返回
如果加了ORDER BY 100--+页面依然正常,没报错也没变化,先别急着认为靶场不存在注入。大概率是 ORDER BY 生效了,但这列排序后的结果和原来的排序刚好一样,页面看起来没变化。更常见的原因是你的注释符没有生效,后面的内容被继续执行,导致整个 SQL 语句的语义变得不可预测。
排查顺序建议是:先单独发一截id=1'看会不会报错,确认注入点没有失效;再换一个不太可能存在的列号,比如 999;再尝试把注释符换成--+之外的%23。在 Burp 的 Repeater 里,重复发送不费力,关键在于系统性地排除变量。
5.2 UNION SELECT NULL 之后页面直接空白或 500
这种情况通常不是列数问题,而是类型问题。虽然 NULL 是万能类型,但如果原始查询里面某个列本身是BLOB或者TEXT类型,在某些数据库的特定模式下,NULL 仍然可能引发类型不匹配的报错。
另一种可能是,原始查询的结果集本身为空。你看似加进去的 UNION 查询返回了结果,但页面逻辑是先渲染原始查询,如果原始查询因为你的条件(比如id传了不存在的值)返回空,部分靶场的页面逻辑会直接短路,表现为空白页。这时候把参数值从1改成0或一个不存在的 ID,把原始查询也置空,只让 UNION 的结果显示,反而能看到效果。
5.3 注释符没生效导致查询语法出错
新手踩得最多的坑就是注释符。在 MySQL 里--后面必须跟空格,但在 URL 里直接写空格容易被浏览器编码搞乱,所以要用--+或者--%20。我见过有人写--结尾没加空格,导致报错,然后以为是列数判断错了。
这个问题的排查很简单:把注释符单独测试。发送?id=1'--+,如果页面正常,说明注释生效;如果报错,说明注释的方式不对,换%23试试。
5.4 判断列数的效率技巧:二分法
默认从 1 递增到 10,响应快没问题,但如果目标查询背后关联了多张表,返回列数可能多达几十列。这时候最有效率的方法是二分探测。
思路是先试一个较大的数,比如 50,如果ORDER BY 50正常,说明列数不少于 50;如果报错,再试 25,依此类推,不断把范围缩小。这种方式在真实渗透测试中非常实用,一次请求就能把范围砍半,配合 Burp 的上一条下一条响应对比,流畅得很。
我实测时曾经对着一个列数 32 的查询做 ORDER BY 探测,从 1 加到 32 花了几十次请求,后来同事提醒我用二分法,四五个请求就锁定了范围,效率天差地别。靶场练习时就可以刻意用一下二分法,熟能生巧。
最后说点实在的。这个“确定查询返回的列数”的动作,说难不难,说简单也不简单。难是不难在理解 UNION 的规则,简单但容易被忽视的恰恰是那些看似琐碎的细节——注释符的选择、错误信息的解读、不同数据库方言的适配。我在实际测试里最常看到的场景,反而是那种花了一堆时间构造精美 payload 却因为注释符没处理好而失败的情况。所以我的建议是,基本功要砸实,多花点时间在靶场里把每个报错信息都看明白,比盲目追求复杂的 payload 有用得多。后面你再用 UNION 去拖数据的时候,会感谢现在认真数列数的自己。