欢迎光临
我们一直在努力

SQL 注入漏洞详解:从原理到防御的完整学习指南

文章目录

    • 一、什么是 SQL 注入?
      • 1.1 定义
      • 1.2 一句话理解
      • 1.3 为什么会产生?
      • 1.4 举个最简单的例子
      • 1.5 SQL 注入的危害
    • 二、项目概览
      • 2.1 项目结构
      • 2.2 技术栈
      • 2.3 数据库表结构
      • 2.4 测试数据
    • 三、演示一:登录绕过(Login Bypass)
      • 3.1 漏洞原理
      • 3.2 攻击方法
        • 攻击载荷 1:用注释符绕过密码
        • 攻击载荷 2:永真条件 OR 1=1
        • 攻击载荷 3:闭合两端引号
      • 3.3 漏洞代码分析
      • 3.4 防御代码分析
      • 3.5 对比总结
    • 四、演示二:UNION 查询注入
      • 4.1 漏洞原理
      • 4.2 攻击方法
        • 攻击载荷 1:确定列数
        • 攻击载荷 2:窃取用户名和密码
        • 攻击载荷 3:窃取管理员日志
      • 4.3 漏洞代码分析
      • 4.4 防御代码分析
      • 4.5 对比总结
    • 五、演示三:布尔盲注(Boolean-Based Blind)
      • 5.1 漏洞原理
      • 5.2 攻击方法
        • 攻击载荷 1:猜解密码长度
        • 攻击载荷 2:逐字符猜解密码
        • 攻击载荷 3:猜解数据库版本
      • 5.3 漏洞代码分析
      • 5.4 防御代码分析
      • 5.5 对比总结
    • 六、演示四:延时注入(Time-Based Blind Injection)
      • 6.1 漏洞原理
      • 6.2 攻击方法
        • 场景:利用用户存在检查接口
        • 攻击载荷 1:测试延时是否可用
        • 攻击载荷 2:猜解密码长度
        • 攻击载荷 3:逐字符猜解密码
        • 完整猜解流程(演示 admin 密码 "admin123")
      • 6.3 漏洞代码分析
      • 6.4 防御代码分析
      • 6.5 对比总结
    • 七、演示五:报错注入(Error-Based Injection)
      • 7.1 漏洞原理
      • 7.2 攻击方法
        • 步骤 1:正常查询
        • 步骤 2:注入 CAST 报错(泄露密码)
        • 攻击载荷 2:泄露其他用户的密码
        • 攻击载荷 3:泄露邮箱
        • 攻击载荷 4:泄露数据库版本
      • 7.3 漏洞代码分析
      • 7.4 防御代码分析
      • 7.5 对比总结
    • 八、演示六:堆叠查询注入(Stacked Queries)
      • 8.1 漏洞原理
      • 8.2 攻击方法
        • 场景:更新个人简介
        • 攻击载荷 1:删除其他用户
        • 攻击载荷 2:修改管理员密码
        • 攻击载荷 3:创建新的管理员账户
      • 8.3 漏洞代码分析
      • 8.4 防御代码分析
      • 8.5 对比总结
    • 九、演示七:二次注入(Second-Order Injection)
      • 9.1 漏洞原理
      • 9.2 攻击方法
        • 本演示的流程:
      • 9.3 漏洞代码分析
      • 9.4 防御代码分析
      • 9.5 对比总结
    • 十、核心防御:参数化查询
      • 10.1 什么是参数化查询?
      • 10.2 参数化查询的工作原理
      • 10.3 在 PostgreSQL (pg 驱动) 中的写法
      • 10.4 为什么参数化查询能防止 SQL 注入?
      • 10.5 参数化查询不能防御的情况
    • 十一、安全最佳实践
      • 11.1 防御体系总结
      • 11.2 代码层面的最佳实践
        • 永远使用参数化查询
        • 验证输入类型
        • 不在错误消息中暴露信息
        • 使用最小权限原则
        • 对动态表名/列名使用白名单
      • 11.3 不要依赖的"防御"手段
    • 十二、总结
      • 12.1 七种 SQL 注入对比
      • 12.2 核心要点
      • 12.3 学习路径建议

基于 Node.js + PostgreSQL 的 SQL 注入漏洞演示平台,面向初学者的交互式学习教程。 完整漏洞演示源码:https://gitcode.com/lcreek/Security


一、什么是 SQL 注入?

1.1 定义

SQL 注入(SQL Injection) 是一种代码注入技术,攻击者通过在应用程序的输入字段中插入恶意 SQL 语句,来操纵后端数据库执行非预期的操作。

1.2 一句话理解

程序把用户输入当作 SQL 代码执行了,而不是当作普通数据来处理。

1.3 为什么会产生?

当一个应用程序的开发者使用字符串拼接的方式构建 SQL 查询语句时,用户的输入内容会直接成为 SQL 语句的一部分。如果用户输入了精心构造的特殊字符(如单引号、分号、SQL 关键字等),就可能改变原始 SQL 语句的语义。

1.4 举个最简单的例子

假设后端代码是这样的:

const sql = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;

如果用户输入:

  • 用户名:admin'–
  • 密码:任意值

实际执行的 SQL 变为:

SELECT * FROM users WHERE username='admin'–' AND password='任意值'

— 是 SQL 的注释符,后面的 AND password=… 全部被注释掉了。查询变成了只要 username='admin' 就返回结果,完全绕过了密码验证。

1.5 SQL 注入的危害

危害类型具体表现
数据泄露 窃取用户密码、邮箱、手机号等敏感信息
身份冒用 绕过登录验证,以管理员身份登录
数据篡改 修改数据库中的记录(如修改价格、余额)
数据删除 删除数据库表或整个数据库
权限提升 创建新的管理员账户
服务器沦陷 在某些配置下,甚至可能执行操作系统命令

二、项目概览

2.1 项目结构

SQLInjectionDemos/
├── server.js # 服务器主入口(Express + 路由 + API)
├── package.json # 项目依赖配置
├── db/
│ ├── pool.js # PostgreSQL 连接池配置
│ └── init.sql # 数据库初始化脚本(建表 + 测试数据)
└── public/
├── index.html # 首页导航
├── style.css # 全局样式表
├── login.html # 演示1:登录绕过
├── union.html # 演示2:UNION 注入
├── boolean-blind.html # 演示3:布尔盲注
├── time-blind.html # 演示4:延时注入
├── error-based.html # 演示5:报错注入
├── stacked.html # 演示6:堆叠注入
└── second-order.html # 演示7:二次注入

2.2 技术栈

技术用途
Node.js 运行时环境
Express Web 框架,处理 HTTP 请求和路由
PostgreSQL 关系型数据库
pg (node-postgres) Node.js 的 PostgreSQL 驱动
HTML/CSS/JavaScript 前端页面和交互

2.3 数据库表结构

项目使用 4 张表来演示不同的 SQL 注入场景:

— 用户表:用于登录绕过、布尔盲注、报错注入、堆叠注入、二次注入
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
email VARCHAR(100),
role VARCHAR(20) DEFAULT 'user'
);

— 商品表:用于 UNION 注入
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
price DECIMAL(10,2)
);

— 用户资料表:用于二次注入、堆叠注入
CREATE TABLE profiles (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
bio TEXT
);

— 管理员日志表:用于敏感数据泄露演示
CREATE TABLE admin_logs (
id SERIAL PRIMARY KEY,
action VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW()
);

2.4 测试数据

— 5 个用户:1 个管理员 + 4 个普通用户
INSERT INTO users (username, password, email, role) VALUES
('admin', 'admin123', 'admin@demo.com', 'admin'),
('alice', 'pass123', 'alice@demo.com', 'user'),
('bob', 'secret456', 'bob@demo.com', 'user'),
('charlie', 'p@ssw0rd!', 'charlie@demo.com', 'user'),
('david', 'david2024', 'david@demo.com', 'user');

— 8 个商品
INSERT INTO products (name, description, price) VALUES
('笔记本电脑', '15寸轻薄本,16GB内存', 5999.00),
('无线鼠标', '静音设计,蓝牙5.0', 129.00),
('机械键盘', '青轴,全键无冲', 349.00),
— … 更多商品


三、演示一:登录绕过(Login Bypass)

3.1 漏洞原理

难度等级:初级

这是最经典、最易理解的 SQL 注入类型。在用户登录时,后端程序需要用用户名和密码去数据库查询匹配的记录。如果开发者使用字符串拼接来构建这条 SQL 查询,攻击者就可以通过精心构造的输入来绕过密码验证。

正常的登录 SQL:

SELECT * FROM users WHERE username='admin' AND password='admin123'

这条 SQL 的语义是:找一个用户名是 admin 且 密码是 admin123 的用户。

3.2 攻击方法

攻击载荷 1:用注释符绕过密码

输入:

  • 用户名:admin'–
  • 密码:任意值

实际执行的 SQL:

SELECT * FROM users WHERE username='admin'–' AND password='任意值'

原理解析:

— 是 SQL 中的单行注释符(# 也可以)。当 admin' 闭合了前面的单引号后,– 将后面的 AND password=… 全部注释掉了。数据库实际执行的逻辑等价于:

— 原始:username='admin' AND password='admin123'
— 注入后:username='admin' AND password='admin123' ← 被注释掉了
— 实际执行:username='admin'

查询变成了只要 username='admin' 就返回结果,完全绕过了密码验证。

攻击载荷 2:永真条件 OR 1=1

输入:

  • 用户名:' OR 1=1–
  • 密码:任意值

实际执行的 SQL:

SELECT * FROM users WHERE username='' OR 1=1–' AND password='任意值'

原理解析:

1=1 永远为真。OR 操作符的特性是:只要两个条件中有一个为真,整个表达式就为真。所以 username='' OR 1=1 永远是 TRUE,数据库会返回 所有用户 的第一条记录(通常是管理员)。

攻击载荷 3:闭合两端引号

输入:

  • 用户名:' OR '1'='1
  • 密码:' OR '1'='1

实际执行的 SQL:

SELECT * FROM users WHERE username='' OR '1'='1' AND password='' OR '1'='1'

利用 '1'='1' 这个永真条件,无论是在用户名还是密码处注入,都能让 WHERE 子句始终为真。

3.3 漏洞代码分析

// ===== 漏洞版本 =====
app.post("/api/login/vulnerable", async (req, res) => {
const { username, password } = req.body;

// 【漏洞核心】使用模板字符串直接拼接用户输入到 SQL 中
// ${username} 和 ${password} 的内容会原样拼入 SQL,不经过任何过滤
const sql = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;

try {
const result = await pool.query(sql);
if (result.rows.length > 0) {
res.json({ success: true, message: `登录成功!`, user: result.rows[0] });
} else {
res.json({ success: false, message: "用户名或密码错误" });
}
} catch (err) {
res.json({ success: false, message: `SQL 执行错误: ${err.message}` });
}
});

问题出在哪里? 第 6 行,${username} 和 ${password} 直接嵌入到 SQL 字符串中。用户输入的任何字符——包括单引号、SQL 关键字、注释符——都会成为 SQL 语句的一部分。

3.4 防御代码分析

// ===== 安全版本 =====
app.post("/api/login/safe", async (req, res) => {
const { username, password } = req.body;

// 【安全核心】使用 $1, $2 占位符,SQL 结构与数据分离
const sql = "SELECT * FROM users WHERE username=$1 AND password=$2";

try {
// 将参数数组作为第二个参数传入 pool.query()
// pg 驱动负责将 username 替换 $1,password 替换 $2,并安全转义
const result = await pool.query(sql, [username, password]);
if (result.rows.length > 0) {
res.json({ success: true, message: `登录成功!` });
} else {
res.json({ success: false, message: "用户名或密码错误" });
}
} catch (err) {
res.json({ success: false, message: `服务器错误` });
}
});

为什么安全?

  • SQL 结构与数据分离:SQL 语句的骨架(SELECT … WHERE username=$1 AND password=$2)是固定的,$1 和 $2 只是占位符。
  • 参数单独传递:[username, password] 作为参数数组传入,pg 驱动会将其中的内容安全转义后再填充到占位符位置。
  • 攻击者输入失效:如果攻击者输入 admin'–,它会被当作一个完整的用户名字符串 admin'–(带单引号的)去匹配,而不是作为 SQL 语法的一部分。数据库会查找用户名为 admin'– 的用户,自然找不到。
  • 3.5 对比总结

    对比维度漏洞版本安全版本
    SQL 构建方式 模板字符串拼接 参数化查询($1 占位符)
    用户输入角色 既是数据,也是 SQL 代码 纯粹是数据
    攻击 admin'– 效果 绕过密码,登录成功 查找用户名 admin'–,登录失败
    安全性 不安全 安全

    四、演示二:UNION 查询注入

    4.1 漏洞原理

    难度等级:中级

    UNION 是 SQL 中的集合操作符,它可以将多个 SELECT 语句的结果合并为一个结果集。UNION 注入的核心思路是:攻击者在正常查询的后面追加一个 UNION SELECT,将恶意查询的结果合并到正常结果中,从而窃取其他表的数据。

    前提条件(两个关键):

  • 列数要匹配:UNION 前后两个 SELECT 语句的列数必须相同。
  • 数据类型要兼容:对应列的数据类型必须兼容(或可隐式转换)。
  • 原始查询:

    SELECT id, name, price FROM products WHERE name LIKE '%鼠标%'

    返回 3 列:id(整数)、name(字符串)、price(小数)。

    4.2 攻击方法

    攻击载荷 1:确定列数

    攻击者首先需要确定原始查询返回几列,通常使用 ORDER BY 试探:

    关键字:' ORDER BY 1–
    关键字:' ORDER BY 2–
    关键字:' ORDER BY 3–
    关键字:' ORDER BY 4– ← 报错!说明只有 3 列

    攻击载荷 2:窃取用户名和密码

    关键字:' UNION SELECT id, username, password FROM users–

    实际执行的 SQL:

    SELECT id, name, price FROM products WHERE name LIKE '%' UNION SELECT id, username, password FROM users–%'

    结果: 商品列表和用户数据(id、username、password)合并在一起返回,攻击者拿到了所有用户的密码!

    攻击载荷 3:窃取管理员日志

    关键字:' UNION SELECT id, action, created_at::text FROM admin_logs–

    注意 ::text 类型转换:admin_logs 表的 created_at 是 TIMESTAMP 类型,与原始查询的 price(DECIMAL)类型不兼容,需要用 ::text 转换为字符串。

    4.3 漏洞代码分析

    // ===== 漏洞版本 =====
    app.get("/api/products/vulnerable", async (req, res) => {
    const { keyword } = req.query;

    // 【漏洞核心】% 通配符和 keyword 都拼在 SQL 字符串中
    // 攻击者可以通过闭合 ' 和 % 来注入自己的 SQL
    const sql = `SELECT id, name, price FROM products WHERE name LIKE '%${keyword || ""}%'`;

    try {
    const result = await pool.query(sql);
    res.json({
    success: true,
    data: result.rows,
    executedSql: sql, // 返回实际执行的 SQL,帮助理解注入过程
    });
    } catch (err) {
    res.json({
    success: false,
    message: `SQL 执行错误: ${err.message}`,
    executedSql: sql,
    });
    }
    });

    问题分析:

    第 6 行,'%${keyword}%' 的结构意味着:

    • 正常搜索 鼠标 时,SQL 为 … LIKE '%鼠标%',正常工作。
    • 攻击者输入 ' UNION SELECT …– 时,SQL 变为 … LIKE '%' UNION SELECT …–%',' 闭合了前面的 %,– 注释掉了后面的 %',UNION 注入成功!

    4.4 防御代码分析

    // ===== 安全版本 =====
    app.get("/api/products/safe", async (req, res) => {
    const { keyword } = req.query;
    const sql = "SELECT id, name, price FROM products WHERE name LIKE $1";

    try {
    // 将 %keyword% 作为参数值传入,keyword 的内容被安全转义
    // 攻击者输入的任何内容都会被当作搜索关键词字符串,而不是 SQL 语法
    const result = await pool.query(sql, [`%${keyword || ""}%`]);
    res.json({ success: true, data: result.rows });
    } catch (err) {
    res.json({ success: false, message: `服务器错误` });
    }
    });

    为什么安全?

    $1 占位符接收的是 %keyword% 这个整体字符串,keyword 的内容被 pg 驱动安全转义。攻击者输入 ' UNION SELECT … 时,它会被当作一个普通搜索词 %' UNION SELECT …% 在 name 字段中模糊匹配,显然找不到任何匹配的商品。

    4.5 对比总结

    对比维度漏洞版本安全版本
    SQL 构建方式 字符串拼接 参数化查询($1 占位符)
    攻击 ' UNION SELECT … 效果 窃取用户表数据 搜索无结果,注入失败
    返回的 SQL 信息 返回实际执行的 SQL(教学目的) 不返回 SQL
    安全性 不安全 安全

    五、演示三:布尔盲注(Boolean-Based Blind)

    5.1 漏洞原理

    难度等级:中级

    盲注(Blind Injection) 发生在页面不直接返回数据库内容,但会根据查询结果呈现不同状态的场景。攻击者无法直接看到数据,但可以通过观察"两种不同响应"来推断信息。

    核心思路:

    将"数据是否等于某个字符"这个信息,转化为"用户存在" vs "用户不存在"两种响应,然后逐字符猜解。

    场景: 一个用户名检查功能,只返回"用户存在"或"用户不存在"。攻击者可以通过构造条件判断语句,将敏感数据逐字符地"盲猜"出来。

    5.2 攻击方法

    攻击载荷 1:猜解密码长度

    用户名:admin' AND LENGTH(password) > 5–

    逻辑: 如果 admin 的密码长度 > 5,返回"用户存在";否则返回"用户不存在"。通过不断调整数字,可以确定密码长度。

    攻击载荷 2:逐字符猜解密码

    用户名:admin' AND SUBSTRING(password, 1, 1)='a'–
    用户名:admin' AND SUBSTRING(password, 1, 1)='b'–

    用户名:admin' AND SUBSTRING(password, 1, 1)='z'–

    逻辑: 假设密码长度是 8:

    • 第一步:遍历 a-z 和 0-9,猜密码第 1 个字符。当 SUBSTRING(password,1,1)='a' 时返回"用户存在",说明密码首字符是 a。
    • 第二步:猜第 2 个字符,SUBSTRING(password,2,1)='d' 返回"用户存在",说明是 d。
    • 以此类推,逐字符猜出密码 admin123。

    所需请求数: 假设密码长度 8,字符集 36(a-z + 0-9),最坏情况 8 × 36 = 288 次请求即可猜出密码。实际攻击中通常使用自动化脚本。

    攻击载荷 3:猜解数据库版本

    用户名:admin' AND SUBSTRING(version(), 1, 3)='Pos'–

    如果返回"用户存在",说明数据库是 PostgreSQL(version() 返回 PostgreSQL x.x.x)。

    5.3 漏洞代码分析

    // ===== 漏洞版本 =====
    app.get("/api/user/exists/vulnerable", async (req, res) => {
    const { username } = req.query;

    // 【漏洞核心】直接将用户输入拼接到 WHERE 子句
    const sql = `SELECT * FROM users WHERE username='${username || ""}'`;

    try {
    const result = await pool.query(sql);
    if (result.rows.length > 0) {
    res.json({ exists: true, message: "用户存在" }); // True 状态
    } else {
    res.json({ exists: false, message: "用户不存在" }); // False 状态
    }
    } catch (err) {
    res.json({ exists: false, message: "查询出错" });
    }
    });

    为什么这会形成盲注?

  • 页面只返回两种状态:用户存在(True)和 用户不存在(False)。
  • 攻击者注入条件判断语句(如 AND SUBSTRING(…)='a'),将"数据是否等于某字符"转化为"用户是否存在"。
  • 正常情况下,admin' AND SUBSTRING(password,1,1)='a' 这种查询,如果密码首字符是 a,admin 用户存在且条件为真 → 返回"用户存在";如果密码首字符不是 a,条件为假 → 返回"用户不存在"。
  • 5.4 防御代码分析

    // ===== 安全版本 =====
    app.get("/api/user/exists/safe", async (req, res) => {
    const { username } = req.query;
    try {
    // 使用 $1 占位符,整个 username 输入作为字符串参数
    // 攻击者输入 ' AND SUBSTRING(…) 会被当作一个完整的用户名字符串来匹配
    const result = await pool.query("SELECT * FROM users WHERE username=$1", [
    username || "",
    ]);
    res.json({ exists: result.rows.length > 0 });
    } catch (err) {
    res.json({ exists: false, message: "查询出错" });
    }
    });

    为什么安全?

    参数化查询将 admin' AND SUBSTRING(password,1,1)='a' 当作一个完整的用户名字符串来匹配。数据库会查找用户名恰好等于这个奇怪字符串的用户,显然找不到,返回"用户不存在"。SUBSTRING() 函数不会被当作 SQL 代码执行。

    5.5 对比总结

    对比维度漏洞版本安全版本
    攻击方式 利用"存在/不存在"两种状态逐字符猜解 无法注入,注入语句被当作用户名匹配
    攻击 admin' AND SUBSTRING(…) 返回"存在"或"不存在",泄露信息 查找该字符串用户,返回"不存在"
    所需请求数 约 288 次可猜出 8 位密码 注入无效
    安全性 不安全 安全

    六、演示四:延时注入(Time-Based Blind Injection)

    6.1 漏洞原理

    难度等级:中高级

    延时注入(时间盲注) 是盲注的一种高级变体。当页面不返回任何数据差异(只有一种响应),布尔盲注无法使用时,攻击者可以注入延时函数,通过测量服务器响应时间来判断条件真假。

    核心思路:

    页面虽然看起来一样,但如果条件为真,服务器会"多等几秒"再响应。

    与布尔盲注的对比:

    对比维度布尔盲注延时注入
    判断依据 页面显示"存在" vs “不存在” 响应时间快 vs 慢
    适用场景 页面有两种不同响应 页面只有一种响应(如统一返回"操作成功")
    速度 极慢(每次判断需要等待数秒)
    隐蔽性 中等 高(响应内容完全一致)
    数据库函数 不需要特殊函数 需要延时函数(如 PG_SLEEP)

    PostgreSQL 的延时函数:

    SELECT PG_SLEEP(3); — 数据库休眠 3 秒

    攻击者如何利用:

  • 注入 AND (条件判断 + PG_SLEEP)
  • 条件为真 → 数据库休眠 N 秒 → 响应延迟 N 秒
  • 条件为假 → 数据库不休眠 → 响应即时返回
  • 通过测量响应时间来判断条件真假
  • 6.2 攻击方法

    场景:利用用户存在检查接口

    原始查询:

    SELECT * FROM users WHERE username='admin'

    页面只返回"操作完成"(一种响应),没有"存在/不存在"的差异。

    攻击载荷 1:测试延时是否可用

    用户名:admin' AND (SELECT 1 FROM (SELECT PG_SLEEP(3)) AS t)=1–

    实际执行的 SQL:

    SELECT * FROM users WHERE username='admin' AND (SELECT 1 FROM (SELECT PG_SLEEP(3)) AS t)=1–'

    判断: 如果响应延迟了约 3 秒 → 存在延时注入!

    原理: PG_SLEEP(3) 让数据库休眠 3 秒。攻击者测量 HTTP 请求的往返时间,与正常响应时间对比,如果明显增加就确认注入存在。

    攻击载荷 2:猜解密码长度

    用户名:admin' AND (SELECT CASE WHEN (LENGTH(password)>5) THEN PG_SLEEP(2) ELSE 0 END)=0–

    实际执行的 SQL:

    SELECT * FROM users WHERE username='admin' AND (SELECT CASE WHEN (LENGTH(password)>5) THEN PG_SLEEP(2) ELSE 0 END)=0–'

    解析:

    CASE WHEN (LENGTH(password)>5) — 判断 admin 的密码长度是否大于 5
    THEN PG_SLEEP(2) — 条件为真:休眠 2 秒
    ELSE 0 — 条件为假:不休眠
    END

    • 响应延迟 2 秒 → admin 密码长度 > 5
    • 响应即时返回 → admin 密码长度 ≤ 5

    通过二分法调整数字,可以精确确定密码长度为 8。

    攻击载荷 3:逐字符猜解密码

    用户名:admin' AND (SELECT CASE WHEN (SUBSTRING(password,1,1)='a') THEN PG_SLEEP(2) ELSE 0 END)=0–

    解析:

    CASE WHEN (SUBSTRING(password,1,1)='a') — 判断密码第 1 个字符是否为 'a'
    THEN PG_SLEEP(2) — 是 'a':休眠 2 秒
    ELSE 0 — 不是 'a':不休眠
    END

    • 响应延迟 2 秒 → 密码首字符是 a
    • 响应即时返回 → 密码首字符不是 a,换下一个字符继续猜
    完整猜解流程(演示 admin 密码 “admin123”)
    步骤Payload(简化)响应时间结论
    1 SUBSTRING(password,1,1)='a' ~2.1s 首字符是 a
    2 SUBSTRING(password,2,1)='d' ~2.1s 第2字符是 d
    3 SUBSTRING(password,3,1)='m' ~2.1s 第3字符是 m
    4 SUBSTRING(password,4,1)='i' ~2.1s 第4字符是 i
    5 SUBSTRING(password,5,1)='n' ~2.1s 第5字符是 n
    8 SUBSTRING(password,8,1)='3' ~2.1s 第8字符是 3

    结果: 密码 = admin123,8 个字符全部猜出。

    所需请求数估算:

    • 密码长度 8,字符集 36(a-z + 0-9)
    • 最坏情况:8 × 36 = 288 次请求
    • 每个请求等待 2 秒,总耗时约 288 × 2 = 576 秒(约 10 分钟)

    ⚠️ 注意: 延时注入是所有注入技术中最慢的,但也是最难防御的——因为响应内容完全一致,WAF 很难通过内容检测来判断是否存在攻击。

    6.3 漏洞代码分析

    延时注入可以在任何存在注入点的接口上实施。这里以用户存在检查接口为例:

    // ===== 漏洞版本 =====
    app.get("/api/user/exists/time-blind/vulnerable", async (req, res) => {
    const { username } = req.query;

    // 【漏洞核心】直接将用户输入拼接到 WHERE 子句
    const sql = `SELECT * FROM users WHERE username='${username || ""}'`;

    try {
    await pool.query(sql);
    // 注意:即使这里统一返回 "操作完成" 而不区分"存在/不存在"
    // 攻击者仍可以通过 PG_SLEEP() 延时函数来判断条件真假
    res.json({ status: "ok" });
    } catch (err) {
    res.json({ status: "ok" }); // 错误也不返回详情
    }
    });

    为什么统一响应仍然挡不住延时注入?

    攻击者注入 PG_SLEEP():

    请求 A:admin' AND (PG_SLEEP(2) IS NOT NULL)–
    → 数据库休眠 2 秒 → 响应时间 ≈ 2000ms

    请求 B:不存在的用户' AND (PG_SLEEP(2) IS NOT NULL)–
    → 数据库不休眠(因为 WHERE 不匹配,不会执行 PG_SLEEP)
    → 响应时间 ≈ 50ms

    攻击者通过响应时间差异判断:
    – 2 秒响应 → 用户 "admin" 存在
    – 50ms 响应 → 用户不存在

    关键洞察: 即使页面内容完全一样,时间本身就是信息通道!

    6.4 防御代码分析

    // ===== 安全版本 =====
    app.get("/api/user/exists/time-blind/safe", async (req, res) => {
    const { username } = req.query;
    try {
    // 参数化查询:PG_SLEEP 等函数不会被当作 SQL 代码执行
    const result = await pool.query("SELECT * FROM users WHERE username=$1", [
    username || "",
    ]);
    res.json({ status: "ok" });
    } catch (err) {
    res.json({ status: "ok" });
    }
    });

    为什么安全?

    参数化查询将整个输入作为字符串值处理。攻击者输入 admin' AND PG_SLEEP(3)– 时,数据库会查找用户名等于这个完整字符串的用户,PG_SLEEP() 不会被执行。

    额外的防御措施:

    措施说明
    请求频率限制 限制同一 IP 的请求频率,增加攻击时间成本
    查询超时设置 设置 SQL 查询超时(如 5 秒),防止长时间休眠
    参数化查询 根本解决方案,阻止任何 SQL 函数注入
    响应时间随机化 在响应中加入随机延迟(如 10-100ms),干扰时间测量

    6.5 对比总结

    对比维度漏洞版本安全版本
    攻击方式 注入 PG_SLEEP() 通过响应时间推断数据 无法注入,注入语句被当作用户名匹配
    隐蔽性 极高(响应内容完全一致) 无漏洞
    攻击速度 极慢(每个字符需数秒) 注入无效
    检测难度 高(WAF 难以通过内容检测) 无需检测
    安全性 不安全 安全

    七、演示五:报错注入(Error-Based Injection)

    7.1 漏洞原理

    难度等级:中级

    报错注入 利用数据库在执行错误 SQL 时返回的错误消息来获取数据。当程序将 SQL 错误信息直接展示给用户时,攻击者可以构造特意会报错的 SQL 语句,在错误消息中嵌入敏感数据。

    核心思路:

    攻击者故意让数据库报错——但错误消息不是副作用,错误消息本身就是攻击者想要的数据载体!

    为什么报错注入能工作?

    报错注入需要同时满足两个条件:

    条件说明本项目如何满足
    存在注入点 用户输入能改变 SQL 语义 id 直接拼接到 SQL:WHERE id=${id}
    错误消息回显 服务器将数据库错误返回给客户端 catch 块返回 err.message

    CAST 函数的工作原理:

    PostgreSQL 的 CAST(值 AS 目标类型) 函数尝试将值转换为目标类型。如果转换失败,PostgreSQL 会抛出错误,错误消息中包含原始值:

    — 正常:将字符串 '123' 转为整数
    SELECT CAST('123' AS INTEGER);
    — 结果:123

    — 报错:无法将 'admin123' 转为整数!
    SELECT CAST('admin123' AS INTEGER);
    — 错误:invalid input syntax for type integer: "admin123"
    — ^^^^^^^^^^
    — 原始值在错误消息中!

    攻击者的思路:

  • 把敏感数据(如密码)放到 CAST() 函数中
  • 数据库尝试类型转换 → 必然失败
  • 错误消息中直接包含了密码明文
  • 7.2 攻击方法

    步骤 1:正常查询

    用户ID:1

    实际执行的 SQL:

    SELECT username FROM users WHERE id=1

    结果: 返回 admin 用户,一切正常。

    步骤 2:注入 CAST 报错(泄露密码)

    用户ID:1 AND CAST((SELECT password FROM users WHERE id=1) AS INTEGER)=1

    逐字符解析 Payload:

    1 AND CAST((SELECT password FROM users WHERE id=1) AS INTEGER)=1
    │ │ │ │ │ │
    │ │ │ └── 子查询:获取 id=1 用户的 password = "admin123"
    │ │ └── CAST(…AS INTEGER):尝试将子查询结果转为整数
    │ └── =1:与整数比较,使 AND 获得布尔参数(PostgreSQL 要求 AND 的参数必须是布尔类型)
    └── 正常参数

    注入点分析:
    – 代码模板:WHERE id=${id || 1}
    – id 参数没有用引号包裹(数字型注入)
    – 不需要闭合引号,直接追加 SQL 逻辑即可

    实际执行的 SQL:

    SELECT username FROM users WHERE id=1 AND CAST((SELECT password FROM users WHERE id=1) AS INTEGER)=1

    执行流程:

    1. id=1 → 找到 id 为 1 的用户(admin)
    2. (SELECT password FROM users WHERE id=1) → 子查询返回 "admin123"
    3. CAST('admin123' AS INTEGER)=1 → 类型转换失败!
    4. PostgreSQL 抛出错误:invalid input syntax for type integer: "admin123"
    5. catch 块捕获错误,返回给客户端

    返回的错误消息:

    {
    "success": false,
    "message": "SQL 执行错误: invalid input syntax for type integer: \\"admin123\\""
    }

    密码 admin123 直接在错误消息中泄露了!

    攻击载荷 2:泄露其他用户的密码

    用户ID:1 AND CAST((SELECT password FROM users WHERE id=2) AS INTEGER)=1

    错误消息: invalid input syntax for type integer: "pass123" ← alice 的密码

    攻击载荷 3:泄露邮箱

    用户ID:1 AND CAST((SELECT email FROM users WHERE id=1) AS INTEGER)=1

    错误消息: invalid input syntax for type integer: "admin@demo.com" ← admin 的邮箱

    攻击载荷 4:泄露数据库版本

    用户ID:1 AND CAST((SELECT version()) AS INTEGER)=1

    错误消息: invalid input syntax for type integer: "PostgreSQL 16.x…"

    攻击者不需要看到正常查询结果,错误消息就是他们的"输出窗口"!

    7.3 漏洞代码分析

    // ===== 漏洞版本 =====
    app.get("/api/user/search/vulnerable", async (req, res) => {
    const { id } = req.query;

    try {
    // 【漏洞核心】不验证 id 参数类型,直接拼接到 SQL 中
    const sql = `SELECT username FROM users WHERE id=${id || 1}`;
    const result = await pool.query(sql);
    res.json({
    success: true,
    data: result.rows,
    executedSql: sql,
    });
    } catch (err) {
    // 【漏洞点】将完整的数据库错误信息返回给客户端
    res.json({
    success: false,
    message: `SQL 执行错误: ${err.message}`, // 错误消息泄露
    });
    }
    });

    两个漏洞点:

  • SQL 注入:id 参数直接拼接到 SQL 中,没有类型校验。
  • 信息泄露:catch 块将完整的数据库错误消息返回给客户端(第 16 行),包含敏感数据。
  • 7.4 防御代码分析

    // ===== 安全版本 =====
    app.get("/api/user/search/safe", async (req, res) => {
    const { id } = req.query;

    // 严格校验:只接受纯数字输入
    // 使用正则 /^\\\\d+$/ 确保整个字符串都是数字,防止 parseInt 的静默截断
    // 例如 parseInt("1 AND CAST(…)") 会返回 1,但正则校验会拒绝
    if (!id || !/^\\\\d+$/.test(id)) {
    return res.json({ success: false, message: "无效的用户ID" });
    }

    const numId = parseInt(id, 10);

    try {
    const result = await pool.query(
    "SELECT username FROM users WHERE id=$1",
    [numId], // 传入已验证的整数
    );
    res.json({ success: true, data: result.rows });
    } catch (err) {
    // 不返回详细错误,避免信息泄露
    res.json({ success: false, message: "服务器错误" });
    }
    });

    三层防御:

  • 严格类型校验:/^\\d+$/ 正则确保整个输入都是数字,防止 parseInt 的静默截断问题(如 parseInt("1 AND …") 会返回 1)。
  • 参数化查询:$1 占位符,即使通过校验也无法注入。
  • 不返回错误详情:只返回通用的"服务器错误",不泄露数据库结构信息。
  • 7.5 对比总结

    对比维度漏洞版本安全版本
    输入校验 无校验,直接拼接到 SQL 正则严格校验,非纯数字拒绝
    SQL 构建 字符串拼接 参数化查询($1)
    错误处理 返回完整数据库错误信息 返回通用错误消息
    漏洞类型 SQL 注入 + 信息泄露 无漏洞
    安全性 不安全 安全

    八、演示六:堆叠查询注入(Stacked Queries)

    8.1 漏洞原理

    难度等级:高级

    堆叠注入 是指攻击者在一个 SQL 语句中通过分号(;) 分隔,执行多条 SQL 语句。正常情况下,数据库驱动一次只执行一条 SQL,但某些配置或驱动允许执行多条语句,这就为攻击者提供了更大的操作空间。

    核心思路:

    在一个注入点同时执行多条 SQL 语句,攻击者可以完成 INSERT、UPDATE、DELETE 甚至 DROP 等破坏性操作。

    ️ 堆叠注入的危害极大,是 SQL 注入中最危险的一类。

    8.2 攻击方法

    场景:更新个人简介

    原始 SQL(更新 bio 字段):

    UPDATE profiles SET bio='我的新简介' WHERE username='admin'

    攻击载荷 1:删除其他用户

    输入(在 bio 中):

    '; DELETE FROM users WHERE username='david';–

    实际执行的 SQL:

    UPDATE profiles SET bio=''; DELETE FROM users WHERE username='david';–' WHERE username='admin'

    逐字符解析这个 Payload 是如何构造的:

    原始 SQL 模板:UPDATE profiles SET bio='${bio}' WHERE username='${username}'

    bio 的值被单引号包裹

    攻击者输入:'; DELETE FROM users WHERE username='david';–
    ↑↑ ↑↑
    ││ │└── — 注释掉后面的 ' WHERE…
    ││ └─── ; 分号结束 DELETE 语句
    │└── ' 闭合 bio 字段的起始单引号,使 bio 值为空字符串 ''
    └─── ; 分号分隔,开始新的一条 SQL 语句

    最终解析:
    '; → 闭合 bio='' 并结束 UPDATE 语句
    DELETE … → 第二条 SQL 语句(恶意操作)
    ;– → 结束 DELETE 并注释掉原始 SQL 的剩余部分

    实际执行了 2 条 SQL 语句:

    — 语句 1:将 bio 设为空字符串(无害)
    UPDATE profiles SET bio='';

    — 语句 2:删除 david 用户!← 恶意操作
    DELETE FROM users WHERE username='david';

    — 语句 3:被注释掉,不执行
    –' WHERE username='admin'

    关键点:只需一个 '(单引号)来闭合 bio 字段,因为代码中是 bio='${bio}',不是 bio='${bio}')。'); 会多出一个 ),导致语法错误。

    攻击载荷 2:修改管理员密码

    '; UPDATE users SET password='hacked' WHERE username='admin';–

    实际执行的 SQL:

    UPDATE profiles SET bio=''; UPDATE users SET password='hacked' WHERE username='admin';–' WHERE username='admin'

    管理员密码被改为 hacked,攻击者可以用新密码登录。

    攻击载荷 3:创建新的管理员账户

    '; INSERT INTO users (username, password, role) VALUES ('hacker', '123456', 'admin');–

    实际执行的 SQL:

    UPDATE profiles SET bio=''; INSERT INTO users (username, password, role) VALUES ('hacker', '123456', 'admin');–' WHERE username='admin'

    攻击者创建了一个管理员账户,拥有了完整的系统权限。

    8.3 漏洞代码分析

    // ===== 漏洞版本 =====
    app.post("/api/profile/update/vulnerable", async (req, res) => {
    const { username, bio } = req.body;

    // 【漏洞核心】bio 内容直接拼接到 VALUES 中
    // 分号可以分隔多条 SQL,pg 驱动默认不允许但有些配置可以
    const sql = `UPDATE profiles SET bio='${bio || ""}' WHERE username='${username || ""}'`;

    try {
    const result = await pool.query(sql);
    res.json({
    success: true,
    message: "资料更新成功",
    rowCount: result.rowCount,
    executedSql: sql,
    });
    } catch (err) {
    res.json({
    success: false,
    message: `SQL 执行错误: ${err.message}`,
    executedSql: sql,
    });
    }
    });

    问题分析:

    bio 参数直接插入到 SQL 字符串中。攻击者输入 '; DELETE FROM …;– 时:

  • ' 闭合了 bio 字段的起始单引号,使 bio 值为空字符串 ''。
  • ; 分隔出新的 SQL 语句。
  • DELETE FROM … 是攻击者的恶意操作。
  • — 注释掉原始 SQL 的剩余部分(' WHERE username='admin')。
  • 注意:只需一个 ' 来闭合,因为代码中 SQL 模板是 bio='${bio}',不是 bio='${bio}')。'); 会多出一个 ),导致 PostgreSQL 语法错误:“语法错误 在 “)” 或附近的”。

    8.4 防御代码分析

    // ===== 安全版本 =====
    app.post("/api/profile/update/safe", async (req, res) => {
    const { username, bio } = req.body;
    try {
    const result = await pool.query(
    "UPDATE profiles SET bio=$1 WHERE username=$2",
    [bio || "", username || ""],
    );
    res.json({ success: true, rowCount: result.rowCount });
    } catch (err) {
    res.json({ success: false, message: `服务器错误` });
    }
    });

    为什么安全?

    参数化查询将 bio 作为字符串值处理。攻击者输入 '; DELETE FROM users;– 时,它被当作 bio 字段的一个普通字符串,分号被视为普通字符而非 SQL 语句分隔符。pg 驱动会安全转义所有特殊字符,数据库存储的 bio 就是字面值 '; DELETE FROM users;–。

    8.5 对比总结

    对比维度漏洞版本安全版本
    攻击能力 可执行多条 SQL(INSERT/UPDATE/DELETE/DROP) 无法注入,分号被视为普通字符
    攻击 '; DELETE FROM … 用户被删除 bio 字段存储该字符串,无副作用
    危害等级 极高(可破坏数据库) 无危害
    安全性 不安全 安全

    九、演示七:二次注入(Second-Order Injection)

    9.1 漏洞原理

    难度等级:高级

    二次注入 是最隐蔽的 SQL 注入类型之一。它的特点是:恶意数据先被安全地存入数据库(通过参数化查询),但在后续某个操作中,这些数据被取出后又拼接到新的 SQL 语句中,从而触发注入。

    核心思路:

    第一次存储是安全的(参数化查询),但第二次使用时又拼接了,漏洞在第二次暴露。

    为什么最隐蔽?

  • 绕过第一道防线:第一次存储时用了参数化查询,恶意数据被安全存储,安全审计可能认为这里没问题。
  • 延迟触发:漏洞不在数据输入时发生,而是在数据使用时发生,时间上可能相隔很久。
  • 信任链问题:开发者潜意识里认为"从数据库取出的数据是安全的",但实际上并非如此。
  • 9.2 攻击方法

    本演示的流程:

    步骤 1:注册时安全存储恶意用户名

    用户名:alice' OR '1'='1
    密码:pass123

    注册时使用参数化查询,alice' OR '1'='1 被安全存入 users 表,这一步没有问题。

    步骤 2:查询资料时触发漏洞

    查询资料:?username=alice' OR '1'='1

    实际执行的 SQL:

    SELECT * FROM profiles WHERE username='alice' OR '1'='1'

    结果: '1'='1' 是永真条件,OR 使得 WHERE 子句始终为真,返回了所有用户的资料,而不是只返回 alice 的资料!

    9.3 漏洞代码分析

    第一步:注册(安全存储)

    // 注册时使用参数化查询 – 这一步是安全的!
    app.post("/api/register", async (req, res) => {
    const { username, password } = req.body;

    try {
    // 使用 $1, $2, $3 占位符,安全存储
    const result = await pool.query(
    "INSERT INTO users (username, password, email) VALUES ($1, $2, $3) RETURNING *",
    [username, password, `${username}@demo.com`],
    );
    // 同时在 profiles 表创建记录
    await pool.query("INSERT INTO profiles (username, bio) VALUES ($1, $2)", [
    username,
    "新用户",
    ]);
    res.json({ success: true, message: `用户 ${username} 注册成功!` });
    } catch (err) {
    res.json({ success: false, message: `注册失败: ${err.message}` });
    }
    });

    第二步:查询资料(漏洞触发)

    // 漏洞版本:查询用户资料 – 拼接已存储的数据
    app.get("/api/profile/vulnerable", async (req, res) => {
    const { username } = req.query;

    try {
    // 【漏洞核心】从数据库取出的用户名,又被拼接到新的 SQL 中
    // 如果 username 是 "alice' OR '1'='1",就会泄露所有用户资料
    const sql = `SELECT * FROM profiles WHERE username='${username || ""}'`;
    const result = await pool.query(sql);
    res.json({
    success: true,
    data: result.rows,
    executedSql: sql,
    });
    } catch (err) {
    res.json({
    success: false,
    message: `SQL 执行错误: ${err.message}`,
    });
    }
    });

    关键问题: 第 8 行,username 参数被直接拼接到 SQL 中。这个 username 可能来自:

    • 用户直接输入
    • 从数据库取出(通过注册时存储的恶意值)

    无论来源如何,只要是字符串拼接,就存在注入风险。

    9.4 防御代码分析

    // 安全版本:查询用户资料 – 始终使用参数化查询
    app.get("/api/profile/safe", async (req, res) => {
    const { username } = req.query;
    try {
    // 无论数据来自哪里(用户输入、数据库、文件等),都使用参数化查询
    const result = await pool.query(
    "SELECT * FROM profiles WHERE username=$1",
    [username || ""],
    );
    res.json({ success: true, data: result.rows });
    } catch (err) {
    res.json({ success: false, message: `服务器错误` });
    }
    });

    防御原则: 无论数据来自哪里——用户输入、数据库、文件、API 调用——都始终使用参数化查询。永远不要信任任何数据源。

    9.5 对比总结

    对比维度漏洞版本安全版本
    第一次存储(注册) 参数化查询(安全) 参数化查询(安全)
    第二次使用(查询) 字符串拼接(漏洞) 参数化查询(安全)
    攻击效果 注入 alice' OR '1'='1,泄露所有用户资料 注入无效
    隐蔽性 极高(第一次安全,第二次才出问题) 无漏洞
    安全性 不安全 安全

    十、核心防御:参数化查询

    10.1 什么是参数化查询?

    参数化查询(Parameterized Query) 是一种将 SQL 语句的结构与数据分离的技术。SQL 语句的骨架使用占位符(如 $1、$2、?),用户数据通过单独的参数数组传入,由数据库驱动负责安全地填充。

    10.2 参数化查询的工作原理

    字符串拼接(不安全):
    SQL 骨架 + 用户数据 → 混在一起 → 发送给数据库
    "SELECT * FROM users WHERE username='" + userInput + "'"
    如果 userInput = "admin'–",SQL 变成了:
    "SELECT * FROM users WHERE username='admin'–'"

    参数化查询(安全):
    SQL 骨架(带占位符) → 先发送给数据库编译
    用户数据(单独) → 再发送给数据库填充
    "SELECT * FROM users WHERE username=$1" + ["admin'–"]
    数据库理解:查找 username 值等于 "admin'–" 的用户
    (带引号的 admin'– 是数据值,不是 SQL 语法)

    10.3 在 PostgreSQL (pg 驱动) 中的写法

    // 字符串拼接(不安全):
    const sql = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;
    const result = await pool.query(sql);

    // 参数化查询(安全):
    const sql = "SELECT * FROM users WHERE username=$1 AND password=$2";
    const result = await pool.query(sql, [username, password]);

    关键要点:

    要素说明
    $1, $2, … 占位符,从 1 开始编号
    [username, password] 参数数组,按顺序对应占位符
    数据库驱动处理 pg 自动转义特殊字符,确保数据安全

    10.4 为什么参数化查询能防止 SQL 注入?

    攻击者输入:username = "admin' OR 1=1–"

    字符串拼接时:
    SQL = "SELECT * FROM users WHERE username='admin' OR 1=1–'"
    数据库解析:username='admin' OR 1=1 ← 1=1 是 SQL 表达式,永真!
    结果:饶过验证

    参数化查询时:
    SQL = "SELECT * FROM users WHERE username=$1"
    params = ["admin' OR 1=1–"]
    数据库解析:查找 username 字段值等于 "admin' OR 1=1–" 这个字符串的记录
    结果:找不到这个用户名的用户,登录失败

    核心区别: 参数化查询让攻击者的输入从"SQL 代码"变成了"字符串数据"。单引号、分号、SQL 关键字等特殊字符不再具有语法意义,只是普通字符。

    10.5 参数化查询不能防御的情况

    参数化查询不是万能的。以下情况仍需注意:

    场景原因解决方案
    动态表名/列名 占位符不能用于表名、列名 使用白名单校验
    ORDER BY 动态排序 占位符不能用于 ORDER BY 子句 使用白名单校验
    LIMIT / OFFSET 部分驱动不支持占位符 参数类型校验
    LIKE 模糊查询 % 需要在参数中拼接 在参数值中拼接 %keyword%

    十一、安全最佳实践

    11.1 防御体系总结

    防御层级措施说明
    第一层:输入校验 类型校验、长度限制、格式校验 拒绝不符合预期的输入
    第二层:参数化查询 使用占位符,SQL 与数据分离 最核心的防御手段
    第三层:最小权限 数据库用户只授予必要的权限 限制注入成功后的危害范围
    第四层:错误处理 不返回数据库错误详情 防止信息泄露
    第五层:WAF Web 应用防火墙 检测和拦截恶意请求

    11.2 代码层面的最佳实践

    永远使用参数化查询

    // 正确:参数化查询
    const result = await pool.query(
    "SELECT * FROM users WHERE username=$1 AND password=$2",
    [username, password],
    );

    // 错误:字符串拼接
    const result = await pool.query(
    `SELECT * FROM users WHERE username='${username}' AND password='${password}'`,
    );

    验证输入类型

    // 正确:校验 ID 为数字
    const id = parseInt(req.query.id, 10);
    if (isNaN(id) || id <= 0) {
    return res.status(400).json({ error: "无效的ID" });
    }

    // 错误:直接使用未校验的输入
    const id = req.query.id;

    不在错误消息中暴露信息

    // 正确:返回通用错误消息
    catch (err) {
    res.status(500).json({ error: '服务器错误,请稍后重试' });
    }

    // 错误:返回数据库错误详情
    catch (err) {
    res.status(500).json({ error: err.message }); // 可能泄露数据库结构
    }

    使用最小权限原则

    — 为应用创建专门的数据库用户,只授予必要权限
    CREATE USER app_user WITH PASSWORD 'strong_password';
    GRANT SELECT, INSERT, UPDATE ON users TO app_user;
    GRANT SELECT ON products TO app_user;
    — 不授予 DELETE、DROP、ALTER 等危险权限

    对动态表名/列名使用白名单

    // 正确:白名单校验
    const ALLOWED_COLUMNS = ["id", "username", "email", "created_at"];
    const orderBy = req.query.orderBy;

    if (!ALLOWED_COLUMNS.includes(orderBy)) {
    return res.status(400).json({ error: "无效的排序字段" });
    }

    const result = await pool.query(
    `SELECT * FROM users ORDER BY ${orderBy}`, // 此时 orderBy 是安全的
    );

    11.3 不要依赖的"防御"手段

    做法为什么不可靠
    转义单引号 不是所有注入都需要单引号(如数字型注入)
    黑名单过滤关键字 总有绕过方法(大小写变体、编码、注释穿插)
    前端校验 攻击者可以绕过前端直接发送 HTTP 请求
    存储过程 如果存储过程内部也是拼接 SQL,同样不安全

    十二、总结

    12.1 七种 SQL 注入对比

    编号注入类型难度核心原理攻击效果危害等级
    1 登录绕过 初级 — 注释掉密码检查 无需密码登录任意账户 ⭐⭐⭐
    2 UNION 注入 中级 UNION SELECT 合并查询结果 窃取其他表数据 ⭐⭐⭐⭐
    3 布尔盲注 中级 通过"存在/不存在"推断数据 逐字符猜解密码和敏感数据 ⭐⭐⭐
    4 延时注入 中高级 通过响应时间差异推断数据 无页面差异时仍能逐字符猜解 ⭐⭐⭐
    5 报错注入 中级 触发类型转换错误泄露数据 从错误消息中获取敏感数据 ⭐⭐⭐
    6 堆叠注入 高级 ; 分隔多条 SQL 语句 删除/修改数据,创建账户 ⭐⭐⭐⭐⭐
    7 二次注入 高级 安全存储但后续拼接使用 绕过第一次的安全检查 ⭐⭐⭐⭐

    12.2 核心要点

  • SQL 注入的根本原因:用户输入被当作 SQL 代码执行,而不是当作数据处理。
  • 最有效的防御方法:参数化查询(Parameterized Query),将 SQL 结构与数据分离。
  • 永远不要信任任何数据源:无论是用户输入、数据库、文件还是 API 调用,只要是拼接到 SQL 中,就必须使用参数化查询。
  • 纵深防御:不要只依赖一种防御手段。结合输入校验、参数化查询、最小权限、错误处理、WAF 等多层防御。
  • 安全意识:SQL 注入在今天仍然普遍存在,每个开发者都应该了解其原理和防御方法。
  • 12.3 学习路径建议

  • 先理解登录绕过,这是最直观的 SQL 注入。
  • 再学习 UNION 注入,理解如何窃取其他表的数据。
  • 掌握布尔盲注和延时注入,理解在无直接数据回显时如何推断数据。
  • 学习报错注入,利用数据库错误消息泄露敏感信息。
  • 最后深入堆叠注入和二次注入,理解更隐蔽和更危险的攻击方式。
  • 在每个演示中,先尝试漏洞版本的攻击载荷,再对比安全版本的防御效果。
  • 注意观察每个页面底部显示的实际执行的 SQL 语句,这是理解注入原理的关键。

  • ⚠️ 免责声明: 本文及演示平台仅用于教学和安全研究目的。请勿将此处描述的技术用于非法用途。所有演示都在本地环境运行,切勿将此类漏洞代码部署到生产环境。

    赞(0)
    未经允许不得转载:171主机测评 » SQL 注入漏洞详解:从原理到防御的完整学习指南
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址