BinaryFormatter 位于 System.Runtime.Serialization.Formatters.Binary 命名空间,是 .NET 早期版本中处理二进制序列化的核心类型。它不依赖 XML 或 JSON 这样的文本格式,而是把对象的字段名、类型信息和字段值按照二进制布局写入流中。正因为二进制格式会记录完整的类型元数据,反序列化时 CLR 可以直接根据这些信息重新创建对象实例,不需要公开的无参构造函数,私有字段也会被恢复。这种能力在保存复杂对象状态、深拷贝或进程间传输对象时非常方便,但同时也埋下了安全隐患。

一、添加 Serializable 特性并准备可序列化类
要让 BinaryFormatter 处理一个自定义类,最基础的条件是给该类标注 [Serializable] 特性。这个特性会告诉 CLR 该类型的实例可以被序列化。如果一个类没有标注该特性,BinaryFormatter 在序列化时会抛出 SerializationException。下面定义一个联系人信息类,其中包含字符串、整数和嵌套的地址类,用来展示对象图的序列化能力。
[Serializable]
public class Address
{
public string City { get; set; }
public string Street { get; set; }
}
[Serializable]
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Address HomeAddress { get; set; }
}
这里的 Person 类内部持有一个 Address 类型的引用,BinaryFormatter 不会只保存 Person 自身,而是会沿着引用关系把 Address 对象也一起序列化。如果对象之间存在循环引用,例如父子对象互相引用,BinaryFormatter 也能维护引用标识,避免无限递归。相比 JSON 序列化器需要专门处理引用循环,这是二进制序列化器的一大特点。
另外需要注意,被标记为 [NonSerialized] 的字段会被排除在序列化之外,例如缓存数据、线程句柄或临时计算结果。被排除的字段在反序列化后会恢复为类型的默认值,而不是序列化前的值。因此如果类中有不适合写入流的字段,应当主动标记 NonSerialized,序列化完成后再通过 OnDeserialized 特性重新初始化。
二、实现序列化到文件与反序列化
BinaryFormatter 的典型用法是配合 FileStream 把对象保存为二进制文件。序列化方法创建 BinaryFormatter 实例,调用 Serialize 方法并传入流和对象引用。反序列化时调用 Deserialize 方法,将返回值强制转换为目标类型。下面给出完整代码。
using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
public static class BinaryFormatterHelper
{
public static void SaveToFile(string path, object obj)
{
BinaryFormatter formatter = new BinaryFormatter();
using (FileStream stream = new FileStream(path, FileMode.Create, FileAccess.Write))
{
formatter.Serialize(stream, obj);
}
}
public static object LoadFromFile(string path)
{
BinaryFormatter formatter = new BinaryFormatter();
using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read))
{
return formatter.Deserialize(stream);
}
}
}
调用时只需要先创建 Person 对象,然后写入文件,再读取并转换。示例路径可以是 C:\temp\person.bin,读取出来的对象虽然来自全新的内存地址,但字段内容与原始对象一致。代码中使用了 using 语句确保文件流被关闭,避免句柄泄漏。如果文件较大,可以把 FileStream 换成 MemoryStream 或者网络流,BinaryFormatter 并不关心底层流的具体类型。
这里有一个容易混淆的地方:Deserialize 返回的是 object 类型,必须手动进行类型转换。转换错误会在运行时抛出异常。另外,如果磁盘文件被篡改,反序列化过程不会校验数据完整性,它只读取数据并尝试重建对象,这就是为什么 BinaryFormatter 不适合处理不受信任的输入。
三、通过 ISerializable 接口自定义序列化过程
如果默认的字段序列化不能满足需求,可以让类实现 ISerializable 接口,手动控制哪些数据写入流以及如何恢复。实现该接口时需要添加 [Serializable] 特性,提供一个带有 SerializationInfo 和 StreamingContext 参数的构造函数,并在 GetObjectData 方法中写入数据。下面以一个账号类为例,密码字段不直接保存明文,而是保存哈希值。
[Serializable]
public class Account : ISerializable
{
public string UserName { get; set; }
public string PasswordHash { get; set; }
[NonSerialized]
private string plainPassword;
public Account() { }
protected Account(SerializationInfo info, StreamingContext context)
{
UserName = info.GetString("UserName");
PasswordHash = info.GetString("PasswordHash");
}
public void SetPassword(string password)
{
plainPassword = password;
PasswordHash = Convert.ToBase64String(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("UserName", UserName);
info.AddValue("PasswordHash", PasswordHash);
}
}
在 GetObjectData 方法中,只有 UserName 和 PasswordHash 被写入 SerializationInfo,而 plainPassword 因为标记了 [NonSerialized] 并且没有被手动写入,反序列化后仍然是 null。构造函数从 SerializationInfo 中按名称读取值,名称必须与写入时完全一致,否则会抛出 SerializationException。
这种自定义方式适合需要压缩、加密或版本兼容的场景。例如在写入前对字段做加密,在读取时做解密,或者根据 StreamingContext 判断当前是跨进程通信还是持久化存储,从而写入不同的数据。不过实现 ISerializable 也增加了维护成本,而且类派生时还需要注意调用基类的序列化构造函数,否则基类字段可能丢失。
四、使用 SerializationBinder 控制类型加载
BinaryFormatter 最大的安全风险来自类型解析。反序列化时会根据流内的程序集限定名加载类型,攻击者可以构造恶意数据,让应用尝试加载不存在的类型,或者利用已有类型触发反序列化漏洞。为了降低风险,可以自定义 SerializationBinder,只允许反序列化成指定的类型集合。下面是一个白名单绑定器示例。
using System;
using System.Runtime.Serialization;
public sealed class SafeBinder : SerializationBinder
{
private static readonly Type[] AllowedTypes =
{
typeof(Person),
typeof(Address)
};
public override Type BindToType(string assemblyName, string typeName)
{
string shortName = typeName.Split(',')[0].Trim();
foreach (Type allowed in AllowedTypes)
{
if (allowed.FullName == shortName)
{
return allowed;
}
}
throw new SerializationException("禁止反序列化未知类型: " + typeName);
}
}
使用时需要把绑定器赋给 BinaryFormatter.Binder 属性,再调用 Deserialize。这样即使流中包含其他类型的名称,也会因为不在白名单中而被拒绝。白名单方式比黑名单更可靠,因为攻击者可以不断寻找新的可利用类型,而白名单从源头限制了加载范围。
不过 SerializationBinder 只能降低风险,并不能完全消除 BinaryFormatter 的不安全性。因为即使是合法类型,其字段数据仍然可能被篡改为非法状态,或者触发对象构造过程中的副作用。微软官方从 .NET 5 开始将 BinaryFormatter 标记为 Obsolete,并在 .NET 8 及后续版本中默认禁止在 ASP.NET Core 等应用中使用。因此生产环境里应该尽量避免继续使用 BinaryFormatter,而不是仅依赖绑定器做防护。
五、替代方案与迁移建议
对于新项目,推荐使用 System.Text.Json 进行 JSON 序列化。它是高性能的文本序列化器,不会在反序列化时根据类型名实例化任意对象,默认只操作目标类型的公共属性。如果必须保持二进制格式且追求更小体积,可以考虑 Protobuf、MessagePack 等第三方库。这些库通常基于契约,不包含完整的 .NET 类型元数据,因此安全性要好得多。
如果旧系统中已有大量 BinaryFormatter 生成的二进制文件,迁移时需要设计一个过渡方案。可以先保留旧的读取逻辑,将对象转换为 DTO 后写入新的 JSON 或 Protobuf 格式。对于无法重新生成的旧数据,可以继续在受限的内网环境、受信任的输入源中使用 BinaryFormatter,但必须清楚其风险并设置白名单绑定器。迁移完成后应删除对 System.Runtime.Serialization.Formatters.Binary 的依赖。
简单对比一下:BinaryFormatter 保存的是 .NET 类型体系,跨语言和跨版本兼容性差;System.Text.Json 保存的是可读文本,跨平台兼容性好;Protobuf 二进制格式紧凑,适合网络传输。三者不存在绝对的优劣,关键是根据数据边界和信任模型选择。只要输入数据可能来自不受信任的客户端,就不应该使用 BinaryFormatter。

