对象关系映射框架为何仍会允许SQL注入攻击的发生(以及如何弥补这些安全漏洞)
许多开发人员认为,一旦使用了对象关系映射工具,SQL注入问题就不再存在了。但实际上,这个问题并没有消失,只是转移到了其他地方。 像Sequelize、Prisma、TypeORM和Knex这样的对象关系映射工具会自动为你生成参数化的查询语句,这一功能确实非常实用。但问题往往出现在那些超出了这些工具安全处理范围的代码部分——比如那些无法被对象关系映射工具的查询构建器清晰表达的原始SQL语句、动态排序字段,或者那些被不加思考就直接使用的辅助函数。 恰恰就是这些地方,会成为攻击者首先关注的目标。而且由于应用程序的其他部分看起来都受到了保护,开发者很容易忽视这些问题。 本文将重点讨论四种这类常见的安全
sequelize始终被假设为一个已经初始化好的实例,其中QueryTypes和Op是从'sequelize'模块中导入的,而User/Product则是应用程序其他部分定义的Sequelize模型。示例中使用的都是Sequelize v6及以上版本的语法,因为这里提到的一些API在不同版本之间发生了变化。你可以根据自己实际使用的版本和对象关系映射工具来调整代码示例中的语法。
## 目录
- 1. 原始SQL语句的防护措施
- 2. 无法参数化的标识符
- 3> 通过存储数据实现的第二级注入攻击
- 4> 偷偷混入普通对象关系映射函数调用中的原始SQL代码
- 总结
## 1. 原始SQL语句的防护措施
几乎所有的对象关系映射工具都会为那些无法被查询构建器清晰表达的SQL语句提供相应的防护机制:例如Sequelize的query()方法、Prisma的$queryRawUnsafe方法,以及TypeORM的query()方法。这些机制的存在是有原因的——比如为了支持复杂的联接操作、窗口函数,或者那些特定于某些数据库系统的SQL语句。问题就出在开发人员将这种“逃生通道”视为普通的ORM工具,并直接将用户输入插入到他们生成的SQL字符串中。
如何在代码中识别这一漏洞
以下是一个典型的过滤报告接口示例:
// 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'这样的请求会将SQL语句变成SELECT * FROM sales WHERE region = '' OR '1'='1',而这种条件永远都是成立的。因此,无论区域字段的值是什么,数据库都会返回表中的所有记录。
更糟糕的是,由于这段代码存在于基于ORM的项目中,它往往不会像在非ORM代码环境中那样受到严格的审查。审阅人员会认为ORM已经处理好了这些问题。
如何修复这一漏洞
应该使用原始查询API所提供的替换/绑定机制来传递参数值,而不是自己手动构建SQL字符串:
// 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字符串。
切勿将用户输入直接拼接到原始查询字符串中。请使用原始查询方法所提供的替换/绑定接口。
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 ALLOWEDSORT_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 - 存在漏洞的版本
// 第一步:用户注册,输入数据被正确地参数化了
app.post('/register', async (req, res) => {
await User.create({ username: req.body.username });
res.json({ success: true });
});
// 第二步:代码库中的另一个地方,管理员搜索功能
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);
});
第一步本身是完全安全的,问题出在第二步。
为什么这很重要
像admin' OR '1'='1'这样的用户名在通过第一步审核时不会遇到任何问题。 Sequelize的create()>方法会按照用户输入的格式来存储这些用户名,因此它们在数据库中的存储形式与用户输入时完全一致,看起来也完全正常。只有当第二步将该值提取出来并直接将其插入到原始SQL查询字符串中时,这种漏洞才会被触发。“该数据已经存在于我们的数据库中”并不意味着它就是安全的。如果这些数据最初来源于用户输入,那么它们仍然处于攻击者的控制之下。
如何修复这一漏洞
修复方法与第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. 原始SQL代码被隐藏在ORM调用中
第1种情况主要针对那些“显而易见”的原始SQL注入漏洞,而这些情况通常是人们在确实需要使用原始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()`被使用为止。
为什么这很重要
`sequelize.literal()`这个方法告诉ORM框架:“不要对这段代码进行任何修改,直接将其原封不动地插入到SQL查询语句中。”任何通过这种方式插入到查询语句中的内容,都同样容易成为攻击目标,只不过它们伪装成了普通的ORM方法而已。
如果发送`?minPrice=0 OR 1=1`这样的请求,那么`WHERE`子句就会变成无条件为真的条件:无论价格是否符合筛选条件,所有数据都会被返回出来。这次不需要使用复杂的嵌套查询语句了,因为这只是一个简单的布尔表达式,所以无论使用哪种数据库驱动程序,它都能正常工作。
如何修复这一漏洞
不要再使用`sequelize.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必须是一个数值' });
}
const products = await Product.findAll({
where: { price: { [Op.gt]: minPrice } }
});
res.json(products);
});
Op.gt、Op.between、Op.in以及 Sequelize提供的其他操作符API已经涵盖了人们通常会使用literal()来解决的大多数情况,而且这些API在默认情况下就已经能够正确地处理各种参数传递方式。在将minPrice这个值用于查询之前,先验证它确实是一个数字,这样一来,即使将来有代码重构导致literal()在其他地方被重新使用,也能有效避免问题的发生。
需要在代码库中查找literal(, fn()以及ORM提供的其他原始数据转义方法,这些代码不仅会出现在那些明显是“原始查询”的地方,也会出现在其他地方。
总结
下面简单归纳一下这四种漏洞的成因及解决方法:
漏洞类型
根本原因
)解决措施
原始查询转义机制被滥用
用户输入直接被拼接到原始查询字符串中
应通过参数传递机制来处理这些输入值
标识符注入漏洞
无法对列名、表名或排序条件等进行参数化处理
应明确指定允许使用的标识符列表
二级注入漏洞
因为数据曾经被参数化处理过,所以开发人员认为这些数据是安全的
所有查询都应使用参数化方式来执行,包括那些使用自定义存储数据的查询
字面值注入漏洞
原始SQL代码被偷偷混入ORM处理的查询中
应避免使用literal()/raw()方法,而应直接使用ORM提供的操作符API
这四种漏洞其实都很容易被发现——因为它们本质上都是由于某些值没有被正确地纳入参数化处理流程所导致的。
在实际工作中,我们应该养成以下习惯:对所有需要使用的值都要进行参数化处理;明确指定允许使用的标识符;将从自己数据库中提取的数据视为与新的请求数据一样具有风险性;并且始终把使用literal()/raw()的方法视为与专门的原始查询方法一样危险(无论周围代码看起来多么安全)。
仅仅修复这些漏洞还不够,真正重要的是要了解攻击者究竟是如何找到这些漏洞的——以及他们在实际进行渗透测试时是如何将这些漏洞串联起来发动攻击的。然而,大多数开发人员却从未意识到这一点。
如果你对这个话题感兴趣,这篇关于通过ORM实现SQL注入攻击的深入分析会详细说明在真实的渗透测试中,这四种漏洞是如何被一步步利用的。
相关文章
Radicle揭示了那些会暴露私有代码仓库的关键缺陷,而这些缺陷实际上是以明文形式存在的。
Radicle在其网络协议中发现了两处严重的安全漏洞,这些漏洞会影响所有版本的节点的正常运行,从而导致数据保密性遭到破坏。攻击者可以利用这些漏洞直接访问私有存储库中的数据,并伪造节点的身份进行恶意操作。由于存在架构上的缺陷,建议立即停止所有涉及明文传输的操作。为修复这些漏洞,需要更换为新的协议(Iroh),但这会导致旧版本与新版本之间的不兼容问题。 作者:Olimpiu Pop
阅读全文
TimescaleDB课程——用于处理时间序列数据的PostgreSQL
对于现代开发者而言,高效地管理庞大且快速增长的数据集是一项至关重要的能力。无论你是负责跟踪API请求日志、监控物联网设备的遥测数据,还是为人工智能应用构建仪表盘,如果不对时间序列数据进行优化处理,那么这些操作很快就会导致标准的PostgreSQL查询速度变得极慢。我们刚刚在freeCodeCamp.org的YouTube频道上发布了一门新课程,这门课程将帮助你全面了解TimescaleDB。 通过这门课程,你将获得实际使用TimescaleDB的经验,学习如何将PostgreSQL升级为经过优化的时间序列数据库。你还将学会优化查询速度、大幅减少存储空间占用,并确保你的仪表盘在处理大量数据时仍能
阅读全文
如何将Jekyll博客主题移植到Python环境中:实际操作中的经验与教训
几年来,我一直在使用一个采用 tufte-jekyll 样式设计的博客,正是这个经历让我发现了Edward Tufte所提出的布局理念。 Edward Tufte 因在数据可视化与信息设计领域的贡献而闻名,他是高数据密度设计的坚定支持者,同时也极力反对使用那些毫无意义的视觉元素。 tufte-css (以及它的许多衍生版本)为网页设计带来了诸多优势:充足的空白空间、适合阅读的排版格式,还有用于提供补充信息的 侧边注释 (而非干扰用户体验的弹出窗口)。 除了那些与写作无关的部分外,我对 tufe-jekyll 博客的设计几乎毫无意见。这个基于 Jekyll 框架、使用 Ruby 语言开发的版本,
阅读全文
iOS NFC使用指南:如何使用React Native读取、写入NFC标签以及锁定这些标签
将iPhone靠近贴纸,就会发生一些奇妙的事情:名片会自动添加到联系人列表中,某个聚焦操作会结束,或者某扇门会自动打开。这种芯片的成本大约为20便士,其存储容量约为130字节。 读取一条NFC信息需要执行两次函数调用;而要获得执行这些调用的权限,则需要花费更长的时间。之后,CoreNFC还会要求你再次完成这个流程。 第一个障碍来自苹果公司:你需要拥有一个付费开发者账户,在某个网站平台上注册应用ID,勾选相关选项,并重新生成配置文件。如果其中任何一步出错,构建过程就会因为代码签名错误而失败,而这些错误信息中根本不会提到“NFC”这个词。 第二个障碍则来自CoreNFC本身,而且没有人会提醒你注意
阅读全文