免责声明:本文为个人技术学习与工程实践笔记,所涉操作仅应在获得授权的环境中进行。因不当使用造成的后果由使用者自行承担。
类的生命周期
在开始之前,需要先了解 Java 类加载机制的整体结构。
1 | 类加载器 双亲委派 热部署 自定义类加载器 |
Java 在加载类时,一般存在如下生命周期:类从被加载到虚拟机内存中开始,到卸载出内存为止,其整个生命周期可以概括为 7 个阶段:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading) 其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。一个类若想被运行,必须先加载到虚拟机当中,也就是要加载到 JVM 中才能被运行和使用。JVM 加载一个类一般有三个步骤:加载->连接->初始化,其中连接过程又可细分为验证->准备->解析。JVM 启动时并不会一次性加载所有的类,而是按需索取,将要加载的类提前放到 JVM 里,在需要调用时再去调用;并且在加载一个新的类时,会先判断该类此前是否已被加载过
类加载器层次结构
JVM 内部主要内置了三个重要的 ClassLoader
1 | Bootstrap ClassLoader (JVM内核,C++实现) |
自底向上查找,判断类是否已被加载;自顶向下尝试加载类
Bootstrap ClassLoader
该类加载器为启动类加载器,位于最顶层,是 Java 中最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被 -Xbootclasspath 参数指定的路径下的所有类。
Extension ClassLoader
主要负责加载 %JRE_HOME%/lib/ext目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
Application ClassLoader
面向用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都在 JVM 外部实现,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自行决定如何去获取所需的类。每个 ClassLoader 可以通过 getParent() 获取其父 ClassLoader,如果获取到的 ClassLoader 为 null,那么该类加载器的父加载器即为 BootstrapClassLoader 。
双亲委派
加载类的过程中通常涉及大量类,Java 需要自行确定应当加载哪些类,此时便用上了双亲委派机制。
ClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 虚拟机中被称为 “bootstrap class loader”的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。
- 这里我们可以知道,每一个 ClassLoader 都有一个最顶层的父类
- 并且启动一个 ClassLoader 实例之前,会先查找与自身存在关联的类
由上述机制可知,双亲委派实际上为 Java 提供了一层安全保护:它通过委派父加载器优先加载类的方式,实现一系列安全指标,例如防止系统的关键 API 类被恶意篡改,以及避免同一个类被重复加载。例如我们无法重复加载 java.lang.String 这个类。下面尝试自行定义一个 java.lang.String,代码如下
1 | package java.lang; |
此时运行会输出如下信息,这正是双亲委派机制生效的结果。
1 | Error: Main method not found in class java.lang.String, please define the main method as: |
加载时,类加载器会逐层向上查找;一旦找到已经存在的类,它就会停下来,不再继续加载。例如 java.lang.String 这个类在 BootStrap ClassLoader(也就是 JRE)中已经存在,因此发现之后便会停止,不会再次加载
打破双亲委派
下面先观察正常的双亲委派机制是什么样的,示例代码如下
1 | // ClassLoader.loadClass() 默认实现 |
从上述代码很容易发现,如果要加载一个类,系统会先判断该类是否已经加载进 JVM,然后委派给父加载器,最后委派给 BootStrap;如果以上都加载失败,则由自己加载。因此,打破双亲委派主要有三种方式
- 覆写重写
loadClass()方法 - 使用线程上下文类加载器(典型场景:SPI)
- 自定义 ClassLoader 直接调用 findClass
重写 loadClass() 方法
1 | public class BreakParentDelegationLoader extends ClassLoader{ |
应用
以 Tomcat 为例,Web 应用默认的类加载顺序如下(打破了双亲委派规则):
- 先从JVM的BootStrapClassLoader中加载。
- 加载Web应用下
/WEB-INF/classes中的类。 - 加载Web应用下
/WEB-INF/lib/*.jar中的jar包中的类。 - 加载上面定义的
System路径下面的类。 - 加载上面定义的
Common路径下面的类。 如果在配置文件中进行了相应配置,那么就是遵循双亲委派规则,加载顺序如下:
- 先从JVM的BootStrapClassLoader中加载。
- 加载上面定义的System路径下面的类。
- 加载上面定义的Common路径下面的类。
- 加载Web应用下
/WEB-INF/classes中的类。 - 加载Web应用下
/WEB-INF/lib/*.jar中的jar包中的类。
这里也有一个容易被忽略的运维事实:由于 Web 应用自身的 WEB-INF/classes 与 WEB-INF/lib 优先级高于公共路径,任何对应用目录的写入权限都等价于代码执行权限。因此部署目录必须对运行账号只读,发布流程只允许通过受控的构建产物覆盖,避免出现”上传一个 jar 到 lib 目录即完成植入”的路径。
动态加载字节码
Java 中的字节码通常指的是 .class 文件。所谓动态加载字节码,一般指在运行时加载/生成/修改类的字节码,而非在编译时确定,它是实现热部署、AOP、动态代理等技术的基础。也正因为类加载会把字节码变成真正可执行的类型并触发其静态初始化,这条链路同样是一些反序列化利用链与内存驻留组件的收尾环节:一旦类名或字节码来源可以被外部输入影响,代码执行就不再受应用逻辑控制。下面的示例统一使用一个只打印日志的演示类 DemoService.java,以便专注于类加载机制本身
1 | package demo; |
这段代码没有实际业务含义,但它体现了一个关键事实:类被初始化时,静态代码块会先于任何方法调用执行。真实的恶意类正是把需要执行的动作放在静态代码块或构造方法中,从而在”加载并实例化”这一步完成触发,这也是防守方需要重点关注类加载行为的原因。
编译之后即可得到路径 ~/Documents/Coding/java_basic/target/classes/DemoService.class。那么该如何加载这段字节码?此时就会用到 URLClassLoader。它实际上是我们平时默认使用的 AppClassLoader 的父类,因此,解释 URLClassLoader 的工作过程,实际上就是在解释默认 Java 类加载器的工作流程。下面给出 ClassLoader 的继承关系
1 | java.lang.Object |
传统加载
可以借助它来实现字节码的动态加载
1 | package loader; |
上面是通过 file 协议来加载本地目录中的类。真正需要警惕的是把 URL 的来源换成远端地址这一变体:URLClassLoader 会在解析类时主动向外发起请求,并执行取回的字节码。也就是说,只要应用的某个入口能影响”从哪里加载类”,攻击者就不需要把文件写进服务器,也能完成代码执行——这也是历史上大量反序列化利用链与”远程类加载”类风险的共同收尾步骤。
对于这类风险,检测点比利用细节更有价值:
- 代码审计时检索
new URLClassLoader(...)、Class.forName(name, true, loader)、URL.openStream()与defineClass的组合调用,确认其中的 URL 或类名是否可能来自外部输入; - 在出口侧审计应用服务器发出的 HTTP 请求,正常的类加载不应产生访问外部站点的流量,出现”应用服务器去拉取
.class/.jar“的记录即可疑; - 运行时可通过 javaagent 或 RASP 在
ClassLoader#defineClass处挂钩,记录被定义类的名称、来源CodeSource(本地 jar 路径还是远端 URL)以及发起加载的类加载器实例。
如果业务确实需要远端加载能力,应把地址限制在受信任的制品仓库白名单内,并对取回的字节码做签名校验。
或者也可以通过 jar+file 协议进行加载。首先将 class 打包成一个 jar 包
1 | jar -cvf demo.jar demo/DemoService.class |
然后使用 jar 协议进行加载,需要注意末尾感叹号的写法
1 | package loader; |
ClassLoader#defineClass直接加载
无论以何种方式加载一个 .class,基本都按照下面这些步骤进行
1 | ClassLoader#loadClass -> ClassLoader#findClass -> ClassLoader#defineClass |
loadClass的作用是从已加载的类型、父加载器出发,结合双亲委派机制加载一个类;如果前面都没有找到,则会调用findClass来查找类- 根据URL指定的方式来加载类的字节码,其中会调用
defineClass(); defineClass的作用是处理前面传入的字节码,将其转换为真正的 Java 类。示例代码如下
1 | package loader; |
上面代码通过反射直接调用 defineClass 来加载字节码,下图为 java.lang.ClassLoader#defineClass 的示例代码
1 | protected final Class<?> defineClass(String name, byte[] b, int off, int len, |
这段代码只用于说明 defineClass 在类加载流程中的位置:它接收的已经是完整的字节码,内部调用 preDefineClass 校验包名、defineClass1 完成真正的类型定义、postDefineClass 做收尾。对于运维与防守方,有两点值得记录:
defineClass是双亲委派链路之外的”最后一道门”,任何绕过loadClass的写法(反射调用defineClass、MethodHandles.Lookup#defineClass、Unsafe#defineClass)都会绕过包名与委派检查,因此它是运行时检测的关键 Hook 点;- JDK 9 引入模块化之后,对 JDK 内部 API 的反射访问默认受到强封装限制,上述
setAccessible(true)需要显式开放模块才能生效。反过来说,生产环境中出现针对ClassLoader#defineClass的反射调用,本身就是值得告警的异常信号。
UnSafe 加载字节码
相关内容此前已经介绍过,可以查看 26.关于Unsafe。其核心在于可以不用 ClassLoader 来加载字节码,因为 Unsafe 内置了一个 defineClass,其签名形如 defineClass(String name, byte[] b, int off, int len, ClassLoader loader, ProtectionDomain protectionDomain)。Unsafe 实例不能直接 new 出来,通常需要通过反射读取静态字段 theUnsafe 获取,这也决定了这条路径至少需要两次绕过:一次绕过 ClassLoader 的委派与校验,一次绕过 JVM 对 Unsafe 的访问限制。
从防守角度看,这条路径的价值不在于它”能做什么”,而在于它的行为特征足够明显,因此非常适合作为检测规则:
Unsafe实例的获取方式(反射读取theUnsafe字段并setAccessible(true))在生产代码中几乎不存在合法用途,出现即可视为可疑;- 内存马与部分反序列化工具链会落在
Unsafe#defineClass上,因为它不经过loadClass,不会在常规类加载日志中留下记录; - 因此运行时检测应同时覆盖
ClassLoader#defineClass、MethodHandles.Lookup#defineClass与Unsafe#defineClass三处,并记录调用栈、发起加载的线程与类名,而不是只监控其中一处。
排查时可以配合 -verbose:class 或 JFR 的类加载事件,比对”运行时新出现的类”与发布包内的类清单:二者不一致的差值,就是需要人工确认的部分。
TemplatesImpl 作为字节码加载入口
如果了解过 CC 链,对此应该相当熟悉,这里也可以查看 26.Java基础关键组件分析 > TemplatesImpl
TemplatesImpl 是 XSLTC 编译器的运行时载体,它的私有字段 _bytecodes(byte[][])用于保存编译产物,_name、_tfactory 则记录模板名与工厂实例。当调用 newTransformer() 时,内部会经由 defineTransletClasses() 对 _bytecodes 中保存的字节码逐个调用 defineClass,随后实例化得到的类。也就是说,只要能够给这三个字段赋值并触发一次 newTransformer(),就相当于获得了一个”从内存字节码到类”的入口,不需要往磁盘写任何文件。
这正是它在多条反序列化利用链中作为收尾环节被使用的原因,也解释了为什么防守方对它的关注点是”谁会去写这些私有字段”:
- 静态审计阶段检索对
TemplatesImpl的反射写入(setAccessible(true)后写_bytecodes/_tfactory)。正常业务流程中这些字段由 XSLTC 内部填充,外部代码主动赋值属于异常; - 运行时在
defineClass处挂钩,检查调用栈中是否出现defineTransletClasses,这是识别该路径最直接的判据; - 反序列化防护层面,将
TemplatesImpl与相关 JDK 内部类加入黑名单,或直接采用白名单式反序列化;反序列化链中的黑名单只能作为缓解手段,根本措施是不反序列化不可信数据。
BCEL ClassLoader 与”以类名携带字节码”
这种方式一般会搭配 fastjson 使用,可以参见这两篇文章 15.Fastjson反序列化漏洞 和 [1.BCEL ClassLoader](1.BCEL ClassLoader)。
JDK 内部曾经随附一份 BCEL 实现,其中的 com.sun.org.apache.bcel.internal.util.ClassLoader 在遇到以 $$BCEL$$ 前缀开头的类名时,会把前缀之后的部分当作 BCEL 编码的字节码解码,再交给 defineClass 完成定义。由于字节码可以被编码成一段以 $ 分隔、只含可打印字符的文本,原本”只能是标识符”的类名字段就变成了可以携带完整类定义的载体。这也是它经常与 fastjson 的 autoType 机制一起出现的原因:反序列化时指定的类型名如果可以被外部输入控制,攻击者就不必依赖 CLASSPATH 上已有的类。需要注意,jdk8u71 中已不包含该组件。
该路径同样是”检测特征比构造细节更有价值”的典型例子:
- 在 HTTP 请求体、反序列化输入、日志与告警数据中检索
$$BCEL$$前缀,以及由大量$分隔的长字符串。这个特征误报率极低,可以作为一个成本很低的固定检测规则; - 在
defineClass挂钩点记录被定义类的名称与来源类加载器,类名以$$BCEL$$开头、来源为 BCEL ClassLoader 的组合应当直接告警; - 依赖与运行时治理方面,确认当前 JDK 版本与依赖树中是否仍存在 BCEL 组件。老版本 JDK 中的内部实现无法通过升级某个第三方依赖消除,只能通过升级 JDK 解决;
- 反序列化入口关闭 fastjson 的
autoType自动类型解析(safeMode),并对反序列化的类型做白名单校验,避免”输入可以决定加载哪个类”这一前提成立。
BCEL 的编码格式与解码流程本身属于实现细节,感兴趣的读者可以进一步了解其生成与解码过程。
JDK 动态代理
下面直接通过案例来说明,首先区分静态代理与动态代理的区别
静态代理
- 编写一个 UserService 接口及其实现类(包含
save()等方法) - 希望在不修改原有代码的前提下,为
save()增加日志、权限检查 → 采用静态代理(手写代理类) - 发现每个方法都要重复编写代理逻辑 → 引出动态代理的需求
代码示例如下
1 | package proxy; |
此时出现了一个新的需求:每个方法执行前打印 [LOG] 开始执行 XX 方法,执行后打印 [LOG] 结束执行 XX 方法。这种情况下基本上只能这样编写
1 | public class UserServiceStaticProxy implements UserService { |
使用方式如下
1 | public class Main { |
上述问题已经很明显:存在大量重复代码、复用性较差,并且添加新的接口后维护成本更高,因此这时候便引入了动态代理
动态代理
主要需要了解两个核心 java.lang.reflect.Proxy + InvocationHandler。其基本思路与静态代理相近,无非是动态代理的代理类在运行时动态生成,而静态代理的代理类需要提前编写好
InvocationHandler,即调用处理程序,其中invoke()负责拦截方法调用,并且每个代理实例都关联一个调用处理程序。重新审视上面那段静态代理代码,可以整理出动态代理的核心三要素
- 被代理对象(目标对象) → 就是我们刚才的
UserServiceImpl - 增强逻辑 → 日志、权限、事务等(写在
InvocationHandler中) - 代理对象 → JDK 运行时自动生成(不需要手写代理类)
下面直接编写动态代理代码,以便深入理解
1 | package proxy; |
运行结果如下
1 | ===== 调用原始对象 ===== |
动态代理的安全含义
动态代理是框架实现 AOP、事务与权限切面的基础,从风险分析的角度看,它还有两点值得记录:
- 代理类在运行时生成(
$Proxy系列),并不存在于发布包中。因此”发布包里查不到、运行时却存在”的类型,正是排查内存驻留组件时需要逐个确认的对象。结合上一节的类加载监控,可以把$Proxy类的生成数量与调用栈纳入基线,偏离基线的部分再人工判断; Proxy.newProxyInstance允许把任意的InvocationHandler绑定到一组接口上,因此在反序列化场景中,伪造的代理对象可以把任意方法调用路由到处理器的invoke方法,进而触发其中封装的操作。这正是不少利用链选择动态代理作为中间环节的原因。对应的加固手段依然是拒绝反序列化不可信数据、采用白名单式反序列化,并在反序列化入口配合 RASP 监控。
SPI 机制
SPI 是 Java 提供的一种服务发现机制,允许框架或库定义接口,而让第三方(或不同厂商)提供具体实现,并在运行时动态加载这些实现,无需修改原有代码。
原理:SPI 的原理基于 Java 的 ClassLoader 机制实现。在 Java 中,类的加载由 ClassLoader 负责,ClassLoader 可以从不同的源加载类,例如从本地文件系统、网络、JAR 文件或其他任何资源中加载类。SPI 将服务的接口定义放在一个模块中,服务的实现放在另外的模块中,并通过 ClassLoader 动态地加载实现类。
为什么需要 SPI?例如我们设计了一个登录认证框架,并定义了一个接口
1 | public interface LoginService { |
不同公司可能希望采用自己的认证方式(数据库、LDAP、OAuth)。在没有 SPI 时,框架需要硬编码具体实现类,更换一种认证方式就要修改框架代码;有了 SPI 之后,框架只定义接口,具体实现由外部提供,并在运行时自动发现并加载
工作方式
- 定义接口(服务提供方定义规范)
- 第三方实现接口(写自己的实现类)
- 配置文件注册:在
META-INF/services/目录下,创建一个以接口全限定名命名的文件,文件内容写实现类的全限定名 - 使用 ServiceLoader 加载:框架调用
ServiceLoader.load(接口.class)自动获取所有可用实现
总的来说,API 是你调用别人提供的实现,SPI 则是别人调用你编写的实现
组件治理视角下的 SPI
SPI 的扩展点是按 classpath 自动发现的,这带来一个运维层面的事实:能够往 classpath 中放入文件的一方,就能够替换服务的实现,而替换后的实现会在框架调用该接口时被执行。因此有必要把它纳入日常治理:
META-INF/services/下的注册文件与相关 jar 清单应纳入发布物的变更审计,出现未预期的实现类注册时追查来源;- 引入第三方依赖时,除了比对漏洞信息,还应确认它注册了哪些扩展点、是否会覆盖框架的默认实现;
- 在容器化部署中保持应用目录与依赖所在层只读,避免运行期被追加实现类。
运维与防守视角
检测点
- 静态审计:检索
new URLClassLoader(...)、defineClass的反射调用、Unsafe静态实例的反射获取,以及对_bytecodes一类私有字段的赋值,并核对META-INF/services的变更记录; - 运行时挂钩:在
ClassLoader#defineClass(同时覆盖MethodHandles.Lookup#defineClass与Unsafe#defineClass)统一记录类名、来源CodeSource、调用栈与发起线程。正常业务系统的类加载集中在启动阶段,运行期持续出现新类型——尤其是$Proxy类、匿名类或来源指向网络地址的类——应当触发告警; - 流量侧:应用服务器在运行期不应主动拉取
.class、.jar制品,也没有访问管理网段的需要,这两类目的一旦出现即可疑; - 文件与进程侧:把
-javaagent启动参数、lib目录内容与Class-Path清单纳入变更监控,并保存 JVM 的完整启动命令作为基线。
加固建议
- 依赖治理:移除不再维护的类加载组件(例如老版本 JDK 中的内部 BCEL 实现,需通过升级 JDK 消除);对具备
autoType能力的反序列化组件关闭自动类型解析并保持版本更新; - 消除”输入可以影响类加载”的路径:不拼接类名、不从不可信来源加载字节码;确需远端加载时,使用受信任仓库白名单并对制品做签名校验;
- 最小权限与隔离:以非特权账号运行应用,应用目录只读,限制容器内的卷挂载范围,并为应用服务器配置出口白名单;
- 分层防护:RASP、字节码校验与 WAF 只能作为补充,根本手段仍是把危险入口在代码层消除。
排查清单
- 确认当前 JDK 版本与依赖树中是否仍包含 BCEL 等类加载组件;
- 检索代码中全部类加载入口,逐一确认类名与字节码来源是否可能来自外部输入;
- 用发布包内的类清单与
-verbose:class或 JFR 记录做差集,定位运行期新增的类型; - 检查
META-INF/services、-javaagent参数与依赖目录的近期变更记录。