Skip to content

필사 모드: SQL 注入与参数绑定 — 用了 ORM 仍然会被打穿的那些地方

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

开篇 — 如果你在找转义函数,那方向就错了

在 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 语句手里。

현재 단락 (1/241)

在 SQL 注入相关的代码评审里,最常见的修改建议是这种形态。"把引号转义掉""过滤特殊字符""把单引号写成两个"。搜索结果靠前的位置,至今仍然是转义函数和黑名单正则。

작성 글자: 0원문 글자: 11,095작성 단락: 0/241