0
点赞
收藏
分享

微信扫一扫

Java序列化攻击和最佳实践

你的益达233 2022-04-29 阅读 99

85. 其他序列化优于Java序列化

原因在于,反序列化不被信任的数据,容易受到序列化攻击,例如远程代码执行(RCE),拒绝服务(DoS)等等。研究人员可以基于Java序列化机制开发出很多巧妙的攻击片段。例如:

public class BombSerializable {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        serialize();
        System.out.println("serialized");
        deserialize();
    }

    // Deserialization bomb - deserializing this stream takes forever
    static void serialize() throws IOException {
        Set<Object> root = new HashSet<>();
        Set<Object> s1 = root;
        Set<Object> s2 = new HashSet<>();
        for (int i = 0; i < 100; i++) {
            Set<Object> t1 = new HashSet<>();
            Set<Object> t2 = new HashSet<>();
            t1.add("foo"); // Make t1 unequal to t2
            s1.add(t1);
            s1.add(t2);
            s2.add(t1);
            s2.add(t2);
            s1 = t1;
            s2 = t2;
        }
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("bomb.txt"));
        oos.writeObject(root);
    }

    static Object deserialize() throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("bomb.txt"));
        Object o = ois.readObject();
        return o;
    }

}

上述代码在反序列的时候会卡住,原因在于,调用流的读取/写入方法之后,会自动调用序列化对象的readObject, wirteObject等方法。但HashSet重写了readObject,wirteObject,如下代码,它会根据集合的数量从流中调用相应次数的写入/读取方法,递归调用。原来是没有问题的,因为Java序列化发现当前对象已经序列化过,它不会再深入递归。但关键在于,readObject方法调用了map.put->hash->hashCode,该方法会递归遍历所有元素。这样一来,hashCode的方法就会被调用超过2^100次方次,造成反序列化爆炸。

以上的例子,指出了Java序列化的风险本质——序列化之后调用的方法(例如readObject,wirteObject)由序列化对象提供,尤其是反序列化的时候,这些超出了调用者(程序)的控制。

86. 明智地实现Serializable

Serializable是一个标记接口,看起来只要实现它就能享受序列化机制,非常简单,毫不费力,但实际上需要付出长期开销非常高。以下列出相关建议并解释原因。

相当于类所有的私有和包私有的实例域都会变成导出API的一部分——你必须保证这些域一直符合序列化要求,这就不符合“最低限度访问原则”。
而且一旦类内部改变,自动生成的序列化UID就会改变,新版本可能无法反序列化旧版本的产物。总之,一旦实现类序列化接口,就意味着未来的实现受到众多限制。

Java序列化绕开了构造器,同时也绕开了构造器可能设定的类约束。

如果要保证新版本序列化的实例,旧版本可以反序列化(应用兼容性要求),那么就必须对所有旧版本的类进行测试,开销 = 序列化类的数量 x 版本数量。

正如上面及上一条目提及,首先Java的序列化机制本身就是有缺陷的,实现Serializable意味着,以后所有版本中,相关的类都必须强制遵循约定(很可能要一直使用危险的Java序列化机制)。所以看起来是一个“免费”的标记接口,其实“免费的才是最贵的”。
同理,为继承而设计的类也尽可能不要使用Serializable序列化,因为这会给未来的子类、所有实现带来沉重的负担。

内部类指非静态内部类,它们持有对外部类的引用,目前内部类的序列化形式没有清楚的定义。但静态内部类可以,因为静态内部类不持有外部引用。

87. 考虑使用自定义序列化替换默认序列化

物理表示法与逻辑内容

怎么理解呢?书中有一个精彩的案例如下。这是一个用于保存字符串集合的双向链表,如果直接使用默认序列化,则物理上(物理存储)会直接保留双向链表的形式,实际上只需要保存字符串集合,以及集合的size即可。

// Awful candidate for default serialized form
public final class StringList implements Serializable {
   private int size = 0;
   private Entry head = null;
   private static class Entry implements Serializable {
       String data;
       Entry next;
       Entry  previous;
   }
   ... // Remainder omitted
}

当物理表示法不等于逻辑内容的时候,使用默认序列化,会增加很多没有必要的开销。
具体而言,有如下缺点;

这时候可以考虑自定义序列化形式,只需要序列化字符串和size即可。

// StringList with a reasonable custom serialized form
public final class StringList implements Serializable {
    private transient int size   = 0;
    private transient Entry head = null;
    // No longer Serializable!
    private static class Entry {
        String data;
        Entry next;
        Entry  previous;
    }
    // Appends the specified string to the list
    public final void add(String s) { ... }
    /**
    * Serialize this {@code StringList} instance.
    *
    * @serialData The size of the list (the number of strings * it contains) is emitted ({@code int}), followed by all of * its elements (each a {@code String}), in the proper
    * sequence.
    */
    private void writeObject(ObjectOutputStream s)
            throws IOException {
        s.defaultWriteObject();
        s.writeInt(size);
        // Write out all elements in the proper order.
        for (Entry e = head; e != null; e = e.next)
            s.writeObject(e.data);
    }
    private void readObject(ObjectInputStream s)
            throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int numElements = s.readInt();
        // Read in all elements and insert them in list
        for (int i = 0; i < numElements; i++)
            add((String) s.readObject());
    }
    ... // Remainder omitted
}

从上述正确的序列化案例中,我们需要引出很多有正确自定义序列化的最佳实践。

transient最佳实践

案例展示了transient的典型用法。

文档最佳实践

即使是private修饰的字段(非transient),方法(readObject/writeObject),由于序列化成为导出的API,因而都需要文档进行注释。
非transient字段使用@serial标注,readObject/writeObject使用@serialData标注,正如上述案例,注明序列化保存什么内容。

同步安全性

如果类本身是线程安全的,readObject/writeObject也应该保证线程安全,并且注意锁的一致性,避免产生死锁。

序列化版本UID

不管什么形式的序列化,都需要声明一个显式的序列化版本UID,控制序列化兼容性问题。如果不是需要产生新的版本,不要改动序列化版本,否则会破坏类现有已被序列化实例的兼容性。

88. 保护性地编写readObject和readResovle

要理解这一条,首先需要了解一下,常见的两种序列化攻击形式。

两种序列化攻击

伪造数据流。
通过进行设计,你可以伪造一份字节流,破坏类原先的约束。例如,使得起始时间>结束时间,在很多时候,这显然是不合理的。但由于Java序列化机制不需要构造器,绕开了构造器,这种伪造但数据流破坏了类的约束。
来看一个案例:

// 书中给出的案例,mac无法成功运行,但一定能够构造出这种数据流
public class BogusPeriod {
    // Byte stream couldn't have come from a real Period instance!
    private static final byte[] serializedForm = {
            (byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
            0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte) 0xf8,
            0x2b, 0x4f, 0x46, (byte) 0xc0, (byte) 0xf4, 0x02, 0x00, 0x02,
            0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
            0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
            0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
            0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
            0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
            0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
            (byte) 0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
            0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte) 0xdf,
            0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
            0x77, 0x08, 0x00, 0x00, 0x00, (byte) 0xd5, 0x17, 0x69, 0x22,
            0x00, 0x78
    };

    public static void main(String[] args) {
        // 反序列化伪数据流,破坏 开始时间 < 结束时间 的约束
        Period p = (Period) deserialize(serializedForm);
        System.out.println(p);
    }

    // Returns the object with the specified serialized form
    static Object deserialize(byte[] sf) {
        try {
            return new ObjectInputStream(
                    new ByteArrayInputStream(sf)).readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }
}

用指针“偷走”私有域。
私有域本来是外部无法直接访问的,但可以在序列化类之后,附上精心设计的指针,指向内部私有域。在反序列化之后,偷偷读取这些引用,从而在外部拥有对象内部的私有域指针。

public class MutablePeriod {
    // A period instance
    public final Period period;
    // period's end field, to which we shouldn't have access
    public final Date end;

    public MutablePeriod() {
        try {
            ByteArrayOutputStream bos =
                    new ByteArrayOutputStream();
            ObjectOutputStream out =
                    new ObjectOutputStream(bos);
            // Serialize a valid Period instance
            out.writeObject(new Period(new Date(), new Date()));
            // 序列化之后,附上精心设计的引用,指向内部私有域
            byte[] ref = {0x71, 0, 0x7e, 0, 5}; 
            ref[4] = 4;
            bos.write(ref);
            // Deserialize Period and "stolen" Date references
            ObjectInputStream in = new ObjectInputStream(
                    new ByteArrayInputStream(bos.toByteArray()));
            period = (Period) in.readObject();
            end = (Date) in.readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new AssertionError(e);
        }
    }

    public static void main(String[] args) {
       MutablePeriod mp = new MutablePeriod();
        Period p = mp.period;
        System.out.println(p);
        mp.end.setYear(-24);
        System.out.println(p);
        // 输出:
        // Thu Apr 28 16:36:41 CST 2022 - Thu Apr 28 16:36:41 CST 2022
        // Thu Apr 28 16:36:41 CST 2022 - Fri Apr 28 16:36:41 CST 1876

    }
}

// 不可变类
public final class Period implements Serializable {
    // 不可变类中的可变私有域
    private Date start;
    private Date end;

    /**
     * @param start the beginning of the period
     * @param end   the end of the period; must not precede start * @throws IllegalArgumentException if start is after end
     * @throws NullPointerException if start or end is null
     */
    public Period(Date start, Date end) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if (this.start.compareTo(this.end) > 0)
            throw new IllegalArgumentException(
                    start + " after " + end);
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    public String toString() {
        return start + " - " + end;
    }

    // readObject method with defensive copying and validity checking
//    private void readObject(ObjectInputStream s)
//            throws IOException, ClassNotFoundException {
//        s.defaultReadObject();
//        // Defensively copy our mutable components
//        start = new Date(start.getTime());
//        end   = new Date(end.getTime());
//        // Check that our invariants are satisfied
//        if (start.compareTo(end) > 0)
//            throw new InvalidObjectException(start +" after "+ end);
//    }
}

保护性编码

针对上述两个攻击点,保护性编码的两个重要任务:

  1. 维持构造器约束
  2. 防御性拷贝

一种方式是通过编写readObject来维持约束,并且进行防御性拷贝,避免初始反序列化对象引用被盗用。注意防御性拷贝——即使通过指针偷走原有反序列化对象的引用,也无法修改最终反序列化对象的私有域。

public final class Period implements Serializable {
    private Date start;
    private Date end;

    /**
     * @param start the beginning of the period
     * @param end   the end of the period; must not precede start * @throws IllegalArgumentException if start is after end
     * @throws NullPointerException if start or end is null
     */
    public Period(Date start, Date end) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if (this.start.compareTo(this.end) > 0)
            throw new IllegalArgumentException(
                    start + " after " + end);
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    public String toString() {
        return start + " - " + end;
    }

    // readObject method with defensive copying and validity checking
    private void readObject(ObjectInputStream s)
            throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        // Defensively copy our mutable components
        start = new Date(start.getTime());
        end   = new Date(end.getTime());
        // Check that our invariants are satisfied
        if (start.compareTo(end) > 0)
            throw new InvalidObjectException(start +" after "+ end);
    }

}

另一种更加简单的方式,是通过readResolve方法,进行防御性拷贝。这种方式更加简洁,它直接使用了构造函数的约束检查。

public final class Period implements Serializable {
    private Date start;
    private Date end;

    /**
     * @param start the beginning of the period
     * @param end   the end of the period; must not precede start * @throws IllegalArgumentException if start is after end
     * @throws NullPointerException if start or end is null
     */
    public Period(Date start, Date end) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if (this.start.compareTo(this.end) > 0)
            throw new IllegalArgumentException(
                    start + " after " + end);
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    public String toString() {
        return start + " - " + end;
    }

    private Object readResolve() {
        System.out.println("防御性拷贝");
        return new Period(start, end);
    }
}

89. 枚举类型优于readResolve

枚举实现单例

JVM无偿提供序列化安全性。

// Enum singleton - the preferred approach
public enum Elvis {
     INSTANCE;
     private String[] favoriteSongs =
         { "Hound Dog", "Heartbreak Hotel" };
     public void printFavorites() {
         System.out.println(Arrays.toString(favoriteSongs));
     } 
}

readResolve方式


// Broken singleton - has nontransient object reference field!
public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { }
    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
    public void printFavorites() {
       System.out.println(Arrays.toString(favoriteSongs));
    }
    private Object readResolve() {
       return INSTANCE;
    } 
}

确保该类所有实例域都是瞬时的,否则容易“盗走”内部域引用,书中构造了一个例子,看起来不太合理。

90. 考虑使用序列化代理

序列化代理模式非常强大,实现非常容易,但第一次理解起来会比较复杂——需要对整个Java序列化机制有一定对了解,并且理解88条中讲到的序列化攻击,才能明白序列化代理模式为什么这么设计,以及这些设计有什么用。

public class Period implements Serializable {

    private static final long serialVersionUID = 1L;
    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {

        this.start = start;
        this.end = end;
        if (this.start.compareTo(this.end) > 0)
            throw new IllegalArgumentException(
                    start + " after " + end);
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    /**
     * 序列化外围类时,会提前调用这个方法,使用代理对象替代外围类对象,参数为外围类对象
     */
    private Object writeReplace() {
        System.out.println("writeReplace()");
        return new SerializabtionProxy(this);
    }

    /**
     * 禁止直接反序列化外围类,防止伪造流攻击
     */
    private void readObject(ObjectInputStream ois) throws InvalidObjectException {
        throw new InvalidObjectException("Proxy required!");
    }

    /**
     * 序列化代理类,他精确表示了其当前外围类对象的状态
     */
    private static class SerializabtionProxy implements Serializable {

        private static final long serialVersionUID = 1L;
        private final Date start;
        private final Date end;

        SerializabtionProxy(Period p) {

            this.start = p.start;
            this.end = p.end;
        }

        /**
         * 反序列化之后虚拟机会调用这个方法,返回外围类拷贝对象。
         * 保护性拷贝,防止“偷窃”内部域引用攻击
         */
        private Object readResolve() {

            System.out.println("readResolve()");
            // 保护性拷贝,调用构造器保证约束
            return new Period(new Date(start.getTime()), new Date(end.getTime()));
        }

    }
}

你会发现,它其实在序列化的时候悄悄替换了序列化的对象,反序列化时又偷偷换回来。

看起来复杂,其实实现很简单,只要提供

  • 静态内部嵌套类Proxy
  • 外部类提供writeObject替换为Proxy,readObject禁止反序列化
  • Proxy内部提供readResolve,还原为外部类

这种偷偷替换序列化反序列化对象,比较灵活,EnumSet就使用了类似的方式——EnumSet会根据元素多少,新建不同的子类RegularEnumSet (小于64个)、JumboEnumSet (大于64个),想象一下,如果一开始序列化RegularEnumSet,然后在附加几个元素,那么反序列化之后会是什么呢?
答案是JumboEnumSet,因为它就使用了序列化代理,有了上面的知识,就能一下子理解EnumSet的序列化代理实现。

// EnumSet's serialization proxy
private static class SerializationProxy <E extends Enum<E>>
       implements Serializable {
   // The element type of this enum set.
   private final Class<E> elementType;
   // The elements contained in this enum set.
   private final Enum<?>[] elements;
   SerializationProxy(EnumSet<E> set) {
       elementType = set.elementType;
       elements = set.toArray(new Enum<?>[0]);
   }
   private Object readResolve() {
       EnumSet<E> result = EnumSet.noneOf(elementType);
       for (Enum<?> e : elements)
           result.add((E)e);
       return result;
   }
   private static final long serialVersionUID =
       362491234563181265L;
}
举报

相关推荐

0 条评论