- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 开篇 — 如果你在找转义函数,那方向就错了
- 为什么是绑定而不是转义 — 解析器层面的分离
- 预处理语句在服务端是怎么被处理的
- 用了 ORM 仍然会被打穿的三个地方
- 绑不了的那些东西 — 标识符、ORDER BY、IN、LIKE
- 二次注入与 NoSQL 注入 — 同一原理,不同表面
- 纵深防御 — 最小权限账号与检测
- 收尾 — 请去看 execute 调用的第二个参数
开篇 — 如果你在找转义函数,那方向就错了
在 SQL 注入相关的代码评审里,最常见的修改建议是这种形态。"把引号转义掉""过滤特殊字符""把单引号写成两个"。搜索结果靠前的位置,至今仍然是转义函数和黑名单正则。
这条路在原理上就是一场必输的仗。转义是试图让值在字符串字面量里变得安全,而这个前提被打破的情况层出不穷。下面这段代码转义做得没错,却照样被打穿。
# 数字上下文里根本没有可供转义的引号
order_id = request.args["id"] # "1 OR 1=1"
cur.execute("SELECT * FROM orders WHERE id = " + escape(order_id))
SELECT * FROM orders WHERE id = 1 OR 1=1
转义函数处理的是引号和反斜杠,但在值不带引号进入的位置上,它无事可做。LIMIT 子句、排序方向、布尔比较,以及字符集对不上的场景,都会出现同样的问题。
正确答案在另一个层面上。与其把值变成安全的字符串,不如让值根本不进入 SQL 文本。本文讲的就是这个结构实际上如何运作,以及"用了 ORM 就自动解决了"这个常识在哪里失效。
为什么是绑定而不是转义 — 解析器层面的分离
数据库处理查询的过程大致分为解析、生成计划和执行。注入是解析阶段的问题。一旦用户输入成为 SQL 文本的一部分,解析器就会把它当作语法元素来解释。解析器手里并没有"这一段是用户塞进来的"这种信息。
绑定把这个顺序倒了过来。先把带占位符的 SQL 交给解析器,确定语法树,值则在其后作为单独的消息送达。值到达的时候语法已经定好了,所以无论值里面装着什么,都改不了结构。
把脆弱的代码和安全的代码并排放,差别就很清楚了。
import psycopg
email = request.args["email"]
# 脆弱:值变成了 SQL 文本
with conn.cursor() as cur:
cur.execute(f"SELECT id, email, role FROM users WHERE email = '{email}'")
# 安全:值走的是另一条通道
with conn.cursor() as cur:
cur.execute("SELECT id, email, role FROM users WHERE email = %s", (email,))
这里必须点明 %s 的真身。它不是 Python 的字符串格式化,而是 DB-API 驱动的占位符。所以下面这两行是完全不同的代码。
cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # 绑定
cur.execute("SELECT * FROM users WHERE email = %s" % email) # 字符串格式化,脆弱
第二行里 Python 先把字符串拼完了,所以到达驱动的只有已经组装好的 SQL。一个字符的笔误就变成了漏洞。代码评审时,确认 execute 调用有没有第二个参数是最快的检查。
JavaScript 也一样。
// 脆弱:用模板字面量拼接
const { rows } = await pool.query(
`SELECT id, email FROM users WHERE email = '` + req.query.email + `'`
)
// 安全:占位符加上值数组
const { rows } = await pool.query('SELECT id, email FROM users WHERE email = $1', [
req.query.email,
])
把绑定优于转义的理由用一句话概括就是这样。转义要求开发者去证明值是安全的,而绑定直接消灭了值成为代码的那条路径。前者必须考虑输入的所有情况,后者则没有什么需要考虑的。
预处理语句在服务端是怎么被处理的
我们来确认一下绑定是不是真的把值分离了。PostgreSQL 的扩展查询协议分为 Parse、Bind、Execute 三个阶段。可以显式地复现出来。
PREPARE user_by_email (text) AS
SELECT id, email, role FROM users WHERE email = $1;
EXECUTE user_by_email ('alice@example.com');
现在把攻击字符串原样放进去。
EXECUTE user_by_email ('nobody@example.com'' OR ''1''=''1');
id | email | role
----+-------+------
(0 rows)
没有返回行。因为 OR 1=1 没有被当作条件解释,而是被当成了邮箱值的一部分。把服务器日志打开,就能精确看到发生了什么。
psql -c "ALTER SYSTEM SET log_statement = 'all'" -c "SELECT pg_reload_conf()"
tail -f /var/log/postgresql/postgresql-16-main.log
LOG: execute user_by_email: SELECT id, email, role FROM users WHERE email = $1
DETAIL: parameters: $1 = 'nobody@example.com'' OR ''1''=''1'
查询文本和参数被记录在不同的行上。这就是绑定的本质。服务器收到的 SQL 文本里没有用户输入。
这里有一个经常被忽略的陷阱。并不是所有驱动都真的使用服务端预处理。有些驱动会在客户端把值转义、拼出 SQL,然后作为简单查询发出去。这叫做仿真。
// PHP PDO 的默认值可能因驱动不同而是仿真模式
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false, // 强制走服务端预处理
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
# MySQL JDBC:想用服务端预处理必须显式指定
jdbc:mysql://db:3306/app?useServerPrepStmts=true&cachePrepStmts=true
仿真本身并不等于漏洞。实现正确就是安全的。只是安全性的依据从"解析器已经分离了"下滑到了"驱动的转义是准确的"。过去 MySQL 的 GBK 系字符集上出现过绕过转义的手法,问题正是出在这一层。如果可以选,还是打开服务端预处理更好。
用了 ORM 仍然会被打穿的三个地方
"我们用了 ORM,所以不用操心注入"这句话只对了一半。经过 ORM 查询构建器 API 的值确实会被绑定,但 ORM 一定会留一扇通往原始 SQL 的门。而实务代码经常走那扇门。
第一个地方是原始查询。
from sqlalchemy import text
# 脆弱:text() 会把字符串原样变成 SQL
result = session.execute(
text(f"SELECT * FROM orders WHERE status = '{status}' ORDER BY created_at DESC")
)
# 安全:text() 里同样有绑定占位符
result = session.execute(
text("SELECT * FROM orders WHERE status = :status ORDER BY created_at DESC"),
{"status": status},
)
用了 text() 这件事本身不是问题。在里面用 f-string 才是问题。这个区分会成为代码评审的核心判据。
Django 也一样。
# 脆弱
User.objects.raw("SELECT * FROM auth_user WHERE username = '%s'" % username)
User.objects.extra(where=[f"last_login > '{since}'"])
# 安全
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])
User.objects.filter(last_login__gt=since)
Django 的 extra() 是连官方文档都不推荐使用的 API。在代码库里搜一下 extra(、RawSQL(、.raw(,通常都能找出几处。
rg -n --type py '\.extra\(|RawSQL\(|\.raw\(' src/
src/reports/views.py:88: qs = Order.objects.extra(where=[f"total_amount > {threshold}"])
src/admin/search.py:41: rows = User.objects.raw("SELECT * FROM auth_user WHERE email LIKE '%%%s%%'" % q)
第二个地方是把条件子句拼成字符串的模式。在搜索过滤条件很多的页面上尤其常见。
// 脆弱:用着 knex,却只把条件拼成字符串贴上去
let query = knex('orders')
if (req.query.status) {
query = query.whereRaw(`status = '${req.query.status}'`)
}
if (req.query.minAmount) {
query = query.whereRaw(`total_amount >= ${req.query.minAmount}`)
}
// 安全:用构建器 API,或者用 whereRaw 的绑定参数
let query = knex('orders')
if (req.query.status) {
query = query.where('status', req.query.status)
}
if (req.query.minAmount) {
query = query.whereRaw('total_amount >= ?', [Number(req.query.minAmount)])
}
Sequelize 也是如此。sequelize.query 必须搭配 replacements 或者 bind 使用。
// 安全
const rows = await sequelize.query(
'SELECT id, email FROM users WHERE tenant_id = :tenantId AND email = :email',
{ replacements: { tenantId, email }, type: QueryTypes.SELECT }
)
第三个地方是把 ORM 的过滤条件直接用客户端输入填满的模式。这在语法上不算注入,但结果是一样的。
// 脆弱:请求体原样变成了 where 子句
const users = await User.findAll({ where: req.body.filter })
只要客户端往 filter 里塞进别的租户的条件或者运算符,就会出来不该出来的数据。输入必须先用 schema 校验,然后显式地映射。
const schema = z.object({
status: z.enum(['pending', 'paid', 'refunded']).optional(),
minAmount: z.coerce.number().min(0).optional(),
})
const filter = schema.parse(req.body)
绑不了的那些东西 — 标识符、ORDER BY、IN、LIKE
这里是实务中最常卡住的地方。绑定只对值有效。在决定语法结构的位置上不能使用占位符。
-- 不工作。列名会被解释成字符串字面量
SELECT * FROM orders ORDER BY $1;
ERROR: cannot use column reference in ORDER BY with a parameter
要让用户自己挑排序列,只有白名单一条路。做的不是检查输入,而是把输入替换成事先定好的安全 SQL 片段。
SORT_COLUMNS = {
"created": "o.created_at",
"amount": "o.total_amount",
"status": "o.status",
}
SORT_DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
def build_order_by(sort_key: str, direction: str) -> str:
column = SORT_COLUMNS.get(sort_key, "o.created_at")
order = SORT_DIRECTIONS.get(direction.lower(), "DESC")
return f"ORDER BY {column} {order}"
sql = f"SELECT o.id, o.total_amount FROM orders o WHERE o.tenant_id = %s {build_order_by(sort, dir)} LIMIT %s"
cur.execute(sql, (tenant_id, limit))
不在 SORT_COLUMNS 里的输入会悄悄落到默认值上。用户输入并没有进入 SQL,而只是被当作字典的键来使用,所以无论来的是什么字符串,结果都只会是那三个安全值之一。
如果必须动态使用表名或列名,就用驱动提供的标识符引用 API。不要用字符串把它裹起来。
from psycopg import sql
cur.execute(
sql.SQL("SELECT * FROM {} WHERE tenant_id = %s").format(sql.Identifier(table_name)),
(tenant_id,),
)
在 PostgreSQL 的函数里构造动态 SQL 时也有同样的原则。
-- %I 按标识符、%L 按字面量进行安全引用
EXECUTE format('REFRESH MATERIALIZED VIEW %I', target_view);
EXECUTE format('SELECT count(*) FROM orders WHERE status = %L', status_value);
IN 列表因为占位符个数是可变的而容易让人犯迷糊。正路有两条。
# 方法 1:按个数生成占位符(值依然是被绑定的)
placeholders = ", ".join(["%s"] * len(ids))
cur.execute(f"SELECT * FROM orders WHERE id IN ({placeholders})", tuple(ids))
# 方法 2:绑定一个数组(PostgreSQL)
cur.execute("SELECT * FROM orders WHERE id = ANY(%s)", (list(ids),))
方法 2 更好。占位符个数不会变化,所以执行计划缓存能被复用,给列表长度设上限也更容易。
LIKE 子句有一个和注入不同的陷阱。即使绑定做得没问题,用户塞进来的 % 和 _ 依然会作为通配符生效。
# 绑定是做了,但用户只输入一个 "%" 就会返回全部行
cur.execute("SELECT * FROM users WHERE name LIKE %s", (f"%{q}%",))
这也会变成安全事故。它会诱发全表扫描进而导致拒绝服务,或者越过预期的检索范围暴露出别的数据。通配符必须转义。
def like_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
cur.execute(
r"SELECT * FROM users WHERE name LIKE %s ESCAPE '\'",
(f"%{like_escape(q)}%",),
)
总结一下,注入面必须按位置分别处理。
| 位置 | 能否绑定 | 正确处理方式 | 常见错误答案 |
|---|---|---|---|
| WHERE 子句中的值 | 可以 | 用占位符绑定 | 转义之后做字符串拼接 |
| IN 列表 | 可以 | 绑定数组,或按个数生成占位符 | 插入用逗号 join 出来的字符串 |
| LIKE 模式 | 可以 | 绑定加通配符转义加 ESCAPE 子句 | 只做绑定而放任百分号 |
| LIMIT、OFFSET | 可以 | 先转成整数再绑定 | 觉得是数字就安全,直接拼接 |
| ORDER BY 的列 | 不可以 | 用白名单映射到安全的 SQL 片段 | 用正则过滤特殊字符 |
| 排序方向 | 不可以 | 只有两个取值的映射表 | 把字符串转成大写后直接插入 |
| 表名、模式名、列名 | 不可以 | 标识符引用 API 或白名单 | 用双引号裹起来 |
| 保存之后被再次使用的值 | 视情况而定 | 使用时再绑定一次 | 只信任输入时刻的校验 |
| MongoDB 的过滤对象 | 不适用 | 校验 schema 之后固定类型 | 把请求体原样传过去 |
二次注入与 NoSQL 注入 — 同一原理,不同表面
只靠输入校验抓不住的类型就是二次注入。存的时候是安全的,但后来另一段代码把那个值拼成了字符串。
# 注册时:用绑定安全地保存
cur.execute("INSERT INTO users (username, email) VALUES (%s, %s)", (username, email))
即使 username 里进了 report'; DROP TABLE audit_log; -- 这样的值,这个时刻也什么都不会发生。它只是一个字符串。问题出在几天后运行的批处理上。
# 夜间批处理:为每个用户创建视图
for row in cur.execute("SELECT username FROM users").fetchall():
name = row[0]
cur.execute(f"CREATE OR REPLACE VIEW report_{name} AS SELECT * FROM orders WHERE owner = '{name}'")
就在这里引爆。根因是因为"这是从数据库里读出来的值"就信任了它。二次注入之所以危险,是因为攻击链条横跨两个代码库,在评审中看不见;而且从日志上看,就像批处理作业自己执行了一段奇怪的 SQL。
原则很简单。无论值的来源是哪里,往 SQL 里放的时候永远绑定。"这是我们自己数据库里的值"不构成信任的依据。如果像报表视图名那样必须当作标识符使用,那就用用户 ID 这类内部整数,而不是用户提供的字符串。
NoSQL 的原理也一样。只是语法不是 SQL 而已,用户输入能够改变查询结构这一点完全相同。
// 脆弱:请求体里的值原样变成了过滤条件
app.post('/login', async (req, res) => {
const user = await db.collection('users').findOne({
email: req.body.email,
password: req.body.password,
})
if (user) return res.json({ token: issueToken(user) })
return res.status(401).json({ error: 'invalid credentials' })
})
JSON 请求体装的不只是字符串。它也可以装对象。
curl -X POST https://api.example.com/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":{"$ne":null}}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
不知道密码也能登录进去。驱动收到的不是值而是运算符对象,并把它解释成了查询运算符。表单编码的请求同样可以用方括号写法构造嵌套对象,于是会发生一模一样的事情。
防御的做法是把类型固定住。
import { z } from 'zod'
const LoginBody = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(200),
})
app.post('/login', async (req, res) => {
const parsed = LoginBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'invalid payload' })
const { email, password } = parsed.data
const user = await db.collection('users').findOne({ email })
if (!user || !(await argon2.verify(user.passwordHash, password))) {
return res.status(401).json({ error: 'invalid credentials' })
}
return res.json({ token: issueToken(user) })
})
出于同样的理由,服务端的 JavaScript 求值功能最好关掉。MongoDB 的 where 运算符、聚合管道里的函数执行,都是输入变成代码的典型路径。
纵深防御 — 最小权限账号与检测
即便绑定做得很彻底,也必须假设代码库里某处还是有漏网之处。那时决定受害范围的,是数据库账号的权限。
如果应用账号拥有属主权限或超级用户权限,一次注入就会演变成整个模式被删除。反过来,如果它只有必需表上的 DML 权限,同样的注入就止步于查询数据。
-- 应用专用角色:没有 DDL,没有属主权
CREATE ROLE app_rw LOGIN PASSWORD 'set-by-secret-manager';
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA app TO app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_rw;
-- 掐掉失控的查询
ALTER ROLE app_rw SET statement_timeout = '5s';
ALTER ROLE app_rw SET idle_in_transaction_session_timeout = '30s';
像报表或管理后台这种只读的路径,账号要分开。
CREATE ROLE app_ro LOGIN PASSWORD 'set-by-secret-manager';
GRANT USAGE ON SCHEMA app TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;
也需要养成核对权限的习惯。
SELECT grantee, table_name, string_agg(privilege_type, ', ' ORDER BY privilege_type) AS privs
FROM information_schema.role_table_grants
WHERE grantee IN ('app_rw', 'app_ro')
GROUP BY grantee, table_name
ORDER BY grantee, table_name;
grantee | table_name | privs
---------+------------+--------------------------------
app_ro | orders | SELECT
app_rw | orders | DELETE, INSERT, SELECT, UPDATE
app_rw | users | DELETE, INSERT, SELECT, UPDATE
检测方面,静态分析的性价比最高。它会以数据流的方式追踪字符串拼接流向查询执行函数的路径。
semgrep --config 'p/sql-injection' --config 'p/nosql-injection' src/
src/reports/views.py
88┆ qs = Order.objects.extra(where=[f"total_amount > {threshold}"])
⚠ python.django.security.injection.sql.sql-injection-extra
User-controlled data flows into a raw SQL clause.
src/api/search.js
34┆ knex.raw(`SELECT * FROM orders WHERE status = '${status}'`)
⚠ javascript.knex.security.knex-raw-sql-injection
在 CI 里只让新增的违规失败,就不会被存量代码绊住脚。
semgrep ci --config 'p/sql-injection' --baseline-commit "$(git merge-base origin/main HEAD)"
最后,关于 Web 防火墙有一点需要更正。WAF 能挡住 UNION SELECT 这类已知模式,但绕过手法层出不穷,它也会产生拦截正常输入的误报。它是在修复上线之前争取时间的缓解装置,而不是对策。加一条 WAF 规则然后把工单关掉,是最危险的收尾方式。
收尾 — 请去看 execute 调用的第二个参数
本文的内容可以压缩成三行代码评审规则。
第一,看值是不是作为单独的参数传给查询执行函数的。只要 f-string、模板字面量、字符串相加、百分号格式化出现在查询字符串内部,那个位置就是漏洞。用了 ORM 这件事并不能豁免这项检查。
第二,绑不了的位置一律只用白名单处理。ORDER BY 的列、排序方向、表名,靠的是映射而不是校验。要让用户输入不进入 SQL,只作为挑选安全值的键来使用。
第三,不要把值的来源当成信任的依据。从数据库读出来的值,内部服务给过来的值,往 SQL 里放的时候都一视同仁地绑定。二次注入靠这一条原则就会消失。
另外,为了应对这一切都失效的时刻,请把 DDL 权限从应用账号上收走。发生注入之后事故报告里写的是"部分表被查询"还是"模式被删除",决定权不在代码手里,而在 GRANT 语句手里。