写存储过程时直接把外部输入的参数拼接到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语句变量赋值的地方,就应该立刻警觉并改用参数化方案。