1. 项目概述:从靶场到实战,理解SQL注入的攻防脉络
如果你刚接触网络安全,或者想找一个地方系统地、安全地练习SQL注入,那么DVWA(Damn Vulnerable Web Application)的SQL注入模块绝对是你绕不开的经典。这不仅仅是一个“靶场”,它更像一个精心设计的教学实验室,将Web安全中最古老、最危险、也最普遍的漏洞——SQL注入,拆解成从易到难四个级别(Low, Medium, High, Impossible),让你能亲手触摸到漏洞从存在到被修复的全过程。
我最初接触DVWA时,也是从SQL注入开始的。很多人觉得SQL注入就是输入个' or '1'='1,然后就能“为所欲为”,但实际操作过你就会发现,远没这么简单。不同的代码防御策略、不同的输入点、不同的数据库特性,都会让注入手法千变万化。DVWA的SQL注入模块,恰恰模拟了开发者在不同安全意识水平下可能编写的代码,让你能清晰地看到,一个简单的参数未过滤(Low级别)是如何一步步被加固,直到几乎无法被利用(Impossible级别)的。
这篇文章,我将带你手把手通关DVWA的SQL注入所有级别。我不会只给你最终的Payload(注入语句),那样你只是记住了答案,而不是学会了方法。我会详细拆解每一关的代码逻辑、防御手段,并告诉你我是如何思考、如何构造Payload、如何绕过防御的。整个过程,你会用到浏览器、Burp Suite(或任何你顺手的抓包工具)以及你的大脑。我们的目标不仅是“通关”,更是理解每一次点击、每一次输入背后的原理,从而在未来的渗透测试或代码审计中,能一眼识别出漏洞的根源。
2. 环境准备与靶场搭建:一切从“能跑起来”开始
在开始我们的“黑客”之旅前,得先把靶场搭建好。DVWA本质上是一个用PHP写的、故意包含各种漏洞的Web应用。它需要运行在一个支持PHP和MySQL(或SQLite)的Web服务器环境中。
2.1 选择你的“作战平台”
对于新手,我最推荐的是使用集成环境。这能帮你省去大量配置Apache、PHP、MySQL的繁琐步骤,让你专注于漏洞本身。
- XAMPP / WAMP (Windows):这是最经典的选择。下载安装包,一路下一步,它会自动安装好Apache、MySQL、PHP和phpMyAdmin。安装完成后,启动Apache和MySQL服务,你的本地Web服务器就运行起来了。
- MAMP (macOS):macOS用户的首选,功能和XAMPP类似,界面更友好。
- Docker:如果你对容器技术有了解,用Docker部署DVWA是最干净、最隔离的方式。一条命令就能拉取镜像并运行,完全不影响宿主机环境。
- 预置虚拟机:像OWASP Broken Web Apps (BWA)或Metasploitable这类虚拟机,已经内置了DVWA和其他几十个漏洞应用。直接用VMware或VirtualBox导入就能用,非常适合学习和实验。
我个人在早期学习时用的是XAMPP,后来为了环境隔离和快速重建,转向了Docker。对于纯粹的学习者,我建议从XAMPP开始,简单直接。
2.2 DVWA的部署与初始配置
假设你已经安装好了XAMPP并启动了服务。
- 下载DVWA:从DVWA的官方GitHub仓库(搜索“DVWA GitHub”)下载最新的ZIP压缩包。
- 部署:将解压后的
dvwa文件夹,整个复制到XAMPP的htdocs目录下(例如C:\xampp\htdocs\)。这样,你就可以通过浏览器访问http://localhost/dvwa了。 - 配置文件:找到
dvwa/config目录,将config.inc.php.dist文件复制一份,并重命名为config.inc.php。用文本编辑器打开这个新文件。 - 关键配置修改:
$_DVWA[ 'db_server' ] = '127.0.0.1';– 数据库地址,本地保持127.0.0.1即可。$_DVWA[ 'db_user' ] = 'root';– 数据库用户名,XAMPP默认是root。$_DVWA[ 'db_password' ] = 'p@ssw0rd';– 数据库密码,XAMPP默认密码为空,所以这里应该改为''(两个单引号,中间为空)。这是新手最容易卡住的地方!$_DVWA[ 'db_database' ] = 'dvwa';– 数据库名,保持dvwa。
- 初始化数据库:在浏览器中访问
http://localhost/dvwa/setup.php。点击页面底部绿色的“Create / Reset Database”按钮。这会自动创建dvwa数据库和所需的数据表。如果一切顺利,页面会显示“Setup Successful”。 - 登录:默认的登录账号是
admin,密码是password。登录后,在左侧菜单栏找到“DVWA Security”,将安全级别设置为“Low”。我们所有的练习都从最低难度开始。
注意:如果遇到“数据库连接失败”的错误,99%的原因是
config.inc.php中的数据库密码没设对。XAMPP的MySQL默认root密码就是空,务必确认。另外,确保你的MySQL服务确实已经启动。
2.3 必备工具:你的“瑞士军刀”
- 浏览器:Chrome或Firefox。它们的开发者工具(F12)是分析请求、查看响应、调试前端代码的利器。
- 抓包/改包工具:Burp Suite Community Edition是行业标准。它不仅能拦截和修改HTTP/HTTPS请求(这对Medium级别至关重要),还能进行漏洞扫描、重放攻击等。学习使用它的Proxy(代理)和Repeater(重放)模块,是Web安全入门的必修课。如果觉得Burp Suite上手有难度,OWASP ZAP也是一个免费且强大的替代品。
- 编码/解码工具:SQL注入中经常需要将字符串转为十六进制(Hex)或进行URL编码。浏览器插件如HackBar或在线工具网站都能方便地完成这些操作。Burp Suite的Decoder模块也极其强大。
环境就绪,工具在手,让我们正式进入DVWA的SQL注入世界。
3. Low级别:毫无防备的“门户大开”
将DVWA安全级别设置为Low,然后进入“SQL Injection”页面。你会看到一个简单的输入框,提示你输入User ID。这就是我们的攻击入口点。
3.1 代码审计:漏洞根源一目了然
在动手之前,先看看靶场是怎么“犯错”的。点击页面上的“View Source”,查看Low级别的后端PHP代码。核心部分如下:
if( isset( $_REQUEST[ 'Submit' ] ) ) { $id = $_REQUEST[ 'id' ]; $query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';"; $result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' ); ... }这段代码清晰得令人发指:
$_REQUEST[ 'id' ]直接获取用户输入,没有任何过滤、转义或验证。- 直接将用户输入的
$id变量,用单引号包裹,拼接进了SQL查询字符串中。 - 执行拼接后的SQL语句。
漏洞原理:假设用户输入1,那么生成的SQL语句是SELECT ... WHERE user_id = '1';,这没问题。但如果用户输入1' or '1'='1,拼接后的语句就变成了:
SELECT first_name, last_name FROM users WHERE user_id = '1' or '1'='1';WHERE子句的条件变成了user_id = '1'或者'1'='1'。由于'1'='1'这个条件永远为真(恒真),整个WHERE条件就永远为真。这意味着这条查询会返回users表中所有用户的first_name和last_name,而不仅仅是ID为1的用户。
3.2 手工注入实战:步步为营的信息窃取
手工注入的过程,就像侦探破案,一步步试探、推理,最终拿到想要的数据。我们按照标准的SQL注入流程来操作。
第一步:探测注入点与注入类型在输入框输入1,回显正常(ID:1, First name: admin, Surname: admin)。输入1'(数字1加一个单引号)。页面返回了数据库报错信息。这是一个关键信号!报错意味着我们输入的单引号破坏了原SQL语句的语法,证明这里存在SQL注入漏洞,并且是字符型注入(因为参数被单引号包裹)。
第二步:判断字段数(ORDER BY)我们需要知道当前查询的SELECT语句选取了多少个字段,以便后续使用UNION查询合并我们自己的数据。使用ORDER BY子句,它根据指定列排序,如果指定的列索引超出实际字段数,就会报错。
- 输入:
1' order by 1 ##在MySQL中是注释符,它会注释掉原SQL语句中后面的单引号和分号,保证我们语句的完整性。有时也需要用--(注意后面有个空格)。
- 输入:
1' order by 2 #,页面正常回显。 - 输入:
1' order by 3 #,页面报错。 结论:当前查询的字段数是2个(就是SELECT first_name, last_name这两个)。
第三步:确定回显位置(UNION SELECT)UNION操作符用于合并两个SELECT语句的结果集。前提是两个SELECT语句必须拥有相同数量的列,且列的数据类型相似。我们上一步知道了字段数是2,现在用UNION SELECT来探测哪个字段的内容会被显示在页面上。
- 输入:
1' union select 1,2 #- 这里我们
SELECT了数字1和2。如果页面原本显示first_name和last_name的地方,变成了数字1和2,就说明这两个位置都是可以回显我们查询结果的地方。
- 这里我们
- 实际回显:页面显示了
ID: 1,但下面两行变成了First name: 1和Surname: 2。完美!两个位置都可用。
第四步:获取数据库信息现在,我们可以把union select后面的1和2,替换成我们想查询的数据库函数。
- 获取当前数据库名:输入
1' union select 1,database() #database()函数返回当前连接的数据库名称。回显的Surname位置会显示dvwa。
- 获取数据库版本和用户:输入
1' union select version(),user() #version()返回MySQL版本,user()返回当前数据库用户。这有助于我们了解目标环境。
第五步:枚举数据库表名在MySQL中,information_schema数据库存储了所有其他数据库的元数据(如表、列信息)。information_schema.tables表存储了所有表的信息。
- 输入:
1' union select 1,group_concat(table_name) from information_schema.tables where table_schema=database() #table_schema=database():限定只查询当前数据库(dvwa)下的表。group_concat(table_name):将查询到的所有表名合并成一个字符串返回,避免UNION查询只返回一行。
- 回显结果通常会包含
guestbook和users两个表。显然,users表是我们的主要目标。
第六步:枚举表字段名知道了表名(users),接下来要获取这个表有哪些列(字段)。查询information_schema.columns表。
- 输入:
1' union select 1,group_concat(column_name) from information_schema.columns where table_name='users' #- 注意:这里的
'users'是字符串,需要用单引号括起来。
- 注意:这里的
- 回显会得到类似
user_id,first_name,last_name,user,password,avatar,last_login,failed_login的结果。我们最关心的当然是user和password字段。
第七步:提取最终数据——用户名与密码现在,表名、字段名都知道了,可以直接查询数据了。
- 输入:
1' or 1=1 union select group_concat(user), group_concat(password) from users #1' or 1=1使得前半部分查询返回users表的所有行(因为1=1恒真)。union合并我们自己的查询:将user列和password列的所有内容分别合并后查询出来。
- 回显中,你会在
First name和Surname位置看到所有的用户名和经过MD5哈希的密码字符串。
实操心得:在Low级别,整个过程非常顺畅,几乎没有任何阻碍。这模拟了最糟糕的代码实践:对用户输入完全信任。你在这里练习的
order by、union select、information_schema查询,是手工注入最核心的“三板斧”,务必熟练掌握。另外,注意使用#或--来注释掉原SQL语句的剩余部分,这是保证我们注入语句语法正确的关键。
4. Medium级别:初现端倪的防御与绕过
将安全级别切换到Medium,再次进入SQL注入页面。你会发现,输入框变成了一个下拉选择菜单,只能选择1到5的数字。前端限制了我们的输入,但这从来不是真正的障碍。
4.1 代码审计:转义与数字型注入
查看Medium级别的源码:
$id = $_POST[ 'id' ]; $id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id); $query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";关键变化有三点:
- 接收参数的方式从
$_REQUEST变成了$_POST。 - 对
$id使用了mysqli_real_escape_string()函数。这个函数会转义字符串中的特殊字符,如单引号(')、双引号(")、反斜杠(\)等。例如,输入1'会被转义成1\',这样单引号就失去了破坏SQL语法的作用。 - SQL查询语句变成了
WHERE user_id = $id,参数$id没有被单引号包裹。
漏洞分析:由于mysqli_real_escape_string()是针对字符串的转义,而现在的SQL语句是数字型查询(没有单引号),那么如果我们输入的不是字符串,而是数字或数字构成的Payload,这个转义函数就完全失效了。例如,输入1 or 1=1,转义后还是1 or 1=1,拼接进SQL语句是WHERE user_id = 1 or 1=1,这依然是一个有效的、恒真的条件。所以,Medium级别的漏洞从字符型注入转变为了数字型注入。
4.2 实战绕过:抓包改参与十六进制编码
因为前端是下拉菜单,我们无法直接输入Payload。这时,抓包工具就派上用场了。
第一步:拦截并修改POST请求
- 在浏览器中,选择User ID为1,点击Submit。
- 打开Burp Suite,确保代理拦截(Intercept is on)是开启状态。
- 在DVWA页面点击Submit,请求会被Burp Suite截获。
- 在Burp Suite的Raw界面,你会看到类似这样的请求:
POST /dvwa/vulnerabilities/sqli/ HTTP/1.1 ... id=1&Submit=Submit - 将
id=1修改为我们的Payload,例如id=1 or 1=1,然后点击“Forward”发送。
第二步:数字型注入探测由于是数字型注入,我们不需要闭合单引号,Payload会简单很多。
- 判断字段数:
id=1 order by 2和id=1 order by 3。通过响应判断字段数仍为2。 - 确定回显点:
id=1 union select 1,2。确认回显位置。 - 获取数据库名:
id=1 union select 1,database()。
第三步:绕过表名引号限制当我们尝试获取users表的字段时,会遇到一个问题。常规Payload是:id=1 union select 1,group_concat(column_name) from information_schema.columns where table_name='users'但是,'users'这个字符串中的单引号会被mysqli_real_escape_string()转义,导致语法错误。如何绕过?
方法:使用十六进制(Hex)编码我们可以将字符串users转换成其十六进制表示,这样就不需要单引号了。在MySQL中,0x开头的数字会被解释为十六进制值。
- 将
users转换为十六进制。users的十六进制是7573657273。 - 构造Payload:
id=1 union select 1,group_concat(column_name) from information_schema.columns where table_name=0x7573657273- 这里
table_name=0x7573657273等价于table_name='users',但完全避免了单引号。
- 这里
你可以用Python快速转换,也可以在Burp Suite的Decoder模块里直接转换。
import binascii print(binascii.hexlify(b'users').decode()) # 输出:7573657273第四步:获取数据最后获取数据的Payload和Low级别类似,只是去掉了单引号和注释符(因为数字型注入不需要):id=1 or 1=1 union select group_concat(user), group_concat(password) from users
注意事项:在Medium级别,最大的思维转变是从“字符型”到“数字型”的识别。前端限制形同虚设,核心在于分析后端代码如何处理输入。
mysqli_real_escape_string()的局限性在此暴露无遗——它只能防御字符型注入。同时,掌握十六进制编码绕过引号限制,是实战中非常实用的技巧,尤其当引号被过滤或转义时。
5. High级别:会话隔离与LIMIT的陷阱
High级别的界面有所不同,它跳转到了一个新的页面 (/dvwa/vulnerabilities/sqli/session-input.php),让你在一个单独的输入框提交ID,然后结果会显示在另一个页面。这模拟了某些将用户输入存入会话(Session)后再查询的场景。
5.1 代码审计:LIMIT 1的障眼法
查看High级别源码:
if( isset( $_SESSION [ 'id' ] ) ) { $id = $_SESSION[ 'id' ]; $query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;"; ... }关键点:
- 参数
$id来自$_SESSION[ 'id' ],而不是直接来自$_GET或$_POST。这意味着输入可能经过了一次页面跳转。 - SQL语句中,参数
$id依然被单引号包裹,说明是字符型注入。 - 查询语句末尾加上了
LIMIT 1。这意味着无论查询条件如何,数据库最多只返回一行结果。
漏洞分析:LIMIT 1看起来是个有效的防御,因为它阻止了我们通过union select一次性爆出大量数据(比如所有用户名密码)。但是,它并不能阻止注入本身。我们依然可以注入' or '1'='1来让WHERE条件恒真,此时查询会返回users表中的第一行数据(通常是admin)。更重要的是,我们可以用注释符#将LIMIT 1注释掉!
5.2 实战注入:注释符的妙用
High级别的注入过程,本质上又变回了Low级别的字符型注入,只是多了一个需要注释掉的LIMIT 1。
- 在
session-input.php页面的输入框,输入Payload:1' or '1'='1' #- 注意这里需要闭合原本的单引号。原语句是
... user_id = '$id' LIMIT 1; - 我们输入
1' or '1'='1' #,拼接后成为:... user_id = '1' or '1'='1' #' LIMIT 1; #后面的所有内容(包括第二个单引号和LIMIT 1)都被注释掉了。最终的生效语句是:... user_id = '1' or '1'='1',成功实现注入。
- 注意这里需要闭合原本的单引号。原语句是
- 点击提交后,页面会显示第一条用户信息(admin)。
- 后续的注入步骤(判断字段、联合查询等)与Low级别完全一致,只需在Payload末尾加上
#注释掉LIMIT 1即可。- 例如,判断字段数:
1' order by 2 # - 联合查询:
1' union select 1,2 # - 获取表名:
1' union select 1,group_concat(table_name) from information_schema.tables where table_schema=database() #
- 例如,判断字段数:
实操心得:High级别教会我们两件事。第一,不要被
LIMIT子句吓到,注释符是它的天敌。第二,理解数据流很重要。参数从$_SESSION而来,意味着攻击可能需要多步完成(先在一个页面提交,触发Session存储,再在另一个页面触发查询),但在DVWA中这个流程被简化了。在实际漏洞利用时,你需要追踪整个数据流,找到最终的注入点。
6. Impossible级别:教科书式的安全防御
Impossible级别展示了如何从根本上防御SQL注入。查看其源码,你会发现它采用了“最佳实践”组合拳:
6.1 多重防御机制解析
Anti-CSRF Token:代码开头检查
checkToken()。这虽然主要防御跨站请求伪造(CSRF),但也增加了攻击的复杂性,要求攻击者必须先获取有效的Token。输入类型检查:
if(is_numeric( $id ))。严格检查输入是否为数字。如果不是数字,直接拒绝处理。这从根本上杜绝了非数字型Payload。类型转换:
$id = intval ($id);。即使通过了is_numeric检查,还使用intval()函数将输入强制转换为整数。任何非数字字符都会被丢弃或转换。例如,输入1' or '1'='1会被转换成整数1。预编译语句(Prepared Statements):这是最核心、最有效的防御手段。
$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()方法先发送SQL语句模板到数据库进行编译。语句中的:id是一个占位符。bindParam()方法将变量$id绑定到占位符:id上,并指定其为整数类型(PDO::PARAM_INT)。execute()执行时,数据库会将绑定的值$id作为纯粹的数据插入到已编译好的SQL结构中。关键区别:在预编译语句中,SQL语句的结构(语法)和用户提供的数据是完全分离的。即使用户输入1' or '1'='1,它也会被当作一个完整的、无意义的字符串或整数绑定到user_id字段去查询,而不会被解释为SQL代码的一部分。这就好比把数据和代码放在了不同的轨道上,永远不会相交,从而彻底免疫了SQL注入。
结果数量检查:
if( $data->rowCount() == 1 )。即使发生了不可思议的情况,也确保只处理恰好返回一条记录的结果,防止数据被大量拖取。
6.2 为什么Impossible级别是安全的?
在Impossible级别,你尝试任何Low、Medium、High级别的Payload都会失败。输入1' or '1'='1会被intval()转换成1,最终执行的SQL等价于SELECT ... WHERE user_id = 1。输入1 union select 1,2会被is_numeric()检测为非法(因为包含空格和非数字字符),或者被intval()转换成1。预编译语句确保了即使有漏网之鱼,数据也不会被当作代码执行。
给开发者的启示:防御SQL注入,不应依赖于简单的字符过滤或转义(如mysqli_real_escape_string),因为总有绕过的可能(如数字型注入、编码绕过)。应该采用白名单输入验证(如这里检查是否为数字),并结合预编译语句(参数化查询)。这是OWASP等权威安全组织推荐的首要方案。
7. 思维扩展:从DVWA到真实世界
通关了DVWA的四个级别,你已经掌握了SQL注入的基本攻击手法和防御原理。但真实世界的场景要复杂得多。
- 盲注(Blind SQLi):很多时候,即使存在注入,页面也不会直接回显数据库数据或错误信息。你需要通过页面行为的差异(如真/假条件导致响应时间不同或内容微调)来一点点“盲猜”数据。DVWA也有专门的“SQL Injection (Blind)”模块供你练习。
- 工具化:手工注入虽然有助于理解原理,但效率低。SQLmap是一款开源的自动化SQL注入工具,它可以自动检测注入点、枚举数据库、提取数据。你可以尝试用SQLmap对DVWA的Low级别进行自动化测试,感受一下工具的威力。但切记,仅用于授权的测试环境!
- 二次注入:有时输入在存入数据库时被转义了,但后来从数据库取出再次用于SQL查询时,却没有被转义。这种“存储型”的注入更难发现和防御。
- 绕过WAF(Web应用防火墙):企业级应用往往部署了WAF,会过滤常见的SQL关键词(如
union,select,or)和特殊字符。这就需要使用各种混淆、编码、等价替换技巧来绕过,例如用UnIoN大小写混合、用||代替or、用十六进制编码字符串等。
DVWA的SQL注入模块是一个完美的起点。它剥离了复杂的环境,直指漏洞核心。建议你反复练习,直到对每一步的原理和Payload构造都了然于胸。然后,可以去尝试更复杂的靶场,如Pikachu、WebGoat、Sqli-Labs等,它们提供了更多变种和更接近真实的场景。
最后,永远记住:学习攻击技术的唯一目的,是为了更好地防御。理解了黑客的思维和手段,你才能写出更安全的代码,设计出更稳固的系统。在合法授权的范围内进行测试,是每一位安全从业者必须恪守的底线。