☰
从UnionCTF看SQL注入:UNION注入原理、绕过技巧与脚本化实战
2026/10/9 12:44:24 网站建设 项目流程

1. UnionCTF:为什么我建议每个学Web安全的人都认真刷一遍

上周末我把UnionCTF这套题完整刷了一遍,说实话刷到后面有点上头。它不像很多比赛题那样上来就甩一个很偏门的CVE考点,而是老老实实把SQL注入里最经典的UNION注入做成了十几个连续关卡,从最简单的单引号报错一路做到需要写脚本才能解的盲注。这几年我在各种靶场和比赛里见过不少SQL注入题,但像UnionCTF这样把同一个技术点从入门到进阶调校得这么顺的,确实不多。

先说清楚这套题适合谁。如果你刚学完SQL语法、对Web安全有兴趣但还没正经打过CTF,那么UnionCTF是个很好的起点,它能让你在几小时内把"什么是注入点""怎么查库名表名列名""UNION SELECT怎么拼"这些概念全部落地。如果你已经有一定经验,这套题里的高级关卡同样有嚼头——它不考偏门函数,考的是你对数据库元信息、闭合方式、过滤规则的应变能力,这一块恰恰是很多实战场景里的基础功。

我个人的建议是:刷UnionCTF之前,先建好本地环境,准备好一个简单的PHP+MySQL靶场或者直接用题目自带的Docker镜像。我第一次刷的时候图省事直接对着在线靶场戳,结果网络波动加上目标不稳,判断列数时出了两个错误结果,白折腾了半小时。后来老老实实把每个关卡在本地跑了一遍,再回到线上确认,整个流程就顺多了。

这套题的出题思路其实非常明确,每一个关卡都围绕UNION注入的一个核心环节做文章:要么考列数推断,要么考闭合方式,要么考过滤绕过。所以刷题的过程不是盲目的尝试,而是像做模块化测试一样,每过一关就补一块知识拼图。这篇文章就把我刷完这套题之后总结出来的完整链路、复盘过程和避坑经验整理出来,希望能帮到正在刷这套题,或者想系统学UNION注入的你。

2. 注入类型判断与UNION构造的完整链路

UNION注入的核心原理其实一句话就能说清:把目标SQL语句和我们自己构造的SELECT语句的结果集拼接在一起,让我们查询的数据出现在页面或响应里。但真正落地的时候,很多人会卡在"该从哪里下手"这一步。我刷UnionCTF前几关的时候深刻体会到,步骤本身不复杂,复杂的是每一步背后的判断逻辑。

2.1 先分清数字型还是字符型

拿到一个注入点,第一件事不是急着拼UNION,而是判断它是数字型还是字符型。这个判断直接决定了后面的闭合方式怎么写。

数字型的典型结构是SELECT * FROM products WHERE id = 1,你输入的内容被当作用户输入的数字。字符型则是SELECT * FROM products WHERE name = 'admin',输入内容被放在单引号里当成字符串。区分方法很简单:在参数后面加一个单引号,观察响应。如果页面报错、输出异常或者和正常页面明显不同,基本可以断定这个参数进了SQL语句的字符串上下文。

UnionCTF的前两个关卡就是标准的数字型和字符型对照。第一关我输入1'报错,换成1 AND 1=1正常、1 AND 1=2无数据,确定是数字型注入。当时我心里还想这也太直接了吧,结果第二关立刻教我做人——同样的测法,在第二关里1'报错、1' AND '1'='1正常,这才反应过来需要在单引号内闭合语句。

我的经验是,不要一上来就拼UNION,先把闭合方式测准。这一步错了,后面全部白搭。另外一个实用技巧是看报错信息里的引号位置,有些环境会把SQL语句的上下文暴露出来,你直接能看出参数是包在引号里还是裸放在数字位置。

2.2 列数推断:从ORDER BY到NULL填充

确认闭合方式之后,下一步是判断目标查询的列数。这一步是所有UNION注入里最关键也最容易出错的地方。列数不对,UNION SELECT的值数量就和前一个查询不一致,整个语句直接语法报错。

我常用的有两种方法。第一种是ORDER BY加数字,从1开始逐个递增,当数字超过实际列数时页面会报错或者行为异常,临界值就是列数。第二种是一口气在UNION SELECT后面填大量NULL,比如UNION SELECT NULL、UNION SELECT NULL,NULL、UNION SELECT NULL,NULL,NULL这样试,哪一次成功就说明列数是多少。NULL在拼接查询时非常安全,因为NULL可以和任意数据类型兼容,不需要关心原查询每一列的类型。

UnionCTF有一关特意考了列数判断的稳定性。我在一个参数上用ORDER BY试到第5列都没报错,觉得很蹊跷,后来发现页面把查询结果做了分页处理,超出范围的ORDER BY并不总会触发报错,只是返回空结果。这时候就体现NULL填充的优势了——如果你用UNION SELECT NULL,NULL,NULL,NULL,1发现回显位置出了数字1,那就既确定了列数,还顺手试出了回显位置,一举两得。

这里还要提醒一个细节:列数推断时尽量用二分式递进而不是每次加1。比如先试10,如果正常说明至少10列,再试20,通过折半逼近的方式减少请求次数。虽然多数题目列数在10以内,但万一碰到类似实际项目里几十列的宽表,逐个试真的会崩溃。

2.3 回显位置的精准定位

列数确认之后,下一个问题是:页面显示的是哪些列?确定回显位置能避免你查出了数据却看不到的尴尬。

定位方法其实很简单,把UNION SELECT后面对应某一列填成一段特殊字符串,比如0x53514c696e6a656374696f6e或者直接填一串数字如23333,然后看页面哪个位置出现了这个标记。逐列替换、逐列观察,找到所有可控的回显点。

UnionCTF中间有个关卡比较恶心,页面上只渲染第一列的内容,其他列虽然查出来了但不显示。很多人在这里卡住,其实原理很简单:每个查询即使有10列,页面也只做echo第一个字段。解决办法是把你真正想要的数据拼到第一列的表达式里,比如SELECT concat(table_name,0x7c,column_name) FROM information_schema.columns这样的形式。这也说明,回显位置不是看看而已,它直接决定你后面构造的表达式要不要拼接。

3. 三关完整复盘:基础题、过滤题、盲注题

光讲原理不落地等于白看。这一节我把UnionCTF里印象最深的三道关卡完整复盘一遍,从第一眼看到参数到最终拿到结果的过程都写出来。这三道题分别覆盖了基础闭合、组合过滤和无回显盲注,基本就是UNION注入的三个层次。

3.1 第一关:单引号闭合的显错注入

第一关是一个典型的GET参数注入,URL大概是/level1?id=1。我访问之后页面显示了一个商品列表,有id和name两列数据。正常请求返回两条记录,我直接在参数后加单引号,页面返回了MySQL的语法错误信息,说明存在注入,而且数据库直接报错——这是最理想的开局,因为报错信息本身就能帮我确认数据库类型和语句上下文。

从报错信息看到WHERE id = '1'',立刻确定是字符型注入且闭合符是单引号。我在参数后拼' ORDER BY 1-- -,页面正常显示,逐步尝试到第4列开始报错,确定了目标查询列数为3。随后构造' UNION SELECT 1,2,3-- -,页面在三个回显位里显示出2和3,说明这两列可以直接显示数据。

接下来的操作就机械了。把2替换成database(),页面直接返回了当前库名;再把那个位置替换成group_concat(table_name),在information_schema.tables里查表名。查列名、查字段值都是同样的套路。整道题不到十分钟就解完了,难度不高,但它把所有基础动作完整串联了一遍——闭合、列数、回显位置、查元数据,缺一个环节都跑不通。

第一关给新手的启示是:显错注入是学习UNION注入最好的环境,因为你有充分的反馈信息来验证每一步。务必在这一关把每个动作都搞明白,不要急着往下跳。

3.2 第二关:空格与逗号双重过滤

第二关开始上强度了。参数同样是id,但输入空格会被直接消除,输入逗号会返回"forbidden"的提示。我试了1' union select 1,2,3-- -,响应直接被拦截。刚开始我以为是WAF,后来仔细看才明白这是代码层做了简单的黑名单过滤,把空格和逗号直接替换成了空字符串。

空格过滤的替代方案比较成熟:用注释符/**/代替空格。1'/**/union/**/select/**/1,2,3-- -可以正常执行。但逗号过滤让UNION SELECT的列列表变得很麻烦,因为逗号是列分隔符,总不能不用。

这里我试了几种思路都没成,最后发现可以用JOIN语法替代部分UNION能力。UNION SELECT的核心是查询我们关心的数据,而JOIN可以把两个查询跨表关联。比如想要查用户名列表,可以构造' UNION SELECT * FROM (SELECT 1) a JOIN (SELECT 2) b JOIN (SELECT 3) c-- -,用JOIN来扩展列,而JOIN语法本身不需要逗号。

这一关还教会我一个更通用的绕过方法:当你确定某一个字符被过滤时,别忘了看它被过滤了几次、在哪个层被过滤。有些过滤逻辑只做一次替换,如果你输入双写字符,比如selselectect,经过一次过滤后反而变成了合法的select。第二关的过滤是可多次匹配的替换,双写就没用了,这种差异在真实环境中非常常见。

3.3 第三关:无回显场景的UNION盲注

第三关要了老命。页面在正常情况下只显示"查询成功"或"查询失败",完全没有数据回显。看看这一关的位置设定,明显是在逼你用盲注手段。UNION注入在这个场景下没法直接看到数据,但可以利用UNION的查询结果影响布尔值——如果UNION SELECT的结果集不为空,页面就显示成功,为空则失败。

思路是先把UNION SELECT的某一列变成条件判断表达式,比如(SELECT (ASCII(SUBSTRING((SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1),1,1)))>100)。如果这个条件为真,UNION的结果集就有内容,页面显示成功;为假则整条查询结果为空,页面显示失败。通过逐字符比较,拼出表名和字段值。

写脚本时我固定用一个HTTP请求函数,每次只替换条件参数。先二分猜ASCII码范围,再精确定位到具体字符。这个过程的难处不在UNION本身,而在于你真的要一行行写清楚请求、响应判断和循环逻辑。第一版脚本我用的是顺序递增猜字符,平均每个字符要请求70多次,后来改成二分法,一个字符只需要7到8次请求,效率差了接近十倍。

4. 绕过技巧与常见过滤的应对方法

UnionCTF后半段的关卡明显在给真实场景打预防针。很多人会写注入语句,但一遇到过滤就抓瞎。这里把我在刷题过程中整理出来的若干绕过思路汇总一下,它们不是互相独立的,真解决问题时经常组合使用。

4.1 注释符与闭合方式的排列组合

注释符在SQL注入里不只是用来截断后面语句的,它还是处理引号闭合的利器。最常见的-- -和#各有各的适用场景,MySQL里#很好使,但并不是所有后端都能正确处理。UnionCTF里有一关专门用了#被过滤的情况,我换成-- -就绕过了,这里有个细节——--后面必须跟一个空格或控制字符,写成-- -比较稳妥,--+也能用但依赖URL编码环境。

闭合方式的排列组合同样重要。单引号闭合是最常见的,但也遇到过双引号闭合和括号闭合。有一个关卡我翻来覆去测单引号都不对,最后看到登录处的SQL模板里写的是WHERE username = ("$name"),这才反应过来需要在参数前面补括号。判断闭合方式的几种入手点包括:观察报错语句中参数前后的字符、尝试")、')这类组合闭合、以及看正常请求里其他字段的处理逻辑。闭合方式决定了我们拼接的UNION SELECT放在哪里,所以这一步值得多花时间。

4.2 关键字过滤后的UNION变形

当union和select两个关键字被过滤时,常规的UNION注入会立刻哑火。最基本的应对是先测过滤逻辑的类型——如果是简单的str_replace一次性替换,双写ununionion和selselectect就能轻松绕过,过滤一次后变成合法关键字。UnionCTF的这个关卡就是这么设计,我第一次双写就直接通过了。

如果过滤逻辑是大小写不敏感的完整删除,双写就不一定好使。此时可以尝试内联注释分割关键字,比如UN/**/ION SEL/**/ECT,这在很多正则匹配不够严谨的场景里依然有效。还有一种思路是利用MySQL的预处理语句做拼接,比如SET @a=concat('sel','ect');PREPARE s FROM @a;EXECUTE s,虽然这套操作在UNION注入里更难写,但在关口较多的场景下值得尝试。

关键字被过滤不等于查询没法做。你可以先把目标库表信息拆成几个部分,用concat拼接出完整的语句字符串,再在可控执行点将其执行出来。这种方式的关键在于找到一个能执行动态SQL的入口,而我们UNION SELECT出来的数据如果正好被后续代码当成SQL执行,整个链路就通了。

4.3 编码混淆与协议层面的差异

这一小节可能是UnionCTF里最容易被忽略的部分。我们习惯用浏览器直接测试参数,但过滤规则的匹配对象是应用层拿到的字符串,而不同编码方式会在到达应用层之前被转换。比如URL编码、Unicode编码、或者直接利用数据库的字符集转换行为。

UnionCTF里有一关对关键字做了严格的过滤,直接输入?id=1' union select 1,2,3-- -会被打回。但我把URL中的部分字符做编码处理,构造了?id=1%27%20union%20select%201%2c2%2c3--%20-,过滤逻辑在解析参数时把%27还原成单引号,却没有对还原后的内容再次扫描,于是整条语句绕过了检查。这个现象经常出现在过滤逻辑放在参数获取之后、但解析流程存在多层解码的情况里。

另一个经常被忽视的点是HTTP请求方法的差异。部分关卡只在GET参数上做了过滤,换成POST提交同样的注入语句,过滤逻辑根本没触发。UnionCTF有一题也这样,我在GET上折腾了很久,最后换成POST把参数放在请求体里,直接用原始语句就打穿了。这说明做SQL注入之前,先全面了解题目允许你控制哪些输入点,比死磕某一条通路重要得多。

5. 脚本化提取数据:从手工到半自动

如果你刷到UnionCTF后半段,一定会遇到一个分水岭:手工注入太慢了,必须开始写脚本。这一节不讲复杂框架,只说我从这套题里总结出来的脚本思路和工具配合方式。

5.1 先手工确认注入点的完整状态

不要一上来就写脚本。我对每一道题的第一动作仍然是手工发送几个请求,确认注入点是否存在、闭合方式是什么、过滤规则有哪些。这个步骤的目的是把题目的限制条件摸清,否则脚本写一半发现空格被过滤,整个逻辑都要重构。

我手工确认的时间一般控制在20分钟以内,重点验证三件事:参数是否能触发SQL语法错误、闭合符类型、以及哪些字符被过滤。针对检测出的过滤规则,直接在手工请求里验证可行的替代写法。只有这一步走通,脚本的payload才能稳定复用。比如某道题过滤了空格,我会手工先确认/**/可以替代空格,再把这个替代写法写进脚本的请求模板里。

5.2 一个通用的列数爆破和布尔盲注脚本

对于需要盲注的关卡,一个简单的Python脚本就能大幅提速。我习惯用requests库发请求,脚本的骨架往往长这样:

import requests url = "http://127.0.0.1/level3" # 先爆破列数 for cols in range(1, 30): payload = f"' UNION SELECT " + ",".join(["NULL"] * cols) + "-- -" r = requests.get(url, params={"id": "1" + payload}) if "success" in r.text: print(f"col count: {cols}") break

这个脚本写的只是一个请求发送和关键字判断的循环,实际应用时需要根据题目回显的差异调整判断标志。布尔盲注时,我把条件表达式替换进UNION的某个列位,通过对比两个响应体的内容判断真假。真正的代码核心就是一个二分函数,逻辑很固定,关键是把请求、判断、循环三个部分解耦,这样换题目时只要改payload和判断条件就能复用。

写脚本的时候要注意响应里的干扰项。有些关卡页面里有动态时间戳、随机版权信息,导致同一条件多次请求时响应内容有细微差异。我踩过这个坑——条件为真时页面始终包含一个动态token,我又用token值判断成功与否,结果脚本偶发误判。后来我把判断依据改成稳定出现的字符串片段,问题才消除。

5.3 sqlmap和手注怎么配合

提到SQL注入就绕不开sqlmap。但这套题里,我的总体建议是:新手优先手注,高级关卡可以结合sqlmap验证。做这套题的初衷就是练基本功,如果全程依赖sqlmap,那你刷完也只是看了一场自动化工具的演示,而不是真正掌握UNION注入。

我个人的配合方式是:先把题目用手注解一遍,解完之后再用sqlmap重新跑一次,对比自己的payload和工具的输出,找出自己忽略的绕过方法。UnionCTF里有一道题我用内联注释绕过了空格过滤,sqlmap却选择了更巧妙的利用方式,这让我学到了一种新的等价写法。反过来,我在另一道题里手注已经找到了回显位,sqlmap反而被某种过滤卡住走不动了,这说明工具的检测路径未必覆盖所有场景。两者配合着用,才能真正建立对注入点的信心。

6. 刷题避坑清单:我踩过的几个经典错误

最后整理一份踩坑清单。这些错误在UnionCTF里出现过,在真实环境的渗透测试中也常见,每一条背后都是实打实的时间成本。

第一坑:拿到一个注入点就急着拼UNION,忽略第一步的闭合方式判断。这个错误浪费了我最多的调试时间,很多对着在线靶场反复尝试的关卡,后来发现只是闭合方式写错了。

第二坑:列数判断只依赖ORDER BY,忽略了页面渲染逻辑对结果的影响。在分页或只展示一行数据的页面上,ORDER BY的报错信号可能不可靠,NULL填充法在这种场景下更稳。

第三坑:过滤规则没测完整就开始构造语句。不少关卡同时过滤了多个字符,我只测了空格,导致脚本没跑几步就卡住。正确做法是先系统测一遍常见字符和关键字的过滤情况,再决定用什么替代方案。

第四坑:忽略HTTP请求中除了参数外的其他可控位置。UnionCTF有一道题GET参数过滤得很严,但POST参数完全是另一套逻辑,这种协议层面的漏洞往往是出题人故意留的后门。

第五坑:脚本里用整页内容做布尔判断。页面中的动态内容会让条件判断失真,应该选一段稳定的标志性字符串作为依据,把判断逻辑从"页面是否相同"改成"指定关键字是否存在"。

第六坑:只顾刷题不回头看自己的payload。我刷完一遍之后重新复盘,发现自己有两道题的解法并不是最优的——后续看了高手的解法,才发现同样的注入点可以有更精简的语句。这套题真正的价值不在于"解出来",而在于你为自己积累了多少种不同的构造思路。

这些坑里我踩得最深的就是第一个。有一次我在某个关卡上卡了将近一小时,因为从一开始就把闭合方式判断错了,后面所有UNION构造都在错误的前提下打转。后来养成了一个习惯:任何注入点,手动发送三个基础请求——正常参数、加单引号、加双引号——把响应差异记录下来再做下一步。这个习惯一直保留到现在,不管面对的是什么框架的靶场,先摸清上下文永远是对的。

UnionCTF这套题刷完之后,我最大的感受是:SQL注入的题面千变万化,但核心边界其实就那么大——闭合方式、列数、回显位置、过滤规则。把这四件事摸清了,剩下的大部分工作都是重复劳动。很多人觉得CTF题目和真实环境差距大,但UnionCTF里这些过滤组合和脚本化提取的思路,放到授权范围内的测试场景里同样能用得上。我建议你把每一道题都刷两遍以上,第一遍追求解出,第二遍追求让自己只发最小次数的请求就拿到结果,这样才算是真正吃透了UNION注入。

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

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

立即咨询