freeCodeCamp.org

How ORMs Still Let SQL Injection Through (and How to Close the Gaps)

8.5内容质量

TL;DR · AI 摘要

ORM无法完全防止SQL注入,开发者需警惕四个关键漏洞模式并采取修复措施。

核心要点

  • 使用ORM原始查询时直接拼接用户输入会导致SQL注入漏洞
  • 动态排序字段和存储数据中的二次注入是常见攻击入口
  • 参数化查询和严格输入校验可有效关闭安全漏洞

结构提纲

按章节快速跳转。

  1. 揭示ORM工具在SQL注入防护中的常见误区及攻击路径

  2. 展示通过ORM原始查询接口直接拼接用户输入导致的注入风险

  3. 分析动态排序字段等无法参数化的场景带来的安全风险

  4. 解释通过存储数据实现的延迟注入攻击原理

  5. 揭示普通ORM调用中可能存在的SQL代码注入路径

  6. 提供针对四类漏洞的系统性修复方案和防御建议

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • ORM SQL注入防护漏洞
    • 四类关键漏洞
      • 原始查询接口漏洞
      • 不可参数化字段风险
      • 二次注入攻击路径
      • SQL走私技术
    • 防御方案
      • 强制参数化查询
      • 输入白名单校验
      • 最小权限原则
      • 查询日志监控

金句 / Highlights

值得收藏与分享的关键句。

  • 直接拼接用户输入的原始查询会导致过滤失效,例如?region=' OR '1'='1可绕过安全检查

    — 第 3 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 动态排序字段若未严格校验,攻击者可通过注入ORDER BY实现数据泄露

    — 第 4 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 存储数据中的恶意 payload 可在后续查询中触发二次注入,形成链式攻击

    — 第 5 段

    ⬇︎ 下载 PNG𝕏 分享到 X
#SQL注入#ORM#安全#Node.js
打开原文

ORM仍然允许SQL注入(以及如何弥补漏洞)

2026年9月29日

/

#JavaScript

Hackita

许多开发者认为一旦使用了ORM,SQL注入就不再是他们需要担心的问题。但事实并非如此。它并没有消失,只是转移了位置。

Sequelize、Prisma、TypeORM和Knex等ORM会自动为构建的查询进行参数化处理。这部分工作是正确的。

问题出现在应用程序中那些偏离安全路径的代码部分:ORM查询构建器无法清晰表达的原始查询、动态排序列,或者未经深思熟虑就调用的单个辅助函数。

这些正是攻击者首先寻找的漏洞点,而开发者往往容易忽略这些位置,因为代码库的其他部分看起来已经受到保护。

本文重点分析四种常见模式。每种模式都会提供一个触发漏洞的工作示例、解释其可被利用的原因,以及修复后的版本。同时我会标注具体发生了哪些变化及原因。

你不需要具备安全背景知识也能理解本文内容。你只需要对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. 原始查询的逃生舱
  • 2. 无法参数化的标识符
  • 3. 通过存储数据的二次注入
  • 4. 混入常规ORM调用的原始SQL
  • 总结

1. 原始查询的逃生舱

每个主要ORM都为查询构建器无法清晰表达的查询提供了"逃生舱":Sequelize的sequelize.query()、Prisma的$queryRawUnsafe或TypeORM的query()。这些功能存在有其合理原因,例如复杂连接、窗口函数和特定厂商的SQL。

问题出现在开发者将这些逃生舱当作ORM其他部分一样使用时,直接将用户输入插值到构建的字符串中。

如何在代码中识别此漏洞

这是一个典型的过滤报告端点示例:

code
// 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 已提供的替换/绑定机制传递参数,而不是手动构建字符串:

code
// 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 代码的地方。

以下是一个典型的可排序列表接口:

code
// 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 子句,中间没有任何过滤。

当有人发送 created_at; DROP TABLE users; -- 这类 payload 时,sortBy 看起来只是一个无害的 UI 便利功能。是否能成功执行取决于数据库驱动:PostgreSQL 的 pg 驱动默认会执行这种堆叠语句,而 MySQL 的 mysql2 驱动需要显式设置 multipleStatements: true 才会允许。

无论如何,根本问题都是一样的:你允许任意 SQL 代码出现在原本只应包含列名的位置。

由于标识符无法作为参数绑定,唯一安全的解决方案是使用白名单:

code
// 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);
});

即使先对输入进行"清理",也绝不要将用户输入传递到标识符位置。标识符的清理比值的清理更容易出错。

标识符无法参数化。如果用户输入决定了列名或表名,请使用白名单进行限制,不要依赖清理操作。

code
// 第二步:在代码库的其他位置,管理员搜索功能
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() 方法对其进行了参数化处理,因此它会以输入的原始形式存储。它在数据库中看起来完全正常。

只有当第二步将该值取出并直接插入到原始查询字符串中时,攻击载荷才会生效。"它已经存储在我们自己的数据库中" 并不等同于 "它是安全的"。如果它来自上游的用户输入,它仍然受攻击者控制。

修复方法与模式 #1 相同。你应绑定值而不是拼接:

code
// Express.js - 安全

// 第一步(注册)保持不变 —— 它已经进行了参数化

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 时有意使用的接口。这个模式更隐蔽:注入隐藏在看似完全由 ORM 管理的调用中。它不是原始 SQL,对吧?它只是众多辅助函数中的一个。

这是一个典型的过滤产品列表:

code
// 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 已经覆盖了这种情况:

code
// 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);
});

Sequelize 的 Op.gt、Op.between、Op.in 以及其余运算符 API 已经覆盖了绝大多数需要使用 literal() 的场景,这些 API 默认情况下会正确进行参数化处理。在查询之前验证 minPrice 实际上是数字,即使未来重构中其他地方重新引入了 literal(),也能弥补这一漏洞。

在代码库中所有出现 literal(、fn( 以及 ORM 其他原始查询逃逸点的地方进行全局搜索,而不仅仅是在明显标记为"原始查询"的查询中进行搜索。

总结

快速回顾这四种模式的对应关系:

| 模式 | 根本原因 | 核心解决方案 | |---------------------|------------------------------|------------------------------------| | 原始查询逃逸点 | 用户输入拼接到原始查询字符串中 | 通过替换/参数 API 绑定值 | | 标识符注入 | 列/表/排序输入无法参数化 | 显式允许白名单中的标识符 | | 二次注入 | 因数据曾被参数化过而被信任 | 每个查询都必须参数化,包括使用自己存储数据的查询 | | 字面量注入 | 原始 SQL 被注入到 ORM 管理的调用中 | 避免使用 literal() / raw(),改用 ORM 运算符 API |

这四种漏洞都不需要高技能攻击者才能发现。每个漏洞本质上都是某个值从未进入 ORM 参数化路径的直接结果。

值得纳入工作流程的要点:参数化所有值,显式白名单所有标识符,将从自己数据库中提取的数据视为与新请求一样不安全,将 literal() / raw() 调用视为与专用原始查询方法一样危险(无论周围代码看起来多么安全)。

修复这些漏洞只是问题的一半。了解攻击者实际如何寻找这些漏洞(并在真实评估中将它们串联利用)才是大多数开发者从未见过的另一半。

如果你对这种攻击视角感兴趣,这篇关于通过 ORM 进行 SQL 注入的深度解析将逐步演示这四种模式在真实渗透测试中是如何被利用的。

推荐课程(来自合作伙伴)

(opens in a new tab)

ADVERTISEMENT

更多 SQL 课程请访问 Class Central

→

https://hackita.it

如果这篇文章对你有帮助,请分享它。

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始