DVWA靶场SQL盲注High级别:Cookie布尔与时间盲注实战
发布时间:2026/11/2 7:26:33 锦皓数字建站

DVWA靶场里的SQL盲注High级别是我带新人时最喜欢拿出来卡人的一关。Low和Medium靠着页面回显、报错信息半小时就能摸到用户名和密码到了High页面开始装哑巴你输入什么它都只回一句User ID exists in the database参数还从地址栏挪进了Cookie很多人在地址栏里改了半天没反应第一反应是靶场坏了。其实靶场没坏是你的信息通道被掐断了。这篇笔记就围绕DVWA靶场的SQL盲注High级别展开讲清楚它到底改了什么、布尔盲注和时间盲注在High下怎么落地、手工流程怎么走、脚本怎么写、以及我踩过的那些坑。适合已经通关Low和Medium、正准备啃High的读者也适合想理解盲注本质是什么的朋友。不需要你事先背一堆payload但需要你愿意动手抓包、看状态码、算时间差。读完你应该能独立把High级别的库名、表名、字段和用户数据完整拖出来并且知道每一步为什么这么做。1. High级别到底改了什么先看清靶子的形状1.1 从URL到Cookie注入位置迁移带来的操作变化Level从Medium升到High最直观的变化是传参位置。Low是GET地址栏里?id1随便改Medium是POST得用表单或者抓包工具High直接换成了CookiePHP端读的是$_COOKIE[id]你在地址栏里写?id1服务端压根不看这个参数。这个设计的初衷是模拟参数藏在请求头里的场景实际项目里也确实有把用户标识放在Cookie里的写法虽然不规范但存在。操作上就要换工具了。浏览器F12的Application面板可以直接改Cookie改完刷新页面也可以用Cookie Editor这类插件更常见的是抓包工具在请求头里手动加一行id...再重放。我一般用抓包工具因为改完能立刻看到响应码和响应体排查起来方便。这里有个细节Cookie里的分号、空格、引号在部分工具里会被处理掉如果发现payload没生效先检查服务端实际收到的Cookie长什么样。还有一个容易忽略的点DVWA的security等级本身也是存在Cookie里的。你把securityhigh改回low页面立刻变回有回显的模式。所以每次测试前先确认当前请求里的security值是不是high别打了半天发现打的是Low白高兴一场。1.2 只回一句存在/不存在盲注的信息通道High级别的页面逻辑很简单查询有结果输出User ID exists in the database没结果输出User ID is MISSING from the database。没有字段名、没有数据、没有报错堆栈。这就是盲注的字面意思——你看不见数据库的答案只能通过是/否这一个比特来提问。这个是/否就是全部的突破口。既然页面能区分真假我们就能把任意一个需要判断的问题塞进SQL里让数据库替我们回答。比如我想知道数据库名的第一个字符是不是大于100就构造一个恒真或恒假的条件看页面返回哪一种结果。一次问一个比特问得足够多整个库就出来了。理解这一点很关键盲注不是某种特殊的注入技术它就是普通SQL注入在无回显条件下的变体。注入点、闭合方式、SQL语法都没变变的只是你把数据读出来的方式——从看结果变成猜结果。想通这一层后面的所有payload都是自然推导出来的不需要死记。1.3 LIMIT 1与404状态码被忽略的两个细节High级别在SQL末尾加了LIMIT 1同时在查不到结果时用header()发了一个404状态码。这两点很多人没注意但都会影响你的判断方式。LIMIT 1的作用是让查询最多返回一行。对布尔盲注影响不大因为你本来就是问真假但对基于UNION的注入基本是毁灭性的——页面不显示数据UNION拖出来的内容也没地方放。所以High级别下别在UNION上浪费时间老老实实做布尔或时间盲注。404状态码则是一个意外收获。查不到结果时服务端返回的是HTTP 404浏览器Network面板里一眼就能看到。这意味着你甚至可以不看页面文字只看状态码就知道真假200是真404是假。这比肉眼看文字更快也更适合脚本判断。我在脚本里就是优先判断status_code 200文本匹配只作为兜底。当然不同版本的DVWA实现略有差异有的版本可能只改文本不改状态码所以两条通道都要会看。2. 动手前的环境校准2.1 靶场初始化与安全等级确认开始之前先把环境理顺。DVWA装好之后先访问setup.php点一下Create / Reset Database把数据库重置到默认状态避免之前测试留下的脏数据影响判断。然后用默认账号登录进DVWA Security页面把等级切成High保存。切完之后别急着打先做一次正常请求Cookie里只放id1看页面是不是返回exists再放id999看是不是返回MISSING并且状态码404。这两步是基线确认了基线后面任何异常你都能快速定位是payload的问题还是环境的问题。我习惯在笔记里记下三个值正常请求的响应时间、正常请求的状态码、不存在ID的状态码。后面做时间盲注时响应时间这个基线是定阈值的重要依据做布尔盲注时两个状态码就是你的真/假信号。别小看这个习惯它能帮你省掉大量到底是注入失败还是网络抖动的纠结。2.2 抓包工具与Cookie编辑的三种姿势改Cookie有三种常见方式各有适用场景。第一种是浏览器开发者工具Application → Cookies直接双击值修改适合快速试探单个payload改完按CtrlF5强制刷新避免缓存干扰。第二种是浏览器插件适合需要反复切换多组Cookie的场景但要注意插件本身可能会对特殊字符做转义。第三种是抓包工具的重放功能把请求拦下来在请求头里改id的值再发送最适合配合脚本做批量测试。不管用哪种方式都要保证Cookie里同时存在PHPSESSID、securityhigh和id三项。少一个都可能被重定向到登录页或者降级到Low。特别是PHPSESSID会话过期之后服务端会返回302跳转到登录页这时候你看到的既不是200也不是404而是302很多人会误以为是payload被拦截了。遇到302先重新登录再说。还有一个实操细节payload里的空格在Cookie里比较敏感。有些工具会把空格截断导致-- -变成--注释失效SQL报错。我的做法是把空格写成%20把#写成%23PHP在解析Cookie时会自动URL解码服务端收到的还是正常字符。如果你用的是脚本直接对payload整体做一次URL编码最省心。2.3 建立基线正常请求长什么样基线测试我用三条请求就能完成记录在一个小表格里后面所有判断都对着这张表看。请求Cookie中的id预期状态码预期页面文本正常存在1200User ID exists in the database正常不存在999404User ID is MISSING from the database注入真条件1 AND 11200exists注入假条件1 AND 12404MISSING如果第三行和第四行分别对应200和404说明闭合方式找对了注入点确认存在。如果两行都是200八成是引号没闭合成功SQL把整个字符串当成了数字处理条件根本没生效。这种情况在数字型注入里特别常见user_id1 AND 12MySQL会把字符串1 AND 12转成数字1依然匹配到id1那一行页面当然返回exists。所以测试闭合一定要带引号别偷懒。注意判断注入点时不要用OR 11。High级别有LIMIT 1OR 11会让所有行都满足条件页面永远返回exists真假无法区分。用AND构造条件才能看出差异。3. 布尔盲注把每个字符问出来3.1 闭合方式与注入点确认闭合方式的判断是整条链路的地基。DVWA这个点的SQL是SELECT first_name, last_name FROM users WHERE user_id $id LIMIT 1$id被单引号包裹所以标准闭合是1注释用#或者-- -。构造1 AND 11#返回2001 AND 12#返回404就说明闭合成功。有时候你可能会遇到1 AND 11返回200但1 AND 12也返回200的情况这通常不是闭合失败而是payload被截断了——比如#没被识别成注释后面的内容被当成SQL语法报错而报错又被抑制页面走了else分支。这时候换-- -试试或者检查空格有没有被吞掉。确认闭合之后先别急着拖库做一次版本探测练手1 AND substring(version(),1,1)5#或者1 AND ascii(substring(version(),1,1))50#。这一步的目的是验证你构造的条件确实能被数据库求值而不是被PHP层过滤掉了。版本号探出来是5.x还是8.x后面写information_schema查询时心里有数虽然大部分语法通用但个别函数行为还是有差异。3.2 库名长度与首字符最小可用验证盲注的第一个实战目标通常是当前数据库名。先问长度1 AND length(database())4#如果返回200说明库名是4个字符如果404就依次试5、6、3直到命中。DVWA默认库名是dvwa长度4所以这一发一般就中。长度确认之后问字符。最笨的办法是等值比较1 AND substring(database(),1,1)a#从a试到z26次出一位。这个方法直观适合第一次理解盲注原理但效率低得感人。我建议新手至少手工跑完一个字符的等值比较感受一下页面告诉我猜对了的那个瞬间然后再换二分法。首字符用等值比较确认是d之后你其实已经完成了盲注的完整闭环提出问题、构造条件、观察响应、得出结论。剩下的所有工作都是把这个闭环重复到不同的表达式上——把database()换成表名子查询把位置1换成2、3、4逻辑完全一样。3.3 二分法加速从127次到7次等值比较一位字符最多26次加上大小写和数字就是36次以上一个10位的表名要跑三百多次请求。二分法把这个问题压到7次以内ASCII码范围32到126每次问大于mid吗根据真假缩小区间最多7次收敛到一个字符。具体构造1 AND ascii(substring(database(),1,1))100#。返回200说明首字符ASCII大于100返回404说明小于等于100。然后调整mid继续逼近。以d为例ASCII是100第一次问100返回404区间缩到32到100第二次问66返回200区间缩到67到100如此往复7次内锁定100。这里有个边界要小心二分法的循环条件是lo hi更新时lo mid 1或hi mid最后收敛到lo。如果写成lo mid遇到相邻两个数时会死循环。我第一版脚本就栽在这跑着跑着卡住不动还以为是网络问题查了半天才发现是循环没退出。另外ASCII范围别开太大。有人从0试到255多花两次请求不说还可能踩到多字节字符的坑。目标数据基本都是可见字符32到126足够覆盖库名、表名、字段名和大部分数据。真遇到中文或者特殊字符再单独放宽范围。3.4 表名、列名、字段值的逐层剥离库名出来之后按库→表→列→数据的顺序往下剥。每一步都是同一个套路先用count问数量再用length问长度最后逐字符问内容。问表的数量1 AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schemadatabase())2#。DVWA默认有users和guestbook两张表试到2返回200就对了。问第一张表的名字长度1 AND length((SELECT table_name FROM information_schema.tables WHERE table_schemadatabase() LIMIT 0,1))5#users长度5命中。然后逐字符把表名问出来。列名同理把information_schema.tables换成information_schema.columns加WHERE table_nameusers先问列数再问每一列的名字和长度。最后拖数据1 AND ascii(substring((SELECT user FROM users LIMIT 0,1),1,1))100#把username和password一行一行问出来。DVWA的密码是MD5哈希32位十六进制问起来比较费时间本地环境跑脚本大概几十秒手工的话建议只验证前几位确认脚本逻辑对就行。阶段目标表达式说明库名database()默认dvwa长度4表数量SELECT COUNT(*) FROM information_schema.tables WHERE table_schemadatabase()默认2张表名SELECT table_name FROM information_schema.tables WHERE table_schemadatabase() LIMIT i,1逐个偏移取列名SELECT column_name FROM information_schema.columns WHERE table_nameusers LIMIT i,1先问列数再逐列数据SELECT user FROM users LIMIT i,1逐行逐字符3.5 手工与脚本的衔接点手工跑到表名就差不多了再往下纯属体力活。我的习惯是手工验证到能稳定问出一个表名的前两个字符确认整条链路没问题然后把剩下的事交给脚本。这个衔接点很重要手工阶段负责验证闭合、验证状态码判断、验证注释符脚本阶段负责规模化的重复劳动。如果直接上脚本一旦哪里不对你连是payload错了还是脚本写错了都分不清。手工阶段还要顺手记下几个锚点数据库名长度4、首字符d、表名users长度5。脚本跑完之后拿这几个值对一遍全对说明逻辑没问题有一个不对就回头查对应的环节。这比盲目相信脚本输出要靠谱得多。4. 时间盲注当页面只剩下快和慢4.1 sleep()的构造与短路陷阱严格来说DVWA的High级别用布尔盲注就够了页面有文本差异还有404状态码信息通道很清晰。但时间盲注必须掌握因为真实场景里经常遇到页面永远返回同一个结果的情况那时候只能靠响应时间读数据。构造方式是在条件里塞sleep()1 AND IF(ascii(substring(database(),1,1))100,SLEEP(3),0)#。条件为真就睡3秒为假立刻返回你通过测量响应时间判断真假。这里用IF而不是直接AND SLEEP(3)是为了避免短路带来的不确定性。短路是时间盲注最容易翻车的地方。WHERE user_id1 AND SLEEP(3)这种写法如果第一行不匹配MySQL可能直接跳过SLEEP不求值你就测不到延迟误判成条件为假。把条件包进IF里并且保证前面的user_id1是存在的IF才一定会被求值。如果id1那一行被删了先换一个存在的id别硬套。还有一个坑LIMIT 1会影响SLEEP的执行次数。查询只取第一行SLEEP最多执行一次所以延迟时间是可控的3秒不会因为你表里有100行就睡300秒。这一点对脚本超时设置很重要超时时间至少要是sleep时长的两倍。4.2 延迟阈值怎么定实测数据说话阈值不能拍脑袋。我先测10次正常请求的响应时间本地环境大概在30到80毫秒之间波动取中位数50毫秒作为基线。然后测一次sleep(3)的请求实际响应大概3.05秒。两者差距60倍阈值设在1.5秒非常安全——低于1.5秒判假高于1.5秒判真。如果你在网络环境里测基线可能到200毫秒甚至更高抖动也更大。这时候要么加大sleep时长要么增加采样次数取中位数。我的经验是sleep时长至少是基线抖动的10倍重复测3次取中位数能过滤掉绝大部分网络抖动带来的误判。测量项实测值说明正常请求基线30~80ms取10次中位数约50mssleep(3)请求约3050ms含网络往返判定阈值1500ms高于判真低于判假超时设置10s至少为sleep时长的两倍4.3 时间盲注在High级别的适用边界时间盲注的代价是慢。一个字符要发一次请求每次至少等3秒一个4字符的库名就是12秒起步拖完整张users表要按分钟算。所以在High级别我的策略是能用布尔盲注就用布尔时间盲注只作为验证手段——比如布尔盲注跑出库名之后用时间盲注复核第一个字符确认两条通道结论一致。时间盲注真正的用武之地是那些页面永远一样的场景比如登录接口只返回登录失败或者查询接口对任何输入都返回同样的JSON。这时候状态码、文本、长度全都不可用只有响应时间还带着信息。掌握它你的盲注能力才算完整。5. 自动化脚本把重复劳动交出去5.1 请求层Cookie、超时与重试脚本用Python的requests就够。核心是把payload塞进Cookie的id字段同时带上PHPSESSID和securityhigh。这里建议手动构造Cookie请求头而不是用cookies参数因为requests对Cookie值里的引号、空格、井号的处理不完全可控手动拼再加一次URL编码最稳。import requests from urllib.parse import quote BASE http://127.0.0.1/dvwa/vulnerabilities/sqli_blind/ SESSION 你的PHPSESSID def check(payload, timeout10): cookie fPHPSESSID{SESSION}; securityhigh; id{quote(payload, safe)} try: r requests.get(BASE, headers{Cookie: cookie}, timeouttimeout, allow_redirectsFalse) if r.status_code 302: raise RuntimeError(会话失效请重新登录并更新PHPSESSID) return r.status_code 200 except requests.RequestException: return None超时设10秒因为时间盲注的sleep可能到3到5秒。allow_redirectsFalse是为了捕获302会话过期时能立刻发现而不是被自动跳到登录页然后拿到一个200的登录页误判成条件为真。返回值用None表示请求异常和True/False区分开避免网络抖动被当成假。5.2 判断层状态码优先文本兜底判断真假时状态码是第一优先级文本是第二优先级。status_code 200判真404判假如果状态码既不是200也不是404再看响应体里有没有exists字样。两条通道都失效就返回None让上层重试。def judge(payload, retry3): for _ in range(retry): r check(payload) if r is not None: return r return None def get_length(expr, max_len64): for n in range(1, max_len 1): if judge(f1 AND length({expr}){n}#): return n return 0 def get_char(expr, pos): lo, hi 32, 126 while lo hi: mid (lo hi) // 2 res judge(f1 AND ascii(substring({expr},{pos},1)){mid}#) if res is None: raise RuntimeError(请求连续失败检查网络或会话) if res: lo mid 1 else: hi mid return chr(lo)这段代码里有个细节值得说get_char的二分边界是32到126while lo hi配合lo mid 1最后lo hi时收敛。如果目标字符的ASCII恰好是32或者126也能正确返回。别小看这个边界我见过有人写成lo mid跑到一半死循环还以为是目标服务器限流。5.3 提取层从库名到MD5提取层就是把上面的原子函数串起来。先拿库名再拿表名列表再拿列名最后逐行拿数据。表名和列名的枚举需要先问数量再按偏移逐个取避免依赖GROUP_CONCAT——那个函数有长度限制默认1024字节表多的时候会被截断而且截断还不报错特别坑。def get_tables(): cnt 0 for i in range(1, 20): if judge(f1 AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schemadatabase()){i}#): cnt i break tables [] for i in range(cnt): expr f(SELECT table_name FROM information_schema.tables WHERE table_schemadatabase() LIMIT {i},1) length get_length(expr) name .join(get_char(expr, p) for p in range(1, length 1)) tables.append(name) return tables跑库名的时候第一个字符出来是d第二个是v第三个w第四个a和手工结果对上就可以放心让它往下跑。整条链路跑完users表的user和password字段会以明文和MD5的形式打印出来。DVWA默认的admin密码是弱口令的MD5拿去本地比对一下就知道对应什么这里不展开。5.4 脚本跑飞后的排查顺序脚本跑出乱码或者空结果按这个顺序查第一看会话有没有过期302有没有被捕获第二看payload的编码#有没有被当成锚点截断空格有没有被吞第三看闭合方式手工再发一次1 AND 11#确认还是200第四看目标表达式是不是返回了多行LIMIT偏移有没有越界第五看字符集目标数据里有没有非ASCII字符导致二分区间不够。我遇到过一次跑出来一堆\x00查了半天发现是substring的位置参数从0开始传了MySQL的substring位置从1开始传0返回空串ascii()返回0二分法就把0当成了字符。改成从1开始就正常了。这种错误很蠢但脚本跑起来的时候你根本想不到只能靠锚点数据对不上来定位。6. 常见问题速查与实战心得6.1 现象与原因对照表High级别的坑集中在Cookie和判断逻辑上整理成表遇到问题直接对号入座。现象可能原因处理方式改地址栏没反应High读的是Cookie不看GET参数改Cookie里的id所有payload都返回200闭合失败字符串被转成数字带引号测试确认AND 12返回404返回302跳转会话过期被重定向到登录页重新登录更新PHPSESSID页面文本对但状态码不对不同版本实现差异以文本为准脚本里做双判断payload里空格丢失Cookie对空格处理不一致用%20编码或改用#注释时间盲注测不到延迟短路导致SLEEP没执行用IF包裹条件确保前面的id存在脚本卡死不退出二分法边界写错死循环检查lo mid 1和hi mid表名枚举不完整GROUP_CONCAT被截断改用LIMIT偏移逐个取6.2 几条踩出来的经验第一条先确认等级再动手。DVWA的security存在Cookie里切换等级后如果没刷新或者Cookie被工具覆盖很可能你打的是Medium。每次开工前发一个id1 AND 12#返回404才说明是High返回页面报错说明是Low返回固定文本说明是Medium。一发请求确认环境比事后排查半小时划算。第二条别迷信工具的红绿灯。有些工具会把404当成请求失败标红把200标绿看起来像是注入成功。实际上404才是假200才是真颜色是给人看的逻辑要自己判断。我在脚本里坚持用状态码显式比较就是为了不被工具的可视化误导。第三条注释符准备两个。#和-- -在不同环境下的表现不一样#在URL里会被当锚点--后面必须跟空格。Cookie里两者都能用但编码之后的行为有差异。我一般先用#不生效换-- -两个都不行就检查是不是闭合方式错了而不是继续换注释符。第四条记录每一次请求。手工阶段用抓包工具的历史记录脚本阶段把payload和结果写日志。盲注的特点是你不知道哪一发出了问题没有日志就只能从头再跑一遍。日志里至少要有payload、状态码、响应时间三个字段出问题时一眼就能定位到具体哪一发。6.3 从通关到防御High级别为什么仍然不安全High级别做了两件事把参数挪进Cookie加了LIMIT 1。这两件事都没有触及注入的本质。参数位置变了注入点还在LIMIT 1限制了返回行数但盲注本来就不依赖多行返回。页面不回显数据看起来像是防住了实际上只是把信息通道从直接读变成了间接问攻击者多花点时间照样能拖库。真正的修复是参数化查询把$id作为绑定参数传给预处理语句让数据和SQL结构彻底分离。其次是统一响应不管查询成功还是失败都返回同样的状态码和同样的文本不给攻击者任何可区分的信号。最后是错误处理别把数据库报错直接吐到页面上抑制错误只是掩耳盗铃日志里照样有记录。我个人的体会是盲注这一关最大的价值不是学会多少个payload而是理解信息泄漏这件事有多难堵。只要应用对不同输入产生可区分的外部表现——状态码、文本、响应时间、甚至响应长度——信息就会一点一点漏出去。防御方要堵住所有通道攻击方只需要找到一个。这个不对称性才是盲注真正想教给你的东西。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。