反序列化漏洞的防护,真正落到代码层面时,最棘手的问题往往不是“要不要做过滤”,而是“怎么过滤才不会被绕过”。黑名单机制在反序列化场景下几乎形同虚设,攻击链的构造往往只需要一个看似无害的类就能完成,而开发者不可能穷举所有危险类。类白名单加载控制是目前公认最有效的阻断方案,但很多人实现的白名单只是做了一半,留下了大量可被利用的缺口。问题的核心在于,Java、.NET、PHP等语言的反序列化机制在底层会递归解析对象图,白名单必须覆盖整个反序列化链路,而不仅仅是入口类。
为什么黑名单在反序列化场景下必然失败黑名单的逻辑是枚举已知的危险类,在反序列化时拦截它们。这个思路在反序列化漏洞面前极其脆弱,原因有三。第一,利用链中的关键类本身可能是JDK或框架自带的合法类,比如Commons Collections中的Transformer类、Fastjson中的TemplatesImpl,这些类在正常业务中也可能被使用,直接拉黑会导致业务异常。第二,新的利用链不断被发现,黑名单永远滞后于攻击者的研究进度。第三,攻击者可以通过构造嵌套对象、利用动态代理、甚至结合JNDI注入等方式绕过类名的字符串匹配。很多WAF和RASP产品还在用黑名单思路做反序列化防护,实测中绕过率非常高。
类白名单的核心原则:只允许预期内的类进入反序列化流程白名单的逻辑正好相反,它不关心哪些类是危险的,只关心哪些类是业务确实需要的。反序列化时,凡是白名单之外的类一律拒绝加载。这个思路从根源上切断了利用链的构造可能,因为无论攻击者如何组合,最终都需要让反序列化器加载一个白名单之外的类来触发漏洞。白名单方案要落地,必须解决三个实际问题:白名单怎么定、在哪个环节拦截、如何保证递归覆盖。
白名单的粒度控制:包级别、类级别、属性级别最粗糙的白名单是包级别的,比如只允许com.mycompany.dto包下的类被反序列化。这种方案在简单场景下有效,但一旦业务需要反序列化JDK自带的集合类如HashMap、ArrayList,就必须把java.util也加入白名单,这就开了一个大口子。攻击者完全可以利用java.util包下的类作为入口,结合其他机制构造攻击。更合理的做法是类级别白名单,精确到具体类名。但即使做到类级别,仍然不够,因为一个类的属性如果本身是复杂对象,反序列化时会递归加载属性类型的类。所以真正硬核的白名单控制必须做到属性级别,即不仅要控制哪些类可以被加载,还要控制这些类的哪些属性可以被反序列化,属性的类型又必须再次经过白名单校验。
Java原生反序列化的白名单实现:ObjectInputFilter的深入用法Java在JDK 9之后提供了ObjectInputFilter接口,可以精确控制反序列化过程中的类加载。很多开发者只用了它的基础功能,设置一个简单的类名白名单就完事了。实际上ObjectInputFilter支持递归深度控制、数组长度限制、以及基于调用栈的上下文判断。一个真正生产可用的白名单过滤器应该这样实现:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.mycompany.dto.*;java.util.HashMap;java.util.ArrayList;!*"
);
ObjectInputFilter.Config.setSerialFilter(filter);
上面的写法中,com.mycompany.dto.*允许该包下所有类,java.util.HashMap和java.util.ArrayList是业务需要的集合类,最后的!*表示拒绝其他所有类。但这个配置仍然有隐患,HashMap和ArrayList的泛型在运行时会被擦除,攻击者如果能在HashMap中放入恶意对象,反序列化时依然会触发危险类的加载。更严格的做法是自定义ObjectInputFilter,在checkInput方法中根据当前上下文动态判断:
ObjectInputFilter customFilter = filterInfo -> {
Class> clazz = filterInfo.serialClass();
if (clazz != null) {
String className = clazz.getName();
if (className.startsWith("com.mycompany.dto.")) {
return ObjectInputFilter.Status.ALLOWED;
}
if (className.equals("java.util.HashMap") ||
className.equals("java.util.ArrayList")) {
return ObjectInputFilter.Status.ALLOWED;
}
}
return ObjectInputFilter.Status.REJECTED;
};
即使这样,递归深度也需要限制。攻击者可以构造深层嵌套的对象导致栈溢出或拒绝服务,所以filterInfo.depth()的值必须检查,通常建议递归深度不超过20层。数组长度同样需要限制,filterInfo.arrayLength()在遇到数组类型时会被调用,建议设置上限比如10000。很多人在实现白名单时完全忽略了这两个参数,导致即使类被限制了,仍然存在DoS风险。
Jackson和Fastjson的类白名单机制差异Java生态中,JSON反序列化库的使用频率远高于原生序列化。Jackson和Fastjson都有自己的类白名单机制,但实现思路和坑点完全不同。Jackson通过TypeResolverBuilder或者更底层的PolymorphicTypeValidator来控制多态反序列化。从Jackson 2.10开始,官方推荐使用BasicPolymorphicTypeValidator来构建白名单:
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(MyBaseClass.class)
.allowIfSubType("com.mycompany.dto.")
.build();
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);
这里的关键是allowIfBaseType和allowIfSubType的组合。只允许继承自MyBaseClass的类,并且这些类必须在com.mycompany.dto包下。双重约束比单一白名单安全得多。但很多开发者偷懒,直接用了activateDefaultTyping而不设置ptv,等于完全放开了多态反序列化,这就是为什么Fastjson和Jackson的漏洞这么多年还层出不穷。
Fastjson的情况更复杂。Fastjson 1.2.x版本中,AutoType功能是默认开启的,虽然后续版本加入了黑名单,但前面说过黑名单根本防不住。Fastjson 1.2.68之后引入了safeMode,开启后完全禁用AutoType,这是最彻底的方案。但如果业务确实需要AutoType,就必须配置autoTypeAccept白名单。Fastjson的白名单配置通过ParserConfig实现:
ParserConfig config = new ParserConfig();
config.addAccept("com.mycompany.dto.");
config.setSafeMode(false);
String jsonStr = "{\"@type\":\"com.mycompany.dto.User\"}";
User user = JSON.parseObject(jsonStr, User.class, config);
注意,即使配置了addAccept,Fastjson在解析@type字段时,如果目标类不在白名单中,会直接抛出异常。这个机制比Jackson更严格,但Fastjson的AutoType功能本身设计就过于灵活,建议在非必要场景下直接开启safeMode,一劳永逸。
.NET环境下的反序列化白名单控制.NET的反序列化漏洞同样严重,尤其是BinaryFormatter和LosFormatter这类序列化器。微软从.NET 5开始引入了SerializationBinder的白名单机制,但在此之前,很多老项目还在用危险的BinaryFormatter。对于.NET Framework 4.x的项目,可以通过自定义SerializationBinder来实现类白名单:
public class SafeSerializationBinder : SerializationBinder
{
private static readonly HashSet AllowedTypes = new HashSet
{
"MyApp.DTO.UserInfo",
"MyApp.DTO.OrderInfo",
"System.Collections.Generic.List`1[[MyApp.DTO.UserInfo, MyApp]]"
};
public override Type BindToType(string assemblyName, string typeName)
{
if (AllowedTypes.Contains(typeName))
{
return Type.GetType($"{typeName}, {assemblyName}");
}
throw new SerializationException($"Type {typeName} is not allowed");
}
}
这里有一个很容易被忽略的细节:泛型类型的白名单处理。List<UserInfo>在反序列化时的类型名是System.Collections.Generic.List"1[[MyApp.DTO.UserInfo]],如果只加了UserInfo而没加List的泛型版本,反序列化包含集合的对象时会失败。更稳妥的做法是维护一个允许的程序集列表,然后对类型名做前缀匹配,但同样要注意不能放得太宽。.NET Core 3.0之后,BinaryFormatter被标记为废弃,微软推荐使用System.Text.Json或者XmlSerializer并配合安全的配置。如果必须用BinaryFormatter,一定要在全局配置中设置Binder,不要给攻击者留下任何无Binder的入口点。
PHP反序列化的类白名单实现与局限性PHP的反序列化漏洞利用链通常依赖于魔术方法如__wakeup、__destruct、__toString等。PHP语言本身没有内置的类白名单机制,但可以通过自定义反序列化逻辑来实现。一种常见做法是重写__wakeup方法,在其中检查当前类名是否在允许列表中。但这种方法治标不治本,因为攻击者如果控制了序列化字符串,完全可以不触发__wakeup,或者利用其他魔术方法。更可靠的做法是在反序列化之前,先用正则表达式提取序列化字符串中的所有类名,然后逐一校验:
function isSerializedDataSafe($data, array $allowedClasses) {
$classes = [];
preg_match_all('/O:\d+:"([^"]+)"/', $data, $matches);
if (!empty($matches[1])) {
foreach ($matches[1] as $className) {
if (!in_array($className, $allowedClasses, true)) {
return false;
}
}
}
return true;
}
$allowed = ['App\\DTO\\User', 'App\\DTO\\Order'];
if (isSerializedDataSafe($userInput, $allowed)) {
$obj = unserialize($userInput);
} else {
throw new \Exception('Unsafe serialized data');
}
这个方案通过正则提取所有O:长度:"类名"的模式,在反序列化之前做白名单校验。但它有两个局限:第一,PHP序列化格式有多种变体,自定义序列化器可能不遵循标准格式;第二,如果业务需要反序列化PHP内置类如DateTime、ArrayObject等,白名单会变得很宽,攻击面随之增大。所以PHP环境下,最根本的建议是尽量避免使用unserialize函数处理用户输入,改用JSON等安全格式。如果非用不可,类白名单加上签名校验双重防护是底线。
白名单方案的盲区:属性注入与逻辑漏洞即使类白名单做得再严格,仍然存在一个容易被忽视的攻击面:白名单内的类本身的属性如果被恶意赋值,可能导致业务逻辑漏洞。比如一个User类有isAdmin属性,攻击者如果能在序列化数据中设置isAdmin为true,反序列化后就会得到一个管理员权限的用户对象。这种攻击不依赖任何危险类,完全在白名单允许范围内。防护方法是在反序列化完成后,对关键属性做二次校验,或者在反序列化过程中使用只读属性、构造函数赋值等不可变对象模式。Java 14引入的Record类型就是很好的实践,Record对象一旦创建,属性不可修改,天然免疫属性注入。在架构设计层面,应该严格区分外部输入的反序列化对象和内部领域对象,反序列化出的只能是DTO,然后通过工厂方法或Builder模式转换为领域对象,在转换过程中做合法性校验。
生产环境的白名单管理策略白名单不是一次性配置完就高枕无忧的,它需要持续维护。业务迭代会引入新的DTO类,白名单必须同步更新。建议将白名单配置外部化,放在配置中心或数据库中,支持动态加载。同时,在反序列化入口处记录所有被拒绝的类加载请求,这些日志是发现攻击行为和新业务需求的双重信号。如果某个类名频繁出现在拒绝日志中,要么是攻击者在探测,要么是业务方新增了类但忘了更新白名单。另外,白名单的测试用例必须覆盖所有业务场景,包括正常对象的序列化往返、集合类型的嵌套、继承结构的处理、以及null值和空集合的边界情况。很多线上故障就是因为白名单配置更新后,某个边缘场景的反序列化失败导致的。
类白名单加载控制是反序列化漏洞防护的基石,但它不是银弹。正确的做法是以白名单为核心,配合递归深度限制、数组长度限制、属性校验、签名机制、以及最小权限原则,构建多层防护。任何一层单独拿出来都不够,但组合起来可以极大地压缩攻击面,让反序列化漏洞从高危变为理论上可被利用但实际极难成功。
