ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#反序列化漏洞深度解析:从攻击原理到防御实战

C#反序列化漏洞深度解析:从攻击原理到防御实战

在C#的进阶路上,反序列化漏洞是个绕不开的话题。它不是那种只会在老旧框架里出现的古董问题,而是直到今天,仍然在不少线上项目里反复被触发。简单说,反序列化是把一段外部传入的字节流还原成对象;如果这个还原过程没有限制类型、没有校验来源,就等于把服务器的代码执行权半推半就地交到了攻击者手里。这篇文章适合两类人:一类是正在把系统跑在生产环境、被各种诡异报错折磨的开发者,另一类是刚接触.NET安全、想系统梳理这块知识的新手。我会从原理讲到攻击入口,再给出一套能直接抄作业的防御方案和排查清单。

1. 反序列化漏洞为什么总被C#开发者忽略

1.1 先搞懂序列化和反序列化到底干了什么

序列化,本质上是把“内存里的对象”变成“可以传输或存储的字节流”。反序列化则是反向操作:把字节流还原成对象。这套机制在.NET里被广泛使用,比如把用户会话状态存到Redis、把一个复杂的订单对象放进消息队列、把配置快照写到磁盘缓存,甚至在进程间通信时传递参数。

很多开发者对序列化的理解只停留在“方便”二字上,所以很少有人会去追问一个关键问题:当程序执行反序列化时,它本质上是在替调用方一段一段地“执行”数据里所包含的类型构造逻辑。普通数据型对象还原当然没问题,可一旦数据流里被攻击者藏入了危险类型的描述信息,程序就会按照数据流里的指令,自动创建这类对象、调用相应的属性或方法。这和SQL注入中“把用户输入当SQL代码执行”是同一个逻辑:数据与代码的边界没有守好。

我见过不少团队,数据库访问层、日志框架、GitHub Actions都做得极其规范,唯独各种序列化配置还是七八年前的老写法。原因也不难理解:序列化不太容易立刻暴露问题,不像SQL注入那样一行报错就糊脸。它平时安安静静,一旦被踩中就是整个进程被打穿。这也是我想先把基础讲明白的原因——你觉得它远,其实它就在你的缓存和消息队列中间。

1.2 漏洞的底层逻辑:信任边界被拆穿

反序列化漏洞之所以难防,核心在于它攻击的并不是某个具体函数,而是“你对外部数据过于信任”这个假设。想象一个场景:你收到一个快递包裹,正常情况你只会打开自己订的那箱牛奶。但如果快递单上被人手写了一行字“同时拆开箱子底部的夹层”,而你完整照做了,那就等于接受了对方附加的指令。

具体到.NET里,二进制序列化协议中会携带类型名、程序集名等元数据。BinaryFormatter在反序列化时看到某条数据说“请创建类型A”,它不会判断这个类型是否安全,而是直接创建。攻击者只要找到一个危险类型,该类型的构造方法、属性setter或方法在触发时能够执行系统命令、读写文件或发起网络请求,攻击链就成立了。

这里有个关键点:漏洞利用不一定需要攻击者知道你的全部源码。很多公开的安全研究已经整理出大量在.NET环境中可以被反复使用的通用类型链,攻击者只需要把一个现成的Payload序列化成字节流,再投递到你的接口或队列里,剩下的交给你的进程完成。这也是为什么“我们只接收内部数据”“我们封了外网”这类说辞完全站不住脚——内部系统的权限往往更大,攻击面也更宽松。

1.3 一次事故复盘:从日志异常到整台机器失守

三年前我参与过一个内部系统的应急演练,模拟场景很简单:外部接口接收一段Base64编码的参数,后端为了省事直接用BinaryFormatter还原成一个临时对象。团队当时的想法是“这个接口只有内部调用,而且参数有签名校验”,所以没有做类型限制。

演练过程不到五分钟就结束了。攻击方构造了一个非常普通的Payload,里面套了两层嵌套类型,反序列化后触发了一个能够创建外部进程的危险类型。服务器上直接出现了一个新的监听端口,CPU开始出现不规则波动。更可怕的是,全程没有触发任何业务告警——因为CPU的确只涨了不到10%,日志里也只有一条毫不起眼的反序列化成功记录。

那之后我才真正意识到:反序列化漏洞的危害不是“多了一条异常日志”,而是完整的远程代码执行。攻击者可以下来任意工具、读取内存中的密钥、横向移动,甚至删掉你所有备份。它属于那种“平时看不见、一看见就是灾难”的漏洞类型。我们后来重新排查整个系统,发现类似的危险调用点一共有七处,全部是历史原因留下的。

2. C#生态里的高危入口都在哪儿

2.1 细数老牌序列化器的“危险基因”

在.NET生态里,有几类序列化器的风险指数是明显偏高的,我根据自己的排查经验,把它们按危险程度列出来:

序列化器主要用途风险特征结论
BinaryFormatter进程状态、远程调用、缓存反序列化时不做类型限制,直接实例化强烈建议禁用
ObjectStateFormatter视图状态、会话状态存储类型信息完全可控,历史漏洞多高风险的旧组件
NetDataContractSerializerWCF某些扩展场景类型信息内嵌在数据里,可被伪造非必要不使用
SoapFormatter旧版SOAP场景类型限制弱,且协议过时建议迁移
Json.NET的TypeNameHandling多态JSON序列化允许数据流指定具体类型必须配合白名单

如果你去看那些安全补丁记录,会发现绝大多数被提到的利用链,瞄准的都是这些“会主动读取类型信息”的序列化机制。原因在于:安全序列化方案在设计上会把类型信息与业务数据分离,或者直接把类型列表锁死;而不安全的方案为了灵活,把类型选择权交给了数据流本身,攻击者自然就能在里面做文章。

BinaryFormatter尤其值得多说一句。从.NET 5开始,官方已经把它标记为过时,后续版本甚至默认阻止反序列化操作。但还是有很多老项目里开着历史遗留的调用点。我在一些既有的系统里甚至看到过把BinaryFormatter封装成公共工具类、全项目到处调用的写法,这等于给所有业务都埋了一颗雷。

2.2 Json.NET 的类型自动绑定陷阱

Json.NET本身是一个功能强大的JSON库,绝大多数场景下它是安全的。问题几乎都出在一个配置项上:TypeNameHandling。这个配置的作用,是让JSON字符串里可以包含.NET类型信息,从而支持多态反序列化。听起来很方便,但只要你把它的值设置成All或者Objects,攻击者就可以在JSON里写一段“请把这个字段还原成某个特殊类型”。

这类攻击的可怕之处在于,JSON文本是可读的,攻击者可以直接改、直接试、直接探测你的服务端类型环境。不需要盲注,不需要猜测二进制结构,只要往字段里塞一个类型名,观察服务端是否执行了对应行为,就能判断出漏洞是否存在。

我的建议非常简单直接:如果业务不是非用多态不可,请把TypeNameHandling永远设为None;如果某个场景确实需要多态,必须自己实现一个ISerializationBinder,在绑定类型时做白名单判断。这个动作不是“提高了安全性”,而是把安全性从“依赖配置正确”变成了“依赖代码明确允许”。两者态度完全不同。

2.3 容易被忽略的间接入口:缓存、Cookie、消息队列

我最担心的一部分,其实是业务里那些没有名字的“边缘入口”。它们不在对外接口文档上,但数据流确实会经过不可信来源。

  • 缓存系统:Redis或者内存缓存里存了序列化后的对象。如果缓存被污染,或者缓存键设计不合理导致用户可控内容进入,就会触发反序列化。
  • Cookie与会话状态:把用户信息序列化后放进Cookie,如果密钥泄露,或者服务端主动信任Cookie里的类型信息,就会造成问题。
  • 消息队列:这是我最常发现问题的位置。很多消息生产流程里,发送方把对象序列化后投递到队列,消费方直接反序列化。一旦某个生产者被攻陷,或者队列本身存在权限绕过,恶意消息就能在所有消费者节点上执行代码。
  • 配置文件:有些系统会把配置模板存成序列化文件,启动时反序列化。如果配置文件的写入权限没守住,同样会被利用。

我参与过的多次安全排查中,第一类高发区是“对外接口”,第二类高发区就是消息队列。主要原因在于,消息发送方和消费方通常被开发者默认成“自己人”,所以消费方往往没有任何类型校验,这是非常危险的默认信任。

2.4 攻击者眼中的目标:整个类型空间

要理解为什么反序列化漏洞难以通过简单过滤修复,需要站在攻击者视角去看问题。开发者做防御时,往往是在自己的业务代码里寻找“危险操作”;攻击者则是在整个类型空间里寻找“可以被拼装起来的组件”。

什么意思?比如开发者可能认为自己没有调用Process.Start。但攻击者不需要直接调用,他只需要找一个在属性setter中会间接调用进程创建的第三方类型,再找一个能够控制方法名的反射封装,然后把这两者拼成一条利用链。开发者视角是“我没有危险代码”,攻击者视角是“整个进程加载的所有程序集都是我的武器库”。

这也是为什么单纯升级依赖、修补已知漏洞并不能彻底解决反序列化风险。依赖库里会不断引入新类型,旧版本修了,新版本也可能引入新的可拼装组件。只有从“类型入口”上收紧,让反序列化只能处理一份明确的、极小的类型清单,才能真正关闭风险通道。

3. 用代码演示一个“被抓典型”的风险场景

3.1 环境准备与最小复现工程

这一段我的实验环境非常简单:一台隔离的虚拟机,安装了.NET 8 SDK,新建了一个控制台项目。不需要Web框架,不需要数据库,纯粹为了把问题说清楚。我建议你复制这段操作时,也一定在隔离环境里做,不要拿生产数据或真实业务系统去尝试。

dotnet new console -n DeserializeDemo cd DeserializeDemo dotnet add package Json.NET

然后打开Program.cs,我会先写一个“反面教材”,再写一个防御版。代码本身没有杀伤力,重点是让你看到反序列化过程中的类型流转逻辑。

3.2 危险代码长什么样

下面是典型的不安全写法:

using System.IO; using System.Runtime.Serialization.Formatters.Binary; byte[] payload = ReadFromUntrustedSource(); var formatter = new BinaryFormatter(); using var ms = new MemoryStream(payload); var obj = formatter.Deserialize(ms); // 直接反序列化不可信数据 Console.WriteLine(obj?.GetType());

这段代码的问题在哪?BinaryFormatter.Deserialize在还原对象时,完全信任字节流里携带的类型元数据。只要攻击者能控制payload的内容,他就可以让程序按照自己的指示创建任意类型。所谓“任意”,不是指你业务代码里能见到的类型,而是指目标进程已加载的所有.NET程序集。

再看Json.NET版本的典型误用:

using Newtonsoft.Json; var rawJson = GetUserInput(); var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All }; // 攻击者通过 rawJson 中的 $type 字段指定任意类型 var result = JsonConvert.DeserializeObject(rawJson, settings);

当TypeNameHandling开启后,JSON字符串里如果带着"$type": "某个危险类型, 某个程序集",反序列化器就会原样加载该类型。防御者往往觉得“我已经做了参数校验,不允许包含$type字段”,可攻击者换个编码方式、嵌套层级、大小写组合,过滤就可能被绕过。最好的方式,是从根上关闭类型自动绑定。

3.3 安全Binder把守类型大门的示例

下面是我在实际项目里常用的一个白名单绑定器雏形。它的作用,是让反序列化器只承认一小部分业务类型,除此之外的一切类型都直接拒绝。

using System; using System.Collections.Generic; using System.Runtime.Serialization; public sealed class SafeBinder : SerializationBinder { // 注意:这里的程序集名在不同运行时会不一样,必须按实际部署环境填写 private static readonly HashSet<string> AllowedTypes = new(StringComparer.Ordinal) { "System.String, System.Private.CoreLib", "System.Int32, System.Private.CoreLib", "System.Decimal, System.Private.CoreLib", "System.Collections.Generic.List`1[[System.String, System.Private.CoreLib]], System.Private.CoreLib", "YourNamespace.OrderInfo, YourApp" }; public override Type BindToType(string assemblyName, string typeName) { string candidate = $"{typeName}, {assemblyName}"; if (!AllowedTypes.Contains(candidate)) { throw new NotSupportedException($"类型 {candidate} 不在反序列化白名单中"); } return Type.GetType(candidate) ?? throw new TypeLoadException($"无法加载类型 {candidate}"); } }

如果你用的是Json.NET,可以通过ISerializationBinder实现同样的约束,并把实例传给JsonSerializerSettings.SerializationBinder。白名单必须尽可能精简,只包含业务真正需要的传输类型。类型越多,意味着攻击者可用的拼装模块越多,风险自然更高。

我在一个实际系统里维护过这样一份白名单,最初只有8个类型,后来因为业务需求扩展到40多个。每次有人想增加类型,我都会问一句:这个类型真的有必要直接通过反序列化接收吗?换成普通的DTO再转换好不好?大多数情况下,换一种设计就能减少一个攻击面。

3.4 白名单Binder意外拦截的现场

我印象特别深的一次,是给一个老系统加上白名单Binder之后,测试人员发现某个正常的反序列化请求直接报错:NotSupportedException: 类型 XXX 不在反序列化白名单中。所有人第一反应是“白名单写漏了”。但我把完整的调用链拉出来一看,发现这个“正常请求”里竟然藏着一个历史遗留的、带有反射包装的第三方警告对象。

也就是说,这个请求在到达真正的业务对象之前,会先实例化一个历史遗留类型。如果不是加了白名单,这条路径永远没人注意到。那次之后,团队的代码评审规范里多了一条硬性要求:凡是新增的反序列化调用,必须同时提交白名单配置变更。没有明确的类型清单,不允许合入代码。

4. 落地防线:四层方案让漏洞无处下手

4.1 第一层:从源头上废弃不安全的序列化器

最直接的修复,是不再使用存在根本缺陷的序列化器。对BinaryFormatter、SoapFormatter这类老式二进制方案,我的态度就是“删”。不是加一个开关,而是从代码里彻底移除调用点。

如果你的项目还在用.NET Framework,没法直接从代码层面移除,至少要做到三点:第一,不能直接处理来自用户、消息队列、缓存等低信任源的数据;第二,必须在反序列化前对字节流做强校验,比如HMAC签名验证;第三,所有的类型实例化必须经过自定义Binder白名单检查。这三条同时满足,才能把风险压到可接受的范围。

迁移过程中,数据格式变化是主要阻力。比如旧的二进制缓存无法被新系统读取,这会导致缓存命中率短暂下降,甚至触发一次性的大量数据库回源。我建议采用双写迁移:先用新格式写,同时保留旧格式的读取能力,在后台逐步把旧数据刷新成新格式,最终完成切换。

4.2 第二层:类型白名单与绑定器

这是整个防御体系里最关键的一层。无论使用哪种序列化方案,都要在“类型绑定”这个环节做拦截。做法有几种:

  • 继承SerializationBinder:适用于BinaryFormatter等二进制序列化器,在绑定类型时用白名单判断。
  • 实现ISerializationBinder:适用于Json.NET。
  • 自定义JsonConverter:针对特定DTO字段做严格解析,不发散到任意类型。

白名单的粒度可以按需调整。严格做法是精确到完整的类型加程序集名,宽松一点可以只校验命名空间前缀。但在生产系统里,我几乎总是推荐精确匹配。原因很简单:前缀匹配容易出现误放行,例如你允许了System.Diagnostics.前缀下的某个类型,攻击者可能就能用同类前缀下的另一个类型完成链条拼装。白名单要做减法,不做加法。

4.3 第三层:数据完整性与加密传输

即使有了白名单,也不能忽略数据在到达反序列化点之前的完整性保护。一个典型场景是:攻击者把恶意载荷直接注入到消息队列、缓存或Cookie里,白名单只能保证最终类型的可控,但无法阻止攻击者把这些数据投递到你的处理管道。因此,还需要确认数据的来源、可信度和修改变更的权限。

在实践层面,我建议:

  • 对持久化的序列化数据附加HMAC签名或数字签名,接收时先验签,签名不通过就直接丢弃。
  • 对敏感数据使用加密存储,密钥由独立的密钥管理系统管理,不要硬编码在配置里。
  • 给消息生产者做独立身份认证,队列消费者校验消息头中的身份信息,拒绝匿名生产者。
  • 对所有外部的反序列化接口做频率限制和格式校验,宁可多一层麻烦,不多一个意外。

这些措施不会直接修复反序列化漏洞,但它们会把攻击者“投递Payload”的路径一条条封死。你封掉的那条路,可能恰好就是攻击者唯一走得通的那条。

4.4 第四层:运行时监控、依赖治理与代码审计

防御的最后一层,是让风险在爆发前被看到。反序列化漏洞利用过程中通常会有一些共同特征:反序列化日志里出现极少见的类型、进程短时间内创建了大量小对象、内存和CPU出现周期性的无规律抖动、敏感网络连接突然建立。把这些指标纳入监控,能显著提升发现速度。

依赖治理同样重要。很多利用链依赖的是第三方库中的类型,升级版本会让一部分旧的利用链失效。我建议把反序列化相关依赖的升级频率提高到与安全补丁同步,而不是等业务迭代时才顺手升级。

代码审计上,一个高效的做法是全项目搜索几个高危关键词:BinaryFormatter、TypeNameHandling.All、ObjectStateFormatter、Deserialize(。不要嫌粗暴,这些点就是雷区。每个搜索命中的位置,都需要在上线前明确标注数据来源和处理方式。

4.5 升级迁移中的兼容性注意事项

升级迁移是实际项目里最容易“翻车”的阶段。我在这里整理了几条踩过之后才知道的注意事项:

  • 旧数据无法反序列化:切换序列化方案后,历史存储的二进制数据和JSON格式不同,直接反序列化会抛异常。需要提前准备一次性迁移任务,或者保留一段时间的兼容读取窗口。
  • 版本差异问题:自定义Binder里的程序集名在不同.NET版本下不一样。例如目标框架从.NET Framework迁移到.NET 8后,类型所属程序集会发生变化,白名单必须重新校准。
  • 加密方案切换的节奏:如果在加签的同时改用了新加密方案,旧签名数据会瞬间全部不可用。建议先加签发旧数据,再切换签名密钥,最后再强制要求新签名。
  • 回滚预案:序列化改动不像SQL变更那么容易回滚,因为线上数据和代码是纠缠在一起的。上线前要有一套确认好的回滚计划,比如保留旧版本服务节点一小段时间,直到新格式数据累积足够。

5. 实战记录:三天排查一个诡异的反序列化异常

5.1 现场症状:服务偶发500且进程内存异常

某个业务系统在某次灰度发布之后,出现了一个非常诡异的现象:接口平时正常,但每天总有那么几个小时会零星返回500,错误日志里出现SerializationException和TypeLoadException。服务进程的内存曲线从原来的平滑状态变成了锯齿状,每过一段时间就会快速上涨,然后GC回收一下。

团队一开始认为是新版本引入了内存泄漏,翻遍了业务代码也没有头绪。后来在一个偶然的机会,运维同事发现报错时间和某个外部数据同步任务的时间高度重合,基本精确到分钟级。顺着这条线索,大家才把注意力从“内存泄漏”转向“某种外部输入被加载”。

5.2 一步步排查,最后定位到反序列化入口

排查过程是这样的:

  1. 先看错误日志,发现异常类型集中在System.Runtime.Serialization.SerializationException,而不是常见的空引用或SQL异常。
  2. 抓取异常堆栈,定位到某个消息消费类中的反序列化方法。该方法负责把队列消息还原成业务对象。
  3. 查看队列生产端,发现是另一个子系统在推送消息。新版本灰度后,消息体中多了一个字段,而这个字段对应的类型没有加入消费端的白名单Binder。
  4. 白名单Binder直接抛出了NotSupportedException,但因为外层代码只捕获了Exception并记录成普通错误,所以看起来像500。
  5. 内存锯齿的真正来源,是反序列化失败后消息被反复重试,每次重试都分配了大量临时对象和磁盘缓冲区,导致GC频繁。

修复只用了两行代码:把新增字段的DTO类型加入白名单,并补充了对应的单元测试。但排查过程花了三天,因为它被误判成内存泄漏太久了。这件事给我的经验是:反序列化异常不会总是宣传自己的身份,你需要对序列化相关的错误类型保持敏感。

5.3 常见问题速查表

我把自己和同行遇到过的典型问题整理成了一张速查表,方便你在下一次排查时直接对照:

症状可能根因处理建议
反序列化报类型找不到程序集版本变化,或白名单未更新检查程序集名和类型全名,同步更新白名单
进程内存持续上涨反序列化失败后无限重试,或攻击载荷创建大量对象限制重试次数,增加监控告警,检查数据来源
接口偶发500但业务日志正常反序列化异常被外层代码吞掉检查全局异常捕获逻辑,保留完整堆栈
升级.NET后老数据全部报错序列化格式不兼容或程序集名变化准备数据迁移任务,或临时增加旧格式兼容读取
攻击特征:大量陌生类型被加载存在未加白名单的反序列化入口全项目搜索高危关键词,逐点审计

5.4 自己总结的几个避坑习惯

排查过太多次反序列化问题之后,我自己养成了一套固定动作。每次改动和序列化相关的代码,我会在评审记录里专门填三个选项:数据来源是否可信、类型集合是否明确可控、序列化器是否在安全清单内。三个答案只要有一个不满足,这个改动就不会被合入。

另外一个小习惯是:绝不在日志里完整输出反序列化出来的对象内容。一方面是为了防泄露,另一方面,有些序列化对象在被ToString时还会触发额外的属性访问,这可能让恶意类型在“写日志”这个环节就产生副作用。用类型名、关键业务ID和白名单结果代替完整内容,信息已经足够排查问题。

最后我再说一个我自己的体会:反序列化漏洞的修复,最难的从来不是写代码,而是改变团队对“内部数据”的默认信任。你需要把每一条进入反序列化点的数据流都当作潜在的攻击输入,哪怕它来自你自己写的上一个模块。这种态度上的转变,往往比任何技术方案都更能避免灾难。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进