preparedstatement仅防参数位置注入,不防sql结构注入;表名、列名、order by等非参数位置必须白名单校验,否则拼接即失效。

为什么 PreparedStatement 不能自动防住所有注入场景
很多人以为只要用了 PreparedStatement,就彻底安全了。其实不然——它只防“参数位置上的注入”,不防“SQL结构层面的注入”。比如你拼接表名、字段名、ORDER BY 子句或 WHERE 条件逻辑时用了字符串拼接,哪怕参数全用 ? 占位,照样可能被绕过。
典型翻车现场:"INSERT INTO " + tableName + " (id, name) VALUES (?, ?)",如果 tableName 来自配置或上游数据(比如从消息头里取的库名),攻击者控制该值就能写成 users; DROP TABLE orders;,数据库会执行两条语句(取决于驱动和数据库是否允许多语句)。
- MySQL 默认禁用多语句,但加了
allowMultiQueries=true就危险了 - PostgreSQL 不支持多语句,但可借
UNION SELECT泄露元数据 - 表名/列名无法用
?占位,必须靠白名单校验或转义
动态表名和字段名必须走白名单校验
凡是出现在 SQL 模板中非参数位置的字符串(如 INSERT INTO ? 是非法的,INSERT INTO users 才合法),都得提前约束范围。不要试图用正则过滤单引号或分号——字符集编码、Unicode 变体、注释绕过手段太多,白名单才是唯一靠谱方式。
实操建议:
- 把允许的表名硬编码进枚举或配置 Map,比如
Map.of("order_log", "t_order_log_2026", "user_profile", "t_user_profile") - 运行时只接受 key,查表映射出真实表名,拒绝任何不在 key 列表中的输入
- 字段名同理:插入前校验
fieldList是否全在预定义集合内,比如Set.of("user_id", "amount", "status") - 避免用反射或 JSON key 动态生成字段列表,除非你能保证这些 key 绝对不可控
批量插入时别混用 Statement 和 PreparedStatement
有人为了“灵活”在批处理里切回 Statement,比如先用 PreparedStatement 插基础字段,再用 Statement 补一条 UPDATE 或 INSERT ... SELECT。这等于主动拆掉防护墙——Statement 对任何字符串都不做转义,传入含 /* 注释或 1=1 的值就直接执行。
正确做法是统一用 PreparedStatement,哪怕要拼复杂子查询:
- 把子查询逻辑移到应用层,查出来再塞进主
INSERT的参数里 - 用数据库函数替代动态条件,比如
COALESCE(?, 'default')而不是拼"WHERE status = '" + status + "'" - 真需要动态 WHERE,改用 MyBatis 的
<if></if>标签或 jOOQ 的 DSL,它们底层仍走预编译
rewriteBatchedStatements=true 不影响注入防护,但会放大错误后果
MySQL 驱动加了 rewriteBatchedStatements=true 后,JDBC 会把 1000 条 INSERT INTO t VALUES (?) 改写成一条 INSERT INTO t VALUES (?), (?), ...。这个优化本身不破坏 PreparedStatement 的安全性,因为所有值仍是参数化传入。
但它会让一次注入的影响面变大:原本只污染单条记录的恶意值,现在可能让整批 1000 条都失败,甚至因语法错误导致整批回滚。更麻烦的是,错误日志里只报“第 1 行语法错误”,你根本不知道是哪条数据带了坏字符。
- 上线前务必用含单引号、反斜杠、Unicode 零宽空格的数据压测批处理逻辑
- 在
addBatch()前加一层清洗,比如value.replaceAll("\u200b", "")干掉零宽字符 - 批大小别设太大(建议 ≤500),否则定位脏数据成本陡增
PreparedStatement 再好也救不了。











