原创

给 Java 原生反序列化加过滤器:类型白名单还不够

原生反序列化会按输入描述恢复对象图,而不是只把几项字段填进一个普通DTO。系统如果必须读取既有Java序列化数据,至少应该在对象恢复开始以前,明确允许的类型与对象图预算。

ObjectInputFilter能检查类型、深度、引用数量和数组长度等信息。但加一份过滤器不等于任意外部序列化输入就值得接收。类型内部的反序列化回调、字段合法性和输入体积仍有各自的边界。新建对外协议时,通常更容易控制的是显式数据格式与固定模型,而不是开放对象图。

这次固定JDK 17,构造一份小Packet、一个不允许的类型、一条过深链和一份过长数组。所有数据都由本地测试创建,不使用攻击链,不连接网络,也不改进程全局过滤配置。

接收方的规则比 Serializable 更窄

允许恢复的对象只有Packet和它的int数组。Packet里有label、values与可选next,用来演示类型与深度;接收以后还要求根对象确实是Packet,label非空且长度受限,values存在。

对象图最大深度4、数组最多8项、引用数量最多30项,序列化字节最多4096。它们只是样本协议,不是生产推荐常数。真正的上限应由正常数据模型和并发预算推导,不能把样本数字抄到一个完全不同的对象图上。

特别要把字节限额放在ObjectInputStream外面。本例输入已经是一份有界byte数组,所以在创建ObjectInputStream之前检查其长度。若输入来自网络或文件流,应在更外层完成有界接收,或者使用严格限额包装器,不能等readObject完成以后才看字节数。

完整实验

一条短环,能绕过“深度很浅”的直觉

序列化会保留对象引用关系。一个Packet的next指回自身,输入不需要无限嵌套,也能在恢复以后形成环。对象图过滤器限制的是恢复过程中观察到的预算,不能替业务判断这是不是一条合法的单向链。

如果接收以后直接用for沿next遍历到null,环会让这段普通校验永远不结束。因此代码用基于对象身份的集合记录已经访问过的节点,第二次遇到同一对象就以CYCLE拒绝。这里不能用可能被业务重写的equals判断节点身份,否则不同节点也可能被错当成环。

这项失败测试很小,却说明了过滤器与模型校验的分工。若业务模型本来允许环,就不能简单拒绝,而应让所有遍历都有访问集合和预算;若业务只允许树或链,就应明确验证这项不变量。对象能被恢复,并不代表任何后续算法都能安全遍历它。

保存为 FilteredObjects.java,执行 java FilteredObjects.java。程序只需完整JDK 17。

import java.io.*;
import java.util.*;
public class FilteredObjects {
    static final class Packet implements Serializable {
        private static final long serialVersionUID = 1L;
        String label; int[] values; Packet next;
        Packet(String label, int[] values) { this.label = label; this.values = values; }
    }
    static byte[] encode(Object value) throws IOException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { out.writeObject(value); }
        return bytes.toByteArray();
    }
    static Packet decode(byte[] bytes) throws IOException, ClassNotFoundException {
        if (bytes.length > 4096) throw new IOException("BYTE_LIMIT");
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            in.setObjectInputFilter(info -> {
                if (info.depth() > 4 || info.references() > 30 || info.arrayLength() > 8)
                    return ObjectInputFilter.Status.REJECTED;
                Class<?> type = info.serialClass();
                if (type == null) return ObjectInputFilter.Status.UNDECIDED;
                return type == Packet.class || type == int[].class
                        ? ObjectInputFilter.Status.ALLOWED : ObjectInputFilter.Status.REJECTED;
            });
            Object value = in.readObject();
            if (!(value instanceof Packet packet)) throw new IOException("ROOT_TYPE");
            Set<Packet> seen = Collections.newSetFromMap(new IdentityHashMap<>());
            for (Packet p = packet; p != null; p = p.next) {
                if (!seen.add(p)) throw new IOException("CYCLE");
                if (p.label == null || p.label.isBlank() || p.label.length() > 32 || p.values == null)
                    throw new IOException("INVALID_FIELDS");
            }
            return packet;
        }
    }
    static void reject(String name, Object value) throws Exception {
        try { decode(encode(value)); throw new AssertionError("expected rejection"); }
        catch (InvalidClassException expected) { System.out.println(name + "=FILTERED"); }
        catch (IOException expected) { System.out.println(name + "=" + expected.getMessage()); }
    }
    public static void main(String[] args) throws Exception {
        Packet good = decode(encode(new Packet("ok", new int[]{1, 2})));
        System.out.println("valid=" + good.label + ":" + good.values.length);
        reject("type", new Date(0));
        reject("array", new Packet("many", new int[9]));
        Packet head = new Packet("0", new int[0]), tail = head;
        for (int i = 1; i < 7; i++) { tail.next = new Packet("n", new int[0]); tail = tail.next; }
        reject("depth", head);
        Packet cycle = new Packet("cycle", new int[0]); cycle.next = cycle;
        reject("cycle", cycle);
        reject("fields", new Packet("", new int[0]));
        reject("bytes", "x".repeat(5000));
    }
}

输出:

valid=ok:2
type=FILTERED
array=FILTERED
depth=FILTERED
cycle=CYCLE
fields=INVALID_FIELDS
bytes=BYTE_LIMIT

合法对象能恢复;Date不在白名单,过长数组与过深链也被拒绝。空label虽然没有违反对象图限制,仍在业务验证时失败。过大字符串则在进入反序列化之前被字节预算拒绝。这几条失败路径刻意分开,因为它们证明的不是同一件事。

过滤器必须在恢复对象之前安装

代码先构造ObjectInputStream,设置本流过滤器,再调用readObject。若先读取再过滤,类型恢复和可能的回调已经发生,事后检查没有同样的防护位置。

过滤器可能收到serialClass为空的检查,此时仍有深度、引用数量等信息可判断。不能因为没有类型就直接跳过所有预算。对具体类型采取精确白名单,未在列表中的类明确REJECTED;这里不以UNDECIDED表达默认允许未知类型。

数组的类型判断也要精确。允许int数组不代表允许任意Object数组,更不代表允许数组元素来自所有类。模型新增字段、换集合类型或者增加父类以后,都需要重新核对对象图和过滤规则。一次便利性的包通配符,可能让允许范围远大于实际DTO所需。

本例不修改全局过滤器,也不说明全局策略不存在。生产进程可能已有启动配置或过滤器工厂,实际生效策略应一起核对。将局部过滤器接入已有应用时,要验证它与现有机制的组合,不要为了样本跑通而关闭进程层限制。

字符串与业务字段需要另外检查

原生序列化对某些字符串和基本类型的处理,并不等同于对每个普通对象都回调一遍过滤器。不能期待只凭允许类型和streamBytes判断,就精确限制每个字符串字段。在本例里,外层字节上限先控制整份输入,恢复以后再核对label的业务长度。

Packet的构造器在创建测试数据时会调用,接收端却不能依赖它重新执行全部验证。默认反序列化有自己的对象恢复过程,持久化字段可以带回不满足当前业务规则的值。恢复成功与对象可以进入业务系统,是两个需要分别断言的结果。

根对象类型同样不能省。过滤器的目标是约束整个恢复过程;最后返回给业务的对象仍应通过明确类型检查,不能随手强转然后把ClassCastException当成协议错误。若模型要求只有一份根对象,还要另行约定剩余字节、连续对象与版本头,不应默认接受后面任意追加内容。

失败以后,原流不适合继续猜着读

过滤拒绝通常表现为InvalidClassException;不合法序列化格式、截断与业务验证又有其他错误。对外可以映射成稳定输入错误,但内部应保留分类,方便区分坏数据、版本不兼容和预算过小。

不要在一个已经拒绝或恢复失败的ObjectInputStream上继续readObject,希望跳过坏对象读下一份。流状态可能已经部分消费,协议也未必有可靠恢复边界。本例每份输入独立创建并关闭一条流,让失败作用域明确;批次协议若要逐项容错,应从最外层就采用独立有界消息。

已有系统升级白名单时,先对历史样本做只读回归。某个正常字段实际曾存过不同实现类,突然精确限制以后可能无法读取。应理解是否真有兼容需求,再选择数据迁移或小范围允许,不要因为一条历史记录失败就开放整个java.util包。

测试别只证明“一个坏类型被拦住了”

可复现测试至少要包含正常最小图、正常最大图、刚超过数组上限、刚超过深度、未知类型、错误根对象、截断输入以及业务字段无效。相同字节量可以对应不同对象数量,较浅图也可以含很长数组,每个预算都应单独触发。

还要明确输入来源是否可信、是否有完整性验证,以及序列化数据中的秘密怎样处理。过滤器不提供认证、加密或防篡改,它也不替对象图中的允许类证明所有回调都没有副作用。白名单需要足够小,并经过实际类型行为审查。

如果新功能根本不需要恢复Java对象身份、引用共享与继承结构,就别把Serializable选作默认外部格式。显式字段协议通常让版本和输入验证更直接。保留原生序列化的场景,也应该让允许类型、数据预算、恢复版本和失败清理都能被读者逐项核对,而不是以“已配置过滤器”代替完整接收规则。

正文到此结束
评论插件初始化中...
Loading...