ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL注入入门:union联合注入从原理到实战全解析

SQL注入入门:union联合注入从原理到实战全解析

1. 从第一关说起:为什么union联合注入是理解SQL注入的最佳入口

很多人学SQL注入,一上来就被各种报错注入、布尔盲注、时间盲注绕晕了。我带了不下二十个新人,发现一个规律:凡是第一关没吃透的,后面越学越虚。第一关看着简单,就一个?id=1的参数,但它把SQL注入最核心的几个概念全串起来了——参数拼接、回显位、列数判断、数据库元数据读取。union联合注入之所以适合作为入门第一课,是因为它有一个其他注入方式不具备的优势:结果直接回显在页面上。你不需要靠猜、靠延时、靠布尔真假来判断,页面会明明白白告诉你查询结果是什么。

这一关的典型场景是这样的:一个新闻展示页面,URL里带一个id参数,后端代码大概长这样——SELECT * FROM articles WHERE id = '$id'。你传1进去,它返回第一篇文章;你传1'进去,页面报语法错误。这个报错就是突破口,说明单引号被原样拼进了SQL语句,没有做任何过滤。接下来要做的就是构造一个闭合原查询、再拼接自己查询的payload。

union联合注入的本质,是把两条SELECT语句的结果合并成一张表返回。前提是两条语句的列数必须一致,数据类型要兼容。所以整个注入流程可以拆成四步:判断闭合方式、确定列数、找可回显的列、读取目标数据。每一步都有明确的信号反馈,这也是它适合新手的原因——你能看到自己的每一步操作产生了什么效果。

注意:本文所有操作均在本地搭建的靶场环境中进行,目的是理解注入原理、学会防御方法。任何未经授权对他人系统的测试都是违规行为。

我见过太多人卡在第一步——闭合方式判断。有人拿到一个注入点,上来就union select 1,2,3,结果页面没反应,就以为没有注入。其实很可能闭合方式不对。第一关用的是单引号闭合,但实际环境中可能是双引号、括号、甚至like模糊查询。所以判断闭合方式是所有union注入的前置动作,这一步做不对,后面全白搭。

2. 闭合判断与列数探测:union注入的两块基石

2.1 单引号报错背后的信息量

在URL后面加一个单引号,?id=1',页面如果返回类似You have an error in your SQL syntax的报错,说明单引号被带入了SQL语句。这时候后端的SQL大概变成了SELECT * FROM articles WHERE id = '1'',多出来的单引号破坏了语法结构。这个报错本身就是最有价值的信息——它确认了注入点存在,也确认了参数被单引号包裹。

但要注意,不是所有报错都会显示在页面上。有些系统做了错误屏蔽,页面只显示"系统繁忙"之类的提示。这种情况下单引号报错法就失效了,需要换用其他方式判断,比如and 1=1和and 1=2的页面差异。不过第一关没有做任何防护,报错信息直接输出,所以单引号法最直接。

判断闭合方式有一个小技巧:先试?id=1',如果报错,再试?id=1' --+。--+是注释符,在URL中+会被解析为空格,所以实际发送的是--,把后面的单引号注释掉了。如果加上注释符后页面恢复正常,说明闭合方式就是单引号。这个验证步骤很关键,它排除了"报错是其他原因导致"的可能性。

2.2 order by判断列数的原理与实操

确定闭合方式后,下一步是判断当前查询返回了几列。为什么必须知道列数?因为union要求前后两条SELECT语句的列数完全一致,列数不对就会报错。判断列数最常用的方法是order by。

order by在SQL里的作用是按第N列排序。如果N超过了实际列数,数据库就会报错Unknown column 'N' in 'order clause'。所以我们可以从1开始递增,直到报错为止,最后一个不报错的数字就是列数。

具体操作:?id=1' order by 1 --+,页面正常;?id=1' order by 2 --+,正常;?id=1' order by 3 --+,正常;?id=1' order by 4 --+,报错。说明当前查询返回3列。这个方法的优点是简单直接,缺点是如果页面屏蔽了报错,就看不到反馈。第一关没有屏蔽,所以用order by最方便。

也可以用union select来猜列数。?id=1' union select 1 --+,如果报错说明列数不是1;?id=1' union select 1,2 --+,不报错说明列数是2。但这种方法每次都要改payload,不如order by递增来得系统。我一般建议新手先用order by确定列数,再用union select验证,两个方法互相印证,避免误判。

提示:order by的数字可以用二分法加速。比如先试10,报错说明列数小于10;再试5,不报错说明列数在5到10之间。但第一关列数少,直接递增就行,没必要复杂化。

2.3 确定回显位:为什么1,2,3里只有部分数字会显示

知道列数是3之后,构造?id=1' union select 1,2,3 --+。这时候页面会显示查询结果。但你会发现,页面上显示的不是"1,2,3"这三个数字,而是某些位置显示数字,某些位置显示的是文章内容。这是因为原查询的id=1有结果,union的第二条语句结果被追加在后面,但页面模板只取了第一行数据来渲染。

具体来说,后端代码可能是$row = mysqli_fetch_array($result),只取了结果集的第一行。原查询id=1返回了一行数据,union的第二行数据虽然也在结果集里,但没被取到。所以要让union的结果显示出来,需要让原查询返回空结果。方法很简单:把id=1改成一个不存在的值,比如id=-1。这样原查询返回空,union的第二行就成了结果集的第一行,页面就会显示我们注入的数字。

?id=-1' union select 1,2,3 --+,页面会显示1、2、3分别出现在哪个位置。假设页面显示的是"2"和"3",说明第2列和第3列是回显位,第1列不回显。后续读取数据时,就要把要查的内容放在第2或第3列的位置。

这一步的常见坑是:有人直接用id=1做union,发现页面没变化,就以为注入失败。其实不是失败,是原查询的结果把union结果盖住了。改成id=-1或者id=0就能看到效果。这个细节很多教程一笔带过,但新手最容易在这里卡住。

3. 从回显位到数据库:一步步把数据"钓"出来

3.1 数据库名、版本、当前用户的读取

确定回显位在第2列和第3列之后,就可以把想要查询的函数替换进去。最基础的信息收集包括:数据库名database()、数据库版本version()、当前用户user()。

构造payload:?id=-1' union select 1,database(),version() --+。页面第2列显示数据库名,第3列显示版本信息。第一关的数据库名通常是security,版本可能是5.7.x或8.0.x。这些信息看起来不起眼,但决定了后续的注入策略。比如MySQL 5.0以上才有information_schema库,才能用元数据表查表名列名;版本低于5.0就只能靠猜表名。

当前用户user()也很重要。如果是root@localhost,说明数据库连接用的是高权限账户,后续可能可以读写文件;如果是普通用户,就只能老老实实查数据。第一关用的是root,但这只是靶场环境,实际环境中遇到root权限的概率并不高。

注意:database()、version()、user()这些函数在union注入中可以直接放在回显位。但如果回显位只有一个,可以用concat()把多个信息拼在一起,比如concat(database(),0x3a,version()),0x3a是冒号的十六进制,用来分隔。

3.2 information_schema:注入中最重要的元数据宝库

MySQL 5.0之后引入了一个系统数据库information_schema,里面存储了所有数据库、表、列的信息。对注入来说,这就是一张地图。核心的三张表是:

表名作用关键字段
SCHEMATA存储所有数据库名SCHEMA_NAME
TABLES存储所有表名TABLE_SCHEMA, TABLE_NAME
COLUMNS存储所有列名TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME

查数据库名的payload:?id=-1' union select 1,2,(select group_concat(schema_name) from information_schema.schemata) --+。group_concat()把多行结果拼成一行,避免只显示第一个数据库名。

查指定数据库的表名:?id=-1' union select 1,2,(select group_concat(table_name) from information_schema.tables where table_schema='security') --+。这里table_schema指定数据库名,第一关就是security。

查指定表的列名:?id=-1' union select 1,2,(select group_concat(column_name) from information_schema.columns where table_schema='security' and table_name='users') --+。

这三步走完,数据库的结构就完全暴露了。第一关的security库里通常有users表,users表里有id、username、password三个字段。知道这些之后,直接查数据就行。

3.3 读取users表数据:从字段到具体值

查用户名和密码:?id=-1' union select 1,group_concat(username),group_concat(password) from users --+。页面第2列显示所有用户名,第3列显示所有密码。第一关的密码通常是明文或者MD5,如果是MD5,可以用在线工具或者本地字典跑一下。

这里有一个细节:group_concat()默认有长度限制,MySQL默认是1024个字符。如果数据量大,可能会被截断。可以用select @@group_concat_max_len查看当前限制,或者在注入时用substring()分段读取。不过第一关数据量小,直接group_concat就够了。

如果回显位只有一个,可以用concat_ws()把多个字段拼在一起:?id=-1' union select 1,2,concat_ws(0x3a,username,password) from users limit 0,1 --+。limit 0,1控制每次读一行,通过改变偏移量逐行读取。这种方法适合回显位少、数据量大的场景。

提示:实际注入中,group_concat和limit配合使用最灵活。先用group_concat看总量,如果被截断再用limit逐行读。第一关数据少,group_concat一步到位。

4. 那些教程不会告诉你的实战细节

4.1 注释符的三种写法与适用场景

union注入中,注释符用来把原SQL后面的内容注释掉,保证语法正确。常见的注释符有三种:

  • --(两个减号加一个空格):SQL标准注释,URL中空格要用+或%20代替,所以写成--+或--%20。
  • #:MySQL特有注释,URL中要编码为%23。
  • /* */:块注释,可以注释中间的内容,比如union/*xxx*/select。

第一关用--+最方便,因为+在URL中自动解析为空格。但有些环境对--+不友好,比如参数被urldecode多次,+可能变成字面加号。这时候用%23更稳。我一般建议新手先试--+,不行再换%23。

还有一个坑:--后面必须跟空格才能生效,--+中的+就是空格。如果写成--直接跟字符,比如--abc,MySQL会把它当成减法运算,不是注释。这个细节在手工注入时经常被忽略。

4.2 字符型与数字型的区别:单引号不是必须的

第一关是字符型注入,参数被单引号包裹,所以payload里要闭合单引号。但如果是数字型注入,SQL语句是SELECT * FROM articles WHERE id = $id,没有引号,payload就不需要单引号。判断方法很简单:传?id=1正常,传?id=1'报错,说明有引号;传?id=1 and 1=1正常,传?id=1 and 1=2页面变化,说明没引号。

数字型注入的union payload是?id=-1 union select 1,2,3 --+,没有单引号。字符型则是?id=-1' union select 1,2,3 --+。这个区别看似小,但实际注入中搞错了就完全没反应。我见过有人在一个数字型注入点上硬加单引号,试了半天以为没有注入,其实就是闭合方式判断错了。

4.3 回显位被占用时的替代方案

有时候union select的1,2,3都显示在页面上,但页面模板只渲染特定列,比如只显示第2列。这时候如果第2列被原查询占用,就需要用id=-1让原查询为空。但如果id=-1也不行,可能是原查询用了limit 1或者页面逻辑特殊。

替代方案是用and 1=2让原查询返回空:?id=1' and 1=2 union select 1,2,3 --+。and 1=2让原查询条件为假,返回空结果集,union的结果就成了第一行。这个方法在id=-1不生效时特别有用。

还有一种情况是页面只显示查询结果的某些字段,比如只显示title和content。这时候需要先确定哪些列对应title和content,然后把要查的数据放在对应的列位置。第一关页面显示的是文章标题和内容,对应第2列和第3列,所以payload里把数据放在第2、3列就能显示。

4.4 常见报错与快速排查表

报错信息可能原因解决方法
You have an error in your SQL syntax闭合方式不对或注释符没生效检查单引号是否闭合,注释符是否正确
The used SELECT statements have a different number of columnsunion前后列数不一致用order by重新确认列数
Unknown column 'N' in 'order clause'order by的数字超过实际列数减小数字直到不报错
Illegal mix of collations数据类型或字符集不兼容用concat或hex转换数据类型
Subquery returns more than 1 row子查询返回多行用group_concat或limit 1限制

这张表是我自己踩坑总结的,实际注入中遇到报错先对照排查,能省不少时间。特别是第一个报错,十有八九是闭合或注释的问题。

5. 从注入到防御:理解攻击才能更好防守

5.1 预编译语句为什么能挡住union注入

union注入能成功,根本原因是用户输入被当作SQL代码执行了。防御的核心思路就是让用户输入永远只是数据,不参与SQL语法解析。预编译语句(Prepared Statement)就是干这个的。

以PHP为例,不安全的写法是$sql = "SELECT * FROM articles WHERE id = '$id'",然后mysqli_query($sql)。安全的写法是:

$stmt = $mysqli->prepare("SELECT * FROM articles WHERE id = ?"); $stmt->bind_param("i", $id); $stmt->execute();

?是占位符,bind_param把$id绑定为整数。即使用户传-1' union select 1,2,3 --+,数据库也只会把它当成一个字符串去匹配id字段,不会解析成SQL语句。预编译在数据库层面就把代码和数据分开了,这是最彻底的防御方式。

但预编译不是万能的。如果表名、列名、order by的字段名是动态拼接的,预编译就保护不了。因为这些位置不能用占位符,只能拼接。这时候需要用白名单校验,比如order by只允许id、title、date三个值,其他一律拒绝。

5.2 输入过滤的局限性与绕过思路

很多系统用addslashes()或str_replace()过滤单引号,但这只能挡住字符型注入,挡不住数字型注入。而且过滤规则如果不够全面,很容易被绕过。比如只过滤了单引号,没过滤双引号;只过滤了小写union,没过滤大写UNION;只过滤了空格,没过滤/**/。

从攻击者视角看,常见的绕过方式包括:大小写混写UnIoN、内联注释union/**/select、双写ununionion、编码%55nion。这些绕过手法说明,黑名单过滤永远有漏网之鱼。防御应该用白名单,只允许合法的字符和格式,而不是试图枚举所有非法输入。

提示:addslashes()在GBK编码下还有宽字节注入的问题。%df'经过addslashes变成%df%5c',%df%5c在GBK中是一个合法汉字,单引号就逃逸出来了。所以字符集也要统一用UTF-8,避免宽字节问题。

5.3 最小权限原则:为什么数据库账户不该用root

第一关的靶场用的是root账户,这是为了方便教学。实际环境中,Web应用连接数据库的账户应该遵循最小权限原则:只给必要的SELECT、INSERT、UPDATE、DELETE权限,不给DROP、FILE、PROCESS等权限。这样即使被注入,攻击者也无法读取敏感文件、无法提权、无法删除数据。

具体来说,可以创建一个专用账户:CREATE USER 'webapp'@'localhost' IDENTIFIED BY 'strongpassword'; GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'webapp'@'localhost';。这样webapp账户只能操作appdb库,不能访问information_schema以外的其他库,也不能读写文件。

最小权限原则配合预编译,基本能挡住绝大多数SQL注入攻击。但安全是一个整体,还需要配合WAF、日志监控、定期扫描等手段。单靠某一种防御措施都不够。

6. 手工注入之外:什么时候该用工具,什么时候必须手工

6.1 sqlmap的便利与盲区

说到SQL注入,很多人第一反应是sqlmap。确实,sqlmap能自动完成闭合判断、列数探测、数据读取,省时省力。但sqlmap不是万能的。它依赖大量的请求和响应特征来判断注入类型,如果目标系统有WAF、有频率限制、或者响应不稳定,sqlmap很容易误判或卡住。

而且sqlmap的自动化会掩盖很多细节。新手如果一上来就用sqlmap,可能跑出了数据,但完全不知道数据是怎么出来的。遇到sqlmap跑不出来的注入点,就束手无策了。所以我一直建议:先手工把第一关完整走一遍,理解每一步的原理,再用sqlmap验证和加速。

手工注入的价值在于,你能根据页面的实时反馈调整payload。比如order by报错了,你能立刻判断是列数问题还是闭合问题;union没回显,你能想到用id=-1让原查询为空。这些判断是工具替代不了的。

6.2 手工注入的完整流程复盘

把第一关的完整流程串一遍:

  1. 访问?id=1,页面正常显示第一篇文章。
  2. 访问?id=1',页面报SQL语法错误,确认注入点存在。
  3. 访问?id=1' --+,页面恢复正常,确认单引号闭合。
  4. 访问?id=1' order by 1 --+到order by 4 --+,确定列数为3。
  5. 访问?id=-1' union select 1,2,3 --+,确定回显位为第2、3列。
  6. 访问?id=-1' union select 1,database(),version() --+,读取数据库名和版本。
  7. 访问?id=-1' union select 1,2,(select group_concat(table_name) from information_schema.tables where table_schema='security') --+,读取表名。
  8. 访问?id=-1' union select 1,2,(select group_concat(column_name) from information_schema.columns where table_schema='security' and table_name='users') --+,读取列名。
  9. 访问?id=-1' union select 1,group_concat(username),group_concat(password) from users --+,读取数据。

这九步走完,第一关就彻底通了。每一步都有明确的输入和输出,可以反复验证。我建议新手把这九步抄下来,在本地环境跑三遍以上,直到不看笔记也能独立完成。

6.3 从第一关延伸到后续关卡的学习路径

第一关是union注入的基础,后续关卡会在这个基础上增加难度。比如:

  • 第二关是数字型注入,payload不需要单引号,其他流程一样。
  • 第三关是字符型加括号闭合,payload变成?id=-1') union select 1,2,3 --+。
  • 第四关是双引号加括号,payload变成?id=-1") union select 1,2,3 --+。
  • 第五关开始有过滤,需要绕过and、or、union等关键词。
  • 后面的关卡会涉及报错注入、布尔盲注、时间盲注、堆叠注入等。

学习路径很清晰:先把union注入的四种闭合方式(无引号、单引号、单引号加括号、双引号加括号)练熟,再学过滤绕过,最后学报错和盲注。union注入是基础中的基础,基础打牢了,后面学起来会快很多。

我在实际带新人的过程中发现,第一关花两个小时彻底吃透,比后面囫囵吞枣做十关效果更好。因为第一关涵盖了注入的核心思维:观察反馈、构造payload、验证结果、逐步深入。这个思维模式一旦建立,后面只是换不同的函数和绕过方式而已。

最后分享一个我自己的习惯:每学一种新的注入类型,我都会在本地搭一个最小化的靶场,把payload从简到繁写在一个文本文件里,标注每一步的预期结果。这样下次遇到类似场景,直接翻笔记就能快速定位。第一关的笔记我现在还留着,虽然简单,但每次翻到都能想起当初卡在id=-1那个点上的自己。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进