☰
SQL联合查询注入实战:原理、检测与修复指南
2026/10/21 16:54:37 网站建设 项目流程

1. 联合查询注入的本质:一段SQL是如何被改写的

我自己做安全评估做了有些年头了,SQL注入这东西到今天仍然是Web漏洞里最常见的面孔,其中“联合查询注入”又是上手最快、产出最直接的一种打法。上个月在某客户的后台系统做授权测试时,一个新加的数据报表接口又出现了参数拼接漏洞,我只用了三次请求就确认注入点,半个小时左右就把库名、表名、字段名和核心数据全部提了出来。这篇文章就围绕这次操作,把联合查询注入的原理、完整流程、常见问题和修复方案从头到尾说一遍,不管是刚入门的CTF选手、还在写拼接SQL的全栈开发者,还是需要做漏洞复盘的工程师,都能直接拿去参考。

与盲注一场场请求“猜”答案不同,联合查询注入打的是一次性把数据拉回来的高效路线,核心思路一句话就可以概括:让目标SQL在正常查询后面“再拼接”一条自己的SELECT语句,然后把结果合并输出到页面。听起来暴力,但它成立的前提非常明确,缺一个环节都无法继续。我先从UNION本身说起。

1.1 从UNION语法谈起

UNION是SQL标准里一个很实用的集合运算符,作用是把两个或多个SELECT查询的结果合并成一个结果集。比如有一张商品表products和一张促销表promotions,字段设计刚好一致,就可以用一条语句把两个来源的数据一起查出来:

SELECT name, price FROM products UNION SELECT name, price FROM promotions;

UNION有两个硬性要求:第一,每个SELECT返回的列数必须相同;第二,对应列的数据类型必须兼容。MySQL在执行时会把两个结果集“纵向拼接”,返回给调用方。如果第一个SELECT返回3列,第二个SELECT返回4列,数据库会直接抛错,提示语句中使用的SELECT语句的列数不同。

这原本是给开发者提供便利的语法,但落到存在注入点的SQL语句中,就成了攻击者最顺手的工具。攻击者不需要知道原SQL全部内容,只需要猜中“列数”和“回显位置”这两件事,就能把自己的SELECT结果塞进正常业务数据里,跟着页面一起被吐出来。这也是“联合”二字的由来:你在跟业务SQL做一次拼接合作。

1.2 问题代码:参数拼接为什么会失控

要理解联合查询注入,必须回到根上:用户输入被拼进了SQL语句。我拿这次测试里一类非常典型的后端代码做例子,很多老项目里至今还有类似写法:

// 不推荐的历史写法,仅用于演示漏洞成因 $conn = mysqli_connect('127.0.0.1', 'app_user', 'pass', 'shop'); $id = $_GET['id']; $sql = "SELECT name, price, stock FROM products WHERE id = $id"; $result = mysqli_query($conn, $sql); // 遍历输出结果到页面

这里$_GET['id']直接以字符串形式落进了SQL文本中。如果我传的是id=1,SQL无异常;如果传的是id=1' AND '1'='1,原本的WHERE条件就被改写成了一个恒真式;如果传的是id=-1 UNION SELECT 1,2,3,最终执行的语句变成了:

SELECT name, price, stock FROM products WHERE id = -1 UNION SELECT 1,2,3

注意原查询中products表只有三列,而后面UNION SELECT也是三列,恰好匹配。当id=-1时原查询结果集为空,页面于是直接展示了UNION子句的返回列,也就是1,2,3这几个我们控制的数字。这个现象说明什么?说明我们不仅控制了SQL的执行逻辑,还控制了一部分输出内容,注入链已经打通了。

类似问题不只出现在PHP,Java里的字符串拼接、MyBatis映射文件里的${}取值、Python里用f-string直接拼SQL,都会出现同一个病灶:数据与指令没有分离。

1.3 联合注入成立的前提条件

联合查询注入不是万能的,它的成立依赖几个前提条件,我在现场判断时基本按这个清单逐条核:

  • 参数值被直接拼接到SQL中,且应用没有做参数化查询。
  • 目标数据库支持UNION语法,常规关系型数据库都支持。
  • 页面会把查询结果的至少一列内容输出到响应中,也就是“有回显”。
  • 攻击者能够准确测出原查询的列数。
  • 数据库账号具备读取目标元数据或业务表的权限,这往往是最小权限没做好的表现。

这些条件缺一个都不好走联合注入。比如页面只返回“查询成功/无数据”,没有具体内容回显,那联合注入就没有落脚点,得换布尔盲注或时间盲注。所以我在测试时会先做一轮快速判断,再决定用哪种打法,而不是一上来就盲目拼UNION SELECT。

2. 方案选型:为什么优先考虑联合查询注入而不是其他打法

很多刚开始接触注入检测的同学有一个误解,以为只要判断出注入点,直接上联合查询就行。实际上手时会发现,不同注入手法的适用前提差异很大,选错了方法等于自己给自己加难度。我习惯在测试一开始就把几种常见手法放进脑子里横向排一遍,谁最合适就用谁。

2.1 五种注入手法横向对比

注入类型核心思路对页面回显的要求速度适用场景
联合查询注入UNION拼接第二个SELECT需要有明确数据回显最快列表页、详情页、搜索页
报错注入利用数据库报错信息携带数据需要回显错误详情快页面会输出SQL报错
布尔盲注根据页面真假差异逐位推断至少有一处表现不同慢有回显但没有数据明细
时间盲注用延时函数判断条件是否成立几乎不依赖页面内容最慢页面完全无差异化输出
堆叠注入用分号隔开多条SQL语句取决于数据库与驱动支持因场景而异特殊场景,权限影响大

从这张表能看出来,联合查询注入在速度上是极有优势的。因为它一次就能拿到一组完整结果,盲注则需要几十甚至几百次请求,一个字母一个字母地判断。我见过不少刚开始做渗透测试的同学,明明页面已经把数据结构完整渲染出来了,却还在用布尔盲注慢慢猜,白白浪费时间。

2.2 联合注入的适用场景与局限

那么具体什么情况下我优先选择联合查询注入?其实就是回显条件最好的那类业务功能。典型的比如商品详情页、订单查询、新闻列表,这些页面会把查询结果逐行渲染成HTML,甚至有固定的字段位。只要注入点出现在WHERE子句或者拼接进SELECT结构里,页面上的回显位就天然存在。

但联合注入也有明显的边界。如果注入点是登录判断里的SELECT COUNT(*)这类聚合查询,回显位置无法放业务字段,联合注入往往会撞上列数与类型不匹配的问题。如果参数是插入语句或更新语句,调用的还是后端回调逻辑,同样不适合直接套用。权限方面也要注意,有时候原SQL已经限制了WHERE tenant_id=某个固定值,攻击者注入的UNION查询不受这个限制,确实能读到全表数据,但也意味着该数据库账号的权限明显过大。

另外有个细节容易踩坑:第一个SELECT的列名会被UNION结果集继承,而且原查询的排序、分页逻辑可能会把我们的数据排到页面底部。所以我在实际操作中会刻意把原查询结果集置空,比如把id传成不存在的负数,这样UNION里注入的数据才会干净利落地出现在页面顶端。这也是“联合注入”区别于“单纯报错注入”的一个实用经验。

3. 完整实操流程:从探测注入点到数据落袋

接下去进入这次授权的测试实操记录。目标应用是一个业务后台的报表查询接口,URL长这样:

GET /app/get_product.php?id=1

响应页面会返回商品名称、价格、库存三条字段。我按下面五步完整走完了联合查询注入流程。

3.1 第一步:探测注入点

在抓包工具里把请求改一下,先用最基本的单引号试试后端反应:

GET /app/get_product.php?id=1'

页面直接返回了一个数据库语法错误,大意是SQL语法有问题,说明id值被代入进了SQL执行,并且单引号没有做任何处理,注入的嫌疑非常大。为了确认这不是误报,我再执行两次对照测试:

GET /app/get_product.php?id=1' AND '1'='1 GET /app/get_product.php?id=1' AND '1'='2

第一次页面正常返回商品数据,第二次页面空白。两次请求唯一的区别就是逻辑表达式从真变成了假,后端输出也跟着变了,这就是典型的SQL注入判断依据。此时可以确认参数id存在可控制的注入点,而且大概率是字符型或数字型都可以利用的类型。

这里顺带说明一下注释符的用法。MySQL里--(短横线后必须跟一个空格)和#都能注释掉后续内容,但在URL里原始空格和#符号容易被转义成特殊字符,所以测试时我习惯用--+,因为+在URL的查询字符串中会被解析为空格,正好满足短横线后有空格的注释条件。如果没有这个条件,后续拼接的SQL很容易被原语句末尾的引号、括号破坏掉。

3.2 第二步:ORDER BY确定列数

确认注入点之后,下一步要解决的是“原查询到底返回几列”这个问题。最常用的判断方式是ORDER BY:

GET /app/get_product.php?id=1' ORDER BY 1--+ GET /app/get_product.php?id=1' ORDER BY 2--+ GET /app/get_product.php?id=1' ORDER BY 3--+ GET /app/get_product.php?id=1' ORDER BY 4--+

前三次请求页面都正常,当执行到ORDER BY 4时,数据库报错,提示找不到第4列。这就能确定,原查询SELECT结果一共有3列。为什么ORDER BY能用来测列数?因为ORDER BY后面的数字会被数据库解析成“按查询结果中的第几列排序”,如果数字超出结果集列数,数据库会直接报错。这个报错信息比直接试UNION SELECT 1,2,3要清晰得多,因为它能明确告诉你边界在哪里。

实测下来还有个细节:有些框架或过滤规则会对ORDER BY做特殊处理,这时可以改用GROUP BY试试,因为它也会触发同样的列数检查错误。这就是排查过程中常用的小技巧。

3.3 第三步:定位回显位

知道了列数是3列后,我把id改成一个不存在的负数,让原查询结果集为空,再拼接UNION子句,并在三列里塞入标记数字:

GET /app/get_product.php?id=-1' UNION SELECT 1,2,3--+

页面马上出现了第二列和第三列的标记数字,2显示在了原本商品价格的位置,3显示在了库存的位置,第一列的数字没有展示出来。这说明页面实际输出的回显位只有两个,后续所有数据提取都要放到第2列或第3列上。

这一步是整个联合注入的关键。很多人卡在这里,是因为没有先让原查询结果集为空。比如把id传成1,原查询本身就会返回一条正常商品记录,UNION的数据排在后面,页面可能默认只展示第一行,再加上分页逻辑,注入的结果就不显眼,容易被误判成注入失败。先让原查询置空,后面操作就一目了然。

3.4 第四步:从information_schema顺藤摸瓜

回显位确定后,数据库信息获取就变成“替换字段”的事情了。我先拿数据库版本、当前数据库名和当前用户:

-1' UNION SELECT 1,database(),version()--+ -1' UNION SELECT 1,user(),3--+

这次目标库返回了版本号5.7.34-log、库名shop_db,说明数据库是MySQL 5.7版本,可以使用信息模式。MySQL从5.0开始提供了information_schema这个系统数据库,里面保存了所有数据库、表、列、索引等元数据,相当于整个数据库实例的“户口本”。

接下来查表名:

-1' UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()--+

页面返回了一长串表名,包括products, users, orders, admin。这里用group_concat把多行结果合并成一个字段,省去了来回翻页的麻烦,一次就能把该库下所有表列出来。然后针对感兴趣的用户表查列名:

-1' UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_schema=database() AND table_name='users'--+

返回结果里有id, username, password, mobile, email等字段。到这一步,整个库的表结构和目标表字段已经完全暴露,剩下的就是直接取数据。

3.5 第五步:拖取数据与编码细节

真正取数据时,我会把多个字段用分隔符拼在一起,避免输出挤成一团。常用的写法是用十六进制表示的竖线或冒号做分隔符,比如竖线|的十六进制是0x7c:

-1' UNION SELECT 1,group_concat(username,0x7c,password),3 FROM users--+

用十六进制而不是直接写字符,是为了减少引号冲突。因为整个payload外层已经有了单引号包裹,如果分隔符里再出现引号,很容易把当前WHERE子句的边界搞乱。这次测试中前端密码字段是加过密的散列值,属于另一个话题,但在授权范围内也属于可审计的数据。

另外,如果数据量大到group_concat结果被截断,可以先查一下group_concat_max_len这个系统变量,或者在SQL里动态调整:

-1' UNION SELECT 1,group_concat(username,0x7c,password),3 FROM users LIMIT 20 OFFSET 0--+

用LIMIT配合偏移量分批发数据,每次只取一小段,这样页面输出长度可控,也方便人工校验数据对应关系。整个过程下来,我大概发了几十条请求,就把目标库的核心表和数据拿了个遍。

4. 踩坑实录与常见问题排查

联合查询注入虽然上手快,但实际测试中会遇到不少细节坑。很多问题不是原理不懂,而是卡在了字符集、注释符、不同数据库差异这些表面现象上。我把这次测试和之前实战中最高频的几个问题整理成了速查表,再针对几个场景单独展开。

4.1 高频问题速查表

现象可能原因处理建议
加单引号后页面无任何报错参数被强转数字类型或做了过滤检查参数是否被intval之类包装
ORDER BY逐个加都正常数据库对超过列数的排序没有报错换GROUP BY或直接二分法测试
UNION SELECT 1,2,3没有回显列数数错了或回显位置被过滤回退ORDER BY重新确认列数
提示列数不同后续SELECT列数与原查询不一致保持两侧列数完全一致
提示排序规则冲突前后字符集不一致用CONVERT函数转换内容
group_concat结果被截断系统变量限制长度调整长度或分页LIMIT
请求被防火墙拦截WAF识别出明显特征先确认拦截原因,再合理规避
取出来的密码字段是密文应用做过哈希处理在授权范围内再走离线分析流程

4.2 WAF拦了怎么判断

有防火墙或网关类产品在前面挡着,是最真实不过的情况。判断方法很简单:同样一个请求,把payload里的关键字去掉,页面正常;把UNION SELECT加回去,立刻收到一个拦截页面或者响应状态码变成异常,那基本可以确认是WAF在发挥作用。拦截响应有时会带特征头,有时就是一个清爽的空白页。

这里要说句真心话:遇到WAF拦截,不代表漏洞不存在,更不代表可以无限绕过。在授权测试范围内,可以做一定程度的特征变形研究,比如大小写混写、内联注释、换行符插入等来确认漏洞是否真实存在,但生产环境里绕过WAF本身就应该克制,点到为止即可。因为测试的最终产出是修复建议,而不是证明自己有本事把每一层防护都打穿。如果绕过了WAF才发现漏洞,正确的做法是把发现写进报告,同时强调“单靠WAF不能根治注入问题”。

4.3 联合注入走不通时的Plan B

联合查询注入有一个天然前提:必须有数据回显。如果页面只返回“查询成功”或“无记录”,联合注入就彻底失效了。这时候要根据页面表现切换打法:

  • 页面在某些条件下真假差异明显,但无具体数据输出:转布尔盲注,用AND 1=1和AND 1=2的差异逐位推断数据。
  • 页面完全无差异化输出:转时间盲注,用sleep()或benchmark()构造延迟,根据响应时间判断条件真假。
  • 页面能显示单列数据,但多列占位时报错:只在能回显的那一列上放group_concat(),把多列字段合并成一个字段输出。
  • 原查询的列特别多,一列一列试太麻烦:用ORDER BY n结合二分法确定列数,或者观察页面的HTML模板找到输出字段位。

另一个常见问题是数据库版本差异。MySQL 5.0以下版本没有information_schema,查表名需要走mysql.innodb_table_stats这类表,利用面明显收缩。如果目标是SQL Server,系统表是sysobjects和syscolumns;目标是Oracle,则要查all_tables和all_tab_columns;PostgreSQL则可以用pg_tables和information_schema.columns。所以一次联合注入测试成功与否,很多时候还取决于你对目标数据库的熟悉程度。

5. 开发者必看:如何把联合注入口子堵死

讲完攻击流程,必须得说说怎么修。因为我见过太多项目,测试报告写得很厚,研发拿到之后第一反应是“加个过滤函数”,结果下个迭代换个参数又被打穿。修复SQL注入,参数化查询才是根子上的解法。

5.1 最根本的修复:参数化查询

参数化查询的核心是数据与SQL指令分离。SQL语句的骨架先发送给数据库预编译,用户输入只作为参数值传进去,数据库不会再把它解析成新语法。用PHP PDO的正确写法是:

$stmt = $pdo->prepare('SELECT name, price, stock FROM products WHERE id = :id'); $stmt->execute([':id' => $id]); $rows = $stmt->fetchAll();

Python里用数据库驱动程序自带占位符:

cursor.execute( "SELECT name, price, stock FROM products WHERE id = %s", (product_id,) )

Java里则对应PreparedStatement:

String sql = "SELECT name, price, stock FROM products WHERE id = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setInt(1, productId); ResultSet rs = pstmt.executeQuery();

有一个地方需要格外警惕:很多开发框架确实提供了参数化能力,但如果有人图省事,在MyBatis映射文件里写${id},那依然等于直接把输入拼进SQL,只有#{id}才是预编译传参。${}通常只用于动态表名、排序列名这类需要结构变化的地方,而且必须对传入值做严格白名单校验。这个区别我建议每个用到MyBatis的团队都在代码规范里明确写好。

5.2 纵深防御补位细节

参数化查询是必须项,但不是唯一项,安全是层层叠加的结果。实际项目里我还会建议补上下面几层:

  • 输入校验:对于数字型参数,用强类型转换或白名单正则处理,比如id必须是纯数字;对于字符型参数,限制长度和字符集。
  • 最小权限:Web应用连接数据库时,不要用最高权限账号,只赋予业务表必要的增删改查权限,让攻击者即使注入成功也读不到users表之外的敏感数据。
  • 隐藏报错:关闭生产环境的display_errors,把详细错误信息写进日志而不是吐回给前端页面。没有报错回显,攻击者判断注入点的难度会明显上升。
  • 合理的WAF或数据库审计:WAF负责拦截明显攻击特征,数据库审计负责记录异常查询模式,两者都是防御体系的一部分,不能单独依赖。

5.3 发现与应急

最后,如果你正在维护一套历史包袱很重的代码库,可以先做一轮快速自查。在代码库里搜索类似"where id = $id"、"where id =" + id、${id}这些拼接模式,重点排查搜索框、排序字段、批量操作接口。很多老旧模块是在多年迭代里逐步被改烂的,只有扫描+人工确认才能把漏网之鱼找出来。上线前用自动化扫描工具在测试环境过一遍,也能提前发现大部分明显注入点。

如果确认线上已被利用,第一时间要做的不是删日志,而是先暂停受影响的逻辑入口,把同一类拼接SQL全部参数化,然后评估数据泄露的影响范围,包括哪些库表被读、哪些账号受影响,再按流程做内部通报。安全事件处理讲究的是及时止损,而不是急着修复单个参数。

说句实在话,联合查询注入在渗透测试里效率极高,但同时也是最容易修复的一类漏洞。原理说白了就是一条UNION,却被一句经验不足的SQL拼接造成大量事故。我在授权测试里遇到过很多次,开发收到报告后的第一反应都是惊讶:这么老的知识点还会出事?可现实就是如此,代码库越庞大,历史SQL越多,这种“古老”漏洞就越有机会冒头。希望大家读完这些实操细节后,至少在自己的代码里少留几个这样的口子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询