很多开发者默认一件事:项目上了ORM,SQL注入就不再是自己的问题了。但事实是,它没有消失,只是换了个地方藏身。
Sequelize、Prisma、TypeORM、Knex这类ORM会自动把帮你构建的查询参数化,这部分确实没问题。麻烦出在你走出这条安全路径的地方——ORM查询构建器表达不干净的原始查询、动态排序字段,或者随手拿来用的某个辅助函数。这些恰恰是攻击者最先翻找的位置,也最容易被开发者忽略,因为代码库的其余部分看起来都被保护得好好的。
![]()
下面聚焦四种这样的模式。每一种都会给出一个能触发漏洞的可运行示例、它为什么可被利用的拆解,以及一个能堵住它的改写版本。
先看清前提
这些示例假设你对Node.js和Express的基础、编写基本SQL查询、ORM的基本用法(所有代码示例都用Sequelize,但这些模式同样适用于Prisma、TypeORM、Knex等)以及HTTP请求/响应周期的工作方式都比较熟悉。
代码示例中,sequelize假定已经是一个初始化好的Sequelize实例,QueryTypes和Op从'sequelize'导入,User和Product是在应用其他地方定义的Sequelize模型。示例使用Sequelize v6+语法,因为这里提到的一些API在不同大版本之间有过变化。你可以把语法适配到自己使用的版本和ORM上。
原始查询的逃生舱
每个主流ORM都为查询构建器表达不干净的查询准备了一个"逃生舱":sequelize.query()、Prisma的$queryRawUnsafe,或者TypeORM的query()。它们的存在有正当理由,比如复杂连接、窗口函数和厂商特定的SQL。问题始于开发者把这个逃生舱当成ORM的其余部分来用,把用户输入直接插进它构建的字符串里。
看一个典型的带过滤条件的报表接口:
- 从req.query取出region
- 用模板字符串把region直接拼进SELECT语句
- 以QueryTypes.SELECT执行并返回JSON
查询构建器根本看不到这个字符串,它被原样交给数据库驱动。一个类似 ?region=' OR '1'='1 的请求会把查询变成恒真条件,表里每一行都会返回,与region无关。更糟的是,因为这段代码身处一个基于ORM的项目里,它往往得不到非ORM代码库中raw mysql.query()调用那样的审视——审查者会假设ORM已经处理好了。
修复方式是把值交给原始查询API本身提供的替换/绑定机制,而不是自己拼字符串。把region作为replacements传入,SQL里用:region占位。修复不是完全避开原始查询——有时候你确实需要它们,而是在绑定机制就在手边时,永远不要手工构建SQL字符串。
无法参数化的标识符
参数化查询保护的是值,它不保护表名、列名或ORDER BY方向这类标识符。这些必须成为SQL字符串本身的一部分。这就是为什么动态排序功能是注入溜回写得不错的ORM代码的最常见入口之一。
看一个典型的可排序列表接口:sortBy从查询字符串直接进入ORDER BY子句,中间没有任何拦截。sortBy看起来只是个无害的UI便利功能,直到有人发送 created_at; DROP TABLE users; -- 。这个具体载荷是否执行取决于你的数据库驱动:Postgres的pg驱动默认会执行这类堆叠语句,而MySQL的mysql2除非显式设置multipleStatements: true,否则会拦截。无论哪种情况,底层问题都一样:你让任意SQL坐在一个本来只该放列名的位置上。
由于标识符不能被绑定为参数,唯一安全的选择是白名单。定义一个允许的排序列数组,检查sortBy是否在其中,不在就回退到默认列。永远不要把用户输入传到标识符位置,哪怕先"消毒"过也不行——标识符的消毒比值的消毒容易出错得多。如果用户输入决定列名或表名,用白名单,不要用消毒。
通过存储数据发起的二阶注入
还有一种更隐蔽的路径:注入不是发生在输入进入系统的当下,而是发生在数据被存下来、之后又被取出使用的时候。用户提交的内容先安全地存进数据库,看起来毫无问题;等到某个后台任务、报表脚本或另一个接口把这条数据读出来,再拼进新的查询里,注入就在那一刻生效了。
这类问题的识别难点在于,出问题的代码和注入的入口往往不在同一个地方,甚至不在同一次请求里。审查输入接口时一切正常,审查消费数据的代码时又看不到恶意载荷的来源。
应对思路和前面一致:任何把存储数据拼进SQL的地方,都要走绑定机制;任何由存储数据决定的标识符位置,都要走白名单。不要因为数据"是自己数据库里的"就默认它可信。
混进普通ORM调用里的原始SQL
第四种模式更贴近日常:原始SQL片段被夹带进看起来完全正常的ORM调用中。开发者可能只是在某个条件里塞了一小段手写SQL,或者用了一个接受字符串的排序、分组参数,整体调用仍然是ORM的形态,于是没人觉得需要额外警惕。
这类写法的危险在于它伪装得很好。代码里没有显眼的sequelize.query(),审查者的注意力自然放松,但用户输入依然可能顺着那个字符串参数进入SQL结构。
处理原则没有变化:值走绑定,标识符走白名单。凡是接受字符串并最终影响SQL结构的地方,都要当成原始查询来对待,而不是当成ORM的安全调用。
四类问题的共同点
把这四种模式放在一起看,会发现它们指向同一个事实:ORM保护的是它替你构建的那部分查询,而不是你绕过它的那部分。原始查询、标识符位置、存储数据的二次使用、夹带的SQL片段,都是这条边界之外的地带。
对应的做法也很集中:值永远通过绑定机制传递,标识符永远通过白名单决定,不要手工拼接SQL字符串,也不要因为数据来自自家数据库就放松警惕。攻击者最先翻找的,正是这些看起来被保护得很好的代码里露出的缝隙。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.