在SQL存储过程中安全地传递参数以防范注入攻击,必须严格遵循几项关键原则。其中最核心的一条:参数必须直接引用,绝不能拼接到动态SQL字符串里——这是保障数据库安全的最基本底线。

存储过程参数必须直接使用,不能拼入动态SQL
在MySQL存储过程中,只要参数不参与字符串拼接,天然就能抵御SQL注入攻击。例如 SELECT * FROM users WHERE id = p_user_id 这种写法,p_user_id 作为输入参数,MySQL会按照数据类型安全绑定,无需额外处理。
常见的错误是将参数拿来拼接 CONCAT():SET @sql = CONCAT('SELECT * FROM users WHERE id = ', p_user_id)——即便 p_user_id 声明为整数类型,也等于打开了注入的缺口。一旦传入 1 OR SLEEP(5),查询将直接变成全表扫描并触发延迟。
- 所有 WHERE、JOIN、HAVING 条件中,直接引用参数名,切勿转换为字符串再拼接
- 避免在存储过程里使用
PREPARE+EXECUTE包裹含参数的字符串,除非确实需要动态结构 - 如果参数是字符串类型,注意MySQL默认不会自动加引号,但类型校验(例如
INT参数传入字母会报错)本身就是第一道过滤防线
必须使用动态SQL时,仅拼接结构部分,参数值全部通过USING绑定
当确实需要构建可变表名或字段列表(比如分表查询),PREPARE + EXECUTE 不可避免,但必须严格分离:结构部分(表名、列名)依靠白名单或系统视图校验;数据部分(WHERE 值、LIMIT 数)全部通过 USING 绑定。
错误示例:SET @sql = CONCAT('SELECT * FROM ', p_table_name, ' WHERE status = ''', p_status, '''') —— 这里 p_table_name 和 p_status 都没有做好防护。
- 正确做法:
SET @sql = CONCAT('SELECT * FROM ', (SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = p_table_name LIMIT 1)); - 然后
PREPARE stmt FROM @sql; EXECUTE stmt USING p_status; USING只支持值绑定,不支持标识符,所以p_status是安全的,而表名校验必须前置完成
SQL Server 存储过程必须使用 sp_executesql,禁用 EXEC(@sql)
在SQL Server中,EXEC(@sql) 等同于把整个字符串当作命令执行,用户输入一旦混入,就能立即执行任意语句。而 sp_executesql 支持参数化,参数值不参与语法解析,是唯一的合规路径。
典型的翻车点:有人以为给 @sql 加了 QUOTENAME() 就安全,但若参数本身是拼出来的:N'@p nvarchar(50) = ''' + @user_input + '''',最终还是回到了字符串拼接的老路上。
- 必须写成:
EXEC sp_executesql @sql, N'@name nvarchar(50)', @name = @user_input @sql字符串里只能出现占位符@name,不能出现任何用户输入的字面量- 类型声明(
N'@name nvarchar(50)')必须精确匹配,否则可能触发隐式转换导致截断或执行失败
动态对象名(表/列)必须经过白名单校验,escapeId() 或 QUOTENAME() 只是补救而非替代
connection.escapeId()(Node.js mysql2)或 QUOTENAME()(SQL Server)能够转义反引号或方括号,但它们并不验证对象是否存在、是否合法。攻击者传入 user`; DROP TABLE logs; --,escapeId() 会输出 `user`; DROP TABLE logs; --`,依然可能执行两条语句。
真正可靠的方式是查询元数据表,确认对象存在且归属于预期范围:
- MySQL:
SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'app_db' AND TABLE_NAME = p_table_name - SQL Server:
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) THROW 50000, 'Invalid table', 1; - 不要依赖
OBJECT_ID(@name)单独判断——它对非法名称返回 NULL,但无法区分“不存在”和“恶意截断”
最容易被忽略的是权限上下文:即使表名校验通过,执行账号也必须无权访问其他敏感表。否则,整个校验机制形同虚设。
