☰
SQL注入新视角:无空格条件下用异或实现布尔盲注
2026/10/17 8:49:37 网站建设 项目流程

打比赛的时候拿到一道名叫Hack World的Web题,看到这个题目我心里还嘀咕了一句:名字起得挺大,估计就是常规注入题。结果打开页面我直接就愣住了——一个输入框,一个查询按钮,没有任何提示文案。这个题核心考的就是SQL注入,但它和教程里那些“手把手教你union注入”的套路完全不一样,你辛辛苦苦背下来的union select、1 or 1=1、注释符绕过,在这道题面前基本全部失效。过滤规则极其刁钻,逼着你重新去理解“过滤”本身到底过滤了什么。

这篇博文是我当时的完整解题记录,从信息收集、注入类型判断,到过滤规则探测、payload设计,再到最后跑出flag的Python脚本,每一步我都会说明当时的判断依据。无论你是刚入门CTF的新手,还是被各种WAF折磨过的老手,这份思路应该都能给你一些参考。

1. 初始侦察:一个只有查询框的页面,藏着哪些信息

1.1 功能分析:先搞清楚这个页面到底在干什么

这种只有一个输入框的页面,第一步永远不是急着上payload,而是先摸清楚它的正常行为。我随便输了几个数字进去,发现提交1、2、3返回的内容完全一样,都是同一串文本;提交0或负数,页面就干干净净,什么都不显示。

这一步观察非常重要,它直接确定了后续所有盲注脚本的判定基准:页面上有内容,说明后端查到了记录;页面上没有内容,说明没查到。换句话说,这个页面就是一个天然的布尔指示器,真假两种状态清清楚楚。

我当时推测后端逻辑大概长这样:

select 某个字段 from 某张表 where id = 用户输入

而且可以确定,这个输入是数字型还是字符型,是下一个关键问题。数字型注入和字符型注入的利用方式完全不同,判断错了后面全白搭。

1.2 常规payload全军覆没,反而让我找到了真正的入口

我先试了几个最常规的探测:

  • 输入1',页面提示非法输入被拦了
  • 输入1 and 1=1,提示非法输入
  • 输入1 or 1=1,提示非法输入
  • 输入1 union select 1,2,3,提示非法输入

这一轮下来,or、union、单引号这条路基本堵死了。当时心里凉了半截,因为这意味着经典的报错注入、union注入全部没法用。但换个角度想,它至少告诉我一件事:这道题有一个比较强力的WAF层,单纯堆payload是过不去的。

接下来我试了一个平时不太会第一反应去用的写法:2-1。提交之后页面正常显示内容,和输入1的结果一模一样。这就很有意思了,说明后端把2-1当成表达式参与SQL运算了,where id = 2 - 1等于where id = 1,所以查到了同一条记录。数字型注入,确认了。

再顺着这个思路试^异或运算符,提交1^0页面正常,提交1^1页面完全没有内容。因为1^0=1,能查到id为1的记录,而1^1=0,查询结果为空。

就是这一步让我意识到:这个题的核心可能不是绕WAF,而是把所有逻辑判断都翻译成“异或”的语言。^符号就是整道题的钥匙。

2. 过滤规则解剖:WAF真正拦的不是关键词,而是“关键词加空格”

2.1 逐个字符试出来的过滤表单

确定了^可用之后,我开始系统性地测试过滤规则。这个阶段没有什么技巧,就是一个字:试。把可能用到的东西全部过一遍,记录哪些能过、哪些被拦。我整理了一份核心测试结果:

测试输入页面响应初步结论
1'提示非法输入单引号触发了检测
1 and 1=1提示非法输入and被过滤
1 or 1=1提示非法输入or被过滤
1 union select 1,2,3提示非法输入union、select被过滤
1^0正常显示^可用
1^1无内容数字型注入成立
1/**/and/**/1=1提示非法输入注释符绕过失败
1%0aand%0a1=1提示非法输入换行绕过失败
1 SeLeCt 1提示非法输入大小写绕过无效
1 select(1)无内容且无提示select(1)竟然没被拦

看到最后一行的时候我整个人精神了。select(1)没有触发任何过滤,而且从页面无内容来推断,这个语句是真正执行了的——因为1^select(1)等于1^1等于0,查询结果为空,说明SQL解析器接受了这个写法。

2.2 关键转折:过滤规则匹配的是“select”加空白,而不是“select”本身

为什么select 1被拦,select(1)却能过?我做了个假设:WAF的正则匹配的是select后面必须跟着空白字符,类似/(select|union|and|or|from|where)\s/这种写法,而不是检测独立的单词。也就是说,只要关键词后面不是空白,就不会触发匹配。

用一个生活化的类比来说:这个WAF就像一个只在“关键词后面带空格”时才报警的保安。select 1里有空格,报警;select(1)里没有空格,它就觉得这个select不是SQL关键字,直接放行了。虽然听起来很离谱,但早期很多WAF确实有这个问题——它们的正则只关心关键词和空白字符的组合,忽略了SQL语法本身允许关键字和括号直接相连。

为了验证这个假设,我又试了from(dual)这种写法,果然也过了。from(dual)里的from后面直接跟括号,没有空白,WAF完全不识别。至此,这道题的过滤规则就彻底清楚了:过滤项本身不复杂,复杂的是它们要求“必须伴随空白字符”才触发。

2.3 整理出可用的“白名单”

通过这一步,我已经把可用的字符和写法摸清了:

  • 可用:数字、^、(、)、>、单引号、逗号
  • 可用函数:mid()、ord()、select(字段)、from(表名)
  • 不可用:空格、and、or、union、select加空格、注释符、换行

这个白名单结构非常关键。它意味着我可以编写一个完全没有空格的SQL注入payload,而且不需要依赖注释符,也不需要依赖大小写绕过。空间虽然小,但足以完成一次布尔盲注。

3. 重构SQL语法:无空格条件下怎么把数据读出来

3.1 为什么选择异或作为逻辑开关

and和or都被过滤了,那怎么表达逻辑条件?这个题用^(异或)就非常优雅。

异或的规则是:1^0=1,1^1=0,0^1=1,0^0=0。你可以把它理解成一个“取反开关”:

  • 如果我用0^(条件),条件为真时结果是1,条件为假时结果是0
  • 页面通过这个结果决定是否有内容

所以完全可以把一个比较表达式(比如某个字符的ASCII是否大于某个数字)直接塞进异或表达式里,根据页面有没有内容来判断条件真假。这就是标准的布尔盲注。

对比一下时间盲注和布尔盲注,我通常会优先选择布尔盲注。因为时间盲注依赖sleep()或benchmark(),而这个题把benchmark也过滤了;就算不过滤,网络波动也会给判断带来很大干扰。布尔盲注只要页面的正常/异常状态稳定,判断起来比时间盲注可靠得多。更何况这里没有报错回显,布尔盲注几乎是唯一选择。

3.2 核心查询的构造:直接查flag表和flag列

这一步要解决的是“查什么”的问题。在常规场景里,你需要先通过information_schema找到表名、列名,然后才能构造最终的查询。但这道题有个很特殊的设定:数据库里目标表的名字就叫flag,目标列的名字也叫flag。

所以一条简洁到极致的子查询就够了:

(select(flag)from(flag))

不加空格、不加引号、不需要查系统表,直接就是“从flag表里select flag列”。这个写法本身也是过滤规则逼出来的:select后面直接跟括号,from后面直接跟括号,完全不触发WAF。

然后再用mid()函数逐字符截取。mid((select(flag)from(flag)),1,1)表示取查询结果的第一位字符,逗号没有被过滤,所以这个写法完全合法。

拿到了字符,还要把它转成ASCII码才能做大小比较,这里用ord()函数。最后用>和数字比较,构成一个布尔条件,完整的手工测试payload就像这样:

0^(ord(mid((select(flag)from(flag)),1,1))>80)

如果flag第一位的ASCII大于80,整个表达式等于0^1=1,页面正常显示内容。如果不大于80,表达式等于0^0=0,页面没有内容。通过调整比较数字,就能逐步缩小字符范围,直到确定第一位是什么。

3.3 二分猜解的直观过程

有了“条件真→页面有内容”这个机制,接下来就是怎么高效确定每一位字符。最朴素的办法是从32到126一个数字一个数字地试,但每一位最多要试95次,整个flag四五十位的话,请求量会非常大,而且容易被环境判定为异常流量。

正确做法是二分。可打印字符的范围是32到126,每次取中间值,根据页面结果把范围缩小一半,平均6到7次请求就能确定一个字符。以ASCII为102的字母f为例,完整过程是这样的:

  • 问>79,条件为真,范围缩到80到127
  • 问>103,条件为假,范围缩到80到103
  • 问>91,条件为真,范围缩到92到103
  • 问>98,条件为真,范围缩到99到103
  • 问>101,条件为真,范围缩到102到103
  • 问>102,条件为假,范围缩到102到102

6次请求,成功的把一个字符锁定到了102,也就是f。

这里还有一个细节值得注意:我把比较条件设计成>mid而不是>=mid,是为了避免二分循环陷入死循环。当low和high相邻时,如果条件为真则low=mid+1,最终两者必然收敛。如果换用>=,边界情况会恶心得多。

4. 自动化爆破:Python脚本把盲注变成逐字节追读

4.1 完整脚本:改三个地方就能复用

手动二分只是验证思路用的,真正拿flag必须上脚本。我写了一个基于requests的Python盲注脚本,逻辑非常直白:外层循环遍历每一位,内存循环做二分,每确定一个字符就打印进度,直到遇到}收尾。

import requests import time url = "http://target/index.php" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } session = requests.Session() result = "" for pos in range(1, 100): low, high = 32, 127 while low < high: mid = (low + high) // 2 payload = f"0^(ord(mid((select(flag)from(flag)),{pos},1))>{mid})" try: r = session.post(url, data={"id": payload}, headers=headers, timeout=10) except requests.RequestException: time.sleep(1) continue if "Hello" in r.text: low = mid + 1 else: high = mid char = chr(low) result += char print(f"[pos {pos:02d}] {char} -> {result}") if char == "}": break

这个脚本能直接跑通的核心是它复刻了手工测试的全部逻辑:用0^(条件)把真假映射成页面的有无,用mid()逐位截取flag,用ord()转ASCII,用二分判断大小。你只需要根据目标环境改三个地方,URL地址、请求方式以及页面正常时的特征字符串。

4.2 脚本化之后遇到的一些坑

第一,请求频率一定要控制。刚开始我没有加任何延时,脚本跑得飞快,结果第三个字符开始请求就超时了。这不是网络问题,是短时间请求太密集被环境限制了。后来我每次请求之间加了time.sleep(0.2),稳定多了。

第二,页面的正常特征字符串要选准。我一开始用整个页面里的某个长句作为判断条件,脚本总是误判,后来才发现那个长句偶尔会被截断。换成脚本里简单的"Hello"之后,准确率直接拉满。判断特征越短、越稳定,盲注结果越可靠。

第三,异常处理不能省。网络请求总会有意外,超时、连接重置都有可能发生。我在代码里捕获了requests.RequestException,遇到异常就睡一秒重试,更稳妥的还可以加一个最大重试次数上限。

第四,关于POST还是GET的问题。这个环境用的是POST方式提交参数,所以脚本里是session.post(url, data={"id": payload})。如果目标环境是GET传参,改成requests.get(url, params={"id": payload})即可,payload本身不用变。

4.3 跑出来的结果验证

脚本运行之后,终端里会一行一行打出flag的进度。从第一个字符f开始,接着是l、a、g,然后一个{,最后是一串随机字符,直到出现}程序停下。整个过程大概一两分钟,比预期快不少。

拿到完整flag之后再回头看一眼题目,会发现所有线索都闭环了:页面功能确定了盲注基准,^符号确定了绕过语言,select(flag)from(flag)确定了查询目标,二分脚本确定了自动化方案。每一步都是在前面信息基础上推出来的,没有哪一步是凭空猜的。

5. 这题教会我的:过滤型注入题的通用破题节奏

5.1 面对过滤型题目,先画一张“过滤能力图”

很多人做WAF题的习惯是想到一个payload试一个,被拦了就换下一个,反复横跳,浪费了大量时间。吃过几次亏之后,我的做法是先花10分钟系统性地探测一遍,画出一张“过滤能力图”再动手。

具体来说,就是把可能用到的字符和关键词一次性测完,并记录结果:空格、单双引号、括号、and、or、union、select、from、where、注释符、换行、=、>、<、逗号、^、|、&、!。这张图一旦画出来,后面构造payload就是填空,再也不用一个个试了。

这道题最值得学习的点就在于:过滤规则不是把所有关键词拉黑,而是只拦“关键词后跟空白”的形态。你不做系统性测试,光靠背payload,根本发现不了select(flag)这种写法能绕过。

5.2 绕WAF的本质:在两个解析层之间做翻译

这道题让我彻底想明白了一件事:WAF的过滤和SQL解析器对SQL的理解,完全是两个层面的东西。

WAF在文本层面做正则匹配,它看到的是字符串,所以它的规则往往围绕空格、关键词、注释符这些“看起来像SQL特征”的东西来设计。而SQL解析器看到的是token流,它关心的是语法结构。同样的逻辑,用不同的文本形式表达出来,WAF可能根本不认识,但SQL解析器完全理解。

空格被过滤了,就找括号替代;and和or被过滤了,就找异或替代;select被过滤了,就让select和左括号连在一起。这些操作的本质都是在“WAF的文本规则”和“SQL的语法规则”之间找一个两边都接受的中间形态。理解了这一层,以后遇到任何过滤规则,你都会下意识去想:还有没有第二种写法能表达同样的语义?

5.3 如果过滤比这还狠怎么办

这个题留了一个口子,就是逗号没有被过滤,所以mid((select(flag)from(flag)),1,1)能直接用。如果哪天遇到连逗号都过滤的题,也不是没有退路。

MySQL的substr函数支持一种不依赖逗号的语法:

substr((select(flag)from(flag))from(1)for(1))

这个写法里from和for是函数的关键字参数,和查询语句里的from关键字是两回事,很多WAF的正则并不会拦这种用法。类似的替代方案还有left()和right()函数,通过多次嵌套也能实现逐字符截取。这个扩展思路虽然这道题没用上,但确实值得收藏。

5.4 解题节奏的复盘建议

如果要把这次解题经验压缩成几句话,我会这么总结:

先把环境摸透,再考虑payload,绝大多数过滤型题目的突破口都在“异常的放行规则”里,找到它比背一百个payload都管用。布尔盲注永远优先于时间盲注,因为布尔盲注只依赖页面的有无状态,时间盲注还受网络波动影响,判断起来容易误判。能脚本化就一定脚本化,手工验证只用来确认思路,真正的数据提取交给循环和二分去做。

最后说一点个人体会。CTF里这种“表面极简、内部变态过滤”的题,往往比那些堆了各种漏洞点的题更能训练基本功。因为它逼着你把SQL语法本身吃透,逼着你理解WAF的匹配逻辑,而不是拿着现成工具一把梭。这道题跑通之后,我后来又遇到好几个过滤思路类似的注入题,基本上看一眼过滤规则就能知道怎么绕。经验这东西,确实是踩过坑才长在自己身上的。

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

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

立即咨询