做代码审计这几年,SQL 注入见得不算少。有些写法一看就知道要出事,有些看着没问题但藏着坑。这篇整理了我在实际项目里碰到过的 6 种防御写法,有的靠谱,有的只是看起来靠谱。

先说清楚 SQL 注入是怎么回事

SQL 注入说白了就一件事:用户输入的东西被当成 SQL 语句的一部分执行了。比如你写了个登录接口:

query = "SELECT * FROM users WHERE name='" + username + "' AND pass='" + password + "'"

用户在密码框里填 ' OR '1'='1,拼出来的 SQL 就变了味——不管密码对不对都能登进去。问题就出在"拼"字上。

1. 参数化查询(推荐,但别以为用了就万事大吉)

这是最正经的做法。用占位符代替拼接,数据库驱动会帮你处理转义。

# Python + MySQL
cursor.execute("SELECT * FROM users WHERE name=%s AND pass=%s", (username, password))

// PHP PDO
$stmt = $pdo->prepare("SELECT * FROM users WHERE name = ? AND pass = ?");
$stmt->execute([$username, $password]);

我审过一个项目,接口层用了参数化查询,看着很规范。但往下翻发现后台有个管理页面,开发图省事直接拼了字符串。一个项目里混着两种写法,防了前门没防后门。

参数化查询有个限制:表名和列名不能参数化。遇到动态表名的需求,得用白名单校验,后面会讲。

2. ORM 框架(大多数情况够用)

Django、SQLAlchemy、MyBatis 这些 ORM 默认就用参数化查询,正常用的话不太容易出问题。

# Django
User.objects.filter(name=username, password=password)

# SQLAlchemy
session.query(User).filter(User.name == username).first()

但我碰到过两种翻车场景。一种是 MyBatis 里用 ${} 而不是 #{}

<!-- 有注入风险 -->
<select id="getUser">
  SELECT * FROM users WHERE name = '${name}'
</select>

<!-- 安全写法 -->
<select id="getUser">
  SELECT * FROM users WHERE name = #{name}
</select>

另一种是 Django 的 extra()raw(),开发者绕过 ORM 直接写 SQL,等于自己把门打开了。

3. 白名单校验(处理表名、列名、排序字段)

有些场景确实需要动态拼 SQL,比如前端传过来"按价格排序"或"按时间排序"。这时候参数化查询帮不上忙,得靠白名单。

ALLOWED_SORT_FIELDS = {'price', 'created_at', 'name', 'id'}

sort_field = request.GET.get('sort', 'id')
if sort_field not in ALLOWED_SORT_FIELDS:
    sort_field = 'id'

cursor.execute(f"SELECT * FROM products ORDER BY {sort_field} DESC")

我审过一个电商后台,排序字段直接从前端取,没做任何校验。传个 id; DROP TABLE products-- 进去就完了。加个白名单,几行代码的事。

4. 输入过滤和转义(能用,但别当主力)

有些老项目会自己写过滤函数,把单引号、分号这些危险字符替换掉或者转义。

// PHP 老项目常见写法
function clean_input($data) {
    $data = trim($data);
    $data = stripslashes($data);
    $data = htmlspecialchars($data);
    $data = str_replace("'", "''", $data);
    return $data;
}

这种做法不是完全没用,但问题多。首先是容易漏——你能想到过滤单引号,想不到的字符还多着呢。其次是编码绕过,GBK 编码下的宽字节注入就能绕过 addslashes。我在一个用 GBK 编码的老系统里就见过这个问题,用 %bf%27 就能把转义吃掉。

如果你接手的是老项目改不动底层,过滤可以当临时补丁。但新项目别这么干。

5. 存储过程(不等于安全)

有人觉得用了存储过程就安全了,因为 SQL 逻辑写在数据库端。但存储过程里照样可以拼字符串:

-- 不安全的存储过程
CREATE PROCEDURE GetUser(IN uname VARCHAR(50))
BEGIN
    SET @sql = CONCAT('SELECT * FROM users WHERE name = "', uname, '"');
    PREPARE stmt FROM @sql;
    EXECUTE stmt;
END;

-- 安全写法
CREATE PROCEDURE GetUser(IN uname VARCHAR(50))
BEGIN
    SELECT * FROM users WHERE name = uname;
END;

审过一个金融项目,几十个存储过程,大部分写得还行,但有三四个用了 CONCAT 拼动态 SQL。开发说"存储过程本身就防注入",这是误解。存储过程只是把代码挪了个地方,拼接的问题不会因为换了位置就消失。

6. WAF 和运行时防护(最后一道墙)

Web 应用防火墙在请求到达应用之前拦截可疑输入。ModSecurity、云厂商的 WAF 产品都算这类。

# ModSecurity 规则示例
SecRule ARGS "@detectSQLi" "id:1,phase:2,deny,status:403,msg:'SQL injection detected'"

WAF 能挡住大部分扫描器和自动化攻击,但绕过手段也不少。编码变换、分块传输、利用业务逻辑的盲注,都有可能绕过去。我做渗透测试时绕过 WAF 的次数比被拦住的多。

WAF 该部署还是要部署,但它是兜底的,不能替代代码层面的防御。

实际项目里怎么组合

说几条我自己总结的原则:

  • 新项目用 ORM 或参数化查询,没什么好犹豫的
  • 动态表名、列名用白名单校验
  • 数据库账号给最小权限,应用层的账号不需要 DROP 权限就别给
  • 上 WAF 兜底,但别指望它能防住所有东西
  • 代码审计时重点看 raw()extra()$_GET 直接入 SQL、MyBatis 的 ${} 这些地方

最后一点,SQL 注入这个问题已经存在二十多年了,OWASP Top 10 年年上榜。不是因为防御方法不够,是因为总有人觉得"我这个接口不会被攻击"。