ORM 为何仍难挡 SQL 注入?四大漏洞模式与修复指南
很多开发者以为,只要用上 ORM,SQL 注入就不再是自己的问题了。但风险并没有消失,只是换了个地方。
Sequelize、Prisma、TypeORM、Knex 这类 ORM 会自动为你构建的查询做参数化,这部分确实没问题。
问题出在你绕开这条安全路径的时候:某条 query builder 不好表达的原生查询、一个动态排序字段,或者随手写的一个辅助函数。
攻击者恰恰最爱在这些地方下手,而开发者也最容易漏掉它们——正因为代码库其他部分看起来是受保护的。
本文聚焦四种典型模式。每种都给出一个能触发漏洞的实际例子,分析它为什么可被利用,再给出修复后的改写版本,并说明改动点和原因。
你不需要安全方面的背景知识,只要熟悉 Node.js、SQL,用过至少一种 ORM,能读懂代码就行。
前置要求
示例假设你已经掌握:
Node.js 和 Express 的基础
编写基本的 SQL 查询
ORM 的基本用法(所有代码示例都基于 Sequelize,但模式同样适用于 Prisma、TypeORM、Knex 等)
HTTP 请求/响应循环的工作原理
关于代码示例:本指南中,sequelize 均假设是已初始化的 Sequelize 实例,QueryTypes 和 Op 也已从 'sequelize' 一并导入;User 和 Product 是应用中其他地方定义的 Sequelize 模型。示例使用 Sequelize v6+ 语法(当前主版本),因为文中涉及的部分 API 在不同主版本之间有过变化。你可以根据自己使用的版本和 ORM 调整语法。
目录
1. 原生查询的逃生舱口
每个主流 ORM 都提供“逃生舱口”,用于处理查询构建器无法优雅表达的场景,例如 sequelize.query()、Prisma 的 $queryRawUnsafe 或 TypeORM 的 query()。它们的存在有其合理性,比如处理复杂连接、窗口函数或特定数据库厂商的 SQL。
问题在于,开发者往往将这些逃生舱口视为 ORM 的一部分,直接将用户输入插值到构建的字符串中。
如何在代码中识别此漏洞
以下是一个典型的筛选报告接口示例:
// Express.js - 存在漏洞
app.get('/reports', async (req, res) => {
const { region } = req.query;
const results = await sequelize.query(
`SELECT * FROM sales WHERE region = '${region}'`,
{ type: QueryTypes.SELECT }
);
res.json(results);
});
查询构建器根本不会看到这条字符串。它被原样直接传递给数据库驱动。
为何这一点很重要
类似 ?region=' OR '1'='1 的请求会将查询变为 SELECT * FROM sales WHERE region = '' OR '1'='1',该条件恒为真。无论区域如何,表中所有数据都会返回。
更糟的是,由于这段代码位于基于 ORM 的项目中,它往往不会像非 ORM 代码库中直接调用 mysql.query() 那样受到严格审查。审查者通常假设 ORM 已经处理了安全问题。
如何修复此漏洞
不要手动拼接字符串,而是通过原生查询 API 已提供的替换/绑定机制来传递值:
// Express.js - 安全版本
app.get('/reports', async (req, res) => {
const { region } = req.query;
const results = await sequelize.query(
'SELECT * FROM sales WHERE region = :region',
{
replacements: { region },
type: QueryTypes.SELECT
}
);
res.json(results);
});
修复的关键并非彻底避免使用原生查询,因为有时确实需要它们。关键在于:当存在绑定机制时,绝不要手动构建 SQL 字符串。
切勿将用户输入直接拼接到原生查询字符串中。请使用原生查询方法已提供的替换/绑定 API。
2. 无法参数化的标识符
参数化查询保护的是值,而非表名、列名或 ORDER BY 方向这类标识符。这些标识符必须直接嵌入 SQL 字符串中。
正因如此,动态排序功能成了注入攻击最易渗透的薄弱点,即便在编写规范的 ORM 代码中也常常因此失守。
如何识别代码中的此类漏洞
以下是一个典型的排序列表接口示例:
// Express.js - 存在漏洞
app.get('/users', async (req, res) => {
const { sortBy = 'created_at' } = req.query;
const users = await sequelize.query(
`SELECT * FROM users ORDER BY ${sortBy}`,
{ type: QueryTypes.SELECT }
);
res.json(users);
});
sortBy 直接从查询字符串传入 ORDER BY 子句,中间毫无拦截。
为何这很危险
sortBy 看似只是普通的 UI 便利功能,直到有人发送 created_at; DROP TABLE users; --。该恶意载荷是否生效取决于数据库驱动:Postgres 的 pg 驱动默认允许执行此类堆叠语句,而 MySQL 的 mysql2 驱动除非显式设置 multipleStatements: true,否则会被拦截。
无论哪种情况,核心问题一致:任意 SQL 被放进了本应只容纳列名的位置。
如何修复此类漏洞
由于标识符无法作为参数绑定,唯一的安全做法是白名单校验:
// Express.js - 安全示例
const ALLOWED_SORT_COLUMNS = ['created_at', 'name', 'email'];
app.get('/users', async (req, res) => {
const { sortBy = 'created_at' } = req.query;
const column = ALLOWED_SORT_COLUMNS.includes(sortBy) ? sortBy : 'created_at';
const users = await sequelize.query(
`SELECT * FROM users ORDER BY ${column}`,
{ type: QueryTypes.SELECT }
);
res.json(users);
});
切勿将用户输入直接传入标识符位置,即便先经过“净化”处理也不行。针对标识符的净化逻辑比值的净化更容错率低,极易出错。
标识符无法参数化。若用户输入决定列名或表名,请采用白名单机制,而非试图净化输入。
3. 通过存储数据触发的二阶注入
这种情况会让很多团队措手不及,因为输入确实用了参数化……但只在第一次写入数据库时。
注入发生在后面:当这条已经存进数据库的值被拿去拼进另一条没有参数化的查询时,问题就爆了。
如何在代码中发现这类漏洞
下面是一个注册流程,加上代码库其他地方的一个管理员搜索功能:
// Express.js - 存在漏洞
// 步骤 1:用户注册,正确使用了参数化
app.post('/register', async (req, res) => {
await User.create({ username: req.body.username });
res.json({ success: true });
});
// 步骤 2:代码库其他地方的管理员搜索功能
app.get('/admin/search', async (req, res) => {
const user = await User.findByPk(req.params.id);
const results = await sequelize.query(
`SELECT * FROM audit_log WHERE actor = '${user.username}'`,
{ type: QueryTypes.SELECT }
);
res.json(results);
});
步骤 1 本身完全安全,问题出在步骤 2。
为什么这很重要
像 admin' OR '1'='1 这样的用户名在步骤 1 不会遇到任何阻碍。Sequelize 的 create() 对它做了参数化处理,原样入库,在数据库里看起来毫无异常。
载荷只在步骤 2 把这个值取出来、直接拼进原生查询字符串时才会触发。“数据已经在自己的数据库里了”不等于“它是安全的”。只要上游任何环节来自用户输入,它就依然是攻击者可控的。
如何修复这类漏洞
修复方式和模式 #1 相同:绑定参数,而不是字符串拼接:
// Express.js - 安全
// 步骤 1(注册)保持不变——本来就已经参数化了
app.get('/admin/search', async (req, res) => {
const user = await User.findByPk(req.params.id);
const results = await sequelize.query(
'SELECT * FROM audit_log WHERE actor = :actor',
{
replacements: { actor: user.username },
type: QueryTypes.SELECT
}
);
res.json(results);
});
这个坑比模式 #1 更难发现,因为被污染的数据要经过一次数据库往返之后才变得危险。
来自自己数据库的数据并不自动安全。只要它上游任何环节源自用户输入,用到它的每一处都必须继续参数化。
4. 藏在常规 ORM 调用里的原始 SQL
模式 #1 讲的是 显而易见 的原始查询后门——那是你需要真实 SQL 时故意为之的操作。而当前这种情况更隐蔽:注入点藏在一个看起来完全由 ORM 托管的调用里。这不是原始 SQL,对吧?它只是众多辅助函数中的一个。
如何在代码中识别此漏洞
这是一个典型的带过滤条件的产品列表示例:
// Express.js - 存在漏洞
app.get('/products', async (req, res) => {
const { minPrice } = req.query;
const products = await Product.findAll({
where: sequelize.literal(`price > ${minPrice}`)
});
res.json(products);
});
Product.findAll() 看起来像是一个完全安全、参数化的 ORM 调用……直到 sequelize.literal() 出现在其中。
为什么这很重要
literal() 告诉 Sequelize “别碰这个,把原样插入 SQL”。任何被拼接到其中的变量,其可注入性等同于模式 #1,只不过披上了一个看似正常的 ORM 方法外衣。
发送 ?minPrice=0 OR 1=1 后,WHERE 子句将无条件为真:无论价格过滤与否,所有行都会返回。这次不需要堆叠查询。它只是一个布尔表达式,因此在任何数据库驱动下效果相同。
如何修复此漏洞
别再依赖 literal()。Sequelize 自身的操作符 API 已经覆盖了这个场景:
// Express.js - 安全
app.get('/products', async (req, res) => {
const minPrice = Number(req.query.minPrice);
if (!Number.isFinite(minPrice)) {
return res.status(400).json({ error: 'minPrice must be a number' });
}
const products = await Product.findAll({
where: { price: { [Op.gt]: minPrice } }
});
res.json(products);
});
Op.gt、Op.between、Op.in 以及 Sequelize 操作符 API 的其他部分,已经覆盖了大多数人使用 literal() 的大多数场景,并且默认正确地进行参数化。在 minPrice 进入查询前验证其是否为数字,即使未来重构在其他地方重新引入了 literal(),也能堵住这一缺口。
在整个代码库中搜索 literal(、fn( 以及你所用 ORM 的其他原生逃生通道,而不仅仅检查那些明显是"原生查询"的地方。
总结
快速回顾一下,这四种模式的对照如下:
| 模式 | 根本原因 | 核心修复 |
|---|---|---|
| 原生查询逃生通道 | 用户输入被拼接进原生查询字符串 | 通过替换/参数 API 绑定值 |
| 标识符注入 | 列名/表名/排序输入无法参数化 | 显式使用白名单限制允许的标识符 |
| 二阶注入 | 存储的数据因为被参数化过一次就受到信任 | 对所有查询都做参数化,包括使用自己存储数据的查询 |
| 字面量注入 | 原生 SQL 被夹带进本应由 ORM 管理的调用 | 避免使用 literal()/raw(),改用 ORM 的操作符 API |
这四类漏洞都不需要高水平的攻击者才能发现。每一个都只是某个值没有进入 ORM 的参数化路径而已。
值得纳入日常开发习惯的做法是:对值做参数化,对标识符用白名单,把从自己数据库取出的数据当作和外部请求一样不可信,并且把 literal()/raw() 调用看得和专门的原生查询方法一样危险(无论周围的代码看起来多么安全)。
堵住这些漏洞只是事情的一半。了解攻击者实际上是如何寻找并利用这些漏洞的(以及在一次真实的安全评估中如何把它们串联起来),是大多数开发者从未见识过的另一半。
如果你对这种攻击视角感兴趣,这篇关于 ORM SQL 注入的深度分析会逐步讲解这四种模式在一次真实渗透测试中是如何被利用的。