MySQL 防止 SQL 注入的几种写法

=========================

SQL 注入(SQL Injection)长期位居 OWASP Top 10 榜首,是 Web 安全领域最具破坏力的漏洞类型之一。攻击者通过在用户输入中嵌入恶意 SQL 片段,可以绕过认证、读取敏感数据、篡改记录,甚至直接控制数据库服务器。对于使用 MySQL 的开发者来说,掌握正确的防注入写法,是保障应用安全的基本功。

本文将从代码层面出发,系统梳理 MySQL 环境下防止 SQL 注入的几种主流写法,并指出常见误区。

一、SQL 注入的本质

SQL 注入的根源在于:用户输入被当作 SQL 代码执行。例如下面这段 PHP 代码:

$sql = "SELECT * FROM users WHERE username = '$_GET[user]' AND password = '$_GET[pass]'";

当攻击者提交 user=admin' -- 时,SQL 语句被拼接为:

SELECT * FROM users WHERE username = 'admin' -- ' AND password = '...'

-- 之后的内容被注释掉,密码验证形同虚设。问题的核心不是 MySQL 本身,而是拼接字符串构建 SQL 这一危险做法。

二、写法一:预处理语句(Prepared Statements)

预处理语句是目前最推荐的防注入方案,也是 OWASP 官方首推的做法。其原理是:SQL 模板先发送给 MySQL 编译,参数值随后单独传输,数据库引擎不会将参数值解析为 SQL 语法。

PDO 写法(PHP)

$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :user AND password = :pass');
$stmt->execute([':user' => $username, ':pass' => $password]);
$user = $stmt->fetch();

MySQLi 写法(PHP)

$stmt = $mysqli->prepare('SELECT * FROM users WHERE username = ? AND password = ?');
$stmt->bind_param('ss', $username, $password);
$stmt->execute();

Java JDBC 写法

PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM users WHERE username = ? AND password = ?");
ps.setString(1, username);
ps.setString(2, password);
ResultSet rs = ps.executeQuery();

Python(PyMySQL / mysql-connector)

cursor.execute(
    "SELECT * FROM users WHERE username = %s AND password = %s",
    (username, password)
)

注意:Python 驱动中占位符是 %s不要用 Python 的字符串格式化提前替换,否则预处理失效。

关键点:参数值必须通过 bind / execute 传入,绝不能先拼接再 prepare。

三、写法二:参数化查询 + 白名单校验

预处理语句解决的是“值”层面的注入,但有些场景参数无法占位,例如表名、列名、排序方向(ORDER BY ASC/DESC)。这些位置不能使用占位符,必须使用白名单校验

$allowedOrder = ['id', 'created_at', 'username'];
$order = in_array($_GET['order'], $allowedOrder, true) ? $_GET['order'] : 'id';

$direction = strtoupper($_GET['dir']) === 'DESC' ? 'DESC' : 'ASC';

$sql = "SELECT * FROM users ORDER BY {$order} {$direction}";

白名单的本质是:只允许已知安全的值通过,而不是试图过滤所有恶意输入。

四、写法三:输入类型强制转换

对于明确为整数、布尔值的参数,强制类型转换可以彻底消除注入风险:

$id = (int) $_GET['id'];
$sql = "SELECT * FROM articles WHERE id = $id";
int id = Integer.parseInt(request.getParameter("id"));
user_id = int(request.args.get("id"))

只要类型转换成功,攻击者就无法注入任何 SQL 片段。但要注意:类型转换只适用于数字型参数,字符串型参数仍需预处理。

五、写法四:ORM 框架的正确使用

现代 ORM(如 Laravel Eloquent、Django ORM、Hibernate、MyBatis)底层大多使用预处理语句,只要按规范使用即可自动防注入:

// Laravel
User::where('username', $username)->where('password', $password)->first();
# Django
User.objects.filter(username=username, password=password)

但 ORM 也有陷阱

  • Laravel 中使用 DB::raw() 拼接用户输入会重新引入注入风险;
  • Django 中使用 .extra()RawSQL 需格外小心;
  • MyBatis 中 ${} 是字符串拼接,#{} 才是参数化,务必使用 #{}

六、写法五:存储过程(谨慎使用)

存储过程在数据库端预编译,理论上可以防注入,但前提是存储过程内部也不拼接 SQL

CREATE PROCEDURE GetUser(IN uname VARCHAR(50))
BEGIN
    SELECT * FROM users WHERE username = uname;
END

如果存储过程内部使用 CONCAT 动态拼接,则同样存在注入风险。因此存储过程并非“银弹”,使用门槛较高。

七、常见误区与错误做法

  1. 依赖 addslashes()mysql_real_escape_string():在字符集不匹配(如 GBK)时可能被宽字节注入绕过,且无法覆盖数字型参数。
  2. 使用黑名单过滤 unionselect 关键字:攻击者可通过大小写、注释、编码等方式绕过,防不胜防。
  3. 只在前端做校验:前端 JS 校验可被轻易绕过,服务端必须重新校验。
  4. 认为 ORM 绝对安全:一旦使用原生 SQL 拼接,ORM 的保护即失效。
  5. 错误信息直接回显:泄露表结构、字段名,为攻击者提供便利。

八、纵深防御建议

除了上述写法,还应配合以下措施:

  • 最小权限原则:Web 应用连接数据库的账号只授予必要权限,禁用 DROPFILE 等高危权限;
  • 关闭错误回显:生产环境统一返回通用错误页面,详细错误写入日志;
  • WAF 辅助:部署 Web 应用防火墙,拦截常见注入特征;
  • 定期扫描:使用 SQLMap、AWVS 等工具进行安全测试;
  • 日志审计:记录异常 SQL 行为,及时发现攻击。

结语

防止 SQL 注入没有“一招鲜”,但有一条铁律:永远不要信任用户输入,永远不要拼接 SQL。预处理语句是首选方案,白名单和类型转换是必要补充,ORM 需规范使用,存储过程要谨慎评估。将这些写法融入日常编码习惯,配合最小权限、错误处理和日志审计,才能构建真正稳固的 MySQL 应用安全防线。

安全不是一次性的工作,而是持续的过程。建议开发者将本文提到的几种写法纳入团队代码规范,并在 Code Review 中重点检查 SQL 拼接行为,从源头杜绝注入漏洞。

未经允许不得转载:任鹏个人博客 » MySQL 防止 SQL 注入的几种写法

赞 (0) 打赏

评论 0

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

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏