在月黑风高的夜晚,小贼紧跟着老贼来到老街金店的门前。四顾无人,两人悄无声息地行动起来。此时,小贼突然问道:“为什么要撬锁?”老贼一个大逼斗拍下来,引起远处一声狗吠,是夜,又恢复了平静……
这个故事告诉我们,有时候最直接的路径并非唯一选择,甚至可能绕了远路。在 SQL 安全领域,预编译是预防注入的一道重要屏障,它能防御绝大多数注入攻击。
预编译与字符串拼接的较量
让我们先通过一个经典的登录场景,看看预编译是如何工作的。
场景一:未使用预编译(高危)
// 获取用户输入
String username = request.getParameter("user"); // 恶意输入:admin' OR '1'='1
String password = request.getParameter("pass");
// 字符串拼接SQL(高危操作)
String sql = "SELECT * FROM users WHERE username='" + username + "' AND password='" + password + "'";
// 执行SQL
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);
// 最终拼接出的高危SQL:
// SELECT * FROM users WHERE username='admin' OR '1'='1' AND password='xxx'
攻击效果:OR '1'='1' 使查询条件恒成立,从而绕过了密码验证,直接返回管理员账户数据。
场景二:使用预编译(安全)
// 获取用户输入
String username = request.getParameter("user"); // 恶意输入:admin' OR '1'='1
String password = request.getParameter("pass");
// 使用 ? 作为参数占位符(参数化查询)
String sql = "SELECT * FROM users WHERE username=? AND password=?";
// 预编译并绑定参数
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, username); // 参数1:username
pstmt.setString(2, password); // 参数2:password
// 执行查询
ResultSet rs = pstmt.executeQuery();
乍一看代码变化不大,但背后的安全机制截然不同。从技术上讲,SQL 在预编译阶段会生成一个语法树,此结构在执行过程中不可变。这意味着用户的输入,无论包含什么字符(比如单引号),都只会被当作纯粹的“数据值”填充到预定的位置,而无法改变 SQL 语句本身的结构。
最终,数据库引擎执行的 SQL 类似于:
SELECT * FROM users WHERE username="admin' OR '1'='1" AND password='xxx'
你看,整个可疑的字符串 admin' OR '1'='1 被当作一个完整的用户名去匹配,自然无法找到记录,攻击也就失效了。
预编译的盲区与那“1%”的风险
补完开头的故事:又过了几个“月黑风高”的夜晚,老贼带着小贼还在研究金店的锁。小贼再次忍不住开口:“为什么不从后墙根挖个盗洞?”不出所料,老贼又一个大逼斗拍下来,引起远处一声狗吠,暗骂道:“为何不早说……”
这就像安全防护,如果我们只盯着“锁”(预编译),可能会忽略其他入口。预编译并非万能,在特定场景下它会失效。
动态结构注入(表名/列名拼接)
预编译仅对值参数(如 WHERE 子句中的条件值)有效。当 SQL 语句的结构本身需要动态变化时,例如拼接表名、列名或 SQL 关键字(我们称之为结构参数),占位符 ? 就无能为力了。
尝试将表名参数化,你会发现行不通:
SELECT * FROM ? WHERE id = ?
这行 SQL 在编译阶段就会报语法错误,因为数据库规定 FROM 后面必须是一个标识符(如表名),而不能是一个字符串值。因此,开发者不得不进行字符串拼接:
// Spring Boot 中常见的风险写法
String tableName = request.getParameter("type"); // 输入:users
String column = request.getParameter("field"); // 输入:password
// 预编译失效点:表名和列名无法使用占位符
String sql = "SELECT " + column + " FROM " + tableName + " WHERE id = ?";
jdbcTemplate.query(sql, new Object[]{userId}, mapper);
如果攻击者传入恶意的 type 参数:
users; DELETE FROM admin_logs--
最终拼接出的 SQL 将是灾难性的:
SELECT password FROM users; DELETE FROM admin_logs-- WHERE id = 123
-- 注释掉了后续语句,使得一次查询变成了“查询+删除”组合拳。
预编译与拼接混用(局部安全=全盘崩溃)
有时开发者会错误地混合使用两种方式,以为部分参数用了预编译就安全了。
# 半吊子防护的示例
user_id = request.GET['id'] # 输入:123 OR 1=1
sql = "SELECT * FROM users WHERE id = " + user_id + " AND status = ?"
cursor.execute(sql, ('active',))
拼接后的高危 SQL:
SELECT * FROM users WHERE id = 123 OR 1=1 AND status = ?
结果:预编译仅保护了 status 参数,而 user_id 的注入成功绕过了所有条件限制,返回了全部数据。
ORM 框架中的错误使用
在 MyBatis 等 ORM 框架中,#{} 和 ${} 的区别至关重要:
#{}:使用预编译占位符。
${}:直接进行字符串替换(相当于拼接)。
错误的混用写法:
<select id="getData">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
如果传入 tableName="users; TRUNCATE TABLE logs--",最终参与执行的 SQL 将是:
SELECT * FROM users; TRUNCATE TABLE logs-- WHERE id = ?
又一次,结构被篡改,TRUNCATE 语句得以执行。
SQL 注入的根本原因与防控机制
归根结底,SQL 注入的发生,是因为开发者错误地将不可信的用户输入直接拼接到 SQL 语句结构中。要构建纵深防御,需要一个清晰的决策流程。
防护决策树

这张流程图清晰地展示了面对不同类型 SQL 参数时的安全处理路径,是构建防线的有效指南。
白名单:最后的安全阀
对于无法避免的 ${tableName} 动态拼接,唯一的办法是在应用层(如 Java 业务逻辑层)实施严格的白名单校验。切记,仅在 XML 或注解中判断是远远不够的,攻击者可能直接绕过。
错误示范(依赖XML判断,不安全):
<select id="getData">
<if test="tableName == 'users' or tableName == 'orders'">
SELECT * FROM ${tableName} WHERE id = #{id}
</if>
</select>
攻击者可以传入 tableName = “users; DROP TABLE logs--”,<if> 标签的简单字符串比较可能无法拦截或会被绕过。
正确做法(Java层硬编码白名单):
// ✅ 正确做法:Java层硬编码白名单
public User getData(String tableName, Long id) {
// 定义严格的可信表名集合
Set<String> allowedTables = Set.of(“users”, “orders”, “products”);
if (!allowedTables.contains(tableName)) {
throw new SecurityException(“非法表名: “ + tableName);
}
// 经过白名单校验后,使用 ${tableName} 相对安全
return mapper.getData(tableName, id);
}
通过预编译处理值参数,通过白名单校验控制结构参数,双管齐下,才能最大限度地堵住 SQL 注入的漏洞。安全是一个持续的过程,需要开发者对每一行可能接触用户输入的代码保持警惕。希望本文的剖析能帮助你更牢固地掌握这道防线。想探讨更多安全与架构话题,欢迎访问云栈社区。