写存储过程时直接把外部输入的参数拼接到SQL字符串里,等于给攻击者留了一扇没锁的门。参数化写法不是简单的“用变量代替拼接”,核心在于让数据库引擎把参数值当作纯粹的数据处理,而不是可执行的代码。以SQL Server为例,错误做法是把用户输入的账户名直接拼进去,像这样:
CREATE PROCEDURE GetUserInfo
@AccountName NVARCHAR(50)
AS
BEGIN
DECLARE @Sql NVARCHAR(MAX)
SET @Sql = 'SELECT * FROM Users WHERE AccountName = ''' + @AccountName + ''''
EXEC(@Sql)
END
这段代码表面上能跑通,但传入 AccountName 值为 ' OR '1'='1' -- 时,整个Users表的数据都会被拖出来。更危险的是,攻击者可以通过闭合引号执行任意SQL命令,比如删除表、修改数据甚至获取服务器控制权。问题出在数据库把拼接后的整个字符串当作SQL指令编译执行,参数值和SQL语法混在了一起。
正确的参数化写法,是把SQL语句中的变量位置用参数占位符标记,然后通过系统存储过程 sp_executesql 传递参数值。数据库引擎会先编译不含用户数据的SQL骨架,再把参数值代入已编译的执行计划中运行:
CREATE PROCEDURE GetUserInfo
@AccountName NVARCHAR(50)
AS
BEGIN
DECLARE @Sql NVARCHAR(MAX)
DECLARE @Params NVARCHAR(MAX)
SET @Sql = 'SELECT * FROM Users WHERE AccountName = @AccountName'
SET @Params = '@AccountName NVARCHAR(50)'
EXEC sp_executesql @Sql, @Params, @AccountName = @AccountName
END
这里的关键区别在于,SQL语句字符串中不再出现引号包裹的参数值,而是用 @AccountName 这个参数名占位。sp_executesql 接收三个部分:SQL模板、参数定义字符串、实际参数值。数据库引擎处理这个调用时,会先解析和编译SQL模板,生成一个与参数值无关的执行计划。当攻击者尝试传入恶意字符串时,整个字符串只会被当作 AccountName 字段的一个值去比较,里面的单引号会被自动转义,SQL关键字不会被解析为指令。
不同数据库的参数化语法差异MySQL 中存储过程内的动态SQL参数化,使用 PREPARE 和 EXECUTE 语句实现。写法如下:
CREATE PROCEDURE GetUserInfo(IN accountName VARCHAR(50))
BEGIN
SET @sql = 'SELECT * FROM Users WHERE AccountName = ?';
PREPARE stmt FROM @sql;
SET @name = accountName;
EXECUTE stmt USING @name;
DEALLOCATE PREPARE stmt;
END
问号占位符是MySQL预处理语句的标志,USING子句传递实际参数值。这种机制同样实现了SQL逻辑与数据的分离。Oracle 使用 PL/SQL 中的绑定变量,在存储过程内部如果必须拼接动态SQL,应该用 DBMS_SQL 包或 EXECUTE IMMEDIATE 的 USING 子句:
CREATE OR REPLACE PROCEDURE GetUserInfo(p_AccountName IN VARCHAR2) IS
v_Sql VARCHAR2(1000);
BEGIN
v_Sql := 'SELECT * FROM Users WHERE AccountName = :name';
EXECUTE IMMEDIATE v_Sql USING p_AccountName;
END;
冒号开头的绑定变量 :name 就是参数占位符,USING 把实际值传进去。PostgreSQL 中动态SQL的参数化通常用 PL/pgSQL 的 EXECUTE ... USING 结构,语法和Oracle类似,占位符用 $1、$2 这样的位置参数。
存储过程中容易忽略的注入风险点很多开发者知道SELECT语句要参数化,但在处理表名、字段名、排序方向这类DDL元素时,又回到了字符串拼接的老路。比如一个根据传入排序字段动态生成查询的存储过程:
CREATE PROCEDURE GetSortedUsers
@SortColumn NVARCHAR(50),
@SortDirection NVARCHAR(4)
AS
BEGIN
DECLARE @Sql NVARCHAR(MAX)
SET @Sql = 'SELECT * FROM Users ORDER BY ' + @SortColumn + ' ' + @SortDirection
EXEC(@Sql)
END
这种情况下,表名和字段名不能用参数占位符,因为它们是SQL语法结构的一部分,不是数据值。安全的做法是建立白名单映射,把用户输入的可选值限制在预定义的范围内:
CREATE PROCEDURE GetSortedUsers
@SortColumn NVARCHAR(50),
@SortDirection NVARCHAR(4)
AS
BEGIN
DECLARE @Sql NVARCHAR(MAX)
DECLARE @SafeColumn NVARCHAR(50)
DECLARE @SafeDirection NVARCHAR(4)
-- 白名单验证排序字段
SET @SafeColumn = CASE @SortColumn
WHEN 'UserName' THEN 'UserName'
WHEN 'CreateTime' THEN 'CreateTime'
WHEN 'Email' THEN 'Email'
ELSE 'CreateTime' -- 默认值
END
-- 白名单验证排序方向
SET @SafeDirection = CASE WHEN @SortDirection = 'DESC' THEN 'DESC'
ELSE 'ASC' END
SET @Sql = 'SELECT * FROM Users ORDER BY ' + @SafeColumn + ' ' + @SafeDirection
EXEC sp_executesql @Sql
END
CASE表达式强制把输入值映射到允许的列名集合中,任何不在白名单里的输入都会落到ELSE默认值。排序方向同理,只接受ASC或DESC,其余一律按ASC处理。这种白名单过滤必须放在数据库端执行,不能依赖应用层的校验,因为攻击者可能绕过应用直接向数据库发送请求。
LIKE模糊查询的参数化处理搜索功能中LIKE子句是注入高发区。直接拼接通配符会带来风险,但参数化写法需要把通配符放在参数值里传递,而不是拼在SQL字符串中:
CREATE PROCEDURE SearchUsers
@Keyword NVARCHAR(50)
AS
BEGIN
DECLARE @Sql NVARCHAR(MAX)
DECLARE @Params NVARCHAR(MAX)
DECLARE @SearchTerm NVARCHAR(54)
-- 在参数值中构建带通配符的搜索词
SET @SearchTerm = '%' + @Keyword + '%'
SET @Sql = 'SELECT * FROM Users WHERE UserName LIKE @Keyword'
SET @Params = '@Keyword NVARCHAR(54)'
EXEC sp_executesql @Sql, @Params, @Keyword = @SearchTerm
END
通配符百分号是作为参数值的一部分传入的,SQL语句本身保持干净。这样做还有一个好处,如果用户输入本身包含百分号或下划线这类LIKE通配符,可以用转义字符处理后再拼接通配符,避免用户搜“100%”时匹配到“1000%”这样的结果。
IN子句的参数化难点与解决方案WHERE IN 子句接受多个值的情况,参数化处理相对复杂。因为IN后面跟的是一组值,而不是单个参数。一种常见的错误是把逗号分隔的字符串直接拼进去。正确的做法有几种,SQL Server 中可以用表值参数或者拆分字符串函数配合参数化查询:
-- 先创建一个表值类型
CREATE TYPE IdList AS TABLE (Id INT)
CREATE PROCEDURE GetUsersByIds
@Ids IdList READONLY
AS
BEGIN
SELECT * FROM Users WHERE Id IN (SELECT Id FROM @Ids)
END
表值参数从应用层传入一个结构化的数据表,完全避免了字符串拼接。如果无法使用表值参数,可以用 sp_executesql 动态构建参数列表,但每个参数都必须独立占位:
CREATE PROCEDURE GetUsersByIds
@IdList NVARCHAR(MAX)
AS
BEGIN
DECLARE @Sql NVARCHAR(MAX)
DECLARE @Params NVARCHAR(MAX)
-- 这里需要配合一个字符串拆分函数,把 '1,2,3' 拆成行
-- 假设已有函数 dbo.SplitString 返回表
-- 实际生产环境建议用表值参数代替此方法
SET @Sql = 'SELECT u.* FROM Users u
INNER JOIN dbo.SplitString(@IdList, '','') s
ON u.Id = s.Value'
SET @Params = '@IdList NVARCHAR(MAX)'
EXEC sp_executesql @Sql, @Params, @IdList = @IdList
END
注意这里 SplitString 函数内部也必须用参数化方式处理,不能有字符串拼接漏洞。IN子句的最佳实践是优先使用表值参数,其次是XML或JSON参数配合 OPENXML 或 OPENJSON 函数,这些方法都能保持参数化原则。
防御深度:存储过程不是银弹参数化写法解决了SQL注入的核心问题,但不能解决所有安全风险。如果存储过程内部有动态拼接SQL且未参数化,或者应用层调用存储过程时使用了拼接方式构造调用语句,漏洞依然存在。比如应用代码里这样写:
string sql = "EXEC GetUserInfo '" + userInput + "'";
这等于绕过了存储过程内部的参数化保护。正确的调用方式必须使用命令参数对象,把存储过程参数当作参数传递,而不是拼接到命令文本中。另外,存储过程中的错误信息也可能泄露数据库结构,生产环境应该用 TRY...CATCH 捕获异常并返回通用错误信息。权限控制同样重要,存储过程执行的账户应该只拥有完成业务所需的最小权限,即使发生注入,攻击者能做的操作也受到限制。
参数化写法的本质是把“代码”和“数据”彻底分离,让数据库引擎永远不把用户输入当作指令来解析。这个原则从二十多年前SQL注入出现时就被反复强调,但至今仍有大量系统因为动态拼接而沦陷。存储过程中每一条动态SQL都值得重新审视,只要看到字符串拼接操作符出现在SQL语句变量赋值的地方,就应该立刻警觉并改用参数化方案。
