最近把一个内部服务升级到了 Spring Boot 3.2顺手把安全体系也换成了 Spring Security 6 OAuth2。本以为无非就是改改依赖版本、适配一下 SecurityConfig 的事结果在最后给发布 jar 做加密保护这一环翻车了。项目里有不少客户端密钥不想以明文躺在 classpath 里之前一直用 xjar 对打包后的 fat jar 做一层 AES 加密这次一跑启动直接报找不到 LaunchedURLClassLoader我大概反应了三秒钟这是 Spring Boot 3 loader 重构的后遗症。这篇文章就把整个魔改过程记录下来包括原版 xjar 为什么会在 Spring Boot 3 上失效、两种魔改路线的取舍、Java Agent 方案的完整代码、以及和 Spring Security 6 / OAuth2 配合时处理密钥配置的实战经验。如果你也在用 xjar并且正准备升 Spring Boot 3这篇应该能帮你少踩几个坑。1. 先搞清楚 xjar 是怎么干活的1.1 xjar 的加密原理回顾xjar 本质上是一个“二次打包工具”。正常mvn package会打出一个 Spring Boot fat jar里面是BOOT-INF/classes放项目自己的 classBOOT-INF/lib放所有依赖 jar。xjar 在这个基础上做两件事一是把项目自身 class也就是BOOT-INF/classes下的*.class全部用 AES 加密让反编译工具直接看到乱码二是把 fat jar 的Main-Class从 Spring Boot 自带的JarLauncher替换成 xjar 自己的 launcher。这个替换后的 launcher 在启动时会读取外部密钥然后通过自定义 ClassLoader 在加载 class 时解密。密钥文件不在 jar 内这是 xjar 的核心思路。即使有人解开了 jar没有密钥文件也跑不起来这就把“防止反编译”和“防止篡改后运行”两个诉求同时解决了。1.2 Spring Boot 3 的 loader 到底改了什么Spring Boot 2.x 时代的 fat jar 加载入口是org.springframework.boot.loader.JarLauncher配套的是LaunchedURLClassLoader。这个类继承自java.net.URLClassLoader通过构造 URL 数组来加载BOOT-INF/classes和BOOT-INF/lib下的内容整体模型非常依赖 URL 机制。Spring Boot 3.2 开始官方把org.springframework.boot.loader这一层实现做了一次大重构。主要入口挪到了org.springframework.boot.loader.launch.JarLauncher配套的自定义类加载器也变成了LaunchedClassLoader不再是URLClassLoader的子类而是直接扩展java.lang.ClassLoader底层读取嵌套 jar 数据的方式也换了。这对 xjar 是灭顶之灾因为它很多逻辑写死在LaunchedURLClassLoader这个类名上类都没了自然跑不起来。启动时要么是NoClassDefFoundError要么是NoSuchMethodError反正就是起不来。1.3 原版 xjar 会以什么姿势挂掉最常见的两种第一种替换 launcher 后启动JVM 去找org.springframework.boot.loader.JarLauncher发现这个类在 Spring Boot 3.2 里已经不存在了直接报NoClassDefFoundError。第二种如果项目停在 Spring Boot 3.0 / 3.1 这个过渡版本loader 的类名还在但方法签名变了启动时可能走到某个方法就报NoSuchMethodError。还有一个额外的大坑。就算 loader 版本问题短期内规避了xjar 最早的代码是基于 JDK 8 写的里面有些反射逻辑在 JDK 17 上会撞上模块系统强封装。Spring Boot 3 又强制要求 JDK 17 作为基线所以反射受限带来的IllegalAccessError几乎是必然的。2. 魔改前的准备先想清楚两条路2.1 环境清单动手之前先把环境列清楚。这次我用的组合是这样JDK 17这是 Spring Boot 3 的基线版本Maven 3.8Spring Boot 3.2.x 工程集成 Web、Security、OAuth2 Clientxjar 源码仓库fork 到本地一个最小可运行的 demo 工程专门用来折腾加密强烈建议不要直接在正式项目上动手。加密流程很容易把 jar 结构搞坏先用 demo 验证可行了再上真实项目。2.2 先复现问题这一步不要跳过直接看原版 xjar 在 Spring Boot 3 上怎么挂能帮你确认问题边界。我当时的操作很简单用 IDEA 创建一个 Spring Boot 3 工程加 Web、Security、OAuth2 Client 依赖mvn package打出 fat jar然后用 xjar 跑一遍加密。结果加密过程本身没报错但加密后的 jar 一启动就崩console 里全是NoClassDefFoundError指向org.springframework.boot.loader.LaunchedURLClassLoader。这个现象其实已经说明问题很清晰了xjar 的加密流程还在按 Spring Boot 2 的老结构做替换把Main-Class换成了它自己的 XJarLauncher而 XJarLauncher 内部引用了一堆旧版的 loader 类这些类在 Spring Boot 3.2 的 jar 里已经不存在。2.3 两条路线的取舍复现之后我梳理了两条路。路线 A源码级修改 XJarLauncher适配 Spring Boot 3 的新 loader。这个方向的问题是Spring Boot 3.2 的LaunchedClassLoader是直接继承ClassLoader的实现底层加载方式跟旧的LaunchedURLClassLoader完全不一样不是简单改个类名就能跑起来。而且 Spring Boot 的 loader 从 3.0 到 3.2 变化很大后面 3.3、3.4 可能还有调整源码级魔改很容易变成“改一次版本就要重新改一次”。路线 B保留 xjar 的加密思路但不替换 launcher。写一个 Java Agent通过Instrumentation注册ClassFileTransformer在类加载阶段把密文 class 解密回原样。JVM 启动时通过-javaagent先加载 AgentSpring Boot 的 launcher 后续怎么变都不影响。我实际选择了路线 B。两条路线下面都会讲代码和实践部分以 B 为主。3. 路线 A源码级改 XJarLauncher 的心路历程3.1 从哪入手路线 A 不是不能做但做的过程中会遇到一个很尴尬的边界。fork 完 xjar 源码之后核心要改的是它识别 launcher 的地方一般会在 XJarAgent 或 XJarLauncher 里看到硬编码的org.springframework.boot.loader.JarLauncher需要替换成org.springframework.boot.loader.launch.JarLauncher。同时 pom.xml 里的 Spring Boot 依赖版本要升到 3.2。这一步是机械性的没什么难度。3.2 自定义 ClassLoader 的坑真正的麻烦在于自定义 ClassLoader。xjar 原本的解密逻辑分布在一个继承了旧LaunchedURLClassLoader的自定义类加载器里。Spring Boot 3.2 重构后新的LaunchedClassLoader不是用来给你继承扩展的它的构造方式、类加载路径、嵌套 jar 读取逻辑都变了直接把 xjar 的类加载器改个父类名根本行不通。我试过把XJarClassLoader的父类换成LaunchedClassLoader结果编译都不通过因为新的构造器签名、内部的loadClass流程完全不同。而且LaunchedClassLoader内部对 jar 条目的解析是硬编码的你没法在加载过程中插入一个解密步骤。所以源码级路线走到后面你会发现唯一的可行方案还是得回到“在字节码进入 defineClass 之前做手脚”也就是 Java Agent 或者 Instrumentation。换句话说想兼容 Spring Boot 3最终绕不开 Agent 这条路。3.3 源码级魔改还要注意点什么如果你执意要试源码级修改有几个点先提醒你JDK 17 强封装问题启动参数可能要加--add-opens java.base/java.netALL-UNNAMED这类配置xjar 生成 jar 时如果还写入了旧版 bootstrap key需要清理否则会干扰新的启动流程试跑时一定要保留原始 jar 备份加密流程一旦破坏 jar 结构重新写一个成本很高我从源码级路线退出时最大的收获是与其盯着 xjar 的 launcher 改不如借它加密的思想换一种更贴近新生态的实现方式。4. 路线 B用 Java Agent 把解密搬到类加载之前4.1 核心思路想通之后这个方案其实非常简单。Spring Boot 3 的 loader 再怎么变最终字节码要成为一个类必然要经过 JVM 的类加载流程。JVM 启动时用-javaagent注入一个 Agent里面注册一个ClassFileTransformer在字节码进入虚拟机之前做一次拦截替换。目标类就是项目自身的com.example包下的 class。它们打在 jar 里时已经是密文loader 读出来的字节是密文但 transformer 会在 loader 把字节交给defineClass之前先看一眼发现是密文就直接解密成明文再返回。这个方案的好处很直接完全不依赖任何 Spring Boot loader 具体实现不需要替换Main-Class后续升级 Spring Boot 小版本基本不用改加密逻辑和解密逻辑都集中在你自己手里心里有底4.2 加密侧一个小工具魔改的第一步其实是写一个“加密打包工具”替代 xjar 的 encrypt 命令。流程不复杂读取原始 fat jar遍历所有 entry遇到BOOT-INF/classes/com/example/下的.class就做 AES-GCM 加密加密后的数据格式统一为“IV 密文”其余 entry 原样复制最后生成一个新的 jar同时生成一个密钥文件。这里给出加密侧的核心代码骨架public class JarEncryptor { public static void main(String[] args) throws Exception { Path input Paths.get(demo-0.0.1-SNAPSHOT.jar); Path output Paths.get(demo-0.0.1-SNAPSHOT-encrypted.jar); byte[] keyBytes new byte[32]; new SecureRandom().nextBytes(keyBytes); Files.write(Paths.get(xjar.key), keyBytes); try (JarFile jar new JarFile(input.toFile()); JarOutputStream out new JarOutputStream(Files.newOutputStream(output))) { EnumerationJarEntry entries jar.entries(); while (entries.hasMoreElements()) { JarEntry entry entries.nextElement(); JarEntry newEntry new JarEntry(entry.getName()); out.putNextEntry(newEntry); byte[] data jar.getInputStream(entry).readAllBytes(); if (entry.getName().startsWith(BOOT-INF/classes/com/example/) entry.getName().endsWith(.class)) { out.write(encrypt(data, keyBytes)); } else { out.write(data); } out.closeEntry(); } } } private static byte[] encrypt(byte[] data, byte[] keyBytes) throws Exception { byte[] iv new byte[12]; new SecureRandom().nextBytes(iv); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(keyBytes, AES); cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(128, iv)); byte[] encrypted cipher.doFinal(data); byte[] result new byte[iv.length encrypted.length]; System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(encrypted, 0, result, iv.length, encrypted.length); return result; } }注意这里我用的是 AES/GCM而不是传统的 AES/CBC 或 AES/ECB。GCM 模式自带完整性校验密文被篡改后解密会直接失败而不是解出一堆乱码这对安全场景很关键。IV 直接用随机数生成拼在密文前面不需要额外传输。4.3 解密侧Agent 代码解密侧就是写 Java Agent。Agent 的premain方法里解析参数读取密钥文件注册 transformer 即可。public class XJarAgent { public static void premain(String args, Instrumentation inst) throws Exception { // args 格式: keyfile/opt/keys/xjar.key,packagescom.example MapString, String config parseConfig(args); byte[] keyBytes Files.readAllBytes(Paths.get(config.get(keyfile))); String[] packages config.get(packages).split(,); inst.addTransformer(new DecryptTransformer(keyBytes, packages), true); } private static MapString, String parseConfig(String args) { MapString, String map new HashMap(); if (args ! null) { for (String pair : args.split(,)) { int idx pair.indexOf(); if (idx 0) { map.put(pair.substring(0, idx), pair.substring(idx 1)); } } } return map; } static class DecryptTransformer implements ClassFileTransformer { private final SecretKeySpec key; private final ListString packagePrefixes; DecryptTransformer(byte[] keyBytes, String[] packages) { this.key new SecretKeySpec(keyBytes, AES); this.packagePrefixes Arrays.asList(packages); } Override public byte[] transform(ClassLoader loader, String className, Class? classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { if (className null || classfileBuffer null) { return null; } String dotName className.replace(/, .); if (packagePrefixes.stream().noneMatch(dotName::startsWith)) { return null; } try { return decrypt(classfileBuffer); } catch (Exception e) { throw new IllegalStateException(decrypt class failed: className, e); } } private byte[] decrypt(byte[] data) throws Exception { if (data.length 12) { return data; } byte[] iv Arrays.copyOfRange(data, 0, 12); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv)); return cipher.doFinal(data, 12, data.length - 12); } } }这里有个细节需要说明transform方法收到的className是 JVM 内部格式比如com/example/DemoApplication不是com.example.DemoApplication。所以判断包名的时候要先做一次斜杠转点号否则永远匹配不上。另一个细节是返回null表示不修改返回字节数组表示替换。很多第一次写 transformer 的人会忽略这一点直接返回原数据等于没加密解密但进程又不会报错排查起来很迷惑。Agent 打包时需要配置MANIFEST.MFManifest-Version: 1.0 Premain-Class: com.example.xjar.XJarAgent Agent-Class: com.example.xjar.XJarAgent Can-Redefine-Classes: true Can-Retransform-Classes: true用 maven-shade-plugin 打包成一个可执行 agent jar 即可。4.4 启动命令与实测启动命令非常直接java -javaagent:xjar-agent.jarkeyfile/opt/keys/xjar.key,packagescom.example \ -jar demo-0.0.1-SNAPSHOT-encrypted.jar实测下来的效果让我很满意Spring Boot 3.2 启动正常日志完整Spring Security 6 的登录流程正常OAuth2 Client 的授权跳转正常项目里的application.yml还是能正常读取加密 class 不影响配置加载用 JD-GUI 反编译加密后的 jarcom.example包下的 class 全部是乱码这个方案跑通之后我后续把 Spring Boot 从 3.2 升到 3.3Agent 果然一行没改直接复用。5. 和 Spring Security 6 / OAuth2 一起使用时密钥配置怎么处理5.1 只加密 class 不够如果你的项目里有 OAuth2 客户端配置典型的application.yml大概是这样的spring: security: oauth2: client: registration: github: client-id: ov23li-xxxx client-secret: xxxxxxxxxxxclass 加密保护的是核心逻辑不被反编译但application.yml文件默认是明文保留在BOOT-INF/classes下面的。别人把 jar 解开直接打开 yml 文件就能看到 client-secret。所以只加密 class 完全不够。加密工具那条路继续沿用 xjar 的思路但配置文件的处理必须另想办法。5.2 三种推荐做法第一种环境变量注入。配置文件里不写真实值用${GITHUB_CLIENT_SECRET}占位启动时在部署环境里注入环境变量。这是最朴素也最有效的方式。第二种Jasypt Spring Boot 3。用 Jasypt 对配置项做加密配置里写ENC(...)密文启动时通过--jasypt.encryptor.password传入解密密钥。推荐这种做法因为它让你可以在不依赖外部环境变量的情况下把敏感配置安全地提交到仓库只要部署者知道 Jasypt 的解密口令就行。第三种Spring Cloud Config 或 Vault。把配置集中管理jar 里只留一个 spring cloud config 的地址。这个方案适合已经有微服务基础设施的团队不适用于单机部署的小项目。我当时实际用的是环境变量 Jasypt 混搭。OAuth2 的 client-secret 这种量级的数据走环境变量就够了。Jasypt 则用来加密一些数据库密码、第三方平台密钥等。5.3 一个完整的配置模板下面是建议的配置组合方式spring: security: oauth2: client: registration: github: client-id: ${GITHUB_CLIENT_ID} client-secret: ENC(KJhRAsIECz...) google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET}Jasypt 的依赖坐标dependency groupIdcom.github.ulisesbocchio/groupId artifactIdjasypt-spring-boot-starter/artifactId version3.0.5/version /dependency启动命令变成java -javaagent:xjar-agent.jarkeyfile/opt/keys/xjar.key,packagescom.example \ -jar demo.jar \ --jasypt.encryptor.password${JASYPT_PASSWORD}这里建议把 Jasypt 的密码也放到环境变量里而不是写死在启动脚本中否则启动脚本泄露就等于密文泄露。6. 踩坑记录与排查技巧实录6.1 报错速查表这个方案我跑了不下二十次踩过的坑整理成了一张表方便你排查现象原因解决办法启动报NoClassDefFoundError: org/springframework/boot/loader/LaunchedURLClassLoader原版 xjar 替换了 launcher引用了不存在的旧类换用 Agent 方案不要替换 launcher启动报Invalid or corrupt jarfile加密流程破坏了 jar 结构或条目没有正常 closeEntry加密器里确保每个 entry 都 closeEntry先mvn package再加密启动报InaccessibleObjectExceptionJDK 17 强封装了java.baseAgent 反射受限启动加--add-opens java.base/java.netALL-UNNAMED类加载后反编译仍是乱码包名匹配不上transformer 没生效检查 Agent 里 packages 参数注意 className 是斜杠格式解密成功但业务报BadPaddingException加密和解密的 key 不一致或 GCM IV 处理错误检查密钥文件是否一致IV 是否读全 12 字节Spring Security 报Key with id ... was not found配置里的密钥被 Jasypt 加密但启动时没有传解密密码检查--jasypt.encryptor.password参数加了 Agent 后启动慢了几秒加密类较多每个类都做一次 AES 解密正常现象可接受减少 packages 范围能略微优化6.2 几条掏心窝的避坑建议第一加密流程和 Maven 打包流程分开。先在 CI 里执行mvn package打出一个标准的 fat jar再执行加密工具做二次加工。不要把加密塞进spring-boot-maven-plugin的 repackage 流程里去一旦加密工具报错整个构建链路都会被拖垮。第二永远保留未加密的原始 jar。加密后的 jar 出了问题直接用原始 jar 启动一次能快速定位问题是出在业务代码还是出在加密/解密链路。我一般会把原始 jar 留在 target 目录下一周再清理。第三Agent 里的包名一定要写对范围。不要图省事写com这种顶层包否则会把你不需要保护的依赖代码也拦截一遍增加解密开销还可能误伤 Spring 内部类。写com.example这种业务根包就够了。第四如果项目里用了 CGLIB 代理比如 Spring Security 的EnableMethodSecurity开了方法级权限校验某些代理类是在运行时动态生成的。这些动态生成的类不是从 jar 里加载的所以不会经过解密流程不用担心。但如果你把包名范围写得太宽把 CGLIB 生成类也匹配进去了decrypt会对非密文数据做 GCM 解密大概率直接抛错。所以包名范围宁窄勿宽。第五这个方案不适用于 GraalVM Native Image。Native Image 是构建期把字节码静态编译成原生镜像根本没有 JVM 类加载过程Java Agent 自然也不生效。如果你已经用了 Spring Boot 3 的 Native 能力不要折腾 class 加密老老实实做混淆 外部密钥配置。第六密钥文件权限一定要收紧。我一开始把xjar.key放在项目根目录结果有一次调试时不小心把密钥文件提交到了 git 仓库。虽然项目是私有仓库但这种事宁可不要发生。现在我的习惯是密钥文件只放在部署机的/opt/keys路径下权限设为 600CI 构建产物里完全不包含密钥。我最后实际使用的组合是自研加密器 Java Agent密钥文件放在部署机器/opt/keys下CI 里只在打 release 分支时执行加密步骤。升级 Spring Boot 3.3 时Agent 一行没改就继续用了。回头想想与其说是魔改 xjar不如说借着 xjar 的思路重新造了个更适合新生态的轮子。如果你也卡在同样的地方可以先试试 Agent 这个方向省下来的时间拿去喝杯咖啡不香么。