很多人练SQL注入,最喜欢挑有回显的模块,一提交页面就哗啦哗啦把数据吐出来,成就感拉满。但一到DVWA靶场的SQL Injection(Blind)模块,很容易卡住——页面翻来覆去只有两句话:User ID exists in the database. 或者 User ID is MISSING from the database. 没有报错明细,没有数据表格,连输出内容都看不到变化。其实这正是真实业务里最常见的状态:绝大多数Web应用不会把SQL报错原样甩给用户,攻击者只能靠页面行为差异一点一点把数据库信息"盲猜"出来。我当年在这个模块上磨了整整两天,从完全懵圈到能徒手拖出整个库结构,踩了不少坑,也把布尔盲注和时间盲注彻底吃透了。这篇文章把我完整的练习链路和当时的判断思路都整理出来,适合刚过完入门注入、想系统啃下盲注的同学参考。
1. 为什么拿DVWA练盲注,以及搭环境时更容易踩的两个坑
1.1 Blind模块在DVWA里的独特价值
DVWA一共十个漏洞模块,SQL Injection和SQL Injection(Blind)看起来很像,实际训练目标完全不同。前者练的是"如何利用回显直接提取数据",后者练的是"如何在没有回显的情况下逐位还原数据"。很多教程把Blind模块一笔带过,说"用sqlmap一把梭就行",但我的体会是:手工过一遍Blind,对SQL语句结构、查询执行顺序、数据库元数据表的敏感度提升非常明显。你会在一次次AND判断中,真正理解WHERE子句里的条件是如何影响结果集的。
另外DVWA把同一漏洞分成Low、Medium、High三个安全等级,这能让你直观看到同一段代码在不同防护强度下,注入难度是怎么逐级提升的。Low基本是裸奔,Medium加了转义,High换了输入点,这恰好对应了实际开发中常见的三种历史遗留场景。
1.2 Docker方式搭建,比手动配PHP环境省心得多
如果你还没搭好DVWA,我强烈建议用Docker而不是手动去配PHP+MySQL。手动搭建我要多说一句:PHP版本和MySQL驱动很容易出兼容问题,尤其在你本机已经装了其他版本PHP的情况下,改配置时会牵连别的项目。
用Docker的步骤很直接:
docker pull vulnerables/web-dvwa docker run -d -p 8080:80 vulnerables/web-dvwa启动后浏览器访问http://127.0.0.1:8080,默认账号admin、密码password。登录后先点左侧的"Setup/Reset Database"初始化数据库,再重新登录一次。
1.3 一次真实的启动失败排查
我第一次在Windows上用Docker跑DVWA时,遇到过容器起来但页面一直转圈的情况。用docker logs看了下,发现MySQL没有正常初始化,原因是内存分配不足。解决方案是给Docker Desktop多分配一些内存,比如4GB以上,或者改用下面的命令限制容器内存占用:
docker run -d -p 8080:80 --memory=1g vulnerables/web-dvwa还有个很常见的坑是端口冲突。如果8080被占用,容器会启动失败,这时候换个端口映射即可,不用纠结非要用8080不可。
注意:DVWA自带弱口令和多个已知漏洞,建议只在本地虚拟机或隔离环境里练习。尽量不要把它部署到公网或公司内网段,靶场的目的就是用来打坏主意的,别让坏主意打到别人身上。
2. 盲注的本质:数据库是如何被"问"出答案的
2.1 布尔盲注:把数据库当成一台"是/否"问答机
我先说结论:无论盲注形式怎么变,核心思路都是"构造一个条件表达式,让它因为你的输入而变成True或False,再观察页面结果有没有变化"。
拿Low级别举例,页面存在与不存在分别输出两句话:
- 存在:"User ID exists in the database."
- 不存在:"User ID is MISSING from the database."
当你提交1' AND 1=1 -- -,SQL里实际执行的是:
SELECT first_name, last_name FROM users WHERE user_id = '1' AND 1=1 -- -' LIMIT 1;AND 1=1恒为真,结果集和只查user_id=1一样,页面显示存在。
当你提交1' AND 1=2 -- -,变成:
SELECT first_name, last_name FROM users WHERE user_id = '1' AND 1=2 -- -' LIMIT 1;AND 1=2恒为假,结果集为空,页面显示不存在。
这就是布尔盲注的原始信号:**条件真,页面一种表现;条件假,页面另一种表现。**数据库不会直接告诉你答案,但它会用结果集是否为空这种物理行为来回答你的"是/否"提问。
2.2 时间盲注:当"是/否"没有区别时,用延迟来回答
布尔盲注有个前提:页面必须有可观察的差异。如果开发者做了统一处理,比如不管查没查到都返回一张空表,或者统一返回"没有数据",那True和False在页面上长得一模一样,布尔盲注就失效了。
这时候可以换成时间盲注。思路很简单:让条件成立时执行sleep(),条件不成立时不执行,然后用响应时间作为判断信号。典型Payload长这样:
1' AND IF(SUBSTRING(DATABASE(),1,1)='d',SLEEP(3),0) -- -如果第一秒页面没出来,等了3秒才返回,说明DATABASE()第一位确实是d;如果秒回,说明第一位猜错了。数据库本质上变成了一个"会睡午觉的问答机"。
2.3 字符串比较与子串函数的选择
无论布尔还是时间盲注,提取数据时都离不开逐字符比较。MySQL里最常用的组合是SUBSTRING()配合等值比较。这里有几个细节要注意:
SUBSTRING(str, pos, len)的索引从1开始,不是0。- 比较字符时用
'a'没问题,但在Medium级别如果单引号被转义,就需要用十六进制0x61或CHAR(97)来代替。 - 注释符
--后面必须跟一个空格,否则注释不生效。我在URL里习惯写成-- -,末尾那个减号是给空格占位的。也有人用#,但在URL里要编码成%23。
理解这些之后,你可以把盲注看作是一种"协议":请求→条件判断→响应差异→推断位值。每一步都非常机械,所以后面完全可以脚本化。
3. Low级别通关:纯手工布尔盲注的完整链路
3.1 第一步,确认注入点类型
访问/vulnerabilities/sqli_blind/,提交id=1,页面显示存在。先测单引号,提交1',如果页面变成MISSING或报错,说明单引号干扰了SQL语法。再测1' AND 1=1 -- -和1' AND 1=2 -- -,观察到存在/不存在两种结果,这就确认了:
- 注入类型是字符型,因为需要用引号闭合前边的
user_id = '$id'。 - 页面有布尔差异,可以用布尔盲注。
3.2 第二步,猜库名长度和内容
拿到布尔通道后,第一个目标是确认当前数据库名。先用二分法判断长度。比如DVWA的库名长度是4,我可以这样测:
1' AND LENGTH(DATABASE())=4 -- -返回存在,则长度为4;如果返回不存在,就继续试5、6、7。
接下来逐字符猜解库名。先猜第一位:
1' AND SUBSTRING(DATABASE(),1,1)='d' -- -存在则第一位是d,否则换字母继续试。完整的库名是dvwa,四轮下来就能确定。
这个操作看起来简单,但你要理解背后的含义:**我们并没有把数据直接读出来,而是通过一次次的等值判断,把未知字符缩小到一个确定值。**这个过程和人类猜密码的逻辑本质上是一样的,只是猜得更慢、更蠢。
3.3 第三步,从表名到字段名的推理路径
库名确定后,继续查表名。表名存在information_schema.tables里,标准Payload是:
1' AND SUBSTRING((SELECT table_name FROM information_schema.tables WHERE table_schema='dvwa' LIMIT 0,1),1,1)='g' -- -这里嵌套子查询的作用是:先从元数据表里取第一张表名,再逐字符取出表名每一位进行比较。LIMIT 0,1表示取第一行,猜完第一张表所有字符后,改LIMIT 1,1取第二张表。
DVWA里通常能猜出users表。然后查字段名:
1' AND SUBSTRING((SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 0,1),1,1)='u' -- -依次能得到user_id、first_name、last_name、user和password等字段。
3.4 手工太慢,用Burp Intruder加速字符枚举
手工慢慢换字母能练耐心,但效率实在太低。第二次过的时候我改用Burp Suite的Intruder模块,把整个过程缩短到几分钟,思路如下:
- 把请求发送到Intruder。
- 将Payload里变化的部分(比如
SUBSTRING(DATABASE(),1,1)='d'中的字符d)标记为变量。 - 使用
Brute Force攻击类型,字符集选abcdefghijklmnopqrstuvwxyz0123456789_。 - 跑完后看响应中哪个包返回"exists",那个字符就是当前位。
每轮Intruder只解决一位字符。想更高效,可以把位置也变量化,一次跑完所有位置和字符,但需要处理响应判断逻辑。我建议刚开始还是手工一轮一轮来,能更直观地理解盲注的每一步。
3.5 低级别源码回顾
Low级别的PHP代码大致是这样的:
$id = $_GET['id']; $getid = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";用户输入直接进了SQL字符串,完全没有任何过滤。这个等级存在的意义就是让我们理解漏洞的原始形态,什么防护都没有时,注入可以畅通到何种程度。
4. Medium级别:转义来了,但数字型注入留了个口子
4.1 单引号被转义后,字符串注入为什么失效
Medium级别的源码核心变化是:
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id); $getid = "SELECT first_name, last_name FROM users WHERE user_id = $id;";mysqli_real_escape_string()会对'、"、\等字符做转义。你提交1' AND 1=1 -- -时,单引号会变成\',SQL语句变成了:
WHERE user_id = 1\' AND 1=1 -- -'转义后的单引号不再闭合字符串,反而变成了字符串内容的一部分,语法直接报错。这就是为什么Low级别那套字符型Payload在这里全军覆没。
4.2 数字型盲注:不碰引号也能判断
注意查询语句里user_id = $id,这个$id没有用引号包起来。这意味着输入会被当作数字处理,我们根本不需要闭合单引号,只需要注入数字运算逻辑。
判断注入是否可用:
1 AND 1=1 1 AND 1=2第一句返回存在,第二句返回不存在。原理和Low一模一样,只是少了引号闭合这一环。
提取库名时,使用十六进制绕过单引号:
1 AND SUBSTRING(DATABASE(),1,1)=0x640x64就是字符d的十六进制表示,MySQL会自动把它转成字符进行比较,完全不依赖单引号。如果觉得十六进制记起来麻烦,也可以用CHAR(100),效果相同。
4.3 Medium级别的另一种玩法:UPDATE二次盲注
Medium源码里除了SELECT,还有一个UPDATE操作。很多新手只盯着查询注入,忽略了更新语句的利用价值。页面代码会先根据id查询用户,然后尝试更新first_name字段。这意味着可以构造类似下面的Payload,把布尔盲注转化为数据更新:
1 AND UPDATE('test', user_id, first_name) ...实际DVWA里的UPDATE语句是:
UPDATE users SET first_name = '...' WHERE user_id = '$id';但因为转义和引号的问题,直接利用并不容易。我的建议是:**既然SELECT路径已经能通,就不必在这里死磕UPDATE。**知道有这条路径就够了,它更大的价值在于启发思路——同一段业务逻辑里可能存在多个SQL入口,注入点排查必须把所有访问数据库的位置都过一遍。
4.4 Medium级别的核心总结
Medium级别看起来只加了一个转义函数,实际上把所有"字符串型注入"都堵死了。但开发者犯了一个更隐蔽的错误:把输入拼接到数字上下文里,转义函数对数字上下文无能为力。**过滤函数永远只能处理已知的坏字符,而SQL注入的本质是结构问题,不是字符问题。**这句话我在之后的所有渗透测试里都反复验证过。
5. High级别:藏在Cookie里的时间盲注
5.1 为什么URL里怎么改都没反应
到了High级别,很多人的第一反应是继续在URL参数里试Payload,结果发现怎么改页面都一样。原因在于,这个等级已经不再从$_GET['id']取值了,而是读取Cookie中的id字段。
我以自己练手的DVWA 1.9版本为例,源码大致是这样的:
$id = $_COOKIE['id']; $getid = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";浏览器第一次访问时,其实并没有id这个Cookie,所以后端拿到的可能是空值,页面行为很不直观。你需要自己往请求里加一个idCookie,Payload就放进这个Cookie里。
5.2 用Burp Suite修改Cookie打时间盲注
这个等级我推荐直接上时间盲注,因为Cookie改动不像URL参数那么直观,用布尔盲注来回切换看页面字体大小会很累。用时间盲注就舒服很多。
用Burp Suite抓一个普通的POST/GET请求,在请求头中手工加入:
Cookie: PHPSESSID=你的会话; security=high; id=1' AND IF(SUBSTRING(DATABASE(),1,1)=CHAR(100),SLEEP(3),0) -- -然后发送。如果等待3秒后才有响应,说明当前第一位是d;如果立刻返回,说明猜错了。
为什么用CHAR(100)而不是'd'?因为Cookie值同样会经过字符串拼接,使用十六进制和CHAR()可以避免各种编码干扰,也让Payload在各种版本下更稳定。
5.3 完整提取一次库名
用时间盲注逐位提取DATABASE()的过程是这样的:
id=1' AND IF(SUBSTRING(DATABASE(),1,1)=CHAR(100),SLEEP(3),0) -- - id=1' AND IF(SUBSTRING(DATABASE(),2,1)=CHAR(118),SLEEP(3),0) -- - id=1' AND IF(SUBSTRING(DATABASE(),3,1)=CHAR(119),SLEEP(3),0) -- - id=1' AND IF(SUBSTRING(DATABASE(),4,1)=CHAR(97),SLEEP(3),0) -- -四个字符分别对应d v w a,时间盲注也能还原出完整库名。整个过程比布尔盲注慢,但它在页面表现完全没有差异的场景里是唯一选择。
5.4 用sqlmap偷懒之前,先搞懂它在做什么
很多人到这里就喊sqlmap一把梭,我反而觉得High级别是手工练习时间盲注最好的机会。但如果着急通关,sqlmap用法也很简单:
sqlmap -u "http://127.0.0.1:8080/vulnerabilities/sqli_blind/?Submit=Submit" --cookie="PHPSESSID=xxx; security=high; id=1" -p id --technique=T --dbs注意-p id指定Cookie里的参数,--technique=T只启用时间盲注。如果你不想让sqlmap扫一堆无关的东西,加--level=2让它检测Cookie参数。
但我的建议仍然是:**先用Burp手工跑通一遍时间盲注,再用sqlmap验证结果。**盲注的Payload看上去很简单,但判断逻辑、响应时间阈值、字符集枚举这些经验,只有自己写出来、跑过,才能真正形成肌肉记忆。工具能帮你出结果,但帮不了你理解漏洞。
5.5 为什么High级别还要防一道
High级别的防护其实也不强,只是把输入源从GET挪到了Cookie。这给我们的启示是:**开发人员常常只防"看得见的入口",却忽略了Cookie、Header、Referer这些"看不见的入口"。**很多真实系统里,参数校验只做在Form参数上,但后端读取的数据来源五花八门。安全测试如果只盯着URL参数,很容易漏掉更大的攻击面。
6. 通关之后,必须落实的防御自检
6.1 参数化查询才是治本方案
练完三个等级,你会发现所有注入问题都出在"用户输入直接拼接进了SQL语句结构"。要根治,不是写更严密的过滤函数,而是让SQL语句的结构和用户输入彻底分离,这就是参数化查询(Prepared Statement)做的事情。
以PHP PDO为例:
$stmt = $pdo->prepare("SELECT first_name, last_name FROM users WHERE user_id = :id"); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->execute();用户输入只作为参数值传给数据库,永远不可能变成SQL结构的一部分。这就像你让一个快递员送包裹,他只能送到你写的地址,但没法改变快递单上的路线图。
6.2 黑名单过滤为什么不靠谱
有些开发者喜欢用黑名单过滤危险关键字,比如把'、AND、SELECT、UNION都替换掉。问题是黑名单永远列不全。你不拦CHAR(100),它就绕过;你不拦%0a,它也能换行继续。我见过最离谱的一次,过滤了空格,对方用/**/注释当空格绕过去了。过滤只能增加攻击成本,不能消灭漏洞。
6.3 回到真实业务的自检清单
玩完DVWA的Blind模块后,我养成了一个习惯:每次代码里出现数据库操作,都会下意识问自己几个问题:
- 这条SQL语句是不是参数化查询?如果不是,改成参数化。
- 有没有直接从Cookie、Header、Referer里取值拼SQL?有的话先洗手。
- 数据库账号是否用了最小权限?应用账号能不能
DROP表? - SQL日志有没有开启?即使被注入了,能不能从日志里还原攻击路径?
这些问题看着基础,但真实系统里因为赶上线、图省事而埋下的注入点,远比想象中多。
最后分享一个我自己的感受:盲注练完,最大的收获不是会背几个Payload,而是重新理解了一个老道理——所有攻击手法,本质上都是对系统逻辑的试探和利用。SQL注入如此,其他漏洞也一样。你能把这种"试探逻辑"的思维刻进脑子里,才算真正跨过了靶场到实战的那道坎。