概述

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 的四行代码,就是你面试时的标准答案。


本系列共四篇: