很多 Java 8 项目并不是完全没做防护,而是把注意力放错了位置:一边做关键词过滤,一边还在拼接 SQL,结果真正的注入入口仍然敞开。要把这类问题收住,关键不在“补更多规则”,而在于把 SQL 结构、参数绑定、框架占位符和错误输出这几层边界一次性划清。
这篇文章按开发中最常见的四个落点来拆解:JDBC 层怎样正确使用 PreparedStatement,MyBatis 里 #{} 和 ${} 该怎么分工,异常信息为什么不能原样外露,以及输入验证到底该拦什么、不该拦什么。读完之后,你可以直接判断一段 Java 8 数据访问代码是否存在明显注入风险。
PreparedStatement 为什么是第一道硬性防线
在 Java 8 中,PreparedStatement 不是“推荐使用”,而是防 SQL 注入时必须落实的基础要求。它的核心作用,是在预编译阶段先固定 SQL 的语法结构,再把用户输入作为参数值单独绑定进去。这样一来,哪怕传入的是 ' OR 1=1 --,数据库也只会把它当作普通字符串,而不会当成 SQL 语句的一部分去解析。

真正危险的写法,通常是直接使用 Statement 做字符串拼接,例如:
SELECT * FROM user WHERE id = " + userId这类代码看上去很直白,但风险也最直接。即使开发者认为 userId 应该是数字,攻击者仍可能通过构造类似 1; DROP TABLE user; 的输入,尝试触发堆叠查询或其他非预期执行路径。
正确做法有几条不能退让:
- 统一使用
?作为占位符,再通过pstmt.setString(1, value)、pstmt.setLong(2, id)等方法完成绑定。 - 参数不能省略、不能错位,也不能“先拼一半、再绑定一半”。只要用户输入进入 SQL,就应该走参数绑定。
- 批量写入或批量更新时,优先采用
addBatch()+executeBatch(),避免在循环里反复 prepare,既更稳妥,也更符合性能习惯。 - 不要在占位符之外再做所谓“手动转义”,例如先调用
StringEscapeUtils.escapeSql()再拼接 SQL。这种做法既不能替代参数化,反而可能让代码产生误判,甚至引入新的漏洞。
MyBatis 里 #{} 和 ${} 的边界要写死
很多项目用了 MyBatis 之后,会误以为“框架已经帮我处理安全问题”。这只对了一半。MyBatis 是否安全,关键取决于你用的是 #{} 还是 ${}。

#{} 的行为,本质上等价于 PreparedStatement 的参数绑定,因此适合承接所有值类型参数;而 ${} 是纯文本替换,效果与字符串拼接没有本质区别,只要进入用户可控内容,就会把风险直接带进 SQL。
可以这样理解它们的分工:
#{}用于普通查询条件和值参数,例如WHERE name = #{name}、AND status IN (#{status1}, #{status2})。${}只能用于无法参数化的元数据位置,例如表名、列名或ORDER BY字段,而且必须配套白名单校验。
例如下面这种写法,只有在 tableName 已经被明确限制在固定集合时才可接受:
if (!List.of("user", "order", "product").contains(tableName)) throw new IllegalArgumentException();也就是说,像 SELECT * FROM ${tableName} 这种场景,本身不是绝对禁止,但前提必须是“来源可控、范围固定、校验先行”。
需要特别强调的是,${} 绝不能出现在 WHERE、VALUES 或任何会承接用户输入值的位置。只要把用户提供的名称、状态、关键词、ID 或时间条件通过 ${} 填进 SQL,风险级别就和手写拼接基本一致。
异常信息不脱敏,等于把库表线索直接暴露出去
防注入不只是“别让恶意 SQL 执行成功”,还包括“别在失败时把数据库细节送给攻击者”。实际项目里,很多问题不是因为第一轮注入就得手,而是因为报错信息把表名、字段名、约束名和数据库类型都透露了出去。
SQLException.getMessage() 往往就包含这类敏感内容。比如 MySQL 可能返回 Unknown column 'pwd_hash' in 'where clause',PostgreSQL 可能报出 relation "users_backup" does not exist。对攻击者而言,这些信息足够用来摸清表结构和命名习惯。
因此,生产环境至少要做到三件事:
- HTTP 响应统一返回业务可读、技术细节不可见的信息,例如
{"code": 500, "message": "操作失败,请稍后重试"}。 - 日志里优先记录
e.getSQLState()和e.getErrorCode()。例如23505可以表示唯一约束冲突,这类信息足够排查,又不会把完整上下文暴露出去。 - 不要把
e.printStackTrace()输出到公开可访问的日志、监控页面或 API 返回体中,尤其不能让调用方直接看到数据库错误原文。
这里的重点不是“完全不记日志”,而是把日志分层:对外只给稳定、模糊、可恢复的提示,对内保留足够的错误码和必要上下文,供开发和运维排查。
输入验证是前置闸机,但不能替代参数化
输入验证经常被误用成“防注入主方案”,这会把职责搞反。验证的主要价值,不是把 SQL 变安全,而是尽早拦下明显不合法的请求,减少异常进入数据库层的机会,并且约束那些本来就不该出现的输入形态。

因此,它和 PreparedStatement 是两层独立防线:前者负责减少脏输入,后者负责隔离 SQL 结构。两者都要做,但任何一层都不能代替另一层。
有效的输入验证,重点在语义约束,而不是关键词过滤:
- ID、主键、分页页码这类天然有类型约束的参数,应该直接做类型转换,例如
Long.parseLong(idStr)。一旦抛出NumberFormatException,就应在业务层直接拒绝,而不是继续交给 JDBC。 - 邮箱、手机号等格式明确的字段,适合使用正则白名单,例如
^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$,并且不能只依赖前端 JavaScript 校验。 - 分页参数必须设置硬上限,例如
Math.min(pageSize, 100),避免有人用异常大值拖垮查询和数据库资源。 - 不要启用全局“SQL 关键词黑名单”,比如统一拦截
union select drop。这类规则既容易被绕过,也可能误伤正常业务内容,例如用户名、备注或搜索词中恰好包含select。
换句话说,好的校验是判断“这个字段在业务上是否合理”,而不是猜测“它是不是像一段攻击语句”。前者可维护,后者通常只会越补越乱。
项目里最容易漏掉的风险点
很多 Java 8 项目出问题,并不是完全没有使用参数化,而是局部代码破坏了整体规则。只要某一个接口里混入一次手写 Statement,或者某一段 MyBatis 动态 SQL 错用了 ${},前面做过的防护就会被单点击穿。
如果你要快速排查现有项目,可以优先看这几类位置:
- 搜索是否存在
Statement、createStatement()和明显的 SQL 字符串拼接。 - 检查 MyBatis XML 或注解 SQL 中是否把
${}用在查询条件和值参数上。 - 确认异常处理中是否把
SQLException.getMessage()、完整堆栈或数据库报错原文直接返回给前端。 - 检查入口参数是否有类型约束、格式校验和分页上限,避免无效请求直接打到数据库。
最危险的误区,是以为“项目用了 MyBatis,所以天然安全”。实际上,安全性从来不是框架自动兜底的结果,而是每一层写法都没有越界。只有把参数绑定、占位符边界、错误脱敏和输入校验同时落实,SQL 注入风险才算真正被压到可控范围内。













