String sql = "SELECT * FROM users WHERE name = '" + userName + "'";
userName 传进来 ' OR '1'='1,拼出来的 SQL 变成 SELECT * FROM users WHERE name = '' OR '1'='1',条件恒真,整张表的数据全出来了。传个 '; DROP TABLE users; -- 进去,表直接没了。
解决办法在每本 Web 安全入门教材的同一页上:参数化查询。
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE name = ?");
ps.setString(1, userName);
数据库先拿到 SQL 模板 SELECT * FROM users WHERE name = ?,编译好执行计划,结构锁死。之后填进去的 userName 无论是什么——单引号、分号、DROP TABLE——数据库都只当它是一个字符串值,不会当 SQL 语法去解析。
打个比方:字符串拼接像是把收件地址和包裹内容写在同一张纸上,快递员分不清哪部分是地址哪部分是内容。参数化查询是先递一张写好地址的快递单,然后再把包裹放上去——地址和内容从头就是分开的。
Java 的 PreparedStatement 从 JDK 1.1 就有了,1997 年的事。PHP 的 PDO、Python 的 DB-API、.NET 的 SqlCommand,每个语言都有。MyBatis 里 #{} 是参数化,${} 是拼接。Spring Data JPA 的 JPQL 默认就是参数化的。
工具齐了快三十年,但 OWASP 统计里注入类漏洞到现在还排前五。用了 ORM 的项目,首次安全扫描仍有 18% 检出 SQL 注入风险。
拿一个 MyBatis 项目举例,怎么排查自己有没有注入风险。全局搜 ${:
grep -rn '${' src/main/resources/mapper/
搜出来的每一个 ${} 都要逐个过一遍。#{} 是安全的——MyBatis 会把它编译成 PreparedStatement 的 ? 占位符。${} 是直接拼进 SQL 字符串里的,跟手写字符串拼接没区别。
大部分 ${} 出现在两个地方:动态表名和 ORDER BY 排序字段。这两个参数化查询确实搞不定——表名和列名是 SQL 的结构部分,数据库编译执行计划之前就得知道它们是什么,? 占位符放不进去。
<!-- 这样写有注入风险 -->
<select id="queryByTable" resultType="map">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
<!-- ORDER BY 也是 -->
<select id="listUsers" resultType="User">
SELECT * FROM users ORDER BY ${sortColumn} ${sortOrder}
</select>
${tableName} 如果直接从前端参数里取,传个 users; DROP TABLE users; -- 进来就完了。${sortColumn} 传个 id; DELETE FROM users; -- 一样能打。
安全的做法是在 Java 层做白名单映射,不让前端传来的值直接进 SQL:
public String getSortColumn(String param) {
switch (param) {
case "name": return "user_name";
case "date": return "created_at";
default: return "id";
}
}
返回值只可能是代码里写死的那几个列名,前端传什么都不影响。动态表名同理——维护一个允许的表名集合,不在集合里的直接拒绝。
private static final Set<String> ALLOWED_TABLES = Set.of("orders", "products", "users");
public void query(String tableName) {
if (!ALLOWED_TABLES.contains(tableName)) {
throw new IllegalArgumentException("非法表名: " + tableName);
}
// 到这里 tableName 一定是安全的
}
搜完 ${},再搜 createNativeQuery 和 nativeQuery = true——这两个是 JPA/Hibernate 里写原生 SQL 的口子,走这条路的时候 ORM 不会帮你参数化。Django 项目搜 extra(、raw(、RawSQL,同样的问题。
ORM 的安全性建立在"你用它提供的查询接口"这个前提上。用 Criteria API、JPQL、QueryDSL 这些,框架帮你处理参数化。一旦绕过去写原生 SQL,就回到了手动管安全的状态。
那 SQL 标准能不能从语言层面把这件事堵死?
堵不死。SQL 就是一门语言,语言就意味着可以动态拼它。SELECT * FROM users WHERE name = ' + 用户输入 + '——SQL 解析器看到的只是一整条字符串,它分不清哪部分是程序员写的、哪部分是用户传的。
参数化查询能防注入,靠的不是"聪明地转义特殊字符"。是在协议层面把 SQL 结构和数据拆成了两次传输。SQL 模板先到数据库,编译好,结构锁死;数据后到,只能往槽里填,改不了结构。PostgreSQL 的协议里 Parse 和 Bind 就是两条独立消息,MySQL 的二进制协议也有类似的分离。这比转义靠谱得多——转义是在字符串层面做手脚,总有绕过的可能;协议层的分离是从根上断开的。
但前提是你得走 PreparedStatement 这条路。不走的话,数据库收到一整条拼好的 SQL,跟有没有注入风险它根本看不出来。grep -rn '${' src/ 这一行命令能帮你找到大部分没走这条路的地方。
该文章在 2026/4/17 17:36:47 编辑过