内容纲要

前言

其实本章的内容已经和 JAVA RMI 没有太大关系了 . 但是由于 Java 反序列化技术大量应用于 JRMI( Java远程方法调用 ) , JMX( Java管理扩展 ) , JMS( Java消息服务 ) 中 , 因此 Java RMI 服务也算是 Java 反序列化漏洞的高发地 .

2015年1月28日 , Gabriel LawrenceChris Frohoff( Ysoserial的作者 ) 两位前辈在 AppSecCali 2015 上发表了 Marshalling-Pickles 一文 , 其中就提到了反序列化漏洞 , 并且发布了 Ysoserial 等多种反序列化利用工具 , 拉开了 Java 反序列化漏洞的序幕 . 但是在当时并没有太多人注意到这个漏洞 .

同年11月6日 , FoxGlove Security 安全团队的 breenmachine 前辈发表 What Do WebLogic, WebSphere, JBoss, Jenkins, OpenNMS, and Your Application Have in Common? This Vulnerability 一文 , 文中强调了反序列化漏洞这个概念 . 并且阐述了如何利用 Java 反序列化漏洞和 Commons Collections库实现远程代码执行 .

此外 , breenmachine 前辈还指出了 Java反序列化漏洞产生的原因 , 详细说明了该漏洞的辉煌战绩 --- 横扫当时市面上大部分主流的 JavaWeb 框架(WebLogic, WebSphere, JBoss, Jenkins, OpenNMS) , 最后给出了漏洞修复方案 --- 删除存在漏洞的的文件 .

本文将从 Java 反序列化基础内容说起 , 直到理解这个传奇级别的 ACC(Apache Commons Collections) 利用链 .


Java 序列化与反序列化


什么是 Java 序列化 / 反序列化

Java 序列化就是把一个 Java Object 变成一个二进制字节数组 , 即 byte[] .

Java 反序列化就是把一个二进制字节数组(byte[]) 变回 Java 对象 , 即 Java Object .

  • 在很多应用中 , 为了减轻内存压力或长期保存数据 , 会将部分对象进行序列化操作 , 使其脱离内存空间进入物理磁盘 . 当需要使用这些对象时 , 会将存储的字节数组进行反序列化操作 , 重构出对象.

  • 在很多服务中( 例如 Java RMI 远程方法调用 ) , 客户端和服务端之间传输的是" 对象 " 而不是基本数据类型 . 此时会将对象进行序列化操作后 , 将字节数组传输给对方 . 由对方通过反序列化操作获取原对象 .

  • ... ... ... ...

总而言之 , Java 序列化/反序列化的目的无非就是用于 " 数据存储 "" 数据传输 " .


如何实现 Java 序列化 / 反序列化 .

Serializable 接口 / Externalizable 接口
  • Serializable

    如果一个类要实现序列化操作 , 则必须实现 Serializable 接口 , 并且可以在类中设置 serialVersionUID 属性 .

    Serializable 接口中没有方法和属性字段 , 仅用于标识序列化的语义 , 代表该类可以进行序列化/反序列化操作 .

    如果在序列化时发现某个对象不能被序列化( 例如在遍历某些图形对象时 ) , 则会抛出 NotSerializableException 异常并标识不可序列化对象的类 .

    在序列化或反序列化过程中需要进行特殊处理的类要实现下面三个方法 :

    每个可序列化的类在序列化时都会关联一个版本号 , 这个版本号就是 serialVersionUID 属性 .

    在反序列化过程中会根据这个版本号来判断序列化对象的发送者和接收着是否有与该序列化/反序列化过程兼容的类 .( 简单的说就是序列化过程和反序列化过程都需要被序列化的类 , 通过 serialVersionUID 属性来判断这两个类的版本是否相同 , 是否是同一个类 ) . 如果不相同 , 则会抛出 InvalidClassException 异常

    serialVersionUID 属性必须通过 static final long 修饰符来修饰 .

    如果可序列化的类未声明 serialVersionUID 属性 , 则 Java 序列化时会根据类的各种信息来计算默认的 serialVersionUID 值 . 但是 Oracle 官方文档强烈建议所有可序列化的类都显示声明 serialVersionUID 值 .

    文档给出的原因是 : 默认的 serialVersionUID 计算会对类详细信息高度敏感 , 而类详细信息可能会根据编译器的实现而有所不同 . 因此可能在反序列化期间抛出意外的 InvalidClassExceptions 异常 .


  • Externalizable

    不仅可以通过继承 Serializable 接口来标识某个类是可序列化的 , 还可以通过继承 Externalizable 接口来标识某个类是可序列化的 . 事实上 , Externalizable 接口继承了 Serializable 接口 .

    通过 Externalizable 接口实现序列化和反序列化操作会相对麻烦一点 , 因为我们需要手动编写 writeExternal()方法和readExternal()方法 , 这两个方法将取代定制好的 writeObject()方法和 readObject()方法 .

    那什么时候会使用 Externalizable 接口呢 ? 当我们仅需要序列化类中的某个属性 , 此时就可以通过 Externalizable 接口中的 writeExternal() 方法来指定想要序列化的属性 . 同理 , 如果想让某个属性被反序列化 , 通过 readExternal() 方法来指定该属性就可以了.

    此外 , Externalizable 序列化/反序列化还有一些其他特性 , 比如 readExternal() 方法在反序列化时会调用默认构造函数 , 实现 Externalizable 接口的类必须要提供一个 Public 修饰的无参构造函数等等

    Externalizable 与 Serializable 一文中 , 简单的总结了 SerializableExternalizable 两个接口的区别及优劣 .


writeObject()readObject()

在最简单的情况下 , 开发人员会通过继承 Serializable 类来实现序列化与反序列化 . 这种方法离不开 writeObject()readObject() 两个方法.

从官方文档可以看出 , 在序列化和反序列化时需要实现上述两个方法 , 这两个方法的参数都是 ObjectOutputStream 类型的 . 来简单介绍下这个类 .

  • java.io.ObjectOutputStream

    ObjectOutputStream 类会将支持 java.io.Serializable 接口的 Java对象( 包括部分图形对象 ) 的原始数据类型写入 OutputStream 中 . 然后使用 ObjectInputStream 类读取( 重构 )对象 .

    可以通过使用 " 文件流 " 来实现对象的持久存储 , 也可以使用 " 网络套接字流 " 来传输对象 .

    值得一提的是 , 这里 OutputInput 都是针对 " 内存 " 来说的 . Output 即将 " 内存 " 中的 Java对象 传输到 " 文件流 " 或者 " 网络套接字流 " 中 , 而 Input 则是将 " 文件流 " 或 " 网络套接字流 " 中的数据加载到 " 内存 " 中 , 这个点初学者容易搞混 , 需要重点关注 .

    java.io.ObjectOutputStream 类会通过 writeObject() 方法将 Java 对象写入到数据流中 .'


  • java.io.ObjectOutputStream.writeObject( ObjectOutputStream stream )

    writeObject()方法会将所有 对象的类 , 类签名 , 非瞬态和非静态字段的值 写入到数据流中

    1. 什么是类签名 ?

    在开发 JNI( Java Native Interface , Java 本地接口 ) 时需要调用 Java 层的方法或创建引用 , 此时就会用到 Java 签名机制 . 比如基本数据类型的签名如下所示 :

    还有像 Ljava/lang/Class; , Ljava/lang/String; 等都是类签名 , 这些字符串在解析 Java 序列化数据时会用到 . 详细内容可以参考 java的数据类型的签名 一文 .

    2. 什么是非瞬态 ?

    瞬态变量( Transient ) 是一个 Java 关键词 , 它用于标记类的成员变量在持久化到字节流时不要被序列化 ; 在通过网络套接字流传输字节流时 , transient 关键词标记的成员变量不会被序列化 .

    因此 , 如果仅想序列化某个类中部分变量 , 除了可以通过继承 Externalizable 接口来指定需要序列化的成员变量 ; 还可以将其他变量添加 transient 关键词 , 使得变量不被序列化 .

    写个测试 Demo , 内容如下所示 :


  • java.io.ObjectInputStream

    通过 ObjectOutputStream 类可以将对象写入数据流中 , 而通过 ObjectInputStream 类可以将数据流中的字节数组重构成对象 .

    ObjectInputStream 类在重构对象时会从本地 JVM 虚拟机中加载对应的类 , 以确保重构时使用的类与被序列化的类是同一个 . 也就是说 : 反序列化进程的 JVM 虚拟机中必须加载被序列化的类 .

    java.io.ObjectInputStream 类会通过 readObject() 方法将数据流中的序列化字符串重构成 Java 对象.'


  • java.io.ObjectInputStream.readObject( ObjectInputStream stream )

    readObject() 方法将读取序列化数据中各个字段的数据并分配给新对象的相应字段来恢复状态 . 需要注意的是 : readObject() 方法仅会反序列化 非静态变量 和 非瞬态变量 . 当读取到一个用 transient 修饰符修饰的变量时 , 将直接丢弃该变量 , 不再进行后续操作 .

    此外 , 反序列化过程中 , 需要将重构的对象强制转换成预期的类型 , 比如 String 型变量就需要通过 (String) 修饰符强制转换成原来的类型 .

    例如这里将 test.ser 中的序列化字符串进行反序列化操作 .

    可以看到 , String 对象被重构了 , 对象的值 " epicccal " 也被还原了 .


完整的 Java 序列化 / 反序列化流程

通过上面的内容 , 已经可以得到一个完整的 Java 序列化 / 反序列化 流程 .

  • 序列化过程

    Man 类中存在变量 str方法 prt() . 该类继承了 Serializable 接口 , 表明该类可以被序列化 .

    ser 类用于执行 Java 序列化过程 . 将 Man 类的实例对象序列化后输出到文件流中 , 并保存在 /tmp/test.ser 文件中 .

    通过 xxd 工具查看 /tmp/test.ser 文件 , 可以看到其中存在 Java 反序列化数据 , 反序列化过程执行成功 .

  • 反序列化过程

    unSer 类打开文件流 , 读取 /tmp/test.ser 文件的内容 , 并进行反序列化操作 . 注意要进行强制转换 .

    重构对象后 , 输出 Manstr 变量的值 , 并且执行 Man 类的 prt() 函数 . 全部执行成功 , 说明 Man 对象的相关字段全部被恢复了 .

至此 , 一个最简单的 序列化 / 反序列化 过程就结束了 .


Java 反序列化漏洞

那到底什么是 Java 反序列化漏洞呢 ? 其实问题就出在 Java 反序列化过程的关键函数 readObject() 上 . 这里需要仔细看一下 readObject() 方法 .

图中已经说的比较清楚了 , 官方允许用户在被序列化的类中重写 readObject() 方法 , 重写后的方法将负责在反序列化时重构当前类对象 .

用户只需要在重写的 readObject() 方法中实现 defaultReadObject() 方法 , 就可以确保反序列化过程正常执行 . 至于添加的代码内容 , 官方没有做任何限制 .

写个例子

可以看到 , 在不修改其他代码的情况下 , 通过重写 readObject() 方法时添加其他代码 , 可以使得被添加的代码在反序列化过程中被执行

也就是说 , 如果我们在其中添加恶意代码 , 那么恶意代码也将被反序列化并执行 . 比如下面这个 反弹Shell 的demo .

我们指定的恶意代码被成功执行 , 这就是 Java 反序列化漏洞一个最简单的案例 .


Java 序列化数据分析

您一定对 Java 序列化数据的含义非常感兴趣 , 这里也简单说明一下

通过 SerializationDumper 项目可以很方便的分析 Java 序列化数据 . 如下所示 :

  1. 首先通过一个 Py 获取到序列化数据的十六进制字符串 .

    aced0005737200034d616e00000000000000010200014c00037374727400124c6a6176612f6c616e672f537472696e673b787074000868656c6c6f202120

  2. 然后通过 SerializationDumper 工具解析十六进制字符串.

下面简单说一下 SerializationDumper 的输出结果是什么意思 .

  1. ACED0005

    0xAC 0xED 为 Java 序列化字符串魔术幻数 , 可以看作是 Java 序列化字符串的特征值 , 每次 Java 序列化字符串被创建时 , 都会添加该特征值 .

    0x00 0x05 为 Java 序列化版本号 , 一般来说版本号都为 : 5 . 很少看到其他数字/

  2. 7372

    0x73TC_OBJECT , 代表下面内容是一个新的对象 .

    0x72TC_CLASSDESC , 类描述符 , 代表一个新类的开始

    这些属性的定义位于 java.io.ObjectStreamConstants 类中 , 可以参考 Source for java.io.ObjectStreamConstants .

  3. 00034D616E

    这里会先输出类名的长度 , 然后才是类名的值 .

    例如 0x0003 代表类名长度为 3 个字符 , 然后 0x4D 0x61 0x6E 代表类名为 Man.

  4. 0000000000000001

    然后是 serialVersionUID 属性的值 . 在编写代码时我将它指定为一个长整型数字" 1L " , 因此这里显示为 0x000000000001

  5. 0002

    这里是 Java 序列化属性标识符 , 即 classDescFlags , 该属性字段的值为 5 个标记的算数和

    SC_WRITE_METHOD = 0x01 : 如果被序列化的类中重写了 writeObject() 方法 , 则设置该标志 , 并使用 TC_ENDBLOCKDATA 标记来终止该类的数据 .

    SC_BLOCK_DATA = 0x08 : 如果使用 STREAM_PROTOCOL_2Externalizable 类写入流中 , 则设置该标志 .
    这里用的不多 , 内容也比较复杂 , 关于 Stream Protocol 以及 BLOCK DATA 的内容可以参考 Object Serialization Stream Protocol , 这里不详细说明了 .

    SC_SERIALIZABLE = 0x02 : 如果被序列化的类继承了 Serializable 接口 , 则设置该标志 , 并在反序列化时调用的类也需要继承 Serializable 接口 , 并使用默认的反序列化机制 .

    SC_EXTERNALIZABLE = 0x04 : 如果被序列化的类继承了 Externalizable 接口 , 则设置该标志 , 并在反序列化时调用的类也需要继承 Externalizable 接口 . 并且在序列化/反序列化过程中要使用 readExternal() 方法和 writeExternal() 方法处理序列化数据 .

    SC_ENUM = 0x10 : 如果被序列化的类是枚举类型 , 则设置该标志 . 同时反序列化时调用的类也必须是枚举类型 .
    这里用的不多 , 内容也比较复杂 , 关于枚举类型常量的序列化可以参考 Serialization of Enum Constants, 这里不详细说明了 .

  6. 0001

    这里是类中字段的个数 . 在 Java 序列化时 , 只有类中的属性字段会被序列化, 而 方法/函数 是不会被序列化的 .

    由于 Man 类仅有一个 str 字段 , 所以这里的值为 " 1 " .

  7. 4c

    这里是 " 类类签名 " . 即字段类型的类签名 . 注意 , 这里非常容易搞混 ! 是字段类型的类签名 , 不是字段值的类签名 .

    也就是说 , 这里填写的是类类签名 , 而不是类签名( 实在有点绕 , 您多理解下. )

    弄明白之后就好理解了 , 这里需要填写 " L " , 即 0x4C .

  8. 0003737472

    这里就是类中属性字段的长度和值了 .

    0x0003 为属性字段 str 的长度 , 为 " 3 " .

    0x73 0x74 0x72 为属性字段 str 的值 , 这个没啥好说的 .

  9. 7400124c6a6176612f6c616e672f537472696e673b

    有上面的基础 , 这里就放一起说了 .

    0x74 标识下面将出现一个 String 类型 , 这也是在 java.io.ObjectStreamConstants 类中定义的 .

    0x12 代表String类型标准签名的长度 . 即 " Ljava/lang/String; "的长度 , 一共 18 个字节 , 注意不要忘记 类类签名 " L " 和 分号 " ; " .

    0x4C 0x6A 0x61 0x76 0x61 0x2F 0x6C 0x61 0x6E 0x67 0x2F 0x53 0x74 0x72 0x69 0x6E 0x67 0x3B : 即 " Ljava/lang/String; " .

  10. 7870

    0x78 : Block_data 的结束表示符号 .可以理解为一个原始数据块的结束 .

    0x70 : 用于在流中指定 Null 引用 . 因为这里字段的字段代表 父类的类描述符号 , 而 Man 类仅继承了 Serializable 接口 , 并未继承其他类 , 所以这里应指定为 Null .

  11. 000868656c6c6f202120

    0x0008 : 属性字段值的长度 , 即 " hello ! " 的长度 , 为 " 8 " .

    0x68 0x65 0x6C 0x6C 0x6F 0x20 0x21 0x20 : 即属性值 " hello ! " .

至此 , 这段序列化数据就分析完了 , 其实整体来看并不难 , 但是细化后还是有一些点存在疑问 . 这个之后会补充说明 . 个人认为可以根据 SerializationDumper 工具输出的层级关系来分析 , 会更加清楚 .


总结

篇幅原因 , 本章的内容就到这了 , 本来是准备研究一下 Apache Commons Collections 利用链的 , 现在看来只能拖到下一章了 .

整体的序列化流程不算太难 , 通过 SerializationDumper 工具也可以比较清晰的知道每一部分序列化数据分别对应什么内容 . 不过部分细节还需要深入研究下 .

有兴趣的师傅可以看一下 解析Serializable原理 一文 , 讲的挺好的 .

本章算是为 Java 反序列化开了个头 , 下一章将结合 Ysoserial 工具来谈一谈 Apache Commons Collections POP 利用链 .

Please follow and like us:
最后修改日期:2020年5月7日

作者

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。