Java数据库编程中,绝大多数开发者都知道用PreparedStatement防止SQL注入,但一碰到复杂查询——动态表名、IN子句、ORDER BY排序、LIKE模糊匹配——就纷纷掉进陷阱。要么图省事直接拼接字符串,要么对预编译机制一知半解,以为用了占位符就万事大吉。问题恰恰出在这里:JDBC的PreparedStatement不是万能盾牌,它的防御边界非常明确,用错了地方照样被注入穿透。这篇文章就专门拆解这些复杂场景,给出可直接落地的安全方案。
预编译语句的防御机制到底是什么很多人把预编译和参数化查询混为一谈,实际上这是两个层面的东西。JDBC的PreparedStatement在驱动层先将SQL语句骨架发送给数据库进行编译,生成执行计划,然后再把参数值单独传递过去填充。关键点在于:参数值永远不会被当作SQL代码执行,数据库严格区分“代码”和“数据”。即使参数里包含恶意的SQL片段,比如' OR '1'='1,它也只是被当作一个普通的字符串值处理,不会改变原始SQL的逻辑结构。这就是预编译防御SQL注入的核心原理。
但这里有个致命误区:预编译只保护“值”的位置,不保护“结构”的位置。SQL语句中,表名、列名、ORDER BY字段、GROUP BY字段这些属于数据库对象标识符,不是值,不能用占位符代替。如果你用字符串拼接的方式把用户输入放到这些位置,预编译机制完全帮不上忙,因为恶意代码在编译之前就已经混入了SQL骨架。
动态表名和列名:占位符的禁区假设有一个报表系统,允许用户选择不同的数据表进行查询。很多开发者的第一反应是:
String tableName = request.getParameter("table");
String sql = "SELECT * FROM " + tableName + " WHERE id = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setInt(1, userId);
这段代码里,虽然id参数安全了,但tableName直接拼接,攻击者传入users; DROP TABLE orders;--就能造成灾难。更隐蔽的攻击是传入包含UNION注入的子查询,窃取其他表的数据。
正确的做法是建立白名单映射。数据库里有哪些表、哪些列,后端必须完全掌控,绝不能让用户输入直接决定SQL结构:
private static final Set<String> ALLOWED_TABLES = Set.of("orders", "products", "customers");
private static final Map<String, String> COLUMN_MAPPING = Map.of(
"id", "id",
"name", "customer_name",
"date", "order_date"
);
public List<Data> queryByTable(String tableParam, String orderBy) {
if (tableParam == null || !ALLOWED_TABLES.contains(tableParam)) {
throw new IllegalArgumentException("Invalid table name");
}
String column = COLUMN_MAPPING.get(orderBy);
if (column == null) {
throw new IllegalArgumentException("Invalid column");
}
String sql = "SELECT * FROM " + tableParam + " ORDER BY " + column;
return jdbcTemplate.query(sql, new DataRowMapper());
}
白名单方案看似简单,却是唯一安全的方式。注意这里还做了列名的间接映射,前端传的是业务字段名,后端映射到真实的数据库列名,既隐藏了数据库结构,又杜绝了注入可能。
IN子句的陷阱:参数数量动态变化IN子句是复杂查询中最常见的注入重灾区。用户选择多个筛选条件,比如传入ID列表[1, 2, 3],SQL需要变成WHERE id IN (?, ?, ?)。问题在于占位符的数量是动态的,很多人就放弃了参数化,直接拼接:
String ids = request.getParameter("ids"); // "1,2,3"
String sql = "SELECT * FROM products WHERE id IN (" + ids + ")";
这等于把大门敞开。攻击者传入1) OR 1=1--就能绕过所有条件。正确的方案是动态生成等量的占位符:
public List<Product> findByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
// 限制数量防止资源耗尽
if (ids.size() > 1000) {
throw new IllegalArgumentException("Too many IDs");
}
String placeholders = ids.stream()
.map(id -> "?")
.collect(Collectors.joining(","));
String sql = "SELECT * FROM products WHERE id IN (" + placeholders + ")";
PreparedStatement ps = conn.prepareStatement(sql);
for (int i = 0; i < ids.size(); i++) {
ps.setLong(i + 1, ids.get(i));
}
return extractResults(ps.executeQuery());
}
这个方案里,占位符字符串是由后端根据参数个数自动生成的,用户输入只通过setLong()方法传入,完全隔离在SQL结构之外。注意还加了数量上限检查,防止攻击者传入超长列表导致SQL语句膨胀或数据库资源耗尽。对于不支持大量占位符的数据库,可以考虑分批查询或使用临时表方案。
ORDER BY和GROUP BY:排序注入的隐蔽性排序注入往往被忽视,因为很多人觉得ORDER BY后面跟的只是字段名,不会造成数据泄露。实际上,通过构造特殊的排序表达式,攻击者可以推断出数据内容。比如ORDER BY (CASE WHEN password LIKE 'a%' THEN id ELSE name END),通过观察返回结果的排序差异,就能逐字符猜解出密码。这就是盲注的一种高级形式。
防御方案同样是白名单,但需要额外注意排序方向ASC/DESC的处理:
private static final Set<String> ALLOWED_SORT_FIELDS =
Set.of("id", "username", "create_time", "update_time");
private static final Set<String> ALLOWED_DIRECTIONS =
Set.of("ASC", "DESC");
public List<User> queryWithOrder(String sortField, String sortDir) {
if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) {
sortField = "id"; // 默认排序字段
}
if (sortDir == null || !ALLOWED_DIRECTIONS.contains(sortDir.toUpperCase())) {
sortDir = "ASC";
}
String sql = "SELECT * FROM users ORDER BY " + sortField + " " + sortDir.toUpperCase();
return jdbcTemplate.query(sql, new UserRowMapper());
}
这里的关键是,排序字段和方向都必须在白名单内,任何不在白名单的输入都回退到安全的默认值,而不是直接报错或拼接。报错信息本身也可能泄露数据库结构信息。
LIKE模糊查询:特殊字符的转义问题LIKE查询有两个层面的安全问题。第一层是SQL注入,用预编译可以解决;第二层是LIKE通配符滥用,攻击者传入%或_可以改变查询的语义,导致全表扫描或绕过模糊匹配的限定条件。比如用户搜索一个订单号,传入%%会匹配所有记录。
正确的做法是结合预编译和通配符转义:
public List<Product> searchByName(String keyword) {
if (keyword == null || keyword.trim().isEmpty()) {
return Collections.emptyList();
}
// 转义LIKE通配符,防止用户输入被当作通配符解析
String escapedKeyword = keyword
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
String sql = "SELECT * FROM products WHERE name LIKE ? ESCAPE '\\'";
PreparedStatement ps = conn.preparestatement(sql);
ps.setString(1, "%" + escapedKeyword + "%");
return extractResults(ps.executeQuery());
}
这里做了三件事:用占位符传递搜索词,防止SQL注入;转义用户输入中的%和_字符,让它们被当作普通字符而不是通配符;使用ESCAPE子句指定转义字符。这样即使用户输入包含%或_,也只会被# 精确匹配这些字符本身。同时注意在转义时反斜杠本身也要先转义,否则会形成新的转义序列。
分页查询的LIMIT/OFFSET注入分页参数通常是整数,很多人觉得直接拼接数字没问题。但攻击者可以传入极大的OFFSET值导致数据库进行全表扫描,造成拒绝服务攻击。更隐蔽的是利用OFFSET进行基于时间的盲注。正确的做法是强制类型转换和范围校验:
public List<Item> queryByPage(String pageStr, String sizeStr) {
int page = parseIntSafe(pageStr, 1);
int size = parseIntSafe(sizeStr, 20);
// 强制范围限制
if (page < 1) page = 1;
if (size < 1 || size > 100) size = 20;
int offset = (page - 1) * size;
String sql = "SELECT * FROM items LIMT ? OFFSET ?";
PreparedStatement ps = conn.preparestatement(sql);
ps.setInt(1, size);
ps.setInt(2, offset);
return extractResults(ps.executeQuery());
}
private int parseIntSafe(String value, int defaultVal) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultVal;
}
}
LIMIT和OFFSET在多数数据库驱动中都支持占位符参数化,直接使用setInt()即可。关键是做好范围校验,防止资源滥用。
多条件动态组合查询的完整方案真实业务中,往往是多个筛选条件动态组合,条件之间用AND或OR连接。这里给出一个生产级别的安全构建方案:
public List<Order> queryOrders(OrderQuery query) {
StringBuilder sql = new StringBuilder("SELECT * FROM orders WHERE 1=1");
List<Object> params = new ArrayList<>();
// 静态条件,用占位符
if (query.getStatus() != null) {
sql.append(" AND status = ?");
params.add(query.getStatus());
}
if (query.getMinAmount() != null) {
sql.append(" AND amount >= ?");
params.add(query.getMinAmount());
}
if (query.getKeyword() != null && !query.getKeyword().trim().isEmpty()) {
sql.append(" AND (order_no LIKE ? OR customer_name LIKE ?)");
String escaped = escapeLike(query.getKeyword());
params.add("%" + escaped + "%");
params.add("%" + escaped + "%");
}
// 动态排序,白名单校验
String sortField = validateSortField(query.getSortField());
String sortDir = validateSortDir(query.getSortDir());
sql.append(" ORDER BY ").append(sortField).append(" ").append(sortDir);
// 分页参数化
sql.append(" LIMT ? OFFSET ?");
params.add(query.getPageSize());
params.add((query.getPage() - 1) * query.getPageSize());
PreparedStatement ps = conn.preparestatement(sql.toString());
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
return extractResults(ps.executeQuery());
}
这个方案的核心思想是:SQL结构由后端代码逻辑决定,用户输入只通过参数列表传入。任何需要动态决定SQL结构的地方,都必须经过白名单或严格的格式校验。WHERE条件用占位符,排序字段用白名单,分页参数用占位符加范围限制,LIKE查询转义通配符。整个构建过程没有一处直接拼接用户输入到SQL骨架中。
ORM框架下的预编译注意事项很多人以为用了Hibernate或MyBatis就自动安全了,实际上ORM框架的滥用同样会导致注入。Hibernate的HQL中,如果使用字符串拼接构建查询,一样存在注入风险。MyBatis中,${}是字符串替换(直接拼接,#{}才是预编译参数。务必在动态SQL中坚持使用#{},对于表名、列名等必须动态的部分,使用MyBatis的${}时一定要配合白名单校验。框架只是工具,安全意识才是根本。
数据库驱动层面的一个隐蔽问题某些数据库驱动在特定配置下可能使用客户端模拟预编译,而不是真正的服务端预编译。比如MySL的JDBC驱动中,默认情况下useServerPrepStmts参数是false,此时PrepraredStatement在客户端进行参数替换,虽然也能防止注入,但在极端情况下可能存在差异。建议在连接串中显式开启服务端预编译:useServerPrepStmts=true,同时设置cachePrepStmts=true来缓存预编译语句,提升性能。不过需要注意,服务端预编译会增加一次网络往返,对于简单查询可能反而降低性能,需要根据实际场景权衡。
预编译语句是防御SQL注入的基石,但它的保护范围仅限于SQL语句中的“值”部分。面对复杂查询中的动态结构需求,开发者必须清醒地认识到预编译的边界,在结构层面建立白名单、严格校验、强制类型转换等多层防御。安全不是某个单一技术就能一劳永逸的,而是贯穿整个编码过程的意识和规范。
