从入口抓包到全链路整改Java项目里用户名密码明文传输的排查与修复实录先讲个我上周遇到的真实场景。某项目上线前做安全巡检我用抓包工具在登录页随便提交了一次账号密码明文数据直接躺在HTTP请求里浏览器开发者工具里看得一清二楚。这还不是最离谱的同一个系统里修改密码接口、找回密码接口、甚至后台管理员的登录接口全部是明文传输。我当时就问开发同事你们平时测试的时候是不是从来没看过请求报文这个场景在Java Web项目里太常见了。越核心的系统越容易出现这种低级但致命的问题。用户密码一旦明文暴露在网络链路上等同于把家门钥匙挂在门外任何一个能监听网络的人都能直接拿走。这篇文章我打算完整记录一次针对性的安全整改过程从抓包分析、根因定位到HTTPS加密、应用层加密、日志脱敏、重放防御以及最后的验收排查把这一整套链路讲透。不论你用的是Spring Boot、Spring Cloud还是传统Servlet项目思路都是通用的。1. 明文传输的准确危害别再说“内网没事”1.1 明文传输到底是什么明文传输指客户端和服务端之间的数据交换没有经过任何加密处理数据包里的内容以原始字符形式直接传输。登录请求里携带用户名和密码如果这段数据是明文网络链路上的任何中间节点都能直接读取。我用一句话总结危害明文传输让身份凭证暴露在网络上攻击者不需要破解任何东西只需要能看到数据包就能拿到密码。这和“密文传输被逆向破解”是两码事明文传输的安全等级约等于零。很多开发者的第一反应是“我的系统在内网外网访问不到没那么大风险。”但这个认知需要纠正。内网威胁同样真实存在办公Wi-Fi可以被伪造局域网内可以通过ARP欺骗劫持流量运维跳板机可能被攻破甚至内部员工也可能因为好奇或者恶意去监听。这些都是真实发生过的案例我没法列出具体公司但可以说因为内网明文传输导致账号被撞库或者越权的安全事件在行业里并不罕见。1.2 明文传输的四种典型危害我列一个风险对照表方便你评估自己项目的暴露程度。风险类型具体表现可能造成的后果窃听泄露密码、身份证号、手机号在链路上被第三方截获账号被盗、隐私泄露、撞库攻击中间人篡改攻击者截获数据包后修改内容再转发给服务端登录请求被改、转账金额被改、数据被注入重放攻击攻击者记录完整的合法请求之后原样重发无需知道密码也能重复登录或重复提交信任链污染用户以为在访问正规系统实际流量被导向伪造服务端钓鱼登录页套取大量账号密码这里面最容易被忽视的是重放攻击。有些开发者认为就算密码泄露攻击者拿到的也只是加密后的密文。但如果整个请求可以完整重放攻击者根本不需要解密他把抓到的一大段密文原封不动再发送一次服务端照样会认为这是一个合法请求。这就是为什么加密之外还必须考虑时间戳、随机数和会话绑定。2. 根因排查90%的明文问题都出在这几个环节2.1 链路层没有全站HTTPS明文传输最直接的根源就是链路层没有启用HTTPS。很多Java项目上线时HTTP请求仍然可以直接访问或者HTTPS和HTTP并存用户访问的是HTTP版本前端代码里写的接口地址也是HTTP密码自然就明着走了。见过不少项目证书配置在Nginx上但Java应用本身的接口还能通过HTTP直接访问。运维想着“反正Nginx是入口HTTP会自动跳HTTPS”结果发现合同里配置的是软链只对部分路径做了跳转登录接口漏掉了。排查第一步就是确认所有入口是否强制HTTPS而且要拿真实请求验证不能只看配置。2.2 代码习惯里埋下的隐患看登录接口的代码是最能发现问题的方式。不少项目的登录接口长这样PostMapping(/login) public Result login(String username, String password) { // 直接处理登录逻辑 }前端调用时参数是直接拼在请求体里的$.post(/api/login, { username: username, password: password });这类代码最大的问题不是“没有用某种加密技术”而是整个前后端链路中没有任何一个人对敏感信息的安全传输提出质疑。前端的诉求是“把数据发出去”后端的诉求是“收到数据做校验”中间的安全责任被默认忽略了。还有一个隐蔽的问题服务端日志里打印请求参数。排查问题时图省事在拦截器或者AOP切面里直接输出完整请求内容密码字段就跟着日志文件一起被写入磁盘。日志文件一旦被拖走密码也就跟着走了。2.3 接口设计没有为安全留余地部分系统把登录逻辑做得“过分简单”只有一个username字段加一个password字段没有验证码、没有设备信息、没有任何会话上下文。这种接口设计天然不利于后续做风控。即使你加了HTTPS也仍然面临撞库和暴力破解的风险。更别提如果还是明文传输那整个登录接口基本等同于不设防。3. 修复第一步链路层强制HTTPS覆盖全部访问入口3.1 HTTPS加密的目标和原理先明确一个关键点用户名密码这种敏感信息链路层加密是第一道锁也是最基础的一道锁。HTTPS在TCP之上增加了TLS加密层使得数据在客户端和服务端之间传输时处于密文状态。它的核心流程是客户端向服务端发起握手请求服务端返回数字证书客户端验证证书合法性后双方协商出会话密钥之后所有HTTP报文内容都用会话密钥加密传输。这个过程中即便有人抓包拿到的也只是密文无法直接还原出用户名和密码。3.2 Java应用自身开启HTTPS如果Spring Boot应用直接对外提供服务没有前置Nginx那么可以直接在应用层开启HTTPS。首先生成证书。生产环境建议从正规CA机构申请证书测试环境可以使用自签名证书方便联调。下面是一个完整的配置示例。首先要准备证书文件然后配置application.ymlserver: port: 8443 ssl: enabled: true key-store: classpath:keystore.p12 key-store-type: PKCS12 key-store-password: ${SSL_KEYSTORE_PASSWORD} key-alias: tomcat这里有一个容易忽略的点证书库密码不要硬编码在配置文件里要通过环境变量占位符引用比如上面的${SSL_KEYSTORE_PASSWORD}。我在不少项目里看到过写在application.yml里的明文证书口令这种属于“锁装好了钥匙挂在旁边的墙壁上”。如果项目需要同时限制HTTP访问可以做请求重定向。用Java配置强制跳转Bean public ServletWebServerFactory servletContainer() { TomcatServletWebServerFactory factory new TomcatServletWebServerFactory(); factory.addAdditionalTomcatConnectors(httpConnector()); return factory; } private Connector httpConnector() { Connector connector new Connector(org.apache.coyote.http11.Http11NioProtocol); connector.setScheme(http); connector.setPort(8080); connector.setSecure(false); connector.setRedirectPort(8443); return connector; }这只是一个典型的Servlet容器配置方案。实际部署中我更推荐把TLS卸载放到Nginx层做应用层专注业务逻辑证书更新和密钥管理都集中到网关运维负担明显小很多。3.3 Nginx层强制跳转HTTPS大多数Java项目的架构是Nginx Spring BootNginx承担静态资源服务和反向代理。这种架构下HTTPS的开启主要在Nginx层。我给出一个我常用的配置片段server { listen 80; server_name api.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/nginx/certs/server.crt; ssl_certificate_key /etc/nginx/certs/server.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; } }配置完成后还需要处理一个Java程序经常遇到的反向代理问题应用收到的请求协议还是HTTP导致Spring Security等组件在做安全判断时误判。解决方式是在Nginx里加上X-Forwarded-Proto头然后在Spring Boot中配置server: forward-headers-strategy: framework3.4 证书管理与更新HTTPS不是配一次就能一劳永逸。证书有有效期常见的坑是证书过期后客户端访问时出现安全警告部分用户直接放弃访问。更麻烦的是Java应用内部的HTTP客户端调用其他服务时证书校验失败接口从“偶尔通”变成“必现不通”。生产环境建议把证书续期自动化。使用ACME协议实现证书自动申请和续期配合Nginx的reload操作可以做到无人工介入。关键点是设置好续期任务的定时执行时间并及时检查证书剩余天数。我习惯加一个巡检脚本每天检查一次证书有效期剩余少于30天自动告警。4. 修复第二步应用层加密别让数据裸奔到服务端4.1 为什么有了HTTPS还要做应用层加密很多开发者问过一个问题有了HTTPS为什么还要在应用层加密密码这不是重复劳动。理由有两点。第一HTTPS只能保证传输过程加密服务端拿到数据之后如果日志打印、异常抛出、数据库存储不当密码仍然会在其他环节泄露。第二部分场景下客户端到服务端之间不止一跳例如经过了第三方API网关、消息队列或者日志采集系统每个中间环节都有可能接触到报文内容。应用层加密相当于给数据本身加了一层保险即使传输链路被突破敏感字段也不会直接暴露。最典型的一层防护是前端使用非对称加密的公钥对密码进行加密后端持有私钥进行解密。即使HTTPS证书被劫持或者中间链路泄露攻击者拿到的也只是RSA密文没有私钥无法还原。4.2 前端加密与后端解密的完整实现我以一个实际用到的方式为例。前端使用公钥加密密码后端用私钥解密然后再进入业务校验流程。首先在后端生成RSA密钥对生产环境中私钥不会出现在代码仓库里而是放在独立的密钥管理服务或者环境变量中。前端加密逻辑如下import JSEncrypt from jsencrypt; const encryptor new JSEncrypt(); encryptor.setPublicKey(publicKey); // publicKey由后端接口下发 const encryptedPassword encryptor.encrypt(password);需要说明的是让后端接口下发公钥的做法并不推荐如果公钥被攻击者替换加密就形同虚设。更稳妥的做法是公钥直接内置在前端构建产物中或者通过独立的配置中心下发。前端提交时把加密后的密码放入请求体$.post(/api/login, { username: username, password: encryptedPassword });后端接收后解密。我习惯用Spring的RequestBody配合工具类处理PostMapping(/login) public Result login(RequestBody LoginRequest request) { String username request.getUsername(); String password rsaUtil.decrypt(request.getEncryptedPassword()); // 后续走正常的登录校验流程 }解密完成后密码在处理完之后应当立即清空引用避免长时间滞留在内存中char[] passwordChars rsaUtil.decryptToCharArray(request.getEncryptedPassword()); try { // 进行登录校验 } finally { Arrays.fill(passwordChars, \0); }用char[]处理密码用完后填充清零是Java安全编程里的一个细节。String是不可变对象处理完无法主动清除会一直缓存在内存里。4.3 密码存储明文密码绝不允许落库就算登录请求的密文传输做得无懈可击密码存储环节照样可能翻车。我排查过某个遗留系统数据库里用户表的password字段直接存的是明文运维人员登录数据库就能看到所有人的密码。更夸张的是有一些系统的数据库备份文件被拖走后攻击者直接拿到了完整密码本拿去撞其他网站。正确的做法是哈希加盐存储。哈希是单向的无法从摘要还原出原文。但仅做哈希还不够明文相同的用户哈希值也相同攻击者可以通过彩虹表预计算来批量破解。加盐就是给每个用户生成一个随机字符串拼接后再做哈希让相同的密码得到不同的摘要。推荐直接使用BCrypt。Spring Security内置了BCryptPasswordEncoder使用非常方便Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }注册用户时加密存储String encodedPassword passwordEncoder.encode(rawPassword);登录校验时使用matches方法boolean matches passwordEncoder.matches(rawPassword, encodedPassword);BCrypt的好处在于每次加密都会生成不同的盐值而且算法内部有成本因子可以控制计算耗时增加暴力破解的成本。我在项目中会把成本因子从默认的10调整为12虽然登录时略微变慢但对破解的阻力提升明显。4.4 非对称加密在实战中的细节取舍非对称加密也有一堆坑。最大的坑是RSA的加密长度限制使用1024位RSA时最多只能加密117字节数据使用2048位时最多245字节。如果密码字段后面还要拼接其他信息很可能超出限制。解决方式有两条。一是用RSA加密一个随机的对称密钥AES密钥再用AES加密完整的数据体也就是常见的“混合加密”方案。这个方案实际效果更好但实现复杂度更高需要前后端约定好加密框架同时管理好AES密钥的生命周期。二是干脆拆字段只对password这种固定长度字段用RSA加密其他字段走正常传输。还有一种常见的错误是前后端加密方式不一致。前端用CryptoJS的AES加密后端用Java原生API解密结果因为填充模式、字符集、Key和IV的编码方式不同解出来全是乱码。这个问题在后面的排错章节里我会单独展开。5. 收尾工程日志脱敏、接口规范与统一安全响应5.1 日志脱敏别让排查问题变成信息泄露安全整改做到底层时往往不是技术方案不够而是日志泄露把前面所有的努力全部推翻。登录接口报错后端日志输出2025-01-06 10:23:45 ERROR Login failed, usernameadmin, password123456这行日志等于把密码写在纸上放在办公室桌面上。正确做法有两种一是统一打印参数时不打印敏感字段二是使用脱敏工具对输出内容做替换。我推荐第一种因为更彻底。如果项目里用了拦截器做请求日志记录一定要对敏感字段做过滤。我用过一个简单的脱敏规则类public class SensitiveFieldMasker { private static final Pattern PASSWORD_PATTERN Pattern.compile((\password\\\s*:\\s*\)([^\])(\)); private static final Pattern TOKEN_PATTERN Pattern.compile((\token\\\s*:\\s*\)([^\])(\)); public static String mask(String raw) { return PASSWORD_PATTERN.matcher(raw).replaceAll($1******$3); } }在日志输出前统一走一遍SensitiveFieldMasker.mask()。工具虽小但能防止“排查线上问题时打印完整请求体随手把密码一起打出来”的低级失误。5.2 统一Web层的安全响应头除了传输加密Web安全响应头也是容易被忽略的一部分。Java项目通常在过滤器里统一输出响应头WebFilter(urlPatterns /*) public class SecurityHeadersFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse httpResponse (HttpServletResponse) response; httpResponse.setHeader(X-Content-Type-Options, nosniff); httpResponse.setHeader(X-Frame-Options, DENY); httpResponse.setHeader(Cache-Control, no-store); chain.doFilter(request, response); } }Cache-Control: no-store这个头对登录页特别重要。不然浏览器会把包含敏感信息的页面内容缓存到本地其他用户登录同一台机器时可以从浏览器缓存里翻出上一任用户的登录响应。5.3 接口防重放的基本动作前面提过重放攻击。光靠加密和HTTPS解决不了重放问题因为重放的是完整合法数据包。标准的防重放姿势是在请求中增加时间戳和随机数nonce服务端校验时间戳在有效期内且nonce未被使用过。简单实现方式如下public class AntiReplayService { private CacheString, Boolean nonceCache new CaffeineCache(nonce, 5 * 60 * 1000); public boolean check(String nonce, long timestamp) { long now System.currentTimeMillis(); if (Math.abs(now - timestamp) 60 * 1000) { return false; } String key nonce _ timestamp; if (nonceCache.get(key) ! null) { return false; } nonceCache.put(key, true); return true; } }这个实现里时间窗口设为60秒窗口之外直接拒收窗口之内则靠nonce去重确保同一个请求只能被服务端成功处理一次。6. 实战向排错速查密文乱码、证书过期、HTTPS双证书问题6.1 一张常见问题排查表我把这些年遇到过的高频坑整理成一个速查表按症状、可能原因、处理方式进行说明。症状可能原因处理方式前端加密后后端解密乱码前端字符集与后端字符集不一致Base64编码未统一统一使用UTF-8确认是否用Base64传输密文Java端使用Base64.getDecoder()后再解密登录接口仍然能通过HTTP访问Nginx只对部分路径做了HTTP跳转或者未覆盖所有端口检查所有server块和监听端口对80端口统一做301跳转证书过期导致Java客户端调用报错证书到期未自动续期或JDK信任库未更新更新证书链jdk信任库中加入根证书配置自动化续期HTTPS握手失败客户端提示证书无效证书链不完整中间证书没有下发在Nginx中配置完整证书链一般把中间证书和服务器证书合并为一个crt文件密文长度超限RSA解密报错待加密数据超出RSA单次加密上限改用混合加密方案或拆分字段分别加密登录后日志里出现完整密码拦截器或AOP打印了完整请求体日志打印前进行敏感字段脱敏或直接排除password字段请求数据包没有加密但地址栏显示https页面本身是HTTPS但接口地址写成了HTTP全局搜索前端代码中的硬编码ip/http地址改为相对路径或统一维护API配置6.2 一条高效的验收路径安全整改做完后要有一个可重复执行的验收流程而不是只在发布前临时检查。我自己的验收清单长这样。第一步用抓包工具检查登录接口请求确认请求URL是HTTPS请求体中的password字段不是明文。如果做了应用层加密这里应该是一段Base64密文。第二步重复提交一次相同的请求报文确认服务端返回异常或拒绝处理验证防重放逻辑生效。第三步查看服务端日志文件确认登录成功和失败的日志中都不包含密码原始值也不包含可以还原出密码的密文信息。第四步访问HTTP版本的登录链接确认自动跳转到HTTPS。直接用curl验证curl -I http://api.example.com/login如果响应是301或302并带有Location: https://...说明跳转规则生效。如果返回200说明还有HTTP入口在裸奔。第五步检查数据库用户表的密码字段确认存储的是BCrypt或同类哈希摘要而不是明文或可逆加密的密文。这一套流程走完再上线就放心多了。7. 最后补一刀安全整改的边界和长期机制最后我再补充一点个人经验。很多安全整改不是一次性动作而是持续对抗的过程。明明传输加密做完了半年后某个新同事加入为了调试方便临时写了一个HTTP测试接口又把密码明文打印出来了。这种问题是无法通过一次性修复根除的只能靠机制约束。比较好的做法是把安全要求嵌入到研发流程里。代码评审清单中增加安全项凡是涉及用户名、密码、手机号、身份证号等敏感字段的改动必须经过安全评审。自动化测试中加入抓包校验用例每次构建自动检查登录请求是否携带敏感字段明文。发布前安全巡检必须包含传输加密、存储加密、日志脱敏这三个基本项。还有一个小工具层面的建议在Java项目里启动依赖扫描重点检查是否引用了已经存在漏洞的加密相关依赖库。老旧的加密库可能存在算法实现缺陷及时升级能挡掉一大波已知攻击。我在实际项目里最深的体会是明文传输这个问题看起来人尽皆知但它每次都换着花样出现。有的是因为赶工期忽略了有的是因为前端拿不到HTTPS的测试环境干脆用HTTP联调结果没切成HTTPS就上线了。无论哪种情况最后都要靠一套完整的链路设计去兜底。链路层加密是地基应用层加密是保险日志脱敏是收尾重放防御是补漏四层配合才能做到传输不裸奔、存储不裸奔、日志不裸奔。希望这篇文章能把这条整改路径讲透帮你少走一些我走过的弯路。