存储过程本身不免疫SQL注入,关键在于是否安全执行动态SQL;直接拼接用户输入到EXEC或sp_executesql的SQL字符串中(如表名、列名)仍会导致注入,必须结合白名单校验和QUOTENAME等安全函数。

存储过程里用 EXEC 或 sp_executesql 拼接字符串就危险
很多人误以为“进了存储过程就安全了”,其实不然。SQL Server 的存储过程本身不免疫注入,关键看内部怎么执行动态 SQL。一旦用了 EXEC(或 EXECUTE)直接拼接用户输入,漏洞立刻复现。比如:
CREATE PROCEDURE GetUserByName
@name NVARCHAR(50)
AS
BEGIN
DECLARE @sql NVARCHAR(200)
SET @sql = 'SELECT * FROM users WHERE username = ''' + @name + ''''
EXEC(@sql) -- 危险!@name='admin'' OR 1=1--' 就会逃逸单引号
END这里 @name 未经处理直接拼进字符串,单引号闭合后注入载荷就能生效。MySQL 的 PREPARE+EXECUTE、PostgreSQL 的 EXECUTE 动态语句同理。
常见错误场景包括:
- 搜索功能中根据字段名动态构造
ORDER BY子句 - 按用户选择的表名做通用查询(如报表导出)
- 日志审计模块里把操作描述拼进 INSERT 语句
为什么 sp_executesql 参数化也救不了所有情况
sp_executesql 确实支持参数化,但仅限于**值(value)层面**;它无法参数化表名、列名、排序方向(ASC/DESC)、WHERE 条件结构等语法成分。例如:
-- ✅ 安全:参数化值 DECLARE @sql NVARCHAR(200) = 'SELECT * FROM users WHERE status = @status' EXEC sp_executesql @sql, N'@status INT', @status = 1 <p>-- ❌ 不安全:仍需拼接列名 SET @sql = 'SELECT ' + @column_name + ' FROM users' -- @column_name 不能用参数传 EXEC(@sql)
这时候必须靠白名单校验:@column_name 只能是 'id'、'username'、'email' 中的一个,其他一律拒绝。任何试图用正则匹配、黑名单过滤、大小写绕过的做法都不可靠。
容易被忽略的点:
- 数据库标识符(表名/列名)不支持参数化,必须提前验证合法性
-
QUOTENAME()是 SQL Server 里唯一靠谱的标识符转义函数,别手写REPLACE(@s, ']', ']]') - MySQL 用反引号包裹,但
CONCAT('`', @table, '`')仍需配合白名单,否则@table = 'users` UNION SELECT password FROM mysql.user -- '依然可破
ORM 调用存储过程时,raw() 和 execute() 是逃生舱口
Django 的 cursor.execute()、SQLAlchemy 的 text().bindparam()、Laravel 的 DB::select(DB::raw()) 这些接口,一旦传入拼接后的 SQL 字符串,就等于绕过了 ORM 自带的参数化保护。哪怕调用的是已存在的存储过程,只要传入的参数在应用层被拼进 SQL 字符串,风险就回来了。
典型翻车操作:
- Django 中写
cursor.execute("EXEC GetUserByName '" + name + "'") - Node.js + mssql 中用模板字符串拼
`EXEC GetUserByName '${name}'` - Java MyBatis 的
<script></script>标签里硬编码${tableName}而非#{param}
正确姿势是:存储过程本身要安全(内部不用 EXEC 拼接),且调用方必须用框架推荐的参数化方式传参,比如 Django 的 cursor.execute("EXEC GetUserByName @name", {"name": name})。
真正安全的存储过程设计原则
存储过程不是银弹,它的安全性取决于三件事:输入是否受限、动态逻辑是否可控、执行路径是否可预测。不要指望 WAF 或数据库权限收紧来兜底。
落地建议:
- 禁用
EXEC(@sql),强制使用sp_executesql+ 参数化值 - 所有动态标识符(表名/列名/排序字段)必须查白名单表或硬编码枚举,不允许自由输入
- 用
QUOTENAME(@input)(SQL Server)或quote_ident()(PostgreSQL)包裹标识符,但前提是已通过白名单校验 - 存储过程权限最小化:只授予 EXECUTE,不给 SELECT/INSERT 权限;避免用
sa或dbo账户运行 - 日志里记录被拒的非法输入,而不是静默失败——这是发现绕过尝试的第一道线索
最常被忽视的一点:很多团队把存储过程当“安全封装”用,却忘了它只是另一层代码,一样要审计、一样会写错、一样依赖调用链的安全性。所谓“进了存储过程就安全”,本质是把信任从应用层移到了数据库层,而信任转移不等于风险消失。

















