首页 / 帮助文档 / 代码层防止SQL注入实操优化技巧

代码层防止SQL注入实操优化技巧

代码层防SQL注入,核心不是记住一两个过滤函数,而是理解数据进入SQL语句的路径。绝大多数注入漏洞都源于开发者把用户输入当成了SQL语法的一部分来拼接。要彻底解决,必须从查询构建方式、参数处理、权限控制和异常兜底四个层面同时下手。下面直接讲可落地的实操方案。

优先使用参数化查询,杜绝字符串拼接

参数化查询是目前最有效的防注入手段。它的原理是把SQL结构和数据彻底分离,数据库驱动会把参数值当作纯数据处理,而不是SQL语法的一部分。以Java的JDBC PreparedStatement为例:

String sql = "SELECT id, username FROM users WHERE email = ? AND status = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, userInputEmail);
ps.setInt(2, 1);
ResultSet rs = ps.executeQuery();

这里的问号占位符是核心。无论userInputEmail里包含什么内容,比如' OR '1'='1,数据库都只会把它当作email字段的匹配值,不会改变SQL逻辑。同样的思路在Go的database/sql、Python的sqlite3、PHP的PDO中都适用。关键是团队必须把拼接SQL视为违规操作,代码评审时一旦发现字符串拼接进SQL,直接打回。

ORM框架不能完全替代防注入意识

很多开发者以为用了ORM就高枕无忧,实际上ORM也有注入风险。以常见的Java JPA为例:

String username = request.getParameter("username");
Query query = entityManager.createQuery("SELECT u FROM User u WHERE u.username = '" + username + "'");

这种写法照样会被注入。即使用的是Hibernate、MyBatis、GORM,也必须使用命名参数或位置参数:

// MyBatis正确写法
@Select("SELECT * FROM users WHERE username = #{username}")
User findByUsername(@Param("username") String username);

要特别注意MyBatis中${}和#{}的区别。${}是字符串替换,存在注入风险;#{}是预编译占位符,安全。规则很简单:所有用户可控的数据一律用#{},${}只能用于表名、字段名等无法参数化的场景,而且必须配合白名单校验。

动态SQL中的排序、分页和字段名处理

参数化查询有个盲区:表名、字段名、排序方向、LIMIT等SQL语法元素通常不能使用占位符。这时候必须引入白名单机制。比如用户请求按某个字段排序:

private static final Set ALLOWED_COLUMNS = Set.of("id", "username", "create_time", "score");
private static final Set ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");

public List getUsersByOrder(String orderBy, String direction) {
    if (orderBy == null || !ALLOWED_COLUMNS.contains(orderBy)) {
        throw new IllegalArgumentException("Invalid column: " + orderBy);
    }
    if (direction == null || !ALLOWED_DIRECTIONS.contains(direction.toUpperCase())) {
        throw new IllegalArgumentException("Invalid direction: " + direction);
    }
    String sql = "SELECT * FROM users ORDER BY " + orderBy + " " + direction.toUpperCase();
    return jdbcTemplate.query(sql, new UserRowMapper());
}

这个白名单必须定义在服务端,不能依赖前端传入的值直接拼SQL。分页参数同样要强制转成整数类型,任何非数字输入直接拒绝。如果业务确实需要动态表名,建议维护一个表名到实际表名的映射,前端只传业务标识。

存储过程同样需要参数绑定

存储过程并不天然免疫注入。如果存储过程内部使用了动态SQL拼接,照样会被注入。调用存储过程时也要用参数绑定:

CallableStatement cs = connection.prepareCall("{call getUserById(?)}");
cs.setInt(1, userId);
ResultSet rs = cs.executeQuery();

存储过程内部如果必须使用动态SQL,比如根据条件拼WHERE子句,应该使用数据库提供的参数化动态执行能力。以SQL Server为例,优先用sp_executesql而不是EXEC:

CREATE PROCEDURE GetUsersByFilter
    @Username NVARCHAR(50)
AS
BEGIN
    DECLARE @sql NVARCHAR(MAX);
    SET @sql = N'SELECT * FROM users WHERE username = @p_username';
    EXEC sp_executesql @sql, N'@p_username NVARCHAR(50)', @p_username = @Username;
END

sp_executesql支持参数化,而直接EXEC拼接字符串则完全暴露在注入风险下。

输入验证是最后一道防线,但不能作为唯一防线

很多团队把防注入寄托在输入过滤上,比如过滤单引号、过滤SELECT关键字。这种做法有两个致命问题:一是容易误伤正常用户输入,比如用户名里包含单引号;二是绕过手段太多,编码绕过、大小写绕过、注释绕过层出不穷。输入验证的正确定位是辅助手段,用来限制输入格式和长度。比如邮箱字段用正则校验格式,手机号只允许数字,用户名限制字符集为字母数字下划线。但即使做了这些验证,参数化查询依然不能省略。

数据库权限最小化原则

应用连接数据库的账号不应该拥有DDL权限,也不应该拥有对所有表的读写权限。比如一个用户查询接口只需要读users表,那这个数据库账号就只授予users表的SELECT权限。这样即使某个接口被注入,攻击者也无法通过这个连接执行DROP TABLE或者读取其他敏感表。很多中小项目为了方便,直接给应用账号root权限,这是非常危险的做法。实操中建议为不同的业务模块创建不同的数据库账号,每个账号只授予必要的表级或列级权限。

错误信息脱敏与异常处理

数据库报错信息往往会泄露表名、字段名甚至部分数据。生产环境必须关闭详细错误输出,统一返回通用错误信息。以Java为例,全局异常处理器中捕获SQLException,记录详细日志到服务端,但只给前端返回“系统繁忙,请稍后重试”。同时要避免在日志中记录完整的SQL语句和参数值,尤其是包含用户密码、手机号等敏感数据的SQL。日志脱敏是很多团队容易忽略的环节。

多层防御架构下的注入拦截

除了代码层,还可以在应用层增加WAF规则或者使用框架自带的过滤器。比如Spring Security可以配置请求参数过滤规则,拦截包含SQL关键字的参数值。但这类规则只能作为辅助,因为基于黑名单的拦截很容易被绕过。更有效的做法是在代码提交阶段引入静态代码扫描工具,比如SpotBugs、SonarQube的SQL注入规则,在CI流程中自动检测字符串拼接SQL的情况。把防注入从个人意识提升到工程流程层面,可靠性会高很多。

特殊场景:LIKE查询与IN子句

LIKE查询中用户输入可能包含通配符%和_,如果直接拼进参数,可能导致查询结果超出预期。正确的做法是转义通配符:

String keyword = userInput.replace("\\", "\\\\")
                          .replace("%", "\\%")
                          .replace("_", "\\_");
String sql = "SELECT * FROM products WHERE name LIKE ? ESCAPE '\\'";
ps.setString(1, "%" + keyword + "%");

IN子句的参数数量不固定,无法直接使用单个占位符。这时可以动态生成占位符列表,但所有值仍然用参数绑定:

List ids = Arrays.asList(1, 2, 3);
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
String sql = "SELECT * FROM users WHERE id IN (" + placeholders + ")";
PreparedStatement ps = connection.prepareStatement(sql);
for (int i = 0; i < ids.size(); i++) {
    ps.setInt(i + 1, ids.get(i));
}

占位符的数量由服务端控制,用户输入只作为参数值传入,安全性不受影响。

二次注入与编码绕过

二次注入是指攻击者第一次提交的数据被存储后,在后续查询中被当作SQL语法执行。比如用户注册时用户名包含注入代码,注册时参数化查询没问题,但后续某个功能把用户名直接拼进SQL,就触发了注入。防止二次注入的关键在于:所有从数据库读取出来的数据,在再次进入SQL语句时,同样要参数化处理。不能因为数据来自数据库就放松警惕。

编码绕过主要出现在输入过滤场景。比如过滤了单引号,攻击者用十六进制编码0x27代替;过滤了SELECT,用SeLeCt绕过。参数化查询从根本上不受这些编码影响,因为数据不会被解析为SQL语法。这也是为什么参数化查询是首选方案,而不是输入过滤。

实操落地检查清单

代码层防SQL注入可以总结为以下必查项:所有SQL语句是否使用参数化查询;动态表名、字段名、排序字段是否经过白名单校验;LIKE查询是否转义通配符;IN子句是否动态生成占位符且参数绑定;数据库账号是否最小权限;生产环境是否关闭详细错误输出;日志中是否避免记录敏感SQL;CI流程是否包含SQL注入静态扫描;ORM使用中是否避免了字符串拼接;存储过程内部是否使用参数化动态执行。这十条覆盖了绝大多数代码层注入风险点,建议纳入团队的代码评审标准。