概述
DVWA SQL 注入的四个等级中,Impossible 不是让你攻破的——它是安全样例,告诉你"如果一开始就写对,前面那些漏洞根本不会存在"。本文逐一拆解四级源码,以 Impossible 为重点,看它如何从根源上消除 SQL 注入。
一、Low:裸奔
$id = $_REQUEST[ 'id' ];
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query);
问题清单
| 问题 | 代码体现 | 后果 |
|---|---|---|
| 无过滤 | 直接 $_REQUEST['id'] 进 SQL |
输入即代码 |
| 字符型拼接 | WHERE user_id = '$id' — 引号包裹 |
' 可用于闭合字符串上下文 |
| 报错暴露信息 | or die(...mysqli_error()...) |
攻击者看到语法错误,帮助调试 payload |
为什么 1' or '1'='1' # 能打穿
-- 拼接后:
SELECT ... WHERE user_id = '1' or '1'='1' #'
-- ↑ 闭合 ↑ 注入 ↑ 注释吞尾
攻击者的 ' 提前结束了字符串字面量,or '1'='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;";
加了什么
| 变化 | 意图 | 实际效果 |
|---|---|---|
$_POST 取代 $_REQUEST |
限制请求方式 | 无安全意义,curl -d 即可 |
mysqli_real_escape_string() |
转义 ' → \' |
无效——根本没有引号可转 |
WHERE user_id = $id(无引号) |
— | 这是一个意外:数字型 SQL |
为什么防御失效
-- 后端 SQL(注意:$id 没有被引号包裹):
SELECT ... WHERE user_id = $id;
-- 攻击者输入:1 or 1=1
-- 拼接后:
SELECT ... WHERE user_id = 1 or 1=1;
mysqli_real_escape_string() 转义了 '、"、\ 等字符——但这条 SQL 里根本没有引号。数字型注入不需要引号,转义函数打到空气上。防御必须打在正确的位置上。 把转义函数放在没有引号的数字型查询上,等于给木头房子装了防盗门却忘了墙上有个大洞。
三、High:包装纸
// 结果页面(sqli.php):
$id = $_SESSION[ 'id' ];
$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>' );
加了什么
| 变化 | 意图 | 实际效果 |
|---|---|---|
| payload 存在 session 中 | 输入/输出分页,增加复杂度 | 手动注入不受影响;简单脚本多一步 |
LIMIT 1 |
限制结果数量 | UNION SELECT 仍然能用,每页只显一行而已 |
隐藏报错 Something went wrong |
不给攻击者错误信息 | 这是唯一有效的安全改进,但注入点仍在 |
为什么 payload 不变
High 回归了字符型拼接——WHERE user_id = '$id',有引号,没有转义。跟 Low 一模一样的漏洞结构,只是外面套了一层 session 传输协议。包装纸不是墙。
四、Impossible:安全样例
if( isset( $_GET[ 'Submit' ] ) ) {
// ① Anti-CSRF Token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
$id = $_GET[ 'id' ];
// ② 输入类型校验
if( is_numeric( $id ) ) {
// ③ 参数化查询(Prepared Statement)
$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();
// ④ 结果数量校验
if( $data->rowCount() == 1 ) {
$first = $row[ 'first_name' ];
$last = $row[ 'last_name' ];
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
}
// 不是数字 → 什么都不做,安静拒绝
}
四道防线,道道致命
| 防线 | 代码 | 挡什么攻击 |
|---|---|---|
| ① Anti-CSRF Token | checkToken(...) |
CSRF:防止攻击者构造一个链接,诱导已登录用户点击来执行注入(<img src="http://dvwa/sqli/?id=1' or 1=1 #&Submit=Submit">) |
| ② is_numeric() | is_numeric($id) |
SQL 注入的第一道直接防线。 1' or '1'='1' # 不是数字 → false → 查询不执行 → 攻击连数据库的门都摸不到 |
| ③ 参数化查询 | prepare() + bindParam() |
SQL 注入的终极解。 :id 是占位符,bindParam 告诉数据库:":id 是整数,不管里面有什么字符,只当数据,永远不当代码" |
| ④ 结果校验 | LIMIT 1 + rowCount() == 1 |
即使前两道失守,UNION SELECT 拉一万行也没用——多行直接拒绝,不显示 |
为什么 is_numeric 是第一道墙
$id = "1' or '1'='1' #";
is_numeric($id); // → false —— 包含字母和特殊字符,不是数字
is_numeric() 不关心输入里有没有 ' 或 or 或 #——它只问一个问题:“这是一个合法的数字吗?“不是 → 整段代码不执行。攻击者连数据库的边都没碰到。
参数化查询为什么是终极解
传统拼接和参数化查询的本质区别:
拼接字符串:
"SELECT ... WHERE id = " + "$id"
→ 字符串拼在一起 → 一起解析 → 数据可能变成代码
参数化查询:
① 数据库先解析 SQL 模板:SELECT ... WHERE id = :id
② 解析完成后,SQL 结构已固定
③ 再把 $id 的值插入 :id 占位符
→ 此时插入的一定是数据,永远不会变成代码
bindParam( ':id', $id, PDO::PARAM_INT ) 第三个参数 PDO::PARAM_INT 更进一步——明确告诉数据库"这个是整数”。即使某种极端情况下占位符机制被绕过,数据库也会拒绝把非整数塞进 :id。
为什么 LIMIT 1 + rowCount == 1
这是纵深防御——每一层防线独立起作用,一层破了还有下一层。
is_numeric 防输入 → 非数字直接踢出
prepare 防注入 → 即使绕过了 is_numeric,注入也无法成立
LIMIT 1 + rowCount 防泄露 → 即使注入成立,也拿不到多行数据
五、四级源码并列对比
┌──────────┬──────────────────────────────────────────────────────┐
│ Low │ $id = $_REQUEST['id']; │
│ │ $query = "SELECT ... WHERE user_id = '$id'"; │
│ │ → 字符型拼接,无过滤,错误外露 │
├──────────┼──────────────────────────────────────────────────────┤
│ Medium │ $id = $_POST['id']; │
│ │ $id = mysqli_real_escape_string($id); │
│ │ $query = "SELECT ... WHERE user_id = $id"; │
│ │ → 转义函数打在空气上(数字型,没有引号可转) │
├──────────┼──────────────────────────────────────────────────────┤
│ High │ $id = $_SESSION['id']; │
│ │ $query = "SELECT ... WHERE user_id = '$id' LIMIT 1"; │
│ │ → 字符型拼接回归,加 session 分页 + 隐藏报错 + LIMIT 1 │
│ │ → 包装纸,不防注入 │
├──────────┼──────────────────────────────────────────────────────┤
│Impossible│ if(is_numeric($id)) { │
│ │ $db->prepare('...WHERE user_id = (:id) LIMIT 1'); │
│ │ $db->bindParam(':id', $id, PDO::PARAM_INT); │
│ │ if($db->rowCount() == 1) { ... } │
│ │ } │
│ │ → 类型校验 + 参数化查询 + 结果限制 = 三重纵深防御 │
└──────────┴──────────────────────────────────────────────────────┘
六、核心认知
1. SQL 注入的本质
输入能够从"数据上下文"闯进"代码上下文”,是因为数据和代码的边界被打破了。参数化查询重新划清了这条边界——先解析 SQL 结构,再插入参数值。结构固定后,插入的只能是数据。
2. 防御的层次感
Impossible 的防御架构:
第一层:is_numeric() → 输入就错 → 后面代码不执行(前置阻断)
第二层:prepare + bindParam → 输入对了但含恶意代码 → 参数化让代码失效(核心阻断)
第三层:LIMIT 1 + rowCount → 前两层都漏了 → 限制输出规模(后置阻断)
第四层:checkToken() → 别人冒充你发的 → 拒绝(外围阻断)
不是"加一个 filter 就安全了"——是让每一层独立阻断不同类型的攻击,层与层之间不依赖对方。
3. 考试 vs 工作
CISP-PTE 考:你能攻破 Low → High(攻击能力)
工作面试问:你怎么防御 SQL 注入?(防御知识)
→ is_numeric 做输入校验
→ PDO prepare + bindParam 做参数化查询
→ 报错信息不暴露给用户
→ 加上 CSRF Token
DVWA Impossible 的四行代码,就是你面试时的标准答案。
本系列共四篇: