Bypassing .NET Serialization Binders

Bypassing .NET Serialization Binders

Bài viết này hoàn toàn dựa trên một blog của Code White Sec, mình viết lại một phần là để hiểu rõ hơn về nó và đồng thời dùng làm tài liệu cho bản thân.

Introduction

Một vài concept sử dụng trong bài phân tích

Type Names

Type name được dùng để xác định các types trong .NET (thường được sử dụng trong thao tác liên quan đến reflection). Ở dạng full qualified, nó bao gồm thêm thông tin về assembly mà type sẽ được load.

Một ví dụ về assembly qualified name (AQN) sẽ như sau

Bao gồm 2 phần chính với các thành phần nhỏ bên trong

Có thể thấy, cấu trúc này vẫn được áp dụng cho embedded/nested type AQN. Để đơn giản, type info sẽ được xem là "type name" và assembly info là "assembly name".

Thông tin về assembly và type sẽ được sử dụng bởi Common Language Runtime Binder để xác định và bind các assembly cần thiết cho ứng dụng tại runtime.

Serialization Binders

SerializationBinder có vai trò tương tự như runtime binder nhưng chỉ trong ngữ cảnh serialization/deserialization với các formatters BinaryFormatter, SoapFormatter, và NetDataContractSerializer. User có thể tận dụng nó để kiểm soát các class sẽ được load, bởi vì có thể class đó đã được chuyển đến một assembly khác hoặc có sự khác biệt trong version của class bên phía server và client.

SerializationBinder cung cấp 2 methods

  • public virtual void BindToName(Type serializedType, out string assemblyName, out string typeName);

  • public abstract Type BindToType(string assemblyName, string typeName);

BindToName được gọi trong quá trình serialization và kiểm soát giá trị assemblyNametypeName sẽ ghi vào serialized stream. Mặc khác, BindToType được gọi đến trong quá trình deserialization và kiểm soát được giá trị trả về của Type ứng với assemblyNametypeName đọc từ serialized stream.

Trước đây, SerializationBinder được dùng để triển khai biện pháp bảo mật nhưng đến sau này người ta mới đưa ra cảnh báo về việc nó không nên được sử dụng cho mục đích này nữa.

Root Cause

Việc sử dụng SerializationBinder để validate các type được deserialized có thể bị bypass tùy thuộc vào cách triển khai.

Để validate một type cụ thể, developer có thể

  • dựa trên chuỗi biễu diễn của assembly name và type name.

  • resolve type đó và thực hiện kiểm tra trên giá trị Type nhận được.

Điểm cộng của cách làm đầu tiên đó là: resolve một type (chẳng hạn sử dụng Assembly.Load(assemblyName).GetType(typeName)) sẽ tốn chi phí và có thể dẫn đến DoS ứng dụng hoặc đôi khi có thể failed.

Tuy nhiên, parsing type name vẫn chưa hẳn là cách tốt bởi vì internal type parser/binder của .NET cho phép một vài "quirks" như sau:

Sử dụng SerializationBinder

SerializationBinder có thể được set trong BinaryFormatter, SoapFormatter, và NetDataContractSerializer.

  • System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.ObjectReader.Bind(string, string)

  • System.Runtime.Serialization.Formatters.Soap.SoapFormatter.ObjectReader.Bind(string, string)

  • System.Runtime.Serialization.XmlObjectSerializerReadContextComplex.ResolveDataContractTypeInSharedTypeMode(string, string, out Assembly)

Khi deserialize, các custom binder này sẽ được gọi, đối với BinaryFormatter sẽ call đến

System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.ObjectReader.Bind()

Ở đây ta thấy nếu, this.m_binder khác null thì sẽ gọi dến method this.m_binder.BindToType()

Nếu kết quả trả về của nó là null hay nói cách khác typenull thì sẽ đi vào nhánh if thứ 2 -> ObjectReader.FastBindToType(string, string).

Nếu bSimpleAssembly == true (default đối với BinaryFormatter) thì assembly name sẽ được sử dụng để khởi tạo một instance của AssemblyName và dùng để load assembly tương ứng. Nếu không thành công, ObjectReader.FastBindToType(string, string) return null ngay lập tức. Sau đó sẽ type sẽ được load dựa trên ObjectReader.GetSimplyNamedTypeFromAssembly(Assembly, string, ref Type)

FormatterServices.GetTypeFromAssembly() sẽ thực hiện load type dựa trên typeNameassm với Assembly.GetType()

Nhưng nếu type == null hay nói cách khác là việc load thất bại, nó dùng Type.GetType(string, Func<AssemblyName, Assembly>, Func<Assembly, string, bool, Type>, bool) với typeName làm tham số thứ nhất. Và trong trường hợp typeName này là một AQN có thể dẫn đến load được type từ assembly tương ứng thay vì "currently executing assembly"

Vậy là đã đi đi sơ qua về flow của nhánh if thứ hai trong ObjectReader.Bind(), hiểu được flow của nhánh này khá quan trọng bởi vì kiểu bypass hiện tại lợi dụng sự khác nhau trong việc triển khai logic code giữa this.m_binder.BindToType()ObjectReader.FastBindToType(string, string) mục tiêu là khiến cho cái đầu tiên fail trong việc load type từ asembly trong khi đó cái thứ hai lại load thành công. Việc bypass có thể diễn ra dễ dàng hơn nếu khi failed, custom binder chỉ trả về null mà không throw exception.

Nguồn gốc của Assembly Name và Type Name

Giá trị của assembly name và type name đưa vào SerializationBinder.BindToType(string, string) trong quá tình deserialization bắt nguồn từ serialized stream. Ta có thể dùng tính năng "Analyze" của dnSpy để trace ngược lại các method dùng để đọc chúng

System.Runtime.Serialization.Formatters.Binary.__BinaryParser.Run() sẽ check các header và dựa vào chúng để dọc dữ liệu

Đối với type name sẽ gọi đến System.Runtime.Serialization.Formatters.Binary.__BinaryParser.ReadObjectWithMapTyped()

và việc đọc diễn ra tại BinaryObjectWithMapTyped.Read(__BinaryParser)

Đối với assembly name sẽ gọi đến System.Runtime.Serialization.Formatters.Binary.__BinaryParser.ReadAssembly()

Tiếp tục gọi đến BinaryAssembly.Read(__BinaryParser)

và việc đọc assembly info diễn ra tại đây

Trong quá trình serialization, các giá trị này được ghi vào stream bởi BinaryAssembly.Write(__BinaryWriter)BinaryObjectWithMapTyped.Write(__BinaryWriter) và bắt nguồn từ instance của SerObjectInfoCache - có thể được set thông qua 2 constructors

Trong trường hợp thứ hai, assembly name và type name được lấy từ TypeInformation

(returned bởi BinaryFormatter.GetTypeInformation(Type)), còn đối với trường hợp thứ nhất chúng sẽ được lấy từ SerializationInfo instance (dòng 168, 169) được set thông qua SerializationInfo.AssemblyName and SerializationInfo.FullTypeName

Do đó bên cạnh việc sử dụng SerializationInfo.SetType(Type), ta có thể set assembly name vaf type name một cách rõ ràng và độc lập với nhau bằng cách gán giá trị cho SerializationInfo.AssemblyName and SerializationInfo.FullTypeName

[Serializable]
class Marshal : ISerializable
{
    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        info.AssemblyName = "…";
        info.FullTypeName = "…";
    }
}

Hoặc dùng custom binder

class CustomSerializationBinder : SerializationBinder
{
    public override void BindToName(Type serializedType, out string assemblyName, out string typeName)
    {
        assemblyName = "…";
        typeName     = "…";
    }

    public override Type BindToType(string assemblyName, string typeName)
    {
        throw new NotImplementedException();
    }
}

Thiếu sót khi sử dụng SERIALIZATIONBINDERS

Hai trường hợp có thể dẫn đến việc bypass SerializationBinder

  • parsing assembly name và type name khác so với .NET runtime.

  • resolving type khác biệt so với .NET runtime.

CASE STUDY 1: SAFESERIALIZATIONBINDER IN DEVEXPRESS (CVE-2022-28684)

Dùng Nuget và tải về DevExpress.Data.v21.2

Thực hiện deserialize với SafeBinaryFormatter của DevExpress sử dụng gadget DataSet

using System;
using System.Data;
using System.IO;
using DevExpress.Utils;

namespace BypassSerializationBinders
{
    internal class Program
    {
        public static void Main(string[] args)
        {

            using (var stream =
                   File.OpenRead(              "D:\\RiderProjects\\BypassSerializationBinders\\BypassSerializationBinders\\payload.bin"))
                SafeBinaryFormatter.Deserialize(stream);
        }
    }
}

Lỗi trả về như sau

Unhandled Exception: DevExpress.Data.Internal.XtraSerializationSecurityTrace+UnsafeTypeDeserializationException: The System.Data.DataSet, System.Data, Version=4.0.0.0, Culture=neutral,
 PublicKeyToken=b77a5c561934e089 type is considered unsafe and therefore is not deserialized due to security reasons. See the https://go.devexpress.com/Jan2019_Deserialization_Issue.as
px article for additional information.
   at DevExpress.Data.Internal.XtraSerializationSecurityTrace.Assert(ApiLevel apiLevel, String assembly, String type)
   at DevExpress.Data.Internal.SafeSerializationBinder.Ensure(String assemblyName, String typeName)
   at DevExpress.Data.Internal.SafeSerializationBinder.DXSerializationBinder.BindToType(String assemblyName, String typeName)
   at System.Runtime.Serialization.Formatters.Binary.ObjectReader.Bind(String assemblyString, String typeString)
   at System.Runtime.Serialization.Formatters.Binary.ObjectReader.GetType(BinaryAssemblyInfo assemblyInfo, String name)
...

Từ exception có thể thấy type System.Data.DataSet đã bị blacklist vì thế sẽ không thực hiện deserialize.

SafeBinaryFormatter.Deserialize(Stream stream) gọi đến SafeBinaryFormatter.DeserializeWithSecurityExceptionUnwrap(Stream stream)

Tại đây thực hiện gọi đến Deserialize() của Binaryformatter (được set trong SafeBinaryFormatter.Instance)

Custom Binder được sử dụng ở đây là DXSerializationBinder

DXSerializationBinder.BindToType(string, string) sử dụng method Ensure(string, string) để kiểm tra các safe và unsafe type.

Việc này được thực hiện bằng cách kiểm tra assemblyNametypeName dựa trên lists các unsafe types (UnsafeTypes class) và known safe types (KnownTypes class)

Để pass, ta cần đoạn if thứ hai thỏa mãn bởi vì XtraSerializationSecurityTrace.UnsafeType(string, string)XtraSerializationSecurityTrace.NotTrustedType(string, string) sẽ throw exception.

UnSafeTypes.Match(string, string) thực hiện một loạt các thao tác kiểm tra dựa trên type ranges và full type names.

TypeRanges.Match() check assembly name và type name dựa trên một cặp assembly name và namespace prefix.

UnsafeTypes.typeRanges như sau

UnsafeTypes.types

Nó đơn giản chỉ là một Set các type dùng trong các gadget phổ biển từ YSoSerial.Net.

Lưu ý rằng SafeSerializationBinder.Ensure(string, string) không hề resolve type mà chỉ check trên assembly names và type names đọc từ the serialized stream. Sau đó type binding/resolution được thực hiện thông qua Assembly.GetType(string, bool) để load type từ assembly chỉ định nhưng không hề throw exception.

DXSerializationBinder.BindToType(string, string) trả về null trong hai trường hợp (assembly == null hoặc Assembly.GetType(string, bool) return null), ta có thể craft một cặp assembly name và type name sao cho việc loading fail tuy nhiên method ObjectReader.FastBindToType(string, string) vẫn resolve về đúng type.

Lần thử thứ nhất, update lại code gen payload trong ysoserial.net sao cho assembly name là mscorlib và the type name là dạng AQN of System.Data.DataSet

Tuy nhiên khi thực hiện deserialize với payload vẫn bị báo lỗi.

Set breakpoint tại DXSerializationBinder.BindToType(string, string) để debug ta thấy có thể pass được SafeSerializationBinder.Ensure(string, string) bởi lẽ type name là AQN của System.Data.DataSet trong khi đó UnsafeTypes.types chỉ check với System.Data.DataSet. Bên cạnh đó assembly name là mscorlib và type name prefix System. đều nằm trong KnownTypes.typeRanges do vậy có thể pass được validation.

Tiếp đó gọi tới SafeSerializationBinder.EnsureAssemblyQualifiedTypeName(string, string)

Ở method này thực hiện extract typeName và assemblyName từ AQN truyền thông qua typeName. Bằng cách tìm vị trí cuối của kí tự , trong typeName và check nếu vị trí sau đó bắt đầu với chuỗi version=. Nếu không thỏa, tiếp tục với second last, third last ... cho đến khi tìm được version= thuật toán này cho rằng vòng lặp tiếp theo sẽ ứng với assembly name (bởi vì version là assembly attribute đầu tiên trong asembly name) flag được gán thánh true và ở vòng lặp tiếp theo sẽ thực hiện extract ra assemblyName đem so sánh với assemblyName ban đầu được lưu trong a. Nếu chúng khác nhau, return value là true và assembly name và type name sẽ tiếp tục được kiểm tra bằng một method call tới SafeSerializationBinder.Ensure(string, string) -> lúc này sẽ bị báo lỗi và throw exception.

Vậy cần phải tìm ra một TH mà SafeSerializationBinder.EnsureAssemblyQualifiedTypeName(string, string) return false do đó sẽ không trigger call đến SafeSerializationBinder.Ensure(string, string). Từ source code có thể thấy line 28, 36, 42 là trả về false và line 21, 51 trả về false khi assemblyName truyền vào khác với extracted assembly name.

vi

Trong trường hợp line 28, 42 thực hiện kiểm tra nếu kí tự ] nằm sau kí tự , cuối cùng trong typeName . Và dễ thấy chỉ cần thêm ] vào chuỗi AQN là có thể thỏa được điều kiện này.

Kết quả

CASE STUDY 2: CHAINEDSERIALIZATIONBINDER IN EXCHANGE SERVER (CVE-2022-23277)

ChainedSerializationBinder được dùng cho BinaryFormatter instance tạo bởi Microsoft.Exchange.Diagnostics.ExchangeBinaryFormatterFactory.CreateBinaryFormatter(DeserializeLocation, bool, string[], string[]) để resolve type và sau đó kiểm tra với Set các allow và disallow types.

Bên trong ChainedSerializationBinder.BindToType(string, string), assembly name và type name được đưa vào method InternalBindToType(string, string) và sau đó LoadType(string, string), để ý rằng nếu type được load thành công mới đi vào method ValidateTypeToDeserialize(Type) để check.

Trong LoadType(string, string), try load type thông qua Type.GetType(string) hoặc duyệt qua các loaded assemblies và sau đó sử dụng Assembly.GetType(string). Nếu load type fail, LoadType(string, string) returns null do đó BindToType(string, string) sẽ returns null dẫn đến pass được đoạn code kiểm tra type tại ValidateTypeToDeserialize(Type).

Tương tự như case study 1, ObjectReader.FastBindToType(string, string) được gọi để resolving type. Và bởi vì logic code giữa hai method là khác nhau nên có thể craft assembly name và type name để ObjectReader.FastBindToType(string, string) resolve về đúng type.