在SQL Server存储过程中编写动态SQL时,核心原则是必须使用sp_executesql,而不是EXEC。为什么?因为EXEC天生缺乏SQL注入防护机制,即使传入的参数看起来“干净”,它仍然会将整个字符串作为命令直接编译执行——这相当于将数据库的访问密钥拱手交给了攻击者。

为什么 EXEC 存在注入风险,sp_executesql 才是更安全的选择
SQL Server对EXEC的处理方式比较直接:无论输入内容如何,都会将字符串直接提交给编译器执行。一旦用户输入混入SQL字符串,攻击者便获得了操作权限。而sp_executesql则要求将SQL模板与参数值分开传递,在编译阶段就切断了数据与结构之间的耦合,有效防止SQL注入。
- 典型的错误写法示例:
EXEC('SELECT * FROM users WHERE id = ' + @id)—— 如果传入@id = '1; DROP TABLE users; --',表结构将直接被删除。 - 推荐的正确编写方式:需遵循三个关键步骤:第一步,
@sql中仅放置模板(例如N'SELECT * FROM users WHERE status = @status');第二步,声明参数类型(如N'@status TINYINT');第三步,绑定具体值(例如@status = 1)。 - 任何一个步骤的缺失或错误都可能导致防护失效:省略类型声明、未按
@param = value格式传参,或者在@sql中硬塞变量——只要出现这类错误,安全防护就会失效。
动态对象名(表名、列名、排序字段)如何安全处理
这里需要特别注意:SQL Server不允许将表名、列名作为参数传递给sp_executesql,因此这个函数对对象名无效。直接拼接字符串属于高危操作,而QUOTENAME()函数只能防止单引号注入,无法抵御 ]; DROP TABLE x; -- 这种结尾注入。
- 必须通过系统视图校验对象的存在性与归属:例如
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),这样就能有效阻挡攻击者的恶意输入。 - 排序字段等应使用硬编码白名单:例如
IF @sort_col NOT IN ('created_at', 'status', 'name') THROW 50000, 'Invalid sort column', 1,只允许预先定义好的合法值。 - 避免仅使用
OBJECT_ID(@table_name)进行简单判断——这种方法不校验 schema,恶意输入可能被截断后误判为合法表名。
参数类型声明必须做到窄、强、带前置校验
使用sp_executesql并不代表完全安全。如果参数类型设置过于宽松或未添加约束,攻击者仍可能找到可乘之机。
- 数字类型应使用具体类型:例如
@user_id INT,并在前面添加严格的区间检查:IF @user_id < 1 OR @user_id > 999999 RETURN。避免超范围的值进入执行流程。 - 字符串类型需立即检查长度和内容:例如
IF LEN(@name) = 0 OR LEN(@name) > 50 OR @name NOT LIKE '[a-zA-Z0-9_]%' RETURN,将潜在风险排除在外。 - 禁止在过程中使用
CAST(@input AS NVARCHAR)或CONVERT——转换失败会引发错误,转换成功则可能导致数据失真,为注入留下隐患。
最容易被忽略的隐性拼接点在哪里
这些风险点往往隐藏较深,日常审查时不易发现,但一旦出现问题就会造成高危漏洞:
- 使用
CONTEXT_INFO存储用户ID后,在触发器中构建SQL语句 - 利用
OPENROWSET构造远程查询字符串 - 在日志记录逻辑中,将参数转为字符串再拼接到
INSERT语句
只要涉及“将变量转换为字符串后拼接到SQL中”,无论上下文多么隐蔽,都必须按照动态SQL的规范重新进行校验。不要认为隐藏在触发器或远程查询中就能保证安全——攻击者不会放过任何可乘之机。
