Java序列化机制

什么是序列化、反序列化?

序列化指的是将对象编码为字节流、反序列化指的是将字节流重新构建为对象

用途

  • RMI(Remote Method Invoke) 远程方法调用
  • JMX
  • JMS

如何使用Java默认的序列化能力

  • 类要实现Serializable接口,并且定义版本号(字段serialVersionUID),不定义版本号的话系统会对类的结构运用一个加密的散列函数,这个自动产生的值会受到类名称、实现的接口名称、所有公有和受保护的成员的名称所影响。==实现Serializable接口的类,为了保持兼容性,要显式定义版本号==
  • 单例的类需要定义readResolve方法
  • 定义readObject、writeObject来自定义序列化(与关键字transient结合使用)

默认序列化的劣势

  • 消耗过多空间(ArrayList自定义了序列化方法避免了这个问题)
  • 反序列化的时候可能会破坏了构造器中对参数的约束、对必要的参数进行保护性拷贝这两个原则

隐患

将不被信任的字节流进行反序列化,可能导致

  • 远程代码执行
  • 拒绝服务

一个字节码攻击的例子

如果系统中的执行结果依赖于某个类的可信任状态(Period中start、end的final特性,以及其构造函数内部对参数进行正确性校验),利用字节码攻击的手段可以影响start、end这两个参数的正确性。从而使依赖于Period的代码受到污染。客户端代码持有了Period实例域的引用,在客户端中可以随意修改该引用的状态。要避免这种攻击必须自定义序列化逻辑,对实例域进行保护性拷贝,确保Period类的约束条件不被破坏

  • 系统中存在一个可被序列化的类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    package com.luhc;

    import java.io.Serializable;
    import java.util.Date;

    /**
    * 内部维持了两个final的实例域
    *
    * @author luhuancheng
    * @date 2019/3/17
    */
    public final class Period implements Serializable {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
    // 保护性拷贝final属性
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());

    // 检验参数
    if (start.compareTo(end) > 0) {
    throw new IllegalArgumentException(start + " after " + end);
    }
    }

    public Date getStart() {
    // 保护性拷贝后,返回一个新的对象,避免被外部改变了内部的状态
    return new Date(start.getTime());
    }

    public Date getEnd() {
    // 保护性拷贝后,返回一个新的对象,避免被外部改变了内部的状态
    return new Date(end.getTime());
    }

    @Override
    public String toString() {
    return "Period{" +
    "start=" + start +
    ", end=" + end +
    '}';
    }
    }
  • 一个对字节码进行伪造的攻击类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    package com.luhc;

    import java.io.*;
    import java.util.Date;

    /**
    * @author luhuancheng
    * @date 2019/3/17
    */
    public class AttackPeriod {

    public final Period period;

    public final Date start;

    public final Date end;

    public AttackPeriod() {
    try {
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    ObjectOutputStream out = new ObjectOutputStream(bos);
    // 写入对象字节流
    out.writeObject(new Period(new Date(), new Date()));

    // 伪造字节流,之后我们可以从字节流中直接读取到Period对象中的两个实例域(start、end),这意味着我们能够修改这两个本应该是final的实例域的状态
    byte[] ref = {0x71, 0, 0x7e, 0, 5};
    bos.write(ref); // start
    ref[4] = 4;
    bos.write(ref); // end

    // 将字节流反序列为对象
    ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
    period = (Period) in.readObject();
    // 从字节流中反序列化Period两个final的实例域,指向AttackPeriod的公开实例域,之后可以修改这两个指针所指的对象
    // 此时的Period实例域start、end与AttackPeriod中两个实例域start、end指向的是内存中同一个对象
    start = (Date) in.readObject();
    end = (Date) in.readObject();
    } catch (IOException | ClassNotFoundException e) {
    throw new AssertionError(e);
    }
    }

    public static void main(String[] args) {
    AttackPeriod ap = new AttackPeriod();
    Period p = ap.period;
    Date pEnd = ap.end;

    pEnd.setYear(76);
    System.out.println(p);

    }
    }

如何避免隐患

  • 使用结构化数据表示法(JSON、Protobuf等)序列化方案对比
  • 序列化代理
  • 自定义readObject,在其中进行参数校验和必要的数据保护性拷贝

优化Period类,自定义序列化逻辑

自定义readObject逻辑(进行数据的保护性拷贝、参数校验),避免字节码攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.luhc;

import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.Date;

/**
* 内部维持了两个final的实例域
*
* @author luhuancheng
* @date 2019/3/17
*/
public final class Period implements Serializable {
// 在readObject中对这两个实例域进行保护性拷贝 赋值,因此无法再保持final特性
private Date start;
private Date end;

public Period(Date start, Date end) {
// 保护性拷贝final属性
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());

// 检验参数
if (start.compareTo(end) > 0) {
throw new IllegalArgumentException(start + " after " + end);
}
}

public Date getStart() {
// 保护性拷贝后,返回一个新的对象,避免被外部改变了内部的状态
return new Date(start.getTime());
}

public Date getEnd() {
// 保护性拷贝后,返回一个新的对象,避免被外部改变了内部的状态
return new Date(end.getTime());
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
// 序列化规范必须的步骤
in.defaultReadObject();

// 保护性拷贝(start、end不能使用final修饰了)
start = new Date(start.getTime());
end = new Date(end.getTime());

// 参数校验
if (start.compareTo(end) > 0) {
throw new InvalidObjectException(start + " after " + end);
}
}

@Override
public String toString() {
return "Period{" +
"start=" + start +
", end=" + end +
'}';
}
}

优化Period、使用序列化代理来自定义序列化逻辑

在上一种优化处理中,在readObject方法内部需要修改start、end的值,因此这两个实例域不能再被final修饰。使用序列化代理可以维持实例域依旧是final的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package com.luhc;

import java.io.Serializable;
import java.util.Date;

/**
* 内部维持了两个final的实例域
*
* @author luhuancheng
* @date 2019/3/17
*/
public final class Period implements Serializable {

// 不可变类的内部实例域,使用final修饰确保该类真正是不可变的
private final Date start;
private final Date end;

public Period(Date start, Date end) {
// 保护性拷贝final属性
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());

// 检验参数
if (start.compareTo(end) > 0) {
throw new IllegalArgumentException(start + " after " + end);
}
}

public Date getStart() {
// 保护性拷贝后,返回一个新的对象,避免被外部改变了内部的状态
return new Date(start.getTime());
}

public Date getEnd() {
// 保护性拷贝后,返回一个新的对象,避免被外部改变了内部的状态
return new Date(end.getTime());
}

@Override
public String toString() {
return "Period{" +
"start=" + start +
", end=" + end +
'}';
}

// 序列化代理
// 1. 定义代理内部类
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;

// 构造器传入外围类的实例
public SerializationProxy(Period period) {
this.start = period.start;
this.end = period.end;
}

// 3. 定义readResolve方法,返回外围类实例
private Object readResolve() {
return new Period(start, end);
}
}

// 2. 外围类定义writeReplace方法, 返回代理类
private Object writeReplace() {
return new SerializationProxy(this);
}
}