SQL 注入(CWE-89)是 Web 上最古老的漏洞之一,至今仍是危害最大的之一。成因从未改变:用户输入被拼进查询文本,于是数据库分不清数据和代码。修复方法同样没变:把查询和值分开发送,让驱动去绑定它们。
变化的是漏洞所在的位置。多数团队日常查询都用 ORM,所以注入如今出现在那些逃生舱口里:为报表手写的原始查询、带动态排序的搜索端点、迁移脚本、管理工具。本文走一遍三个生态中的漏洞版本和修复版本,然后讨论仅靠参数无法解决的两种情况。
影响很少局限于一张表。一条可注入的查询通常以应用的完整数据库权限运行,因此它能读取每个客户的数据,包括密码哈希和 API 令牌,能修改数据行,在某些数据库上还能触及文件系统或其他服务器。即使查询结果从不展示给用户,盲注技术也能一位一位地把数据取出来,所以一个只返回真或假的端点照样可以被利用。
为什么参数有效而转义无效
使用参数化查询时,驱动发送带占位符的 SQL 文本,而值作为独立的数据传输。数据库在看到值之前就已经解析完语句,因此输入中的引号或分号只是字符串里的一个字符。手工转义试图让一个值能安全地拼进 SQL 文本,而它会在编码、数值上下文、标识符以及你没想到的各种边界情况上失效。不要转义;要绑定。
Node.js:pg 与 Prisma
使用 node-postgres 时,有漏洞的模式是把模板字符串传给 query。修复办法是 $1 占位符加一个值数组。Prisma 默认是安全的,但它有两个原始查询 API,其中只有一个在插值时是安全的。
import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";
const pool = new Pool();
const prisma = new PrismaClient();
// 存在漏洞:输入成为 SQL 文本的一部分
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);
// 已修复:值作为参数绑定
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);
// Prisma:$queryRaw 是标签模板,插值会变成参数
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;
// Prisma:配合字符串拼接的 $queryRawUnsafe 可被注入
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = '" + email + "'"
);
// 如果必须使用 $queryRawUnsafe,请把值作为参数传入
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = $1",
email
);一个微妙的 Prisma 陷阱:先把查询字符串拼好再传给 $queryRaw,保护就失效了。安全性来自标签模板语法。如果需要组合片段,请使用 Prisma.sql 和 Prisma.join,它们会把值保留为参数。同样的规则适用于 postgres.js、slonik 等其他标签模板库:让插值变安全的是那个标签,因此事先拼装好、再作为普通值传入的查询字符串会绕过它。
Python:psycopg 与 SQLAlchemy
在 Python 中,危险的工具是用在 SQL 上的 f-string、% 运算符和 str.format。psycopg 使用 %s 占位符,它看起来像字符串格式化,其实不是:值放在 execute 的第二个参数里,绝不经过 % 运算符。SQLAlchemy 的 text() 结构在使用具名绑定参数时是安全的,在把值格式化进字符串时则不安全。
有一个 psycopg 细节常让人栽跟头:无论列类型是什么,占位符都是 %s(具名值则是 %(name)s),而且绝不要给它加引号。写成 '%s' 会把参数重新变成字符串字面量的一部分,并破坏查询。SQLAlchemy 的具名参数同理:是 :status,不是 ':status'。
from sqlalchemy import text
# psycopg:存在漏洞
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
# psycopg:已修复,值单独传入
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
# SQLAlchemy text():存在漏洞
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))
# SQLAlchemy text():已用绑定参数修复
conn.execute(
text("SELECT id FROM orders WHERE status = :status"),
{"status": status},
)Go:database/sql
Go 的 database/sql 原生支持占位符,但占位符语法取决于驱动:PostgreSQL 驱动(如 pgx)用 $1,MySQL 和 SQLite 用 ?。有漏洞的模式是用 fmt.Sprintf 或字符串拼接来构造 WHERE 子句。由于 Go 是静态类型的,人们很容易以为 int 参数不可能有危险。对这个值本身确实如此,但用 Sprintf 拼查询的习惯会蔓延到旁边的字符串参数上,所以请让每一条查询都使用占位符。
// 存在漏洞:字符串拼接
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)
// 已修复:占位符加参数(PostgreSQL 语法;MySQL 使用 ?)
var u User
err = db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
// 未找到
}ORM 的逃生舱口
ORM 保护的是查询构建器,而不是 ORM 上的每一个方法。在任何代码库里,这些都是首先要查看的地方:
- Prisma:$queryRawUnsafe 和 $executeRawUnsafe,以及任何接收预先拼好的字符串而非标签模板的 $queryRaw 调用。
- Sequelize 和 TypeORM:sequelize.query、传入拼接字符串的查询构建器 .where() 调用,以及原始的 order 或 group 子句。
- Knex:使用插值而非 ? 绑定的 knex.raw 和 whereRaw。
- SQLAlchemy:搭配 f-string 的 text(),以及 literal_column() 或取自输入的列名。
- Django:.raw()、.extra() 以及使用格式化字符串的 cursor.execute。
- GORM:用 fmt.Sprintf 的结果而非参数来调用 Where、Order 和 Raw。
动态 ORDER BY:参数帮不上忙
占位符绑定的是值,不是标识符。你没法写 ORDER BY $1 再传一个列名;数据库会按一个常量排序。于是可排序的表格往往把开发者推回字符串拼接,而注入正是从这里回来的。同样的限制也适用于表名、schema 名以及 ASC、DESC 这类 SQL 关键字。
安全的模式是用一个允许列表,把面向用户的排序键映射到已知的列名。输入只用于选择一个条目;它永远不会变成 SQL。排序方向同样处理:映射到固定的 ASC 或 DESC。
var sortColumns = map[string]string{
"created": "created_at",
"name": "name",
"price": "price_cents",
}
col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
dir = "DESC"
}
// 安全:col 和 dir 只可能是上面代码中的值
query := fmt.Sprintf(
"SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)如果你确实需要一个无法用允许列表约束的动态标识符,请使用驱动提供的标识符引用功能,而不是自己写。在 psycopg 中是 sql.SQL(...).format(sql.Identifier(name));在 pgx 中是 pgx.Identifier{name}.Sanitize()。允许列表仍然更好,因为引用只能让名字变安全,却不保证它有效或被允许。一个被正确引用的标识符,依然让调用者可以按表中的任意列排序或过滤,包括你从未打算暴露的列,比如密码哈希或内部评分。按隐藏列排序会通过结果的顺序一次一个比较地泄露它的值。
不靠字符串拼接实现 IN 列表
按 ID 列表过滤是另一个常见的拼接借口。每个主流技术栈都有安全的做法:
- PostgreSQL 搭配 pg 或 pgx:把整个列表作为一个数组参数传入,写成 WHERE id = ANY($1)。驱动会把它作为 Postgres 数组发送。
- Prisma:Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` 会展开成每个元素一个占位符。
- SQLAlchemy:text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)),然后传入一个列表。
- Go 搭配 MySQL:根据列表长度生成占位符字符串(strings.Repeat("?,", n) 去掉末尾逗号),并把值作为参数传入。生成的只有占位符,绝不包括值。
- 在查询执行前拒绝空列表,并限制列表大小,以免该端点被用来构造巨大的语句。
纵深防御
参数化才是修复方案。下面这些能在某条查询漏网时缩小影响范围:
- 用一个只拥有应用所需权限的数据库角色连接。Web 应用很少需要 DROP、ALTER 或访问其他 schema。
- 不要把原始数据库错误返回给客户端。基于报错的注入依赖于能看到它们。
- 在边界处校验类型:本应是整数或 UUID 的 ID,应在抵达数据层之前就被解析为对应类型。
- 设置语句超时,它既能限制基于时间的盲注,也能限制失控的查询。
找出你已经存在的问题
先搜索上面列出的逃生舱口,再搜索靠近字符串格式化的 SQL 关键字:模板字符串、f-string、Sprintf 或 + 拼接中的 SELECT 或 WHERE。每一处命中要么绑定正确,要么来自允许列表,要么就是一个漏洞。不要跳过看起来内部的代码路径:CSV 导入器、定时任务和管理后台常常接收最初来自用户、只是转了一手的输入。从你自己数据库里读回来的值,可能带着先前被安全存储、之后又被拼进查询的载荷,这被称为二阶注入。CodeAuditAgent 会为公开 GitHub 仓库和粘贴的代码执行这种跨文件追踪,并报告 CWE-89 问题,附带原文引用的查询、输入抵达它的路径以及修复后的版本。
要记住的规则很短:值放进参数,标识符来自允许列表,请求里的任何东西都不得被拼进 SQL 文本。