最近把一个内部服务升级到了 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放项目自己的 class,BOOT-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.8+
- Spring Boot 3.2.x 工程,集成 Web、Security、OAuth2 Client
- xjar 源码仓库,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先加载 Agent,Spring 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.net=ALL-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))) { Enumeration<JarEntry> 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,packages=com.example Map<String, 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 Map<String, String> parseConfig(String args) { Map<String, 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 List<String> 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.MF:
Manifest-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.jar=keyfile=/opt/keys/xjar.key,packages=com.example \ -jar demo-0.0.1-SNAPSHOT-encrypted.jar实测下来的效果让我很满意:
- Spring Boot 3.2 启动正常,日志完整
- Spring Security 6 的登录流程正常,OAuth2 Client 的授权跳转正常
- 项目里的
application.yml还是能正常读取,加密 class 不影响配置加载 - 用 JD-GUI 反编译加密后的 jar,
com.example包下的 class 全部是乱码
这个方案跑通之后,我后续把 Spring Boot 从 3.2 升到 3.3,Agent 果然一行没改,直接复用。
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(KJhRAs+IECz...) google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET}Jasypt 的依赖坐标:
<dependency> <groupId>com.github.ulisesbocchio</groupId> <artifactId>jasypt-spring-boot-starter</artifactId> <version>3.0.5</version> </dependency>启动命令变成:
java -javaagent:xjar-agent.jar=keyfile=/opt/keys/xjar.key,packages=com.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再加密 |
启动报InaccessibleObjectException | JDK 17 强封装了java.base,Agent 反射受限 | 启动加--add-opens java.base/java.net=ALL-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路径下,权限设为 600,CI 构建产物里完全不包含密钥。
我最后实际使用的组合是:自研加密器 + Java Agent,密钥文件放在部署机器/opt/keys下,CI 里只在打 release 分支时执行加密步骤。升级 Spring Boot 3.3 时,Agent 一行没改就继续用了。回头想想,与其说是魔改 xjar,不如说借着 xjar 的思路重新造了个更适合新生态的轮子。如果你也卡在同样的地方,可以先试试 Agent 这个方向,省下来的时间拿去喝杯咖啡不香么。