SQL注入至今仍是Web安全领域最普遍、破坏性最大的漏洞类型之一。攻击者之所以能得手,根本原因在于开发者把用户输入直接拼接进SQL语句,导致数据库执行了本不该执行的逻辑。要防止SQL注入,核心思路只有一条:严格区分代码与数据,让用户输入永远只被当作数据,而不是SQL语法的一部分。围绕这个原则,过滤、校验、参数化查询、最小权限、错误处理等措施需要组合使用,任何单一手段都难以覆盖全部攻击面。

最可靠的防注入手段是使用参数化查询,也叫预编译语句。它的原理是先把SQL语句的结构发送给数据库,数据库解析并编译好执行计划,然后再把用户输入作为参数单独传递。这样无论用户输入什么内容,数据库都只会把它当作一个值来处理,而不会改变SQL语句的语法结构。以Java的JDBC为例,正确的写法如下:

String customerName = request.getParameter("customerName");
String query = "SELECT account_balance FROM user_data WHERE user_name = ?";
PreparedStatement pstmt = connection.prepareStatement(query);
pstmt.setString(1, customerName);
ResultSet results = pstmt.executeQuery();

上面的代码中,问号是占位符,用户输入通过setString方法绑定,数据库不会把customerName中的单引号、分号或注释符当作SQL语法解析。对比之下,如果写成字符串拼接的形式,攻击者输入“' OR '1'='1' -- ”,查询条件就会被篡改,导致返回全表数据。参数化查询适用于绝大多数查询场景,包括SELECT、INSERT、UPDATE、DELETE以及存储过程调用。

不过参数化查询并非万能。某些场景下SQL语句的结构本身需要动态变化,例如表名、列名、排序字段或排序方向不能作为参数绑定。此时必须对这类标识符做严格的白名单校验。白名单的做法是预先定义一个允许出现的值集合,用户输入必须完全匹配集合中的某一项才被接受,否则直接拒绝。例如排序字段只允许id、username、create_time三个值:

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

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

白名单校验的关键在于使用“允许”逻辑而不是“拒绝”逻辑。黑名单试图枚举所有危险字符或关键词,但攻击者总有办法绕过,比如大小写混合、双重编码、注释插入、等价函数替换等。白名单从根源上排除了所有不在预期范围内的输入,安全性远高于黑名单。

输入过滤是另一层防御,但必须明确它的定位:过滤不能替代参数化查询,只能作为辅助手段。过滤的目标是移除或转义输入中的危险字符,比如单引号、双引号、反斜杠、分号、注释符等。但过滤逻辑很容易出错,因为不同的数据库、不同的字符集、不同的上下文对“危险字符”的定义并不一致。MySQL中反斜杠是转义字符,Oracle中却不是;单引号在字符串上下文中有意义,在数字上下文中则无关紧要。更麻烦的是,如果过滤函数处理不当,可能破坏正常业务数据,比如用户昵称中本来就可能包含单引号。

如果必须使用字符串拼接,并且无法使用参数化查询,那么至少要做数据库特定的转义。以MySQL为例,可以使用mysql_real_escape_string函数对单引号、双引号、反斜杠、NULL字节等字符进行转义。但转义仍然容易遗漏,尤其是涉及多字节字符集时,历史上就出现过利用GBK编码中反斜杠与后续字节组合成新字符绕过转义的案例。因此转义只能作为临时补救,不能作为长期方案。

数据类型校验同样重要。很多注入攻击利用的是开发者对输入类型不做检查。比如一个本应是整数的ID参数,攻击者传入“1 OR 1=1”,如果代码直接拼接进SQL,就形成了注入。正确的做法是先校验输入是否为合法的整数,再将其转换为int或long类型,最后再用于查询。Java中可以使用Integer.parseInt并捕获NumberFormatException,或者使用正则表达式验证。对于邮箱、手机号、日期等格式固定的字段,也应当用正则或专门的解析器做格式校验,格式不符的直接返回参数错误。

长度限制是容易被忽视的细节。即使输入内容本身没有恶意,过长的字符串也可能导致数据库性能下降或缓冲区溢出。对每个字段设置合理的最大长度,超出长度直接拒绝,既能提高安全性,也能减少异常数据进入系统的概率。长度校验应当放在服务端执行,客户端校验只能提升用户体验,不能作为安全边界。

数据库权限最小化是纵深防御的重要环节。应用连接数据库的账号不应使用root或管理员权限,而应根据业务需要授予最小权限。例如一个只读查询功能对应的数据库账号,只授予SELECT权限,不授予INSERT、UPDATE、DELETE、DROP等权限。这样即使应用层出现SQL注入漏洞,攻击者能造成的破坏也会被限制在权限允许的范围内。此外,不同业务模块可以使用不同的数据库账号,进一步隔离风险。

错误信息处理直接关系到信息泄露。数据库报错信息中往往包含表名、列名、数据库版本、SQL语句片段等敏感信息,攻击者可以利用这些信息构造更精准的注入payload。生产环境中绝不能把数据库异常堆栈直接返回给前端。应用层应当捕获所有数据库异常,记录详细日志供开发人员排查,同时向前端返回统一的、模糊的错误提示,比如“系统繁忙,请稍后重试”。同时要确保日志系统本身有访问控制,避免日志成为新的泄露源。

存储过程并不能天然防止SQL注入。如果存储过程内部使用字符串拼接来构造动态SQL,同样存在注入风险。只有存储过程内部也使用参数化方式处理输入,才能保证安全。因此不能因为使用了存储过程就放松对输入的校验。

ORM框架也不能完全杜绝SQL注入。Hibernate、MyBatis等框架在大多数情况下使用参数绑定,安全性较好,但框架提供的原生SQL或动态SQL拼接功能如果使用不当,依然会引入注入漏洞。MyBatis中应避免使用“${}”语法拼接用户输入,而应使用“#{}”语法进行参数绑定。对于必须使用“${}”的场景,比如动态表名或列名,必须配合白名单校验。

二次注入是一种更隐蔽的攻击方式。攻击者第一次提交的恶意数据被应用安全地存储进数据库,第二次某个功能读取这条数据后,未经处理直接拼接进新的SQL语句,从而触发注入。防御二次注入的关键在于:任何从数据库读取出来的数据,在进入新的SQL语句之前,同样要当作不可信输入处理,使用参数化查询或严格校验。不能因为数据来自数据库就默认它是安全的。

编码与字符集问题也值得关注。应用、数据库连接、数据库实例三者的字符集应当保持一致,推荐统一使用UTF-8。字符集不一致可能导致某些字符在转换过程中被绕过或误判。例如应用层使用UTF-8,数据库连接使用GBK,攻击者构造的特殊字节序列可能在转换后形成单引号。统一字符集可以减少这类编码层面的绕过风险。

在实际项目中,建议建立一套统一的输入校验与SQL执行规范。所有数据库访问必须通过统一的数据访问层,禁止在业务代码中随意拼接SQL。数据访问层封装参数化查询、白名单校验、类型转换、长度限制等通用逻辑,业务开发人员只需要调用封装好的方法。这样可以从架构层面降低人为失误的概率。同时配合代码审计工具和自动化安全扫描,定期检查代码中是否存在字符串拼接SQL、危险函数调用等问题。

对于已经上线的系统,如果怀疑存在SQL注入漏洞,应当尽快进行安全测试。测试时可以使用单引号、双引号、反斜杠、注释符、布尔条件、时间延迟等常见payload,观察应用响应是否异常。但要注意,生产环境的安全测试必须在授权范围内进行,避免影响正常业务。更稳妥的方式是在测试环境中复现问题,修复后再发布上线。

总结来说,防止SQL注入的优先级应当是:参数化查询作为第一道也是最强的防线,白名单校验处理动态标识符,数据类型与长度校验拦截异常输入,最小权限限制潜在破坏,错误处理防止信息泄露,统一数据访问层从架构上降低风险。过滤和转义可以作为辅助手段,但绝不能作为主要防线。只有把这些措施组合起来,形成纵深防御体系,才能有效降低SQL注入的风险。