不安全的反序列化是当前Web应用中最危险的漏洞类型之一,攻击者通过构造恶意序列化数据包,绕过正常的类型校验机制,直接在服务端执行任意代码。解决这个问题的核心思路只有三条:第一,尽量避免使用原生反序列化功能;第二,必须使用时加上严格的白名单类型校验;第三,结合签名验证和沙箱隔离做纵深防御。下面我会把每一条都拆开讲透,包括具体的代码实现和行业最佳实践。

什么是不安全的反序列化

反序列化就是把二进制或文本格式的数据还原成程序对象的过程。Java、PHP、Python、.NET这些语言都有自己的反序列化机制。问题出在哪?当程序直接反序列化用户输入的数据,而没有对数据类型做任何限制时,攻击者就可以把一个恶意对象塞进去。这个对象在被还原的瞬间,其内部的魔术方法(比如Java的readObject、PHP的__wakeup)会自动触发,导致远程代码执行、SQL注入、文件读取等一系列攻击。

举个最典型的例子:Java的Apache Commons Collections库曾经爆出过严重的反序列化漏洞(CVE-2015-6420),攻击者只需要发送一段精心构造的序列化字节流,就能让服务器执行任意命令。这类漏洞之所以危险,是因为它不需要任何前置条件,不需要登录,不需要权限,只要接口存在反序列化操作就能利用。

反序列化漏洞为什么能绕过类型校验

很多开发者以为加了类型判断就安全了,比如在反序列化之前检查一下数据格式是不是JSON、是不是XML。但实际上,攻击者可以利用多态和继承链来绕过这种简单校验。比如你的代码只允许反序列化User类,但User类继承了一个父类,父类又实现了某个危险接口,攻击者构造的恶意对象恰好是这个继承链上的某个子类实例,你的白名单如果只写了User而没写全继承链,就会被绕过。

更高级的绕过手法是利用" gadget chain "(利用链)。攻击者不需要直接执行代码,而是通过一系列已有类的组合调用,像搭积木一样拼出一条攻击路径。这条路径上的每一个类单独看都是安全的,但组合在一起就能完成危险操作。这就是为什么单纯的类型名称校验远远不够。

核心防护方案一:使用安全的序列化格式替代

最根本的解决办法是不用原生反序列化。如果业务场景允许,优先选择JSON、Protobuf、MessagePack这类只做数据还原、不会触发代码执行的格式。JSON反序列化只是把字段映射到对象属性,不会调用任何魔术方法,天然安全。

// 安全做法:使用JSON代替Java原生序列化
import com.fasterxml.jackson.databind.ObjectMapper;

ObjectMapper mapper = new ObjectMapper();
// 明确指定只允许反序列化的类型
mapper.activateDefaultTyping(
    mapper.getPolymorphicTypeValidator(),
    ObjectMapper.DefaultTyping.NON_FINAL
);
User user = mapper.readValue(jsonInput, User.class);

如果必须使用Java原生序列化(比如某些老系统迁移),那就必须在ObjectInputStream层面做限制,这是下一个方案要讲的。

核心防护方案二:ObjectInputStream的白名单过滤

Java提供了ObjectInputFilter机制,可以在反序列化之前对类名进行过滤。从Java 9开始,你可以设置一个全局过滤器,也可以针对单个ObjectInputStream实例设置。

// 设置全局反序列化过滤器(Java 9+)
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.example.safe.User;com.example.safe.Order;!*"
);
ObjectInputFilter.Config.setSerialFilter(filter);

// 针对单个流设置过滤器
ObjectInputStream ois = new ObjectInputStream(inputStream);
ois.setObjectInputFilter(filter);
Object obj = ois.readObject();

这个过滤器的语法是:允许的类用分号分隔,最后加!*表示拒绝其他所有类。注意,白名单一定要写全,包括你的业务类、它们的父类、接口实现类,甚至内部类。少写一个都可能被利用链钻空子。

核心防护方案三:自定义类型校验器实现深度检查

白名单过滤只能控制类名,但无法控制对象内部的字段值。比如你允许反序列化User类,但User里面有个admin字段,攻击者把这个字段改成true,就能提权。所以需要在反序列化完成后再做一层字段级别的校验。

// 自定义反序列化后的校验逻辑
public class SafeDeserializer {
    
    public static <T> T safeDeserialize(byte[] data, Class<T> clazz) 
            throws IOException, ClassNotFoundException {
        
        // 第一步:白名单检查
        if (!AllowedClasses.isAllowed(clazz.getName())) {
            throw new SecurityException("Class not allowed: " + clazz.getName());
        }
        
        // 第二步:反序列化
        ByteArrayInputStream bis = new ByteArrayInputStream(data);
        ObjectInputStream ois = new ObjectInputStream(bis);
        ois.setObjectInputFilter(AllowedClasses.getFilter());
        T obj = (T) ois.readObject();
        
        // 第三步:字段级校验
        validateFields(obj);
        
        return obj;
    }
    
    private static void validateFields(Object obj) {
        // 检查敏感字段是否被篡改
        // 检查字段值是否在合理范围内
        // 检查引用完整性,防止循环引用导致的DoS
    }
}

PHP环境下的反序列化防护要点

PHP的unserialize()函数同样危险,而且PHP的对象模型比Java更灵活,魔术方法更多(__wakeup、__destruct、__toString、__call等),攻击面更广。PHP 7之后引入了allowed_classes选项,但很多老代码还在用无限制的unserialize。

// PHP安全反序列化写法
$data = unserialize($input, ['allowed_classes' => ['User', 'Order']]);

// 或者干脆不用unserialize,改用json_decode
$obj = json_decode($input, true);

PHP还有一个容易被忽略的问题:序列化数据中可以包含对象引用(R:2;),攻击者可以通过构造大量引用制造内存耗尽攻击。所以在反序列化之前,还要限制数据大小和嵌套深度。

.NET环境下的反序列化安全实践

.NET的BinaryFormatter和NetDataContractSerializer在较新版本中已经被标记为危险和过时。微软官方推荐使用System.Text.Json或者XmlSerializer。如果必须用BinaryFormatter,需要自定义SerializationBinder来限制类型。

// .NET 自定义Binder限制反序列化类型
public class SafeBinder : SerializationBinder {
    public override Type BindToType(string assemblyName, string typeName) {
        Type type = Type.GetType(typeName);
        if (type == null) return null;
        
        // 只允许特定命名空间下的类型
        if (!type.Namespace.StartsWith("MyApp.Models.")) {
            throw new SecurityException("Type not allowed: " + typeName);
        }
        return type;
    }
}

// 使用方式
BinaryFormatter formatter = new BinaryFormatter();
formatter.Binder = new SafeBinder();

纵深防御:签名验证与完整性校验

除了类型校验,还应该对序列化数据本身做完整性保护。最简单的方法是加HMAC签名:序列化时用密钥对数据计算签名,反序列化时先验证签名再还原。这样即使攻击者截获了数据包,也无法篡改内容。

// HMAC签名保护序列化数据
public static byte[] serializeWithSignature(Object obj, SecretKey key) 
        throws Exception {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(baos);
    oos.writeObject(obj);
    oos.flush();
    byte[] data = baos.toByteArray();
    
    // 计算HMAC
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(key);
    byte[] signature = mac.doFinal(data);
    
    // 拼接:数据 + 签名
    ByteArrayOutputStream result = new ByteArrayOutputStream();
    result.write(data);
    result.write(signature);
    return result.toByteArray();
}

运行时监控与入侵检测

静态防护再好也有被绕过的可能,所以必须配合运行时监控。重点监控以下行为:反序列化操作的频率异常、反序列化的数据体积突增、反序列化后出现非常规的系统调用。可以用RASP(运行时应用自我保护)工具在应用内部埋点,一旦检测到可疑的反序列化行为就阻断并告警。

另外,定期做依赖组件扫描也很关键。很多反序列化漏洞不是你自己代码的问题,而是你引用的第三方库自带的。比如Fastjson、Shiro、Spring框架历史上都出过反序列化相关的安全问题。保持依赖更新、及时打补丁,是最基础也是最容易被忽视的防线。

总结与行动建议

不安全的反序列化本质上是"信任了不该信任的输入"。防护思路归纳起来就是:能不用就不用,必须用就限制类型,限制完类型还要校验字段,校验完字段还要加签名,签名之外还要做监控。没有单一银弹,只有层层叠加才能把风险降到可接受的水平。对于正在开发新系统的团队,强烈建议从架构层面就规避原生反序列化;对于维护老系统的团队,至少把ObjectInputFilter和白名单机制先加上,这是投入产出比最高的第一步。