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ị assemblyName
và typeName
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 assemblyName
và typeName
đọ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 type
là null
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 typeName
và assm
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()
và 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)
và 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 SERIALIZATIONBINDER
S
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 assemblyName
và typeName
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)
và 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
và 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.