欢迎光临
我们一直在努力

SQL 存储过程:语法、PHP 调用与连接中毒排坑实录 附:SQL SECURITY 权限模型与防注入注意事项

SQL 存储过程

本文以 MySQL 8.x 为例。不同数据库(例如 SQL Server、Oracle、PostgreSQL)的存储过程语法和权限模型并不完全相同。 这篇文章讲什么:MySQL 存储过程从创建到调用的完整流程——语法、特征声明、 权限模型,以及 PHP 调用时最容易踩的坑(多结果集没处理完导致"连接中毒")。

适合谁看:学过 SQL 基础、想用存储过程封装业务逻辑的开发者; 刚接触 mysqli 多结果集处理、被 Commands out of sync 折磨过的人。

阅读时间:约 15 分钟,全程代码示例,可跟着跑。

附带收获:存储过程相关的权限模型(SQL SECURITY)与防注入注意事项。

代码

DELIMITER //

CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
READS SQL DATA
BEGIN
SELECT first_name, last_name
FROM users
WHERE user_id = p_user_id;
END //

DELIMITER ;

总体结构如下:

CREATE PROCEDURE 过程名称(
参数方向 参数名称 数据类型
)
过程特征
BEGIN
SQL 语句;
END

存储过程是保存在数据库服务器中的一段 SQL 程序。创建完成后,可以通过 CALL 重复调用,而不必让应用程序每次都重新发送其中的全部 SQL 语句。

DELIMITER

DELIMITER(译:分隔符)用于临时更改 MySQL 客户端识别的语句结束符。

MySQL 客户端通常使用 ; 判断一条输入是否结束。但是创建存储过程时,过程内部的语句也会使用 ;。为了防止客户端误以为整个 CREATE PROCEDURE 已经结束,并提前把不完整的内容发送给服务器,需要暂时将结束符改成 //:

DELIMITER //

CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
BEGIN
SELECT first_name, last_name
FROM users
WHERE user_id = p_user_id;
END //

DELIMITER ;

创建完成后,再通过 DELIMITER ; 将客户端分隔符改回分号。

需要特别注意:

  • DELIMITER 是 mysql 命令行客户端及部分图形化工具识别的命令,不是发送给 MySQL 服务器执行的 SQL 语句;
  • // 不是固定写法,也可以选择其他不容易与过程体冲突的字符,但要注意不要使用他\\因为他是MySQl中的转义字符;
  • 在 PHP 中通过 MySQLi 创建过程时,通常直接把完整的 CREATE PROCEDURE 字符串发送给服务器,不应把 DELIMITER 一起发送。

MySQL:定义存储程序与 DELIMITER

CREATE PROCEDURE

CREATE PROCEDURE 用于创建存储过程,例如:

CREATE PROCEDURE get_user_by_id(IN p_user_id INT)

部分含义
CREATE PROCEDURE 创建存储过程
get_user_by_id 存储过程名称
IN 输入参数
p_user_id 参数名称(自拟,只要符合命名规则)
INT 参数类型

这句话表示创建一个名为 get_user_by_id() 的存储过程。它接收一个整数参数,参数名为 p_user_id。

存储过程参数有三种方向:

参数方向含义
IN 调用者向存储过程传入值;默认方向
OUT 存储过程向调用者输出值
INOUT 调用者先传入值,存储过程可以修改后再输出

如果不写参数方向,MySQL 会按 IN 处理。不过显式写出 IN 更容易阅读。

READS SQL DATA

READS SQL DATA 表示:这个存储过程预计会读取数据库数据,但不修改数据。

相关声明还有:

声明表示
NO SQL 过程体不包含 SQL 语句
CONTAINS SQL 包含 SQL,但不读取或修改表数据
READS SQL DATA 读取数据,例如执行 SELECT
MODIFIES SQL DATA 可能修改数据,例如执行 INSERT、UPDATE、DELETE

在 MySQL 中,这些属于存储过程的特征声明。服务器不会严格根据它们限制过程体的实际行为。即使错误地将一个包含 UPDATE 的过程声明为 READS SQL DATA,该声明本身也不一定阻止更新操作,因此它不能代替权限控制。

这些声明是可选的。省略后,存储过程通常仍然可以正常运行:

CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
BEGIN
SELECT first_name, last_name
FROM users
WHERE user_id = p_user_id;
END

所以写上它的主要意义是表达设计意图,方便阅读、维护和分析,而不是建立一道安全边界。

MySQL:CREATE PROCEDURE

BEGIN … END

BEGIN … END 用于定义一个复合语句块:

BEGIN
SELECT first_name, last_name
FROM users
WHERE user_id = p_user_id;
END //

过程体只有一条简单 SQL 语句时,不一定必须使用 BEGIN … END;需要写多条语句、声明局部变量、使用条件判断或循环时,通常使用它将这些语句组合起来。

一个语句块可以包含多条语句:

BEGIN
DECLARE user_count INT DEFAULT 0;

SELECT COUNT(*)
INTO user_count
FROM users
WHERE user_id = p_user_id;

SELECT user_count;
END

DECLARE 声明必须出现在所属 BEGIN … END 语句块的开头,位于普通可执行语句之前。局部变量如果没有指定 DEFAULT,初始值为 NULL。

这里的 SELECT COUNT(*) 无论是否找到用户都会产生一行结果,因此很适合赋值给 user_count。

如果普通的 SELECT … INTO 可能返回多行,需要特别小心:

  • 返回一行:正常赋值;
  • 没有返回行:产生 No data 警告,变量保持原值;
  • 返回多行:产生 Result consisted of more than one row 错误。

所以 SELECT … INTO 的查询条件最好能够保证至多返回一行,例如根据主键查询;如果只是随意加上 LIMIT 1,还应配合 ORDER BY 明确究竟选择哪一行。

MySQL:DECLARE · MySQL:SELECT … INTO

调用

使用 CALL 调用存储过程:

CALL get_user_by_id(1);

可能得到类似结果:

+————+———–+
| first_name | last_name |
+————+———–+
| Mion | Peng |
+————+———–+
1 row in set

过程名称和参数需要与创建时的定义一致。对于没有参数的过程,MySQL 同时支持 CALL procedure_name 和 CALL procedure_name(),不过写上括号通常更清楚。

也可以明确指定数据库:

CALL my_database.get_user_by_id(1);

CALL 是 MySQL 调用存储过程的标准方式。

MySQL:CALL

在 PHP 代码中调用

<?php

$id = filter_input(
INPUT_GET,
'id',
FILTER_VALIDATE_INT,
[
'options' => [
'min_range' => 1,
],
]
);

if ($id === false || $id === null) {
exit('非法用户 ID');
}

$stmt = mysqli_prepare(
$conn,
'CALL get_user_by_id(?)'
);

if ($stmt === false) {
exit('存储过程调用准备失败');
}

mysqli_stmt_bind_param($stmt, 'i', $id);
mysqli_stmt_execute($stmt);

$result = mysqli_stmt_get_result($stmt);

if ($result !== false) {
while ($row = mysqli_fetch_assoc($result)) {
echo htmlspecialchars(
(string) $row['first_name'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
echo ' ';
echo htmlspecialchars(
(string) $row['last_name'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}

mysqli_free_result($result);
}

// CALL 可能产生额外结果,需要在复用该语句或连接前处理完。
while (mysqli_stmt_more_results($stmt)) {
// 检查该语句对象 `mysqli_stmt $stmt`是否还有未提取的结果集?如果存在,继续下一批次处理;
// 没有则结束循环流程并清理资源(防止内存泄漏)。返回布尔值。
mysqli_stmt_next_result($stmt);

$extraResult = mysqli_stmt_get_result($stmt);
if ($extraResult instanceof mysqli_result) {
mysqli_free_result($extraResult);
}
}

mysqli_stmt_close($stmt);

存储过程不会自动解决 PHP 调用位置的 SQL 注入问题。传入的数据值仍应使用参数化查询,不能把用户输入直接拼接进 CALL 语句。

另外,即使调用位置正确使用了参数绑定,如果存储过程内部又使用字符串拼接构造动态 SQL,过程内部仍然可能产生 SQL 注入。因此需要同时检查:

  • 应用程序如何调用存储过程;
  • 存储过程内部如何构造和执行 SQL。
  • CALL 除了返回过程内部 SELECT 产生的结果集,还会返回调用状态。过程也可能产生多个结果集。如果没有处理完这些结果就继续使用连接,MySQLi 可能出现 Commands out of sync。上面的循环用于消费剩余结果。

    mysqli_stmt_get_result() 和 mysqli_stmt_more_results() 依赖 mysqlnd。如果运行环境没有 mysqlnd,需要改用 mysqli_stmt_bind_result() 等方式读取结果。

    PHP:mysqli_stmt_next_result()

    连接中毒

    我们上面说了如果连接没有处理完就会报错,下面动手验证一下。首先在 MySQL 里创建一个返回多个结果集的存储过程(过程体里每一条不带 INTO 的 SELECT 都会成为一批结果集):

    SQL > delimiter //
    SQL > CREATE PROCEDURE multi_result()
    > READS SQL DATA
    > BEGIN
    > SELECT id FROM users WHERE id < 5;
    > SELECT COUNT(*) AS total FROM users;
    > SELECT 'hello' AS msg, 42 AS num;
    > END //
    SQL > delimiter ;
    SQL > CALL multi_result;
    +—-+
    | id |
    +—-+
    | 1 |
    | 2 |
    | 3 |
    | 4 |
    +—-+
    4 rows in set (0.0383 sec)

    +——-+
    | total |
    +——-+
    | 11 |
    +——-+
    1 row in set (0.0383 sec)

    +——-+—–+
    | msg | num |
    +——-+—–+
    | hello | 42 |
    +——-+—–+
    1 row in set (0.0383 sec)

    我们可以看到返回了三个结果集。下面编写一个 PHP 代码,只读取其中第一批结果,剩余的结果集不做任何处理,再复用该连接执行新查询,看看会发生什么:

    <?php
    $conn = mysqli_connect('localhost', 'account', 'password', 'RUNOOB');
    $stmt = mysqli_prepare($conn, 'call multi_result();');
    mysqli_stmt_execute($stmt);

    $result = mysqli_stmt_get_result($stmt);
    while ($row = mysqli_fetch_assoc($result)) {
    echo "ID:" . $row['id'] . "<br>";
    }
    mysqli_free_result($result);

    // 只读了第 1 批,剩余 2 批 + 状态包都没处理,直接复用连接 ↓
    $res = mysqli_query($conn, 'SELECT 4 as ok;');
    if ($res === false) {
    echo mysqli_error($conn);
    } else {
    echo "读取成功";
    }
    mysqli_close($conn);
    ?>

    运行结果:

    ID:1
    ID:2
    ID:3
    ID:4
    Commands out of sync; you can't run this command now

    这就可以发现,没有处理剩余结果集的情况下,复用该连接就会无辜报错,这就是"连接中毒"。

    那么加上消费剩余结果集的循环再试一次:

    <?php
    $conn = mysqli_connect('localhost', 'account', 'password', 'RUNOOB');
    $stmt = mysqli_prepare($conn, 'call multi_result();');
    mysqli_stmt_execute($stmt);

    $result = mysqli_stmt_get_result($stmt);
    while ($row = mysqli_fetch_assoc($result)) {
    echo "ID:" . $row['id'] . "<br>";
    }
    mysqli_free_result($result);

    // 把剩余的结果集(包括最后的调用状态包)全部消费掉
    while (mysqli_stmt_more_results($stmt)) {
    mysqli_stmt_next_result($stmt);
    $extra = mysqli_stmt_get_result($stmt);
    if ($extra instanceof mysqli_result) {
    mysqli_free_result($extra);
    }
    }

    $res = mysqli_query($conn, 'SELECT 4 as ok;');
    if ($res === false) {
    echo mysqli_error($conn);
    } else {
    echo '读取成功';
    }
    mysqli_close($conn);
    ?>

    我们消耗结果集之后就读取成功了:

    ID:1
    ID:2
    ID:3
    ID:4
    读取成功

    坑 1:为什么消耗结果集时会报 bool given 警告?

    如果你把上面循环里的 if($extra instanceof mysqli_result) 去掉,直接写:

    while (mysqli_stmt_more_results($stmt)) {
    mysqli_stmt_next_result($stmt);
    $extra = mysqli_stmt_get_result($stmt);
    mysqli_free_result($extra); // ← 没有 instanceof 检查
    }

    运行时会看到:

    Warning: mysqli_free_result() expects parameter 1 to be mysqli_result, bool given in …

    原因在于:CALL 返回的并不全是结果集。 一个 CALL multi_result() 实际会向客户端发送一串东西:

    顺序内容mysqli_stmt_get_result() 返回
    1 第 1 批结果集(id < 5 的记录) mysqli_result 对象
    2 第 2 批结果集(total 计数) mysqli_result 对象
    3 第 3 批结果集(hello / 42) mysqli_result 对象
    4 状态包(影响行数、警告数等调用状态) false

    mysqli_stmt_get_result() 的设计是:“这次真的有结果集,就返回对象;没有(比如轮到状态包),就返回 false”。

    循环最后一次进来时,轮到的是状态包,$extra 变成了 false——它不是一个结果集对象,却直接被塞给了 mysqli_free_result(),于是报 Warning。“最后也消耗完了”,是因为循环把状态包也跳过去了,连接因此恢复正常;但跳的过程中踩了一次空。

    坑 2:$extra instanceof mysqli_result 是什么意思?

    这是一句类型检查,逐词拆开:

    部分含义
    $extra 上一步 mysqli_stmt_get_result() 拿到的值
    instanceof PHP 运算符:“左边的对象,是不是右边这个类的实例?”
    mysqli_result mysqli 的结果集类(对象类型)

    整句含义:“$extra 真的是一个结果集对象吗?” 是 → 才执行 mysqli_free_result();不是(比如 false)→ 跳过。

    $extra = mysqli_stmt_get_result($stmt);
    if ($extra instanceof mysqli_result) { // 只有真的是结果集才释放
    mysqli_free_result($extra);
    }

    这就是防御性写法:多一行检查,正好挡掉状态包这个坑。所以"消耗完结果集"的完整定义是:把每一批结果集都读走或释放,连最后的调用状态包也要跳过,连接才算真正干净,可以安全复用。

    删除与查找存储过程

    使用 DROP PROCEDURE 删除存储过程:

    DROP PROCEDURE get_user_by_id;

    更推荐加上 IF EXISTS:

    DROP PROCEDURE IF EXISTS get_user_by_id;

    这样即使过程不存在,也不会产生“找不到存储过程”的错误,只会产生提示。

    删除前可以查看当前数据库的存储过程:

    SHOW PROCEDURE STATUS
    WHERE Db = DATABASE();

    如果需要查看某个过程的完整创建语句,可以使用:

    SHOW CREATE PROCEDURE get_user_by_id;

    它对于检查参数、过程体、DEFINER 和 SQL SECURITY 等信息很有用。能否看到完整定义还取决于当前账号拥有的权限。

    存储过程向调用者产生结果的方式

    返回结果集

    DROP PROCEDURE IF EXISTS get_user_by_id;

    DELIMITER //

    CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
    READS SQL DATA
    BEGIN
    SELECT name, age, birthday
    FROM users
    WHERE id = p_user_id;
    END //

    DELIMITER ;

    调用时,过程内部没有使用 INTO 的 SELECT 会把查询结果集发送给调用者,适用于返回一行或多行数据。

    一个过程可以执行多个这样的 SELECT,因此也可能返回多个结果集。应用程序必须逐个读取或清理。

    使用 OUT 输出参数

    DROP PROCEDURE IF EXISTS get_user_age;

    DELIMITER //

    CREATE PROCEDURE get_user_age(
    IN p_user_id INT,
    OUT p_age INT
    )
    READS SQL DATA
    BEGIN
    SELECT age
    INTO p_age
    FROM users
    WHERE id = p_user_id;
    END //

    DELIMITER ;

    调用并查看输出:

    CALL get_user_age(1, @user_age);
    SELECT @user_age;

    因为第二个参数对应 OUT 参数,所以调用者需要提供一个可以接收结果的变量。

    @user_age 是 MySQL 用户定义的会话变量:

    • 以 @ 开头;
    • 属于当前数据库连接(session);
    • 不需要提前声明;
    • 可以被存储过程赋值;
    • 调用后可以通过 SELECT @user_age 查看;
    • 断开该连接后,不应再依赖它的值;
    • 它不同于通过 DECLARE 创建的局部变量,局部变量只在所属存储程序语句块中有效。

    这里的查询应该最多返回一行。如果 id 不是唯一值并返回多行,SELECT … INTO 会报错。

    通过修改数据库产生效果

    DELIMITER //

    CREATE PROCEDURE activate_user(IN p_user_id INT)
    MODIFIES SQL DATA
    BEGIN
    UPDATE users
    SET is_active = 1
    WHERE id = p_user_id;
    END //

    DELIMITER ;

    调用:

    CALL activate_user(1);

    这类过程不一定返回业务数据,而是通过 INSERT、UPDATE 或 DELETE 完成数据库操作。严格来说,这是过程产生的副作用,不是“返回数据”的第三种方式。调用者可以根据驱动提供的信息或 ROW_COUNT() 了解最后一条语句影响的行数。

    如果过程包含多条修改语句,并且这些操作必须一起成功或一起失败,还应根据业务需要设计事务和异常处理,不能仅仅因为代码写在同一个存储过程中,就认为它们一定会自动回滚。

    事务与异常处理示例:

    DELIMITER //

    CREATE PROCEDURE transfer(
    IN p_from INT,
    IN p_to INT,
    IN p_amount DECIMAL(10,2)
    )
    MODIFIES SQL DATA
    BEGIN
    — 声明一个 EXIT handler,SQL 异常时自动回滚
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
    ROLLBACK;
    RESIGNAL; — 把错误重新抛出给调用者
    END;

    START TRANSACTION;

    UPDATE accounts SET balance = balance p_amount WHERE id = p_from;
    UPDATE accounts SET balance = balance + p_amount WHERE id = p_to;

    COMMIT;
    END //

    DELIMITER ;

    关键是 DECLARE EXIT HANDLER FOR SQLEXCEPTION + ROLLBACK:如果任何一条 UPDATE 失败,整个事务会自动回滚,不会出现"账户 A 扣了钱但账户 B 没收到的"半成品状态。

    存储过程与参数化查询的本质区别

    初学者容易把存储过程直接当成"保存在服务器端的参数化查询",因为从调用方式看,两者确实相似:

    — 参数化查询(Prepared Statement)
    PREPARE stmt FROM 'SELECT name FROM users WHERE id = ?';
    EXECUTE stmt USING @uid;

    — 存储过程
    CALL get_user_name(@uid);

    但这只是表面相似。它们的设计目标、存在位置和安全性约束完全不同。

    对比总表

    参数化查询(Prepared Statement)存储过程(Stored Procedure)
    存在位置 当前连接的临时对象;连接关闭则消失 持久保存在数据库的 mysql.proc 系统表中
    生命周期 当前会话 跨会话、跨连接,直到被 DROP
    主要目的 安全地传递参数、避免 SQL 注入 封装多条 SQL 为一个可复用的逻辑单元
    语句数量 通常对应一条 SQL 可以包含多条语句、变量、条件、循环、事务
    创建权限 不需要特殊权限 需要 CREATE ROUTINE
    调用权限 同连接用户本身的表权限 需要 EXECUTE;过程体按 DEFINER/INVOKER 执行
    跨应用共享 不能 多个应用可以通过 CALL 共用一个过程
    被攻破后果 影响当前会话的查询 持久化后门、高权限 DEFINER 提权、持续数据泄露

    核心区别

    1. 存在位置不同

    参数化查询是连接内的临时对象:

    PREPARE stmt FROM 'SELECT ?';
    — 连接关闭后 stmt 自动消失

    存储过程写入数据库后永久存在,直到显式删除:

    SHOW PROCEDURE STATUS WHERE Db = DATABASE();
    — 只要没有 DROP,换个客户端连接照样可以 CALL

    2. 设计目标不同

    参数化查询解决的是如何安全地把值传给一条 SQL。它的安全价值在于将数据与代码分离——? 占位符里的内容永远是数据,永远不会被当成 SQL 执行。

    存储过程解决的是如何把多条 SQL 逻辑封装在服务器端,实现批量操作、业务校验、权限隔离等。它不天然安全——过程内部照样可以写出不安全的 SQL。

    3. 权限面不同

    参数化查询不引入新的权限概念:你能执行什么 SQL,取决于你连接数据库时用的账号有什么表权限。

    存储过程引入了额外的权限层:

    • 创建过程需要 CREATE ROUTINE
    • 调用过程需要 EXECUTE
    • 过程体内的操作按 SQL SECURITY DEFINER(默认)使用创建者的权限执行

    这就带来一个关键安全事实:**一个只有 SELECT 权限的低权限用户,如果被授予了某个高权限 DEFINER 过程的 EXECUTE 权限,就可以通过调用这个过程间接完成他本不该直接执行的操作。如果这个过程内部还有动态 SQL 拼接,后果会被 DEFINER 的高权限放大。

    4. 被攻破后的影响不同

    参数化查询被注入(比如错误地拼接了表名):

    • 只影响当前连接
    • 权限受制于当前连接用户
    • 连接关闭后不再持续

    存储过程被注入(过程内部拼接动态 SQL):

    • 攻击载荷持久化在数据库里,随时可以再次触发
    • 如果 DEFINER 是高权限账号,注入的 SQL 以高权限执行
    • 可以悄悄修改过程体自身,形成持久化后门
    • 数据库重启后依然存在

    实践中怎么区分

    场景推荐方式
    单条查询/插入/更新/删除,只需防注入 参数化查询就够了
    多条 SQL 必须一起执行(如转账) 存储过程 + 事务
    需要向多个应用暴露统一数据接口 存储过程
    需要利用 SQL SECURITY DEFINER 做权限隔离 存储过程
    不需要复用、不需要事务、不需要权限隔离 不需要存储过程

    原则:不要因为"别人都在用存储过程"而用存储过程。 它增加了一层权限管理负担,增加了持久化攻击面——只有当它带来的好处(封装、事务、权限隔离)确实值得这层负担时,才值得引入。

    存储过程权限与 SQL SECURITY

    与存储过程直接相关的常见权限如下:

    权限作用
    CREATE ROUTINE 创建存储过程或函数
    ALTER ROUTINE 修改过程特征或删除存储过程/函数
    EXECUTE 调用存储过程或函数

    这里最容易误解的是:activate_user() 内部执行了 UPDATE,不代表调用者因此需要 ALTER ROUTINE。

    ALTER ROUTINE 管理的是存储过程自身的定义,例如修改过程特征或删除过程;它不是“允许过程修改表数据”的权限。过程执行 UPDATE 时究竟检查谁的表权限,取决于 SQL SECURITY:

    DELIMITER //

    CREATE PROCEDURE activate_user(IN p_user_id INT)
    SQL SECURITY DEFINER
    MODIFIES SQL DATA
    BEGIN
    UPDATE users
    SET is_active = 1
    WHERE id = p_user_id;
    END //

    DELIMITER ;

    模式执行过程体时使用谁的权限
    SQL SECURITY DEFINER 使用 DEFINER 账号的权限;这是默认值
    SQL SECURITY INVOKER 使用调用者自己的权限

    DEFINER 是谁? DEFINER 就是创建这个过程时使用的账号。如果用 root 创建,DEFINER 就是 root——任何人 CALL 这个过程都能以 root 权限执行里面的 SQL。所以 SQL SECURITY DEFINER 本身不是问题,拿谁当 DEFINER 才是问题。

    不指定时的默认值:省略 SQL SECURITY 子句,MySQL 默认按 DEFINER 处理。INVOKER 必须显式写出,MySQL 不会自动帮你换成更安全的模式。

    在默认的 DEFINER 模式下:

    • 调用者需要对该过程具有 EXECUTE 权限;
    • 过程体中的 UPDATE 根据 DEFINER 账号的权限执行;
    • 调用者不一定需要直接拥有 users 表的 UPDATE 权限。

    在 INVOKER 模式下:

    • 调用者需要 EXECUTE 权限;
    • 调用者还必须拥有过程体实际需要的表权限,例如 UPDATE。

    这说明存储过程可以用来提供受控的数据库操作接口,但并不意味着 Web 应用必须使用类似 db_owner 的高权限账号。db_owner 是 SQL Server 中常见的角色名称,不应直接套用到 MySQL 权限模型中。

    更合理的原则是最小权限:

    • Web 应用账号只获得业务所需的权限;
    • 如果使用 SQL SECURITY DEFINER,DEFINER 账号也只授予过程体真正需要的权限,不应使用高权限管理员账号;
    • 明确限制谁拥有 EXECUTE 权限;
    • 避免在高权限 DEFINER 过程中拼接不可信输入形成动态 SQL;
    • 定期检查 DEFINER 账号是否仍然存在,避免形成孤立的存储对象。

    SQL SECURITY 不负责限制权限。 它只回答"运行时用谁的权限",不负责"那个账号有什么权限"。"只给 DEFINER 过程体需要的权限"这件事,是你通过 GRANT 手动完成的,跟 SQL SECURITY 本身无关。

    实践中可以专门建一个权限很窄的账号来创建过程:

    — 专用账号,权限刚好够用,没有多余
    CREATE USER 'proc_owner'@'localhost' IDENTIFIED BY 'xxx';
    GRANT CREATE ROUTINE, ALTER ROUTINE ON mydb.* TO 'proc_owner'@'localhost';
    GRANT SELECT, UPDATE ON mydb.users TO 'proc_owner'@'localhost';

    然后用 proc_owner 登录创建过程,DEFINER 就是 proc_owner——即使攻击者控制了调用端,他通过过程能做的操作也不会超出 proc_owner 的权限范围。

    默认情况下,MySQL 会把 ALTER ROUTINE 和 EXECUTE 自动授予过程创建者;如果服务器关闭了 automatic_sp_privileges,这一行为会改变,因此不能简单理解为“所有用户默认都有”或“所有用户默认都没有”执行权限。

    MySQL:存储过程权限 · MySQL:存储对象访问控制

    存储过程与安全性的关系

    存储过程既不天然安全,也不天然危险,关键取决于如何编写和授权。

    它可能带来的安全价值包括:

    • 把复杂数据库操作集中在服务器端,减少不同应用重复实现;
    • 只向应用开放有限的过程调用,而不是直接开放所有表权限;
    • 集中进行业务校验、事务处理和审计。

    它也可能带来风险:

    • 使用高权限 DEFINER,让低权限调用者间接完成过多操作;
    • 在过程内部拼接用户输入形成动态 SQL,产生 SQL 注入(见下方示例);
    • 过程逻辑复杂但缺少事务、异常处理或权限检查;
    • 应用没有处理完多个结果集,造成连接状态异常;
    • 错误地认为“使用存储过程”本身就等于完成安全防护。

    过程内部动态 SQL 注入示例:即使调用处使用了参数绑定,只要过程内部用 PREPARE / EXECUTE 拼接不可信输入,仍然可能产生注入。

    — 危险的写法(DEFINER 为高权限时尤其危险)
    DELIMITER //

    CREATE PROCEDURE unsafe_search(IN p_table VARCHAR(64))
    SQL SECURITY DEFINER
    CONTAINS SQL
    BEGIN
    SET @sql = CONCAT('SELECT * FROM ', p_table);
    PREPARE stmt FROM @sql; — 拼接处就是注入点
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
    END //

    DELIMITER ;

    p_table 的值 users; DROP TABLE admin; — 会变成两条语句。这类场景下,应避免拼接表名/列名等标识符;确实需要动态标识符时,务必通过白名单严格校验。

    因此,存储过程安全需要同时考虑参数化、输入验证、DEFINER/INVOKER、最小权限、事务和错误处理。

    注意

    1. 把 DELIMITER 理解为 MySQL 服务器的 SQL 语句

    DELIMITER 是客户端命令,用来控制客户端何时把完整文本发送给服务器,并不是存储过程语法本身。在 PHP 里把它发送给服务器通常会报语法错误。

    2. 认为含有 UPDATE 的过程需要 ALTER ROUTINE

    ALTER ROUTINE 用于修改或删除存储过程本身,不负责授权过程修改表。执行 UPDATE 时使用谁的表权限,由 SQL SECURITY DEFINER/INVOKER 决定。

    3. 不要把“修改数据库”称为存储过程返回数据的方式

    UPDATE 产生的是数据库状态变化,也就是副作用;它不是返回值。存储过程真正向调用者传递数据的常见方式是结果集以及 OUT/INOUT 参数,另外调用者还能取得受影响行数等状态信息。

    4. 认为 Web 应用为了调用存储过程必须拥有数据库所有者权限

    MySQL 可以通过 EXECUTE、SQL SECURITY 和精细的表权限实现最小权限,不需要让 Web 应用以数据库管理员身份运行。

    5. 不要认为 PHP 取得第一个结果集后就已经处理完 CALL

    存储过程可能产生多个结果集,CALL 本身还有状态结果。如果不读取或清理剩余结果,复用连接时可能出现 Commands out of sync。

    6. “存储过程可以防 SQL 注入”的边界

    调用位置安全,并不保证过程内部安全。只要过程内部仍然把不可信输入拼接进动态 SQL,就仍然可能产生注入。

    赞(0)
    未经允许不得转载:171主机测评 » SQL 存储过程:语法、PHP 调用与连接中毒排坑实录 附:SQL SECURITY 权限模型与防注入注意事项
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址