前阵子把 unionctf 的 Web 方向题目整体过了一遍,其中 SQL 注入类的题占了不小比例,而最典型、也最容易被新手卡住的就是 union 注入。这篇文章就把我在这些题上踩过的坑、用到的思路,以及最终沉淀下来的一套固定打法完整写出来,希望对刚接触 CTF 里 Web 题、或者对 SQL 注入只有模糊概念的朋友有点实际帮助。
union 注入本质上是在原 SQL 查询结果后面拼接一段我们自己的 union 查询,把需要的数据强行塞进页面原本的回显位置里。它属于典型的“有回显注入”,一旦成立,拿数据的速度比盲注快一个量级,也是后续手工绕过和自动化工具检测的基础。unionctf 这类比赛里,出题人往往不会只考最基础的 union 注入,还会叠加过滤、隐藏回显、编码转换等干扰项,但不管怎么包装,底层的判断链路是一样的。把这套链路练熟,遇到变种题也能很快回到正轨。
1. union注入的核心思路与方案选型
1.1 为什么优先考虑union注入
很多人一上来就掏出 sqlmap 跑,这当然可以,但比赛环境下靶机状态不稳定、访问频率限制、甚至题目故意过滤了某些特征,sqlmap 经常跑一半就卡死。手工 union 注入的好处在于:它直接建立在“页面有没有回显”这个最基础的事实之上,判断链条短、反馈直接,一条请求发过去,结果马上就能看到,不用等工具去猜。
union 之所以能用,依赖的是数据库本身的查询合并机制。MySQL 中 union 用于把两个 select 的结果纵向拼接成一个结果集返回。这个机制有两个硬性要求:前后两个查询的字段数量必须相等,且对应位置的数据类型要兼容。理解这两条,后面所有操作都有据可依,而不是靠背 payload 碰运气。
实际构造 payload 时,我习惯用 union select 而不是 union all select。union 会做去重处理,如果前后结果恰好相同,可能把我们要的数据合并掉;union all 不去重,回显更稳定。不过某些题目会把 union all 当成过滤特征,这时候再退回 union 也不迟。
顺带说一句,明确适用场景也很重要。union 注入能成立的前提是后端把查询结果直接渲染到了页面上,比如商品列表、用户信息展示这类功能。如果页面只是返回“成功/失败”这种固定文案,没有数据展示区域,那 union 注入基本没有发挥空间,应该第一时间转向盲注思路,而不是继续死磕回显。
1.2 注入点识别与闭合方式判断
识别注入点的核心动作,是让 SQL 语句的语法结构发生可感知的变化。最粗糙也最有效的办法,就是往参数里丢一个单引号。如果后端把参数直接拼进查询,比如 select * from users where id = '$id',多出的引号会让字符串提前闭合,后面的单引号变成语法错误,数据库返回报错,或者页面出现 500。
但很多题目的报错不会直接暴露在页面上,这时候要观察的是“行为差异”。同一个参数,输入 1 和输入 1' 返回的页面内容是否不同;输入 1 and 1=1 和 1 and 1=2 的页面内容是否不同。前者送出真条件,后者送出假条件,如果两个页面有差异,基本可以断定注入成立。这一步不需要看到 SQL 语句本身,只需要对比页面输出。
闭合方式也要在这一步判断清楚。常见的四种:单引号闭合 '、双引号闭合 "、带括号的单引号闭合 ')、带括号的双引号闭合 ")。判断方法很直接,逐个试:1' 报错说明闭的是单引号;1" 报错说明闭的是双引号;1') 报错说明是单引号加右括号。这一步不搞清楚,后面构造的 payload 会因为语法闭合不上而全部失效,这也是新手最容易卡住的地方。
还需要注意,闭合方式和参数在 SQL 语句中的位置绑定。同一道题可能有多个参数,一个参数是数字型拼接,另一个是字符串型拼接,不能用一个结论套全部参数。拿到每个参数,都要单独走一遍探测流程,这是我在多次比赛中总结出来的教训。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字段数探测、显示位定位与数据提取的完整链路
2.1 order by 探字段数的原理与加速技巧
确认注入点之后,第一件事是探字段数。标准做法是 order by N,N 从 1 开始往上加,加到第 N 列时数据库报错,就说明原查询只有 N-1 列。比如 1' order by 1# 不报错,1' order by 2# 不报错,1' order by 3# 报错,说明原查询只有 2 列。
为什么 order by 能用来探测字段数?order by 后跟数字时,数据库按查询结果的第 N 列排序,如果 N 超过实际列数,MySQL 会报 Unknown column 类似的错误。这个行为是标准 SQL 语法,MySQL、PostgreSQL、Oracle 都适用,只是在错误回显上略有差异。对于不展示报错的题目,order by 依然有效,因为排序失败时整个查询结果集为空,页面内容和正常情况完全不同,同样能判断出来。
实际比赛里我建议用二分法加速:先试 8,不报错再试 16,报错就回到 8 和 16 之间折半。二三十列的查询靠顺序加也就测十几次,多数题目的查询只有三到五列,顺序加并不慢。但二分法的好处是心理上更稳,特别是面对大表查询时不会因为数字越加越心虚。探到字段数之后,把 border 数字记住,这是下一步构造 union select 的输入参数。
2.2 空值填充与显示位定位
字段数确认后,构造 union select 1,2,3,...,N 的原型。这个原型有两个目的:验证 union 查询是否真的并进去了,顺便找出数据回显的列位置。
第一个目的,看页面是否出现了数字标记。如果页面里出现了 2,说明第 2 列的数据被渲染到了页面上,那第 2 列就是一个显示位。如果没有任何数字出现,有可能是 union 查询没并进结果集,也有可能是显示位存在但被前端样式隐藏了,这时候可以换成显眼的长字符串,比如 66666 或者十六进制串 0x6c6f676f(解码后是 logo 字符串),再刷新页面观察。
第二个目的,为后面替代数字的表达式确定位置。页面哪个位置显示了标记值,就把 database()、user()、group_concat(table_name) 这类表达式放到对应的位置。这里有一个非常关键的细节:原查询的结果本身也会占一行回显,如果页面只显示结果集的第一行,union 后面的数据会被原因查询的数据盖住。解决办法是把原查询的 id 改成一个不存在的值,比如 -1,强制原查询返回空集,union 的结果才有机会排到第一行被显示出来。这个细节很多人都忽略过,导致页面一直显示老数据,误以为注入无效。
2.3 数据提取的优先级与显示位资源管理
显示位的数量往往有限,常见的是 1 到 3 个。要把有限的位置用出最高效率,数据的提取顺序很重要。我个人固定的顺序是:先拿当前库名 database() 和版本 version(),再拿表名,再拿列名,最后才拿字段值。顺序反了会浪费大量请求,因为表名不对,后面的列名和字段值都无从谈起。
拿表名的标准语句是:
sql复制union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database()
group_concat 的作用是把多行表名拼成一行,解决显示位不够的问题。如果表名特别长导致截断,可以配合 substring() 分段读取。information_schema 是 MySQL 的系统数据库,记录了所有数据库、表、列的元信息,拿到它等于拿到了整个数据库的地图。CTF 题目的 flag 通常存在某个表名很可疑的表里,比如 flag、secret、hint 这种,直接看表名就能定位目标,不用全表扫描。
| 提取目标 | 关键语句片段 | 用途 |
|---|---|---|
| 当前库 | database() | 确定后续 information_schema 过滤条件 |
| 版本 | version() | 确认 MySQL 版本,判断函数兼容性 |
| 表名 | group_concat(table_name) from information_schema.tables | 找出目标表 |
| 列名 | group_concat(column_name) from information_schema.columns | 确认字段名 |
| 字段值 | select flag from target_table | 最终取数 |
3. 实战复盘:从注入点到 flag 的完整解题路径
3.1 环境探测与注入点识别
以 unionctf 里一道典型的商品搜索题为例。题目是一个商品搜索页面,输入任意关键词都能正常返回商品列表。按照常规流程,我在关键词框里输入一个单引号,页面直接空白,和正常返回的列表样式完全不同,初步判断存在 SQL 拼接。
继续用行为差异验证:输入 电源' and 1=1# 和 电源' and 1=2#。前者正常显示列表,后者页面无结果。这里有个前提,闭合用的引号必须正确,并且后面接了注释符。这一组对比能同时验证注入点和闭合方式。
所谓闭合方式,就是后端 SQL 语句长什么样。假设后端代码是 select * from goods where name like '%$keyword%',那么 payload 要写成 %' and 1=1#,让语句变成 select * from goods where name like '%%' and 1=1#',后面的内容被注释符吞掉。#号在 MySQL 里是行注释,可以干净地屏蔽掉原查询尾部。这个阶段的目标不是立刻拿数据,而是确认“我能控制 SQL 的哪个部分”,为后续构造联合查询做准备。
3.2 字段数探测与 union 原型构造
确定闭合方式以后,按前面说的方式探测字段数。%' order by 1# 到 %' order by 3# 都正常,%' order by 4# 报错,说明查询结果是三列。
然后构造原型:%' union select 1,2,3#。页面上第一行是老数据,第二行出现了数字 2,说明第 2 列是实际显示位。为了不让原查询结果干扰观察,把关键词改成一个不存在的词,比如 zzz' union select 1,2,3#,让原查询返回空集,页面干净地显示 2。
这里有一个容易踩的低级坑:用 URL 传参时,# 号要编码成 %23,否则浏览器会把 # 当成锚点截断,后面的 payload 根本发不出去。这个细节在本地测试和线上靶场都遇到过,排查时先看 URL 是否完整,能省很多时间。
3.3 从 database() 到 flag 的完整提取流程
显示位确认后,开始提取数据。先把 2 的位置替换成 database(),页面返回当前数据库名,比如 web_test。
接着爆表名:
sql复制zzz' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema='web_test'#
页面返回了 users、goods、flag_table,其中 flag_table 看起来就是目标。
继续爆列名:
sql复制zzz' union select 1,group_concat(column_name),3 from information_schema.columns where table_name='flag_table'#
返回 id、flag 两个字段。
最后取数据:
sql复制zzz' union select 1,flag,3 from flag_table#
flag 直接出现在页面上。整条链路走下来不到十分钟,关键是每一步都由上一步的结果决定,没有一步是瞎猜。做题不是碰运气,是围绕数据库的行为逻辑做推演。
4. 常见坑位、绕过思路与排查技巧实录
4.1 空格、注释符与关键字被过滤时的替代方案
很多题目不会让你舒舒服服地把 ' union select 1,2,3# 发过去,常见的过滤包括:屏蔽空格、屏蔽 # 和--、屏蔽 union 和 select 关键字。逐个说。
空格被过滤,有几种替代方式:用 /**/ 代替空格,MySQL 会把注释符号当空白处理;用 %0a(换行符)代替空格,URL 解码后在 SQL 层也是空白;还可以把整段子查询包进括号,比如 union(select 1,2,3),括号隔开了关键字,有时候能绕过只过滤空格的规则。
| 过滤对象 | 常见过滤方式 | 替代方案 | 适用前提 |
|---|---|---|---|
| 空格 | 正则替换空格字符 | /**/ 或 %0a 或括号 |
不拦截注释符或换行 |
| 注释符 | 屏蔽 #、-- | or '1'='1 自闭合 |
原查询是字符串拼接 |
| union/select 关键字 | 大小写不敏感替换 | UnIoN SeLeCt、双写、内联注释 |
过滤规则是单次简单替换 |
注释符被过滤时,就不要依赖注释符了,改用闭合语句的合法方式。比如原查询是 where id = '$id',payload 可以构造 ' union select 1,2,3 or '1'='1,让原查询尾部的单引号自然落在 or 条件的字符串里,整个语句语法完整。这种“自闭合”的思路在注释符被过滤时非常实用,本质上是用逻辑条件补全了原来需要注释符处理的尾部语句。
关键字被过滤,先试大小写混合 UnIoN SeLeCt,再试双写 ununionion,很多过滤规则是简单的字符串替换,替换一次就留下了可乘之机。MySQL 还有一个特色技巧:内联注释 /*!50000union*/ select,版本号大于 50000 时会被当作代码执行,常用于绕过关键词检测。这个技巧在 MySQL 5.7 的靶机上实测有效,但在 MySQL 8.0 上偶尔表现不一致,需要现场验证。
4.2 报错行为差异与 payload 调整
同样是 order by 探字段数,不同数据库的报错表现不同。MySQL 报 Unknown column '4' in 'order clause';PostgreSQL 报 ERROR: column "4" does not exist;Oracle 则是 ORA-01732 之类的错误码。看到报错内容,基本就能确定数据库类型,这是后面选函数和取数据语句的基础。
还要注意报错页是否显示完整语句。有些开发环境把 SQL 语句直接打在页面上,等于把源码送给你,这时候别浪费,直接看它拼了什么表、哪个字段,连注入探测都省了。我在一次刷题时遇到过页面底部泄漏 SQL 的情况,原本要探测五分钟的信息一眼就能锁定,后面直接构造 payload 就拿到了 flag。
另一种常见情况是,注入成立但页面没有任何报错,也没有任何数据变化,这类通常是盲注。union 注入这时候已经失效,要转用布尔盲注或者时间盲注,例如 and if(ascii(substr(database(),1,1))>100,1,0)# 通过页面真假判断字符。比赛时间紧张时,这种情况建议直接上 sqlmap 配合手工验证,盲注用工具比人肉快。但前提是自己能看懂工具在做什么,否则工具跑出来的结果你也没法判断对不对。
4.3 数据回显乱码与截断的处理
数据拿出来了却有乱码,大概率是编码问题。MySQL 的连接字符集和目标字段的字符集不一致时,中文字段会出现乱码,可以用 hex() 函数把数据转成十六进制再读,然后本地解码。数字和英文基本不受影响,所以比赛里 flag 如果包含中文,就很容易踩这个坑。
数据被截断也很常见。group_concat 拼接所有表名,如果表特别多、名字特别长,显示的内容会被 PHP 的字符串长度或者前端模板截断。解决方案是给 group_concat 加分隔符和偏移读取:group_concat(table_name separator '|'),再配合 substring() 分段读取,比如读第二段时用 substring(group_concat(...), 50, 100)。这样就能把完整数据分片取回来,而不是面对一串被砍掉的表名干瞪眼。
还有一个非常容易忽略的点:页面可能返回了数据,但内容被 HTML 转义了。比如返回的字符串里包含尖括号,浏览器把它当成标签执行或隐藏,导致页面上看不到。如果发现提取的数据不完整,可以用 hex 编码输出,避免特殊字符干扰页面渲染。这类问题靠肉眼排查效率很低,建议直接把返回的 HTML 源码拉下来看,而不是只看浏览器渲染后的样式。
最后再分享一个我习惯性的做事方式:每做完一道 union 注入题,我会把完整的 URL 和 payload 存进备忘录,按闭合方式、过滤条件、数据库类型三个维度打标签。几次 unionctf 刷下来,翻备忘录比翻文档快得多,而且里面的 payload 都是自己实际跑通过的,用起来非常有底。union 注入的套路不算深,但真正值钱的是把各种过滤和异常情况都见过一遍,到赛场上才不会慌。
