深入剖析 Byte Buddy 绕过 JPMS 的强封装:动态模块权限注入的底层原理
自 Java 9 引入 JPMS(Java Platform Module System,Java 模块系统)以来,强封装(Strong Encapsulation)成为了 JVM 安全架构的核心。传统的反射(Reflection)和动态类定义(Class Definition)在面对非公开(non-exported)或非开放(non-opened)的包时,会直接抛出 InaccessibleObjectException。
然而,像 SkyWalking、Mockito、Spring 等依赖 Byte Buddy 的框架,依然能在 Java 11、17 甚至 21 上无缝地对 JDK 内部类或第三方闭源模块进行插桩(Instrumentation)和代理。
Byte Buddy 究竟是如何在不破坏 JVM 安全底线的前提下,动态注入权限并突破模块边界的?本文将从底层 JVM 机制和 Byte Buddy 源码层面剖析其“动态权限注入”的实现原理。
一、 JPMS 的屏障:为什么不能直接注入了?
在 Java 8 及以前,只要拿到了 ClassLoader 的引用,通过反射调用受保护的 defineClass 方法,就能在任意包名下注入新类:
// Java 8 时代常见的黑魔法:直接向目标 ClassLoader 注入字节码
Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", ...);
defineClass.setAccessible(true);
defineClass.invoke(targetClassLoader, ...);
但在 Java 9+ 中,即使你突破了 setAccessible 的限制(通过 --add-opens),JPMS 依然限制了类与类之间的访问关系。
一个模块(Module)内的类如果要访问另一个模块的类,必须满足以下条件:
- 无条件可读(Reads):目标模块必须被当前模块“读取”。
- 导出(Exports)或开放(Opens):目标包必须向当前模块导出(用于编译期/运行期直接依赖)或开放(用于反射访问)。
如果我们要往一个受保护的模块(例如 java.base 或某个封闭的业务模块)中动态注入一个辅助类(Auxiliary Class),这个辅助类如何与原有的类共享私有访问权限?Byte Buddy 必须动态修改 JVM 的模块依赖图(Module Graph)。
二、 Byte Buddy 动态注入权限的三大核心通路
Byte Buddy 没有采用单一的暴力破解方式,而是根据当前 JVM 的运行环境(是否有 Java Agent、当前 JDK 版本、是否有 Unsafe 权限),自适应地选择以下三种通路。
通路 1:利用 java.lang.instrument.Instrumentation 动态重定义模块(Agent 模式)
如果你是通过 Java Agent 启动的,JVM 会赋予 Agent 极高的特权。Byte Buddy 利用 java.lang.instrument.Instrumentation 接口提供的 redefineModules 方法,在运行时动态修改模块声明。
1. JVM 层的 redefineModules 接口
JDK 提供的 API 定义如下:
void redefineModules(Module module,
Set<Module> extraReads,
Map<String, Set<Module>> extraExports,
Map<String, Set<Module>> extraOpens,
Set<Class<?>> extraUses,
Map<Class<?>, List<Class<?>>> extraProvides);
这个方法允许 Agent 在运行时,动态地为一个已加载的模块增加新的 exports、opens 或 reads 关系。
2. Byte Buddy 的封装实现
在 Byte Buddy 的 AgentBuilder 中,当它识别到目标类属于一个封闭模块,且该类需要与代理辅助类交互时,会触发 AgentBuilder.Listener 或内部的 ModuleSystem 修改逻辑:
// Byte Buddy 内部逻辑伪代码
Module targetModule = targetClass.getModule();
Module bootstrapModule = Byte Buddy自身类.getModule();
if (!targetModule.isOpen(targetPackage, bootstrapModule)) {
instrumentation.redefineModules(
targetModule,
Collections.singleton(bootstrapModule), // 增加读取权限
Collections.emptyMap(),
Collections.singletonMap(targetPackage, Collections.singleton(bootstrapModule)), // 动态 Open 目标包
Collections.emptySet(),
Collections.emptyMap()
);
}
通过这一步,Byte Buddy 动态地将目标包(Target Package)向 Byte Buddy 自身的模块(或生成的辅助类模块)进行了 opens。这样,后续的反射调用和字节码生成就能顺利绕过 JPMS 校验。
通路 2:基于 MethodHandles.Lookup 的“合法侵入”(Runtime 模式)
在没有 Java Agent 的常规 Runtime 代理场景下,Byte Buddy 无法调用 Instrumentation。这时,它依赖于 Java 9 引入的官方通道:MethodHandles.privateLookupIn。
1. 什么是 Lookup 权限传递?
MethodHandles.Lookup 是 Java 强封装下的安全钥匙。如果你能获取到某个类的 Lookup 对象,你就拥有了该类同等甚至更高的访问权限。
Java 9 提供了:
public static MethodHandles.Lookup privateLookupIn(Class<?> targetClass, MethodHandles.Lookup lookup) throws IllegalAccessException;
只要 lookup 拥有对 targetClass 所在包的访问权限,它就可以“传送”进去,获取一个针对 targetClass 的私有 Lookup。
2. Byte Buddy 的 ClassLoadingStrategy.UsingLookup
当 Byte Buddy 需要在目标类的相同包、相同模块内定义一个新类(比如动态代理类)时,它会优先使用 UsingLookup 策略:
// Byte Buddy 使用 Lookup 注入类的核心步骤
MethodHandles.Lookup lookup = MethodHandles.lookup(); // 获取当前上下文的 Lookup
MethodHandles.Lookup privateLookup = MethodHandles.privateLookupIn(targetClass, lookup);
// 利用 privateLookup 直接在目标类所在的 Module 和 Package 中定义类
Class<?> injectedClass = privateLookup.defineClass(byteCode);
为什么这种方式不需要修改 Module 权限?
因为新定义的类是通过目标类的 privateLookup 直接注入到同一个模块、同一个包下的。它天然继承了该模块的所有内部访问权限,不需要任何额外的 exports 或 opens 声明。
通路 3:绕过验证的“后门”—— sun.misc.Unsafe 与 JVM 内部 API 擦除
在某些极端情况下(例如目标 ClassLoader 极其特殊,或者 MethodHandles 被严格限制),Byte Buddy 会退化到最底层的物理注入——通过 sun.misc.Unsafe 绕过所有安全检查。
虽然 JDK 9+ 限制了反射对 sun.misc.Unsafe 的获取,但 Byte Buddy 内部实现了一套非常精致的特权提升通道(Privilege Escalation):
查找
Unsafe的theUnsafe字段:
Byte Buddy 会尝试通过反射直接获取sun.misc.Unsafe实例。如果受到 JPMS 限制,它会尝试定位jdk.internal.misc.Unsafe。使用
defineAnonymousClass(JDK 15 以前)或内部魔改defineClass:
在老版本 JDK 9-14 中,通过Unsafe.defineAnonymousClass可以在不触发模块边界检查的情况下注入类。利用
Field物理修改 Module 对象的内部字段:
在一些特定版本中,Byte Buddy 甚至可以通过直接修改java.lang.Module实例内部的transitiveExports或declaredPackages等私有 Map 字段,直接从内存层面“抹去”模块限制。这种做法极其暴力,但由于直接操作内存,完全绕过了SecurityManager和 JPMS 控制台。
三、 核心架构:Byte Buddy 的决策树
当我们调用 new Byte Buddy().subclass(...) 并尝试 load 到指定的 ClassLoader 时,Byte Buddy 内部的决策链路如下:
[开始加载动态类]
│
┌──────────────┴──────────────┐
▼ ▼
[有 Java Agent 介入] [无 Agent, 纯 Runtime]
│ │
使用 Instrumentation 是否支持 MethodHandles.privateLookupIn?
修改目标模块的 Module Graph ├──────────────────────────┐
(redefineModules) ▼ (是) ▼ (否)
│ 使用 UsingLookup 策略 降级到 Unsafe / 内部反射
│ 直接在目标模块内部定义类 强行开辟通道 (ClassInjector)
▼ │ │
[成功注入权限并加载] <────────────────┴─────────────────────────┘
四、 总结与安全启示
Byte Buddy 针对 Java Module 系统的动态权限注入,其核心思想是**“能礼则礼,兵不厌诈”**:
- 首选合规通道:在有 Agent 的场景下,通过 JVM 允许的
Instrumentation.redefineModules合法重塑模块图;在运行时,通过MethodHandles.privateLookupIn实现权限的安全穿透。 - 底线防御绕过:在合规通道受阻、且用户强行要求代理的场景下,通过操作
Unsafe或 JVM 内部数据结构,在内存层面强行修改模块权限表。
给开发者的启示:
在 JDK 17 及更高版本中,随着强烈封装(Strong Encapsulation)的默认开启,依靠 Unsafe 强行突破模块的道路正在逐步被 JDK 堵死(例如很多内部 API 被彻底移除)。
在设计现代 Java 框架或 Agent 时,应尽量拥抱 MethodHandles 和 java.lang.instrument 规范。Byte Buddy 的优雅之处,正是因为它在提供底层黑魔法的同时,最大化地利用了 JVM 原生提供的安全通路,从而保证了框架在 JDK 17、21 时代的生命力。