必须用 sp_executesql 而非 exec(@sql),因后者无法防止sql注入、强制重编译且不支持真正参数化;sp_executesql 需严格三段式:仅含占位符的sql字符串、显式声明类型长度的参数签名、带 @param=value 的参数赋值;表名列名等结构需白名单校验+quotename()处理。

必须用 sp_executesql,不能用 EXEC(@sql) —— 后者根本不算“安全执行”,只是把字符串当代码扔给 SQL Server 解析,注入风险无法规避。
为什么 EXEC(@sql) 一拼就炸
常见写法是 SET @sql = 'SELECT * FROM ' + @table_name + ' WHERE id = ' + CAST(@id AS NVARCHAR); EXEC(@sql);。只要 @table_name 是 'users; DROP TABLE logs; --',整条语句就会被当作合法 T-SQL 执行。
-
REPLACE(@table_name, '''', '''''')或QUOTENAME()套在已拼好的字符串里毫无意义——拼接完成时语法解析早已结束 -
EXEC每次都强制重新编译,无法复用执行计划,性能差 - 它不区分“结构”和“数据”,所有内容一律当 SQL 文本处理,参数化形同虚设
sp_executesql 的三段式写法必须对齐
不是“用了就行”,而是三部分缺一不可、顺序和类型必须严丝合缝:
- 第一段:
@sql字符串里只出现占位符,如N'SELECT * FROM users WHERE status = @status AND name LIKE @name'—— 里面不能有+、CONCAT或任何变量值 - 第二段:参数签名必须显式声明类型和长度,如
N'@status TINYINT, @name NVARCHAR(50)'—— 写成N'@name NVARCHAR(MAX)'会扩大攻击面;漏掉长度(比如只写NVARCHAR)会导致隐式转换失败 - 第三段及之后:每个参数必须带
@param = value形式,如@status = 1, @name = N'John'—— 不能只写@status, @name,也不能漏掉@param =前缀
表名、列名这些没法参数化的部分怎么保命
它们属于语法结构,sp_executesql 不支持参数化。硬拼就是高危操作,但“不能参数化”不等于“可以乱拼”。安全底线是:先校验,再 QUOTENAME(),最后拼。
- 白名单优先:比如排序字段只允许
'created_at'、'status'、'amount',用IF @sort_col NOT IN ('created_at', 'status', 'amount') THROW 50000, 'Invalid sort column', 1; - 系统视图校验:查
sys.tables确认表真实存在且归属指定 schema:IF NOT EXISTS (SELECT 1 FROM sys.tables t JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE s.name = 'dbo' AND t.name = @table_name) RETURN - 拼接时只用
QUOTENAME(@table_name)处理对象名,它会自动转义并加方括号,如QUOTENAME('user; DROP TABLE x')→[user; DROP TABLE x]—— 仅此一处可用,永远不用于数据值
最容易被忽略的隐性拼接点
别只盯着主逻辑里的 @sql 变量。真正出问题的地方往往藏在你看不见的角落:
- 用
CONTEXT_INFO存用户 ID 后,在触发器里拼 SQL —— 这里没走sp_executesql,也没做白名单 - 用
OPENROWSET构造远程查询字符串 —— 字符串里混入运行时变量,且未校验 - 日志语句里拼接未过滤的字段值,比如
RAISERROR('Failed on %s', 16, 1, @raw_input)—— 虽然不执行,但若@raw_input包含恶意字符,可能污染监控或审计系统
只要字符串里混入未经白名单校验或 QUOTENAME() 处理的运行时变量,就构成注入风险——和是否用了 sp_executesql 无关。











