很多开发者以为用了 Entity Framework Core 就天然免疫 SQL 注入,这其实是一个危险的误解。EF Core 的确在绝大多数情况下通过参数化查询来隔绝恶意输入,但问题恰恰出在那些“非典型”的用法上。当你开始拼接原始 SQL 字符串,或者用错了 FromSql 系列方法,注入的大门就已经被打开了。问题的核心不在于 EF Core 本身安不安全,而在于你是否在所有数据库交互路径上都强制走了参数化。

直接拼接字符串是万恶之源

最常出事的场景就是拼接 SQL。比如你从某个表单拿到一个用户 ID,然后直接把它塞进一段 SQL 语句里传给数据库,这种做法完全绕过了 EF Core 的防护机制。哪怕你整个项目百分之九十九的查询都规规矩矩用了 LINQ,只要有一处使用了字符串拼接的 FromSqlRaw 或 ExecuteSqlRaw,攻击者就能通过这一处撕开口子。

举个例子,下面这种写法就是典型的漏洞代码:

var query = $"SELECT * FROM Users WHERE UserName = '{userName}'";
var user = context.Users.FromSqlRaw(query).FirstOrDefault();

如果变量 userName 被恶意构造为 ' OR '1'='1,整条 SQL 的语义就被篡改了,攻击者可以绕过身份验证甚至拖走整张表的数据。这种错误往往出现在需要动态排序、动态表名或者复杂报表查询的地方,因为开发者觉得 LINQ 表达起来太麻烦,就直接上手拼字符串。

FromSqlRaw 的正确打开方式

EF Core 提供了 FromSqlRaw 和 ExecuteSqlRaw 来执行原生 SQL,这本身不是问题,问题在于怎么传参。你必须使用参数占位符,然后把参数值以独立参数的形式传入,而不是把它们揉进一个完整的字符串里。对于 SQL Server 来说,占位符是 {0}、{1} 这样的格式,对于 PostgreSQL 则是 $1、$2。EF Core 会自动把这些参数值转化为数据库命令的参数,从根本上杜绝注入。

安全的写法应该是这样:

var userNameParam = new SqlParameter("@userName", userName);
var user = context.Users.FromSqlRaw("SELECT * FROM Users WHERE UserName = @userName", userNameParam).FirstOrDefault();

或者使用字符串插值语法,但前提是必须配合 FormattableString,而不是直接用内插字符串。EF Core 的 FromSqlInterpolated 方法就是专门为此设计的,它会将内插字符串中的每个变量都转换为参数化查询的参数,而不是简单拼接字符串。你只需要把 FromSqlRaw 换成 FromSqlInterpolated,然后放心地用内插语法就行:

var user = context.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE UserName = {userName}").FirstOrDefault();

这两行代码看起来差不多,但编译后的行为天差地别。前者生成的是参数化命令,后者如果误用了 FromSqlRaw 加上内插字符串,仍然是在拼接 SQL。很多团队为了代码简洁,会封装一层帮助类,结果在这个封装层里不小心用了错误的 API,导致所有调用点全部沦陷。

动态排序与动态表名的陷阱

参数化查询有一个硬性限制:它只能参数化值,不能参数化数据库对象名,比如表名、列名、排序字段名或者 ASC 和 DESC 这样的关键字。这就意味着,如果你的业务需要根据用户选择来动态排序或者动态切换表,你没办法直接把排序字段名作为参数传给 SQL。很多开发者在这里犯难,最后选择直接拼接字符串,从而埋下注入隐患。

面对这种情况,正确的做法是白名单校验。你必须在服务端维护一个允许的字段名列表,把用户输入与这个白名单进行严格比对,只有匹配成功才允许拼进 SQL 语句。比如用户只能按照“创建时间”、“用户名”、“订单金额”这几个字段排序,那你就定义一个集合,里面存放这些字段在数据库中的实际列名。用户传过来的值如果不在这个集合里,就直接拒绝请求或者回退到默认排序。这样做虽然仍然需要拼接字符串,但拼接的内容完全来自服务端可控的白名单,而不是来自不可信的用户输入。

同样的逻辑也适用于动态表名。如果你的系统需要根据租户 ID 或其他条件动态切换表,绝对不能直接把用户输入当表名用。你可以建立一个映射字典,把前端传入的逻辑表名映射到真实的数据库表名,然后从字典中取值再拼接。这样攻击者无论传什么,最终都会被映射到一个固定的合法值或者直接被拦截。

LINQ 表达式中的隐式风险

很多开发者认为只要全部使用 LINQ 就绝对安全,其实不然。LINQ 本身在翻译成 SQL 时会自动参数化常量值,这一点确实很安全。但问题出在一些第三方库或者自定义的表达式树操作上。当你使用某些动态查询库,允许用户从前端传入筛选条件,然后动态构建表达式树时,如果这个构建过程没有对传入的属性名和操作符做严格限制,攻击者可能会通过构造特殊的表达式来访问不该访问的列,或者触发异常从而探测数据库结构。

这虽然不是传统意义上的 SQL 注入,但同样属于注入类攻击的范畴。解决方法是,在构建动态表达式时,只允许用户操作你预先定义好的字段集合,并且对操作符进行枚举限制,不允许传入任意的字符串作为属性名。任何来自客户端的字段名,都应该先在一个可信的元数据字典中查找,找不到就直接报错。

存储过程调用同样不能掉以轻心

有些团队喜欢把业务逻辑封装在存储过程里,然后用 EF Core 调用。调用存储过程本身是安全的,前提是你仍然使用参数化方式传递参数。如果你用 ExecuteSqlRaw 拼接存储过程的调用字符串,把参数值直接写进 SQL 文本里,那和拼接普通 SQL 没有区别,一样会被注入。正确的做法是使用参数占位符,或者直接使用 EF Core 提供的 DbCommand 对象来显式管理参数。

另外,存储过程内部如果使用了动态 SQL 并且拼接了传入的参数,那即使你的 .NET 代码传参没问题,数据库层面依然存在注入风险。所以安全是一个全链路的事情,不能因为用了 EF Core 就只关注应用层代码,数据库内部的代码也需要遵循同样的参数化原则。

批量操作与第三方扩展的注意事项

EF Core 的原生更新和删除操作通常是通过 SaveChanges 生成参数化 SQL 来完成,但这种方式在批量场景下性能很差。于是很多项目引入了 EF Core Bulk Extensions 之类的第三方库来执行批量操作。这些库底层往往会生成原生 SQL 语句,如果库本身在处理某些边界条件时存在拼接漏洞,或者你通过配置选项传入了未经验证的表名、列名,同样会引入注入风险。

在使用这些扩展库时,务必仔细阅读文档,确认它们是否对所有输入值都做了参数化处理。对于需要传入表名、列名的配置项,同样要应用白名单策略。不要盲目信任第三方库,因为它们的安全假设可能和你的业务场景并不一致。

日志与监控中的敏感信息泄露

虽然不是直接的注入问题,但 EF Core 的日志记录功能如果配置不当,可能会把完整的 SQL 语句和参数值输出到日志文件里。这些日志一旦被攻击者获取,就能从中分析出数据库结构,甚至直接拿到参数化查询中的敏感数据。在生产环境中,应该对 EF Core 的日志级别进行严格控制,至少不能把包含敏感数据的 SQL 语句输出到明文日志中。如果确实需要记录 SQL 用于调试,可以考虑对参数值进行脱敏处理后再记录。

防御思路的总结

说到底,防止 SQL 注入的核心原则只有一条:永远不要把用户输入直接拼进 SQL 语句。在 EF Core 的语境下,这意味着你要做到以下几点。第一,优先使用 LINQ 进行查询和更新,让框架自动处理参数化。第二,必须使用原生 SQL 时,只用 FromSqlInterpolated 或者带参数的 FromSqlRaw,彻底抛弃字符串拼接。第三,遇到需要动态指定表名、列名、排序字段的场景,不要试图参数化这些对象名,而是用服务端白名单来严格校验。第四,任何来自客户端的字段名、操作符,都必须经过映射或枚举校验,不能直接用于构建表达式树或 SQL 片段。第五,把安全审计延伸到数据库层和第三方库,确保整条链路都没有拼接漏洞。

如果你能把这些原则落实到代码审查和自动化扫描中,那么无论业务逻辑多复杂,EF Core 都能成为你抵御 SQL 注入的坚实屏障,而不是一个让你产生虚假安全感的黑盒。