PreparedStatement 防不住的 SQL 注入盲区与安全实践

发布时间:2026/10/20 7:39:37
PreparedStatement 防不住的 SQL 注入盲区与安全实践 做了这么多年 Java 后端我听过太多次这样的论断代码里用了 PreparedStatementSQL 注入这块就稳了。这个想法本身没错PreparedStatement 确实是 JDBC 防注入的第一道也是最重要的一道闸门。但你要是认为它进了保险箱那接下来要说的这些盲区迟早会在你或者你队友的代码里出现。PreparedStatement 的防护机制解决的是“参数值”的问题可 SQL 注入还有相当一部分发生在“结构”层面发生在 ORM 框架的拼接习惯里甚至发生在存储过程内部。这篇文章不否定 PreparedStatement 的价值而是想带你把它的能力边界看清楚再把真实项目里容易踩的坑逐一拆开最后给出可复用的最佳实践。不管你是刚入行的新人还是写过多年数据访问层的熟手花几分钟过一遍都不亏。1. PreparedStatement 到底安不安全先理解它的设计逻辑1.1 SQL 注入的本质数据越界变成了代码讨论 PreparedStatement 之前得先搞清楚 SQL 注入到底在打什么。一条 SQL 本身由两种东西组成SQL 结构关键字、表名、字段名、运算符和用户数据字符串、数字。数据库在解析这条 SQL 时会把两者放进同一个语法树里处理。注入的本质就是用户的输入本来应该只占“数据”的位置结果却突破了边界进入了“结构”的位置改变了整条语句的语义。经典例子大家都见过SELECT * FROM users WHERE name OR 11设计者以为用户输入的是“name”值比如张三。但攻击者输入了 OR 11。这个字符串在拼进 SQL 后把原来的查询条件闭合掉了还额外加了一个恒真条件。这条 SQL 执行后返回的就是整张表的数据。这里的核心问题不是数据库傻而是开发者在构造 SQL 时让用户输入直接参与到了语句结构的生成中。所以防注入这件事本质上不是“把库里的数据加密”或者“过滤掉危险词”——过滤危险词是最脆弱的思路因为攻击者总能找到编码变体。真正要做的是让用户的输入永远只能作为“数据”被处理永远没有机会变成“代码”。PreparedStatement 的设计目标恰恰就是这一点。1.2 PreparedStatement 的防护机制到底做了什么PreparedStatement 的防注入原理经常被简单总结成“预编译”。但“预编译”三个字在 Java 开发者脑中留下的印象太粗浅了很多人以为它像某种魔法字符串把单引号转义一下就完事。实际上它的工作方式是这样的第一步SQL 骨架先被发送到数据库。这条骨架里的可变位置全部用占位符?替代数据库先对骨架做词法解析、语法校验、生成执行计划此时骨架里根本没有用户的数据。第二步参数值通过独立的二进制协议或者转义机制传过去。主流 JDBC 驱动在传输参数时会把值当作纯粹的“数据”而不是 SQL 语法片段。驱动会处理掉单引号、分号、注释符号这些可能干扰结构的字符但这不是“过滤”而是参数协议本身就划分出了数据边界。我用一个生活化的类比PreparedStatement 相当于你和数据库约定好一张固定格式的表格表格里的格子是?你告诉数据库“这个格子是空白的待会儿我会往里面放值”。于是数据库先审核这张表格的结构能不能执行然后用一个单独通道把每个格子对应的值填进去。无论你在格子里面填什么它都只是格子里的内容数据库永远不会把格子里填的字符串当成表格结构的一部分来解析。这个机制对“值注入”是真正有效的。你在WHERE name ?的?里传 OR 11数据库只会认为你要查询的名字字符串就是那一段而不是把它当成语法。这就是为什么不推荐用字符串拼接构造 SQL而推荐参数化的根本原因。1.3 盲区为什么会出现PreparedStatement 能解决的问题非常清晰所有通过setString、setInt、setObject绑定进去的值都是安全的。但问题在于实际业务中的“动态部分”远不止 where 子句里的值。比如分页排序时排序字段是用户选的你能写ORDER BY ?吗表名是配置文件里的你能写SELECT * FROM ?吗LIKE 查询用户输入的通配符PreparedStatement 会处理单引号但会帮你处理%吗存储过程内部如果用了字符串拼接PreparedStatement 又能管到里面吗答案是这些场景下PreparedStatement 统统管不了。这就是盲区的来源——不是它不安全而是我们对“安全”的定义太宽了。很多团队把“用了 PreparedStatement”当成了“整个数据访问层安全”忽略了其他位置的动态 SQL最后被攻击的恰恰是这些看起来不起眼的位置。2. 五个被忽视的防护盲区2.1 盲区一表名、列名、排序字段这些“结构参数”这是最常见的坑也是最容易出问题的位置。先看一个典型的危险场景String sortField request.getParameter(sortField); // 用户传入 String direction request.getParameter(direction); // asc / desc String sql SELECT id, name, age FROM users ORDER BY sortField direction;这里我用的是ORDER BY拼接没有参数化。为什么不用 PreparedStatement因为ORDER BY ?在数据库里根本达不到“按用户传入的列排序”的预期。占位符绑定后这个?会被当成一个普通的常量表达式比如ORDER BY age数据库按一个常量排序结果就是所有记录不排序或者顺序不对。所以很多开发者发现参数化排序失效就干脆拼字符串结果把用户输入直接塞进了 SQL 结构。攻击者如果控制了这个sortField就能注入类似age DESC, (SELECT ...)的片段利用报错信息和响应时间做盲注。同样的问题还出现在表名、列名上。某些分库分表中间件的场景里表名可能需要动态传入开发者图省事直接拼接这是高危操作。这里要说明ORDER BY ?失效不代表ORDER BY这块就一定要靠拼接。正确做法是用白名单映射后面第三节我会写完整实现。2.2 盲区二LIKE 查询里的通配符劫持很多人以为LIKE ?用了 PreparedStatement 就万事大吉。我见过一个实际案例某后台用户搜索接口用下面的写法String sql SELECT * FROM products WHERE name LIKE ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, keyword %);PreparedStatement 保证了keyword不能闭合单引号注入 SQL 语句但是%和_这两个字符在LIKE子句里是通配符数据库会保留它们的特殊语义。也就是说攻击者往keyword里传一个%最终 SQL 语义就变成name LIKE %%等于匹配所有商品。传%admin%还能把你不想暴露的记录全部捞出来。这不是传统意义的 SQL 注入而是更隐蔽的“逻辑扭曲”属于语义层面的越权。更要命的是有些开发者自己没意识到这点反而把参数化后的 SQL 直接拼进日志。比如用logger.info(SQL: sql , keyword: keyword)日志系统会把这句完整 SQL 记录下来。如果日志被集中采集攻击者又能拿到日志读取权限等同于拿到了带数据的结构信息。2.3 盲区三IN 查询的多值参数处理IN子句是 PreparedStatement 最容易踩坑的地方因为占位符?只能代表一个值。你没法写出WHERE id IN (?)然后传一个列表进去。很多人的第一反应是手工拼接列表项ListLong ids parseIds(request.getParameter(ids)); String sql SELECT * FROM users WHERE id IN ( String.join(,, ids) );这里是直接把用户可控的数据拼进了 SQL。攻击者传一个id1) OR 11 --之类的东西就完成了注入。还有个更隐蔽的问题很多人会用FIND_IN_SET(?, field)或者instr(field, ?)去绕过 IN 参数化的麻烦结果要么失去索引导致慢查询要么自带新的拼接风险。正确方向是动态生成一串占位符IN (?, ?, ?, ...)。这个过程中占位符的数量是程序根据列表长度生成的用户输入只通过setLong进入参数位置。但动态占位符也有一个隐患如果列表长度极大会导致 SQL 骨架过长、数据库执行计划缓存膨胀某些数据库还有参数数量上限。这个问题需要限制列表长度或者改用临时表批量查询。实现细节我会在实操部分给完整代码。2.4 盲区四存储过程内部的动态 SQL存储过程这个盲区很多人写过几年代码都没意识到。来看一个 MySQL 里的典型反模式CREATE PROCEDURE user_search(IN kw VARCHAR(100)) BEGIN SET sql CONCAT(SELECT * FROM users WHERE name LIKE %, kw, %); PREPARE stmt FROM sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; END;如果 Java 层调用它CallableStatement cs conn.prepareCall({call user_search(?)}); cs.setString(1, request.getParameter(q)); cs.execute();从 Java 层看确实用了 PreparedStatement用户输入也通过参数绑定了。但问题在于kw在存储过程内部被CONCAT拼接进了新的 SQL 字符串这个拼接发生在数据库内部PreparedStatement 的边界根本管不到这里。攻击者调用user_search时传% OR 11 --存储过程内部的动态 SQL 就会把整个条件推翻。这类风险的排查难度比普通 Java 代码高得多。因为很多团队只有 Java 工程师会做安全评审没有人专门审数据库脚本存储过程就成了安全管理的真空地带。我在实际项目中见过不止一次Java 代码全部参数化但测试环境数据库的存储过程里连着三四处动态拼接等于防火墙上开了个后门。2.5 盲区五ORM 与驱动的“非标准”用法很多 Java 项目已经不在业务代码里直接写 PreparedStatement而是通过 ORM 框架操作数据库。但 ORM 框架内部如果提供了“字符串直接拼接”的通道PreparedStatement 的防护就会被绕过。以某个流行的 Java ORM 框架为例它支持两种占位符写法#{}会生成 JDBC 占位符交给 PreparedStatement而${}会直接把传入内容替换进 SQL 文本。很多开发者习惯写${}觉得只是写法更灵活。结果 SQL 日志里清晰可见完整的查询语句安全扫描却直接亮红灯。我见过有团队规定“业务代码里禁止出现${}”结果还是有人在动态表名、动态排序、动态select列这种框架本来不支持参数化的地方偷偷用让整个团队的防线出现漏洞。驱动层面也有需要注意的点。比如 MySQL 的老版本驱动在特定配置下rewriteBatchedStatements会把多条 INSERT 重写成一条多值 INSERT如果这条 SQL 里混入了拼接内容风险会被批量放大。还有个常见错误是用日志输出器直接把 SQL 和参数合并成完整 SQL 打印出来本来只是为了方便排查一旦日志平台被拖等于帮攻击者完成了注入语句调试。这些都属于“PreparedStatement 安全边界之外”的灰色地带。3. 实操从盲区到安全方案3.1 白名单映射结构参数的正确姿势先处理最常见的 ORDER BY、表名、列名这类结构参数。核心思路是用户输入绝对不能直接成为结构的一部分必须经过一次“翻译”。private static final MapString, String TABLE_WHITE_LIST new HashMap(); static { TABLE_WHITE_LIST.put(users, users); TABLE_WHITE_LIST.put(articles, articles); TABLE_WHITE_LIST.put(orders, orders); } private static final MapString, String COLUMN_WHITE_LIST new HashMap(); static { COLUMN_WHITE_LIST.put(name, name); COLUMN_WHITE_LIST.put(createdAt, created_at); COLUMN_WHITE_LIST.put(price, price); } private static final SetString ORDER_DIRECTION Set.of(ASC, DESC);业务代码处理排序参数时先拿用户输入去白名单里找对应的真实列名String userSort request.getParameter(sortField); String realColumn COLUMN_WHITE_LIST.get(userSort); if (realColumn null) { throw new IllegalArgumentException(非法的排序字段); } String direction ASC.equalsIgnoreCase(request.getParameter(direction)) ? ASC : DESC; String sql SELECT id, name, created_at, price FROM TABLE_WHITE_LIST.get(users) ORDER BY realColumn direction; PreparedStatement ps conn.prepareStatement(sql);这个方案的关键在于白名单相当于一层“翻译映射”用户输入的createdAt会被翻译成数据库里的真实列created_at用户输入根本无法直接影响 SQL 文本。即使用户传了什么name DESC, (SELECT ...)白名单查不到就会直接报错。真实项目中表名、列名、排序字段、分组字段都应该走这套逻辑。我见过有人用正则校验排序字段比如只允许字母数字下划线。正则也能挡掉大部分攻击但白名单的容错率更高列名一多白名单可能维护成本高一些不过对于安全需求来说完全值得。3.2 LIKE 查询的通配符转义与 ESCAPE 子句LIKE 查询要做的不是“过滤掉 % 和 _”而是把它们转义成普通字符同时保留用户正常搜索的含义。我推荐使用ESCAPE子句来自定义转义字符避免依赖不同数据库的默认转义规则。public static String escapeLikeWildcard(String raw) { if (raw null) return null; String escapeChar !; // 顺序不能乱先转义转义符本身再转义两个通配符 return raw.replace(escapeChar, escapeChar escapeChar) .replace(%, escapeChar %) .replace(_, escapeChar _); }使用的时候先对用户输入做转义再拼进 LIKE 模式并显式指定ESCAPE !String keyword request.getParameter(q); String escaped escapeLikeWildcard(keyword); String sql SELECT * FROM products WHERE name LIKE ? ESCAPE !; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, % escaped %);我特别要说一下替换顺序这个细节必须先替换!本身再替换%和_。如果顺序反了用户输入里自带一个!%经过替换后会变成%!%之类的结果数据库解析时转义逻辑就会错乱。这个坑我在代码评审里看到过不止一次。按上面这个顺序用户输入!会被转成!!最终数据库看到的是字面意义上的感叹号而%和_总是跟在!后面成为被保护的字面字符。3.3 IN 查询动态占位符的完整实现IN 查询的正确做法是动态生成占位符但用户输入全部通过参数绑定进入。同时一定要限制列表长度避免执行计划缓存膨胀和被参数上限卡脖子。private static final int MAX_IN_SIZE 500; ListLong ids parseIdsFromRequest(request.getParameter(ids)); if (ids.isEmpty() || ids.size() MAX_IN_SIZE) { throw new IllegalArgumentException(id 数量超限或为空); } StringBuilder placeholders new StringBuilder(); for (int i 0; i ids.size(); i) { if (i 0) placeholders.append(,); placeholders.append(?); } String sql SELECT * FROM users WHERE id IN ( placeholders ); try (PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i ids.size(); i) { ps.setLong(i 1, ids.get(i)); } try (ResultSet rs ps.executeQuery()) { // 处理结果集 } }注意占位符的?是我们程序自己生成的骨架不是用户输入直接拼进去的所以安全。但生成占位符的逻辑本身也属于“SQL 结构动态生成”一定要保证这里只出现?、,这些固定字符绝不能循环拼接用户传进来的原始 id 字符串。如果业务的 id 列表很长超出限制建议分批查询后在业务层合并结果或者把 id 批量写入临时表再JOIN。这能避免一条 SQL 里出现上千个占位符性能和安全性都更好。3.4 存储过程改造与分层防御体系存储过程内部的动态 SQL唯一彻底的方案是改造存储过程本身把内部拼接全部换成绑定变量。以 MySQL 为例上面那个反模式要改造成CREATE PROCEDURE user_search(IN kw VARCHAR(100)) BEGIN SELECT * FROM users WHERE name LIKE CONCAT(%, kw, %); END;注意这里kw是绑定参数直接参与查询不再被CONCAT拼进 SQL 结构再交给PREPARE所以注入路径被堵死了。如果你没有权限改存储过程那就只能禁止用户调用这个存储过程或者在中间层对入参做强校验、过滤所有单引号和注释符。这不是最优解但比裸奔强。除了改造存储过程我更建议在整体架构上做分层防御。下面这几条是我在项目里总结出来的优先级从高到低数据访问层默认使用参数绑定杜绝字符串拼接 SQL结构参数一律走白名单映射。应用层对入参做类型、长度、格式校验不能让;、--、/*这类字符直接影响业务逻辑。数据库权限层给应用分配最小权限账号只授权必要的SELECT、INSERT、UPDATE、DELETE不要给DROP、ALTER、CREATE这类危险权限从根上降低注入后的破坏力。审计与监控层开启数据库慢查询日志、网络层开启安全审计异常 SQL 能第一时间被发现。信息隐藏层异常信息不要直接回显给用户统一封装成通用错误码。攻击者拿不到数据库报错就少了重要的探测依据。再加一条关于日志的建议打日志时永远输出“参数化 SQL 参数列表”这种格式不要输出拼接好的完整 SQL。如果日志采集系统被拖后门攻击者拿到的也只是一堆?而不是带真实结构的可执行语句。4. 常见问题与排查技巧实录4.1 高频问题速查表我把这些年实际遇到过的典型问题汇总成了一张表方便你直接对照排查现象根因排查方向解决方案用了 PreparedStatementORDER BY 没效果或报错排序字段被当成字面量无法参数化检查 SQL 是否使用了ORDER BY ?白名单映射排序字段LIKE 查询用?还是能查出全部数据用户输入%或_被当作通配符检查 LIKE 模式中的原始输入转义通配符 ESCAPE 子句ORM 框架里写#{}仍然报注入某处用了${}直接拼接全局搜索${}全部改#{}结构处用白名单调用存储过程参数化传入仍被注入存储过程内部动态拼接 SQL审查数据库脚本里的 PREPARE/CONCAT改造存储过程为绑定变量安全扫描工具报“SQL injection”但代码看起来没问题某个字符串拼接发生在框架内部工具类里从工具类往上层搜调用链重构工具类传参走绑定批量插入时驱动重写 SQL参数转义异常某些驱动的rewriteBatchedStatements配置检查连接参数和驱动版本升级驱动保持配置默认值或充分测试真要靠记忆去记这些对应关系容易漏建议把这张表直接贴到团队开发文档里遇到类似情况先查一遍。4.2 排查思路与静态扫描辅助排查项目里是否还有 SQL 注入盲区我的习惯从三个维度展开。第一文本层全局搜索。在 Java 代码里搜所有createStatement()的调用这类对象基本不会参数化。还要搜字符串拼接附近出现sql、query、select、insert的代码块逐段人工判断是否会把用户输入拼进 SQL。先扫掉最明显的“手写拼接”再用正则搜 request.getParameter、 id这类危险模式。第二数据流追踪。从 HTTP 入口开始追踪一个请求参数从 Controller 到 DAO 的完整路径。如果中间经过了多个工具类和 DTO重点看有没有某个方法把参数重新拼回了字符串。可以在 IDE 里对request.getParameter的结果做引用搜索顺着引用往下看它会不会落到 SQL 语句上。第三引入静态代码扫描平台并开启 SQL 注入检测规则。规则能识别部分拼接模式但扫描结果需要人工二次确认因为误报率也不低。重点看它报告的“高风险”条目把它当成线索而不是最终结论。我自己在代码评审里还常用一个偏“脏”的测法找几个关键接口在参数里故意传 OR 11 --和1; DROP TABLE users--观察响应结果。如果请求参数参与了结构拼接接口多半会报数据库语法错误或返回异常数据。这个测试只能证明“有问题”不能证明“没问题”但作为快速筛子够了。4.3 几条独家避坑经验再说几个我踩过多次的细节常规文档里不太会写。先说参数校验时机。很多人以为参数校验就是判断“不为空”但长度校验同样重要。PreparedStatement 虽然防止注入但是超长参数可能导致数据库报错、截断尤其在隐式类型转换场景下攻击者可以利用这种异常探测表结构。我现在的习惯是所有入参在下发到 DAO 之前先做类型、长度、格式三重校验任何一项不过就统一抛业务异常。再说连接池里的预编译缓存。使用数据库连接池时PreparedStatement会被池化复用。有些团队为了性能配置了服务端预编译参数这本身没问题但如果中间还有一层数据库代理或网关某些代理会把二进制协议降级成文本协议转发。此时参数值依然会被转义处理安全边界依然有效但如果你用的是很老版本的驱动转义逻辑有缺陷就会产生空白风险。稳妥的做法是定期升级官方维护的驱动版本并且连接参数不要盲目复制网上教程里的配置。最后分享一个项目里的小技巧可以为 DAO 层写一个统一的“安全 SQL 断言”工具所有动态结构参数在进入拼接前必须先经过一个assertStructureName方法校验内部只认白名单和正则。一旦发现不在白名单里的值直接抛异常并记录告警日志。这个方法成本很低但能把“人犯规”的概率降到最低。我用下来效果很不错后来把这条加进了团队的强制代码规范。写在最后的个人体会我自己实际开发中最大的感受是把“PreparedStatement 安全”这句话挂在嘴上不如把“数据永远只走参数通道结构永远只走白名单通道”这两条原则刻在脑子里。防御 SQL 注入从来不是某一个类的功劳而是开发规范、代码审查、数据库权限、审计监控这几层一起使劲的结果。每次看到有人为了省事把排序字段或者表名直接品牌进 SQL我都会想起一句话数据库不负责判断你的输入是数据还是代码它只会执行。把边界划清楚才是真正的安全。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询