ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

布尔盲注原理与实战:从SQL注入检测到自动化脚本编写

布尔盲注原理与实战:从SQL注入检测到自动化脚本编写 当年我第一次撞上布尔盲注是在一个看似人畜无害的查询页面。输入id1有数据输入id2没有数据加单引号报错也不显示当时我就意识到这页面大概率和数据库有交互但常规的联合查询和报错注入全都不出东西。折腾了半小时随手在后面拼了个and 11和and 12页面一个正常一个空白。那一刻的思路一下就通了——这就是布尔盲注不直接给你数据但页面会在真假条件之间做出细微的回应你要做的就是顺着这条缝一点点把数据问出来。这篇文章不是让你学怎么打站而是把布尔盲注从原理到实操剥开揉碎讲清楚。安全测试里它是绕不开的经典知识点开发人员也值得花十几分钟了解因为没搞懂这种漏洞原理的人写出来的查询代码大概率也是裸奔状态。全文我尽量用实战视角来讲包含手工测试的思路、自动化脚本的写法以及我踩过的几个印象深刻的坑。1. 页面不说话数据却在偷偷回答布尔盲注的出场时机1.1 注入类型里为什么偏偏是它最难缠SQL注入按获取数据的方式大致可以分成几类联合查询注入Union Based页面直接把查询结果渲染出来效率最高报错注入Error Based数据库报错信息原样返回靠报错内容带出数据时间盲注Time Based响应时间出现明显延迟用睡不睡来判断真假而布尔盲注Boolean Based Blind靠的是页面在条件为真和条件为假时的内容差异来推断数据。联合查询和报错注入属于显式路线因为服务器把结果或错误信息直接吐出来了。真正的实战里很多系统会做全局的错误处理SQL报错统一返回一个500页面什么细节都不给。还有些系统虽然不报错但你union select进去以后页面的渲染逻辑根本不认你拼出来的其他列照样什么都不显示。这两种情况占了实际测试中相当大的比例。这时候如果你发现页面在and 11时正常、and 12时内容变化那就说明布尔盲注可以走。它不需要数据库吐任何东西出来只需要页面保留条件真和条件假两种状态的差异即可。这意味着对后端代码的要求极低——哪怕开发者做了错误处理、做了输出过滤只要查询结果直接影响了页面内容的渲染布尔盲注就可能成立。1.2 布尔盲注依赖的两个前提条件不是所有注入点都能用布尔盲注来测。根据我的经验至少要满足两个条件缺一个都不行。第一个条件是存在可感知的响应差异。这个差异可以是页面正常返回和空白返回可以是返回记录条数不同可以是HTML源码长度变化甚至可以是HTTP状态码从200变成302。差异的形态无所谓关键是条件真假时它稳定复现不会因为并发、缓存、随机因素干扰而误判。第二个条件是注入点拼接进了可执行的SQL逻辑。数字型参数直接拼进WHERE id $id字符型参数拼进WHERE name $name这类位置的注入点可以灵活地闭合引号和注释符把条件扩展到整个WHERE子句之外。如果参数经过了强转或者预编译处理那就直接断了这条路。判断一个点位是否具备布尔盲注条件的探测手法不复杂。先输入一个必然为真的条件比如数字型就测and 11再输入一个必然为假的条件and 12观察两次返回是否有稳定差异。有差异就意味着参数是可控且可执行的没差异要么是参数被过滤要么是WAF拦掉了要么是存在缓存导致响应被复用。2. 真假信号的本质一个条件如何变成数据库的开关2.1 条件语句在SQL执行时发生了什么要理解布尔盲注说白了就是理解SQL中WHERE子句的执行逻辑。数据库在执行SELECT * FROM users WHERE id $id时如果$id被替换成1 and 11那么整条语句变成SELECT * FROM users WHERE id 1 and 11。这个条件实际上由两部分组成id1为真11恒为真两个真与在一起还是真所以结果集和只查id1一模一样。但如果替换成1 and 12语句变成SELECT * FROM users WHERE id 1 and 12条件中有一个为假整体为假结果集就是空的。页面如果按结果集来渲染内容两次请求的返回就会产生肉眼可见的差异。这个过程中and 11和and 12真正的作用不是查询数据而是构造一个可控的布尔开关。搜索时抓包工具里看到的每一次请求实际上是在问数据库同一个问题我构造的这个条件是真是假数据库不直接回答但页面替它说了。2.2 什么样的响应信号最可靠做布尔盲注时信号的选择直接决定了成功率。我见过不少人拿到一个注入点就开始猜数据结果猜了半天发现真假判断反了原因是他们把页面返回差异判断错了。最可靠的信号类型是结果集的行数变化。比如查id1时有1条数据页面显示一个用户卡片条件为假时结果集为空页面模板循环拿不到数据整块区域空白或者消失。这种差异稳定且容易观察。其次是响应包长度变化。有时候页面不直接显示查询结果但结果影响到了其他逻辑比如生成了不同的下拉选项、不同的统计数字甚至只是隐藏字段的值不同。这种变化不会引起整页内容颠覆但在Burp Suite的Comparison功能或者命令行下对比Content-Length能很清晰地看出来。还有一种情况是HTTP状态码或重定向变化。条件为真时走正常的200流程条件为假时业务逻辑抛错跳转到登录页或错误页或者返回302。这种信号同样有效但在写自动化脚本时要注意跟随重定向的问题否则脚本里看到的响应永远只是那条302。2.3 一个最小的实操验证示例用最简单的方式演示一下。假设有一个查询商品详情的接口参数是product_idGET /product.php?product_id1 HTTP/1.1正常请求返回200页面包含商品名称蓝牙耳机。接着测GET /product.php?product_id1 and 11 HTTP/1.1页面依然返回商品名称蓝牙耳机。再测GET /product.php?product_id1 and 12 HTTP/1.1页面返回200但商品名称那一栏变成空白说明查询结果集为空。到这里布尔盲注的条件就成立了。后续所有的数据猜解都建立在这样一个观察之上条件真时页面有名称条件假时页面没有名称。实际操作中我会顺手再确认一次product_id2 and 11是否也返回了id2的商品避免把参数不存在和条件为假搞混。这种基础验证虽然琐碎但能省掉后面一大堆排查时间。3. 手工构造布尔盲注请求从三个函数到完整猜解3.1 猜解数据的核心函数组合布尔盲注不能像联合注入那样直接一把梭出全部数据它的思路是把数据拆成单个字符再逐个判断。拆字符靠截取函数判断字符靠条件比较。用得最多的三个函数组合是substr()、ascii()或ord()和length()。length()的作用是判断数据长度也是猜解的第一阶段。比如要猜当前数据库名先通过盲注问数据库库名长度是否等于某个数。substr(database(), 1, 1)可以取出数据库名的第一个字符。ascii()把字符转成ASCII码值方便用数字比较大小避免字符集和引号转义带来的麻烦。一个常见的判断库名首字符的payload长这样and ascii(substr(database(),1,1)) 100它的逻辑是如果数据库名的第一个字符的ASCII码大于100条件为真页面正常否则为假页面变化。通过不断调整这个比较值就能把字符范围缩小到一个精确值。MySQL里也可以用ord()替代ascii()效果一样。SQL Server用unicode()或ascii()取字符串函数是substring()不是substr()。Oracle要用substr()配合ascii()而且Oracle的dual表在很多查询场景下不可少。不同数据库在函数名上的差异是个容易踩坑的点下文会专门展开。3.2 按位猜解和二分法少发一半请求的技巧直接逐字符线性比较问是不是a、是不是b虽然直观但效率极低每个字符平均要试50多次。更优的做法是二分法。以ASCII表为参照字符的范围大致在32到126之间也就是95个可打印字符。二分法的思路是先问ASCII码是否大于100如果真则范围缩小到100到126再问是否大于113依此类推每问一次范围缩小一半。最终定位一个精确字符只需要约7次请求因为2的7次方是128足以覆盖95个字符。实际写payload时大于比较通常写作and ascii(substr(database(),1,1)) 100有一次我在测一个SQL Server的注入点用substr构造的语句半天不出结果换成substring以后立刻通了。函数差异这种问题在手工测的时候还能发现写自动化脚本时如果锁死了某个数据库的语法换个库就抓瞎。所以脚本里通常要预设多个数据库方言模板根据指纹信息切换。3.3 绕过过滤的几种常见变体很多系统虽然存在SQL注入但会对输入做一些简单的关键词过滤。常见的绕过思路我归纳为四类。第一类是大小写混淆针对那些只做精确字符串匹配的过滤规则。比如SeLeCt、SuBsTr在MySQL默认不区分关键字大小写的情况下照样执行。这种绕过方式最基础但对付早期的简单WAF规则往往有效。第二类是注释符替换空格。有些过滤规则会删除空格字符而SQL语法里/**/可以替代空格。比如and/**/ascii(substr(...))这类写法。MySQL特有的#注释、--注释以及内联注释/*!...*/MySQL会把内联注释里面的内容当作SQL执行都是可以尝试的方向。第三类是双重编码。如果Web层对输入做了一次URL解码而后端又做了一次就可以把关键字编码成%27、%2572这类形式。但双重编码依赖具体的中间件配置不是所有环境都能用。第四类是字符串拼接与十六进制。如果过滤关键词针对的是information_schema这样的长字符串可以用char(105,110,102,111,...)拼接如果过滤or、and可以考虑用||和运算符替代MySQL的||默认是逻辑或默认是逻辑与。这些技巧本质上属于对抗性手段具体要用哪个取决于目标系统的过滤规则到底拦了什么。没有一套通吃的万能绕过方案碰到WAF的时候更多要结合目标使用的中间件和WAF类型来分析。4. 从检测到出数据一次完整的手工布尔盲注走查4.1 我常用的测试环境假设为了讲清楚流程我假设一个本地起的最小化场景某系统前台有一个新闻详情页面URL格式是news.php?id58。页面正常显示一条新闻标题在h1标签里正文在div classcontent里。后端SQL大致是SELECT title, content FROM news WHERE id 58现在我用布尔盲注的方式从检测注入点到最终取出管理员表里的账号名完整走一遍。整个过程就是我在测试环境里会真实执行的步骤序列。4.2 确认条件可测之后的第一步摸清当前环境和用户确认注入点可测之后我一般不急着猜库名表名而是先花一两个请求确认两件事当前使用的数据库类型以及当前连接用户的权限。数据库类型可以通过函数指纹来判断。向id58后面拼and length(database()) 0正常返回说明database()函数存在大概率是MySQL。再拼and (select count(*) from sysobjects) 0如果这个条件为真说明是SQL Server语法。PostgreSQL可以试and (select count(*) from pg_tables) 0。每条语句最多两个请求的代价就能排除掉大部分数据库类型。当前用户权限的判断通常针对MySQL试and (select count(*) from mysql.user) 0如果返回真说明当前连接有权限访问mysql库也就是大概率是root级别的连接。这个发现直接影响后面的数据获取策略有权限直接翻元数据表没权限只能硬猜表名。4.3 长度猜解和逐字符提取的实际步骤当前数据库名为第一步目标。先猜长度。手工测的时候可以用Burp的Intruder模块把payload设置为58 and length(database()) {数字}跑一遍1到30的字典看哪一次响应特征转变为真。或者更省时间用二分法手测先问length(database()) 10真则再问 15依此类推直到确定长度。假设确认长度是8。接下来逐字符提取。第一个字符的ASCII码用二分法。先发58 and ascii(substr(database(),1,1)) 77页面正常说明首字符ASCII大于77。继续缩小区间发58 and ascii(substr(database(),1,1)) 102如果这次页面异常说明首字符ASCII在78到102之间。继续折半逐步收敛。大约7次请求后就能定位到准确值。我实际测试的时候会顺手开Burp的Comparer功能把每次真假响应的Content-Length差记录下来避免肉眼观察疲劳导致误判。4.4 从库名到表名再到数据的完整链路拿到数据库名之后猜表名。MySQL的元数据表information_schema.tables记录了表信息payload大概是58 and ascii(substr((select table_name from information_schema.tables where table_schemadatabase() limit 0,1),1,1)) 100这里用limit 0,1取结果集第一行然后对表名字符串逐字符猜。猜完第一个表名后换limit 1,1猜第二个表名。拿表名阶段需要注意一个坑information_schema.tables里面除了业务表还有一堆系统自带的表比如MySQL的character_sets、collations、columns这些前缀千篇一律内容又多。我在实际测试中会先通过where table_schemadatabase()把范围限制在当前库再在自动化脚本里把已经猜出的系统表名加入黑名单过滤否则后面70%的请求都在重复猜那些用不上的表。猜出表名之后猜列名。目标锁定在一张疑似存管理员账号的表上假设叫admin_user。对应的payload是58 and ascii(substr((select column_name from information_schema.columns where table_schemadatabase() and table_nameadmin_user limit 0,1),1,1)) 100拿到列名再猜具体数据。猜username和password的每个字符payload变成58 and ascii(substr((select username from admin_user limit 0,1),1,1)) 100整个链路走完通常要发几千个请求。这也是为什么布尔盲注没自动化脚本根本跑不动——手工确认几个表名还行真要完整拖一个库出来手点会点到怀疑人生。5. 写一个自动化脚本布尔盲注的机械化提速思路5.1 为什么不直接用现成工具很多人一上来就会提到sqlmap。但我的观点是工具能跑通不代表你理解了盲注sqlmap确实强大但在一些定制化场景下反而显得笨重。比如目标系统有特殊的过滤规则sqlmap的payload库不匹配比如响应差异的判断需要结合业务逻辑商品被下架和条件为假页面表现一样工具识别不出来再比如线上环境要求低发包频率sqlmap默认的并发策略容易被封。更重要的是自己写一个几十行的脚本能让你对请求、响应、条件判断、二分法这些核心概念有更直观的理解。后面碰到sqlmap识别不了的场景你还有能力手工调整。这不是否定工具而是让工具成为你思路的延伸。5.2 用Python写一个最小可用的布尔盲注脚本这里我用Python加requests库写一个基础脚本目标是从一个模拟的MySQL注入点提取数据库名。脚本逻辑分三块定义判断条件的函数、二分法猜单个字符、循环猜完整字符串。下面的代码我做了简化方便阅读实际使用时要根据目标调整URL、参数名和响应判断方式。import requests url http://192.168.1.105/news.php session requests.Session() headers {User-Agent: Mozilla/5.0} # 正常请求时页面包含的标记 TRUE_MARK 热门新闻 def is_true(payload): 执行条件返回True表示页面为真响应 params {id: 58 payload} try: resp session.get(url, paramsparams, headersheaders, timeout10) # 判断方式1内容中是否存在特定标记 return TRUE_MARK in resp.text except requests.RequestException: return False def get_length(sql): 二分法猜解sql结果的长度 low, high 1, 100 while low high: mid (low high) // 2 payload f and length(({sql})) {mid} if is_true(payload): low mid 1 else: high mid return low def get_char(sql, pos): 二分法猜解sql结果第pos个字符的ascii码 low, high 32, 126 while low high: mid (low high) // 2 payload f and ascii(substr(({sql}),{pos},1)) {mid} if is_true(payload): low mid 1 else: high mid return low def get_value(sql): 完整提取sql结果 length get_length(sql) value for i in range(1, length 1): ascii_code get_char(sql, i) value chr(ascii_code) print(f[*] currently: {value}) return value if __name__ __main__: database get_value(select database()) print(f[] database: {database})这个脚本里is_true()是灵魂函数它决定了条件为真怎么判定。不同目标有不同的判断方式可以是内容标记、长度阈值、状态码。写脚本之前一定要抓几个包确认真响应和假响应之间的差异否则整个脚本的判断基础就是错的。实际效果方面一个长度为8的数据库名用二分法每个字符大约7次请求长度判断按100上限算大约7次总共也就60次左右请求。相较于线性猜解每个字符50多次省了一半还多。5.3 脚本踩过的坑响应判断和超时重试这个脚本在测试环境跑通之后我拿到一个内网目标上用时发现判断老出错。排查了半天发现两个问题。第一个问题是目标响应不稳定。内网里有个设备做了带宽限制偶尔请求要7秒才回但脚本里timeout10慢一点的请求被强制超时直接算成了假响应。后来我把超时放宽到30秒并且加了重试逻辑——响应超时或者状态码异常时同一个payload请求三次取多数票的结果。第二个问题是真假响应差异太小。那个页面的真响应和假响应差别只是一个HTML注释节点在响应包里差30多个字节。如果只判断TRUE_MARK in resp.text就完全失效因为真标记在假响应里也存在。后来我改成判断整个响应的Content-Length是否超过某个阈值比如真响应大于4000假响应小于3900才稳定下来。这两个坑很有代表性一个是网络层的干扰一个是业务层响应设计的不敏感。写自动化脚本时如果遇到判断老出错先回头审视这两个方向比反复调试二分法本身要有效得多。6. 攻防视角下的布尔盲注为什么有的系统拦得住有的拦不住6.1 参数化查询是根治手段过滤只是缓解讨论布尔盲注的防护绕不开参数化查询Prepared Statement。以PHP的PDO为例$stmt $pdo-prepare(SELECT * FROM news WHERE id ?); $stmt-execute([$_GET[id]]);这条语句在数据库端先完成SQL结构编译再把用户输入作为纯粹的参数绑定进去。用户无论输入1 and 11还是1 or 11在数据库看来都只是字符串值不会成为SQL结构的一部分。布尔盲注的所有payload在这种场景下都无效因为条件根本没机会进入WHERE子句。很多历史遗留系统用的是字符串拼接方式比如$sql SELECT * FROM news WHERE id . $_GET[id];这种代码是布尔盲注最舒适的生长环境。所以不管是做开发还是做安全测试判断一个系统是否对注入免疫第一眼的优先级就是看代码里有没有用预编译。用了基本不用再花时间测这个参数没用才谈得上后续的绕过与过滤。6.2 二次过滤为什么经常失效有些开发者意识到有注入问题但改代码成本高就选择在入口处做关键词过滤。比如把and、or、select、union替换成空字符串。这种方案的缺陷在于过滤规则的完备性极难保证。举一个实际例子针对关键字and如果过滤策略是str_replace(and, )那么输入anandd经过替换后变成and完美绕过。针对select的seselectlect同理。这类双写绕过之所以有效根源是过滤逻辑做的是单次替换而不是递归替换。即便过滤器做了递归替换编码绕过、注释符绕过、等价函数绕过依然存在。所以我在安全测试课程里经常强调一句话过滤是缓解手段不是根治手段。真正能扛住注入的只有参数化这一条路。6.3 从报错信息到最小权限纵深防御的几个层次如果一个系统暂时改不动历史代码防御上可以叠加几层措施来降低布尔盲注的风险。第一层是错误信息统一处理。数据库报错不能直接返回给前端统一兜底为通用错误页。这一招主要防报错注入但对布尔盲注的影响有限因为布尔盲注不需要报错信息。第二层是数据库账号最小权限。Web应用连接数据库的账号不应该用root或sa这类高权限账号只授予业务所需的增删改查权限去掉information_schema等元数据表的访问权限。这样即便布尔盲注成立攻击者也猜不到表名列名数据提取链路会在中间断掉。这个措施成本很低但很多运维团队在实际部署中根本没做。第三层是WAF或应用防火墙的规则拦截。拦截思路一般是对参数中出现and、select、union、substr、chr等关键字的请求直接阻断或者对单个IP的高频请求做速率限制。阻断类规则防绕过效果有限但加上速率限制后自动化脚本的猜解效率会指数下降——原本几秒钟几十个请求现在变成每10秒一个请求猜一个表名要跑半天攻击成本大大提升。这中间要特别说明的是WAF的产品形态和绕过技术一直在互相升级。部署WAF不等于绝对安全它只是把攻防的难度提上去了。真正的安全还是要回到代码层面解决。7. 测试布尔盲注时踩过的几个坑7.1 页面缓存导致真假响应错乱有一个内网系统的新闻列表页我测了好几个payload都是真响应页面内容一模一样差点以为没有注入点。后来偶然用了浏览器的无痕窗口测试发现同样的payload真假差异就出来了。原因是系统对新闻详情页做了页面级缓存第一次请求的结果被缓存下来后续相同URL的请求直接命中缓存根本不会触发新的SQL查询。这个问题的根源是URL没变化。布尔盲注的payload虽然参数值不同但URL整体结构不变有些缓存策略会只缓存不带查询参数的页面有些会缓存完整的URL后者就会导致注入测试失真。破解方法是确保每次请求的URL在缓存视角下是新的常见做法是在参数后面拼一个无意义的随机参数或者直接换一个不常见的User-Agent。不过加随机参数时要注意部分后端框架会把未知参数拼进SQL的其他位置可能导致payload失效需要重新验证一遍注入点。7.2 字符集编码导致ASCII码判断错误一次目标站的页面编码是GBK我按UTF-8的思路猜数据猜到一个中文字符时ascii(substr(...))返回的值跟预期的汉字区位码对不上导致后面的字符位置全部偏移。排查后才发现文章内容里包含中文我之前预设的数据全是ASCII可打印字符这个前提本身就错了。这个坑在自动化脚本里尤其隐蔽因为单纯的ASCII范围猜解遇到非ASCII字符时会得出奇怪的结果。解决思路是在脚本中对每个位置的字符先判断是否是常见英文、数字、符号32到126如果区间落到127以上再扩展猜解范围到255并配合页面的字符集编码做解码处理。更稳妥的办法是让payload直接把数据库转成十六进制来判断比如用hex(substr(...))把中文字符的编码值变成纯ASCII的可比较序列彻底绕开字符集干扰。7.3 数据库方言差异导致的函数失效MySQL、SQL Server、Oracle、PostgreSQL在生产环境中的占比都不低它们的函数取名规则差异很大。SQL Server的substring()是从1开始计位的和MySQL相同Oracle的substr()也是从1开始但Oracle的字符串拼接用的是||而不是concat()PostgreSQL的substring()支持从0开始计位容易和MySQL搞混。在一台SQL Server上测试我用MySQL的substr(database(),1,1)拼了一堆payload全无响应。检查之后发现SQL Server里函数名是substring改成substring(db_name(),1,1)立刻正常。在Oracle上则是不能直接select不带from的内容需要写成select ... from dual这个差异在注入payload时同样要适配。写手工测试脚本时最好在脚本开头做一个数据库指纹探测根据识别结果动态切换函数模板避免在单个目标上反复试错。7.4 响应差异太微弱时怎么处理有次测试一个后台登录接口布尔盲注的条件存在但真响应和假响应只差在一个空格的HTML源码上手工看和普通正则判断都容易漏。我的处理方式是用Burp的Sequencer或者自己写脚本对同一payload重复请求10次记录响应长度的分布如果真和假的长度区间完全分离说明信号可用如果两个区间有重叠说明这个信号不稳定换一个观察维度。换维度的思路通常是寻找页面中其他受查询结果影响的元素比如总记录数、分页链接、面包屑导航的第二级、甚至是响应头的某个自定义字段。有一次实在找不到响应差异我最后是通过监测服务器返回的Set-Cookie值变化来区分的这种方式虽然少见但说明布尔盲注的信号判断不只是看页面正文响应头、状态行、延迟特征都能成为信号源。7.5 注入发生在数据更新语句里布尔盲注还能不能打最后的经验是关于UPDATE型注入点。很多时候我们只关注SELECT查询忘了UPDATE和DELETE也可能拼接参数。UPDATE型注入点的布尔盲注判断逻辑有所不同你拼进去的and条件在影响的是哪些行会被更新页面不会显示这个结果但可以通过后续查询来验证——比如UPDATE users SET emailx WHERE id1 and (条件)条件为真则更新成功登录后看到邮箱变了为假则没更新邮箱不变。这种间接信号虽然绕但确实能转成布尔判断。这种方式要注意的是UPDATE和DELETE一旦拼错条件可能影响大量数据测试前必须确认目标环境是隔离的测试环境绝不建议在线上库做这类验证。我在本地演示时会把条件固定成id一个不存在的值保证即使条件判断失误也不会造成实际数据丢失。整条链路走完我对布尔盲注最深的体会是它并不比联合查询、报错注入高级但它逼迫你真正理解SQL执行机制和页面渲染之间的交互关系。每一个payload、每一次二分、每一个绕过技巧背后都是条件和信号这对关系的拆解。你如果能从这两个词出发去思考所有的注入类型很多看似绕的问题会一下子变得特别清晰。这套思维方式比记住几十个payload模板要值钱得多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表