.NET Remoting 遠端程式碼執行漏洞探究

wyzsk發表於2020-08-19
作者: Her0in · 2016/04/22 9:21

本文是一篇關於.NET Remoting安全的科普文,在文章中會使用一個簡單的 RCE 漏洞和提權案例進行說明。

本文主要有以下內容:

  1. 對 .NET Remoting 技術作一個簡單的介紹
  2. 使用 VS 編寫一個簡單的.NET Remoting客戶端和有漏洞的服務端。
  3. 獲取.NET Remoting傳輸的資料。
  4. 使用 dnSpy 對 .NET Remoting 過程進行簡單的除錯。
  5. 重新建立一個有缺陷的應用程式,演示漏洞。
  6. 使用dnSpy對修改過的 .NET 模組打補丁,並對有漏洞的服務端繼續攻擊利用。

0x00 所需的工具及說明


  • Win7 虛擬機器。
  • VS 2015 社群版,本文只需要C#元件。
  • RawCap 抓取本地網路傳輸的資料包。
  • Wireshark 分析抓取到的資料包。
  • dnSpy (1.4.0.0) 反編譯,除錯 C# 程式碼。

0x01 .NET Remoting 技術簡介


總的來說,.NET Remoting是一種程式間通訊(IPC)的方式。一個程式(可以稱之為服務端)暴露一些可以遠端呼叫的物件。其他程式(可以稱之為客戶端)可以建立這些物件的例項,就如同建立本地物件一樣。但是,這些“本地的物件”執行在伺服器端。通常情況下,這些可遠端呼叫的物件放在一個共享的庫中(如:DLL)。客戶端和服務端各自儲存一份此DLL檔案。.NET Remoting 機制可以使用 TCP,HTTP 以及命名管道傳輸可遠端呼叫的物件。

.NET Remoting 的概念與 Java 中的遠端方法呼叫(Java RMI)非常類似。在 Java 的RMI中,傳遞的是序列化的Java物件,在 .NET 中傳遞的則是一個 .NET 物件。

0x02 編寫一個 .NET Remoting 應用程式


接下來我會建立兩個簡單的 .NET Remoting 應用程式。第一個程式主要是說明建立一個.NET Remoting 客戶端/服務端應用程式的方法。第二個程式的目的是描述重新建立一個有缺陷的服務端程式。

整個程式由以下三部分組成:

  1. 遠端可呼叫的庫:是一個DLL檔案,包含了可遠端呼叫的物件。
  2. 服務端
  3. 客戶端

第一個 DLL 檔案。建立一個解決方案和一個新的工程。選擇工程型別為“類庫”。根據 MSDN 文件的描述,要建立一個可遠端呼叫的物件,此物件要麼是一個Serializable物件,要麼需要繼承MarshalByRefObject類。需要了解更多資訊,可以在這裡看到。

遠端呼叫庫(DLL檔案)的程式碼如下:

#!csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace RemotingSample
{
  public class RemoteMath : MarshalByRefObject
  {
    public int Add(int a, int b)    // 加法
    {
      Console.WriteLine("Add({0},{1}) called", a, b);
      return a + b;
    }

    public int Sub(int a, int b)    // 減法
    {
      Console.WriteLine("Sub({0},{1}) called", a, b);
      return a - b;
    }
  }
}

我們需要在工程中新增兩個引用,“工程(選單) > 新增引用”:

  1. System.Runtime.Remoting
  2. Remoting Library project

服務端將會暴露 RemoteMath 類。客戶端則會呼叫 RemoteMath 類中的方法。

客戶端依舊需要新增上述引用。客戶端呼叫 Add 和 Sub 方法,並列印出結果。

客戶端程式碼如下:

#!csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Runtime.Remoting;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Channels.Tcp;

namespace RemotingSample
{
  class Client
  {
    static void Main(string[] args)
    {
      // 建立並註冊 TCP 通道
      // 將通道的安全屬性設定為 false
      TcpChannel clientRemotingChannel = new TcpChannel();
      ChannelServices.RegisterChannel(clientRemotingChannel, false);

      // 建立一個 RemothMath 物件
      // 需要做一個轉換,因為 Activator.GetObject 返回值了一個 RemoteMath 物件
      // 伺服器地址在 Server.cs 檔案中設定 (埠:8888 和 rMath)

      // Server.cs code:
      // TcpChannel remotingChannel = new TcpChannel(8888);
      // ChannelServices.RegisterChannel(remotingChannel, false);
      // WellKnownServiceTypeEntry remoteObject = new WellKnownServiceTypeEntry(typeof(RemoteMath), "rMath", WellKnownObjectMode.SingleCall);

      RemoteMath remoteMathObject = (RemoteMath)Activator.GetObject(typeof(RemoteMath), "tcp://localhost:8888/rMath");

      // 呼叫 Add 和 Sub 方法
      Console.WriteLine("Result of Add(1, 2): {0}", remoteMathObject.Add(1, 2));
      Console.WriteLine("Result of Sub(10, 3): {0}", remoteMathObject.Sub(10, 3));

      Console.WriteLine("Press any key to exit");
      Console.ReadLine();
    }
  }
}

現在編譯此解決方案,之後你會注意到客戶端和服務端程式所在目錄都包含一個 RemotingLibrary.dll 檔案。

如下圖所示:

p1

現在可以啟動 RawCap 對本地網路卡進行抓包。

0x03 .NET Remoting 訊息分析


啟動服務端程式進行初始化,將會彈出 Windows 防火牆例外提示。由於客戶端和服務端都執行在本地,所以這裡你可以安全的選擇“拒絕”。此處的設定也是大多數 .NET Remoting 應用程式出現漏洞的原因。使用 netstat 命令(如下圖所示)可以看到服務端監聽的地址為 0.0.0.0 。這意味著任何人都可以連線到服務端並且在服務端本地執行暴露的功能。

p2

現在執行客戶端和服務端,並觀察執行結果。我們可以看到,客戶端呼叫了上述兩個方法並且列印出了結果。如下圖所示:

p3

同時,我們可以清楚的看到上述方法已經在服務端應用程式的上下文中執行,因為服務端列印出了 RemotingLibrary.dll 中的詳細資訊。

p4

在 Wireshark 中設定過濾器並觀察在 8888 埠中傳輸的資料。

p5

可以在下面的兩個文件瞭解 .NET Remoting 傳輸協議的格式結構:

  1. [MS-NRTP].NET Core Protocol - PDF
  2. [MS-NRBF] .NET Remoting: Binary Format Data Structure - PDF

在 TCP 握手之後,我們可以看到客戶端傳送給服務端的第一個資料包。根據 [MS-NRTP] 文件中的 2.2.3.3 小節 (訊息幀結構)的描述,每一個訊息的起始資訊指示為 ProtocolId ,這是一個 4 位元組的資料結構,通常為 0x54454E2E 對應的字元為 .NET。

p6

之後的資訊取決於 .NET Remoting 的通道並指明瞭客戶端將會使用 rMath 訪問服務端所暴露的類。接下來是第一個訊息的第二部分。

#!bash
00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00  ................
00 15 12 00 00 00 12 03 41 64 64 12 61 52 65 6d  ........Add.aRem
6f 74 69 6e 67 53 61 6d 70 6c 65 2e 52 65 6d 6f  otingSample.Remo
74 65 4d 61 74 68 2c 20 52 65 6d 6f 74 69 6e 67  teMath, Remoting
4c 69 62 72 61 72 79 2c 20 56 65 72 73 69 6f 6e  Library, Version
3d 31 2e 30 2e 30 2e 30 2c 20 43 75 6c 74 75 72  =1.0.0.0, Cultur
65 3d 6e 65 75 74 72 61 6c 2c 20 50 75 62 6c 69  e=neutral, Publi
63 4b 65 79 54 6f 6b 65 6e 3d 6e 75 6c 6c 02 00  cKeyToken=null..
00 00 08 01 00 00 00 08 02 00 00 00 0b           .............

Add RemotingSample.RemoteMath, RemotingLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null

這個是 Add(1, 2) 方法的資料。我們可以在訊息中看到如下資訊:

#!bash
RemotingLibrary: DLL 檔案中所暴露的類
RemotingSample.Remotemath: 暴露的類的名稱
Add: 被呼叫的方法

可以仔細的觀察到 Add 方法的引數在最後一行裡面,並且以小位元組序的方式儲存(32位整數 or 4 位元組的資料)。

#!bash
01 00 00 00: int a
02 00 00 00: int b

關於訊息的具體結構在此不做過多介紹,只是想透過上述分析確認被呼叫的方法,引數,類以及DLL檔案。

有關於訊息中的所有欄位的全面解釋可以參考 [MS-NRTP] 文件的 4.1 小節(使用 TCP 二進位制進行雙向方法呼叫的方式)。

和其他的 .NET Remoting 訊息一樣,TCP 傳輸的 ACK 響應包同樣以 .NET 作為起始標識。根據 [MS-NRTP] 文件的 2.1.1.1.2 小節(接受響應)中所描述的 “如果訊息的操作型別(OperationType)為請求(值為 0 ),類的實現方法必須要等待在同一連線中的雙向響應訊息”。

#!bash
2e 4e 45 54 01 00 02 00 00 00 1c 00 00 00 00 00 .NET............

ProtocolId: 0x54454E2E or .NET
Major version: 0x00
Minor Version: 0x00
OperationType: 0x0002 or Response

從服務端傳送到客戶端的實際結果如下:

#!bash
00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00  ................
00 16 11 08 00 00 08 03 00 00 00 0b              ............

可以看到返回值(依舊是一個32位的整數 or 4 位元組的資料)在資料包的結尾處,字首同樣為 0x08 ,同時我們在這裡能看到之前所描述的引數,為 03 00 00 00 。

Sub 方法的訊息與上述訊息非常相似。在此不做過多分析。

0x04 使用 dnSpy 進行除錯分析


執行 Server.exe 和 dnSpy(x86 平臺執行 dnSpy-x86.exe)。把 Client.exe 拖到 dnSpy 中,然後定位到 Main 方法。dnSpy 會自動載入引用的 DLL 檔案,包括 RemotingLibrary.dll。dnSpy 的反編譯程式碼與原始程式碼一樣,只是沒有了註釋。
如下圖所示:

p7

現在我們可以在 dnSpy 裡面執行客戶端。點選 Start 按鈕,選擇客戶端程式。之後可以指定引數和下斷點的順序及斷點事件。使用預設的選項執行 Client.exe ,dnSpy 將會在 main 函式下斷。

p8

現在我們可以使用快捷鍵進行除錯或者點選右側的 Continue 按鈕。
可以透過 F2 來設定斷點,如下圖,是在第 16 行下了一個斷點。

p9

找到被呼叫的函式

現在,我們可以清楚的看到客戶端在何處呼叫了 Add 函式。但是,實際情況中,會有很多函式且函式名稱不是 Add ,那我們又該如何找到這些函式呢?

在上述傳輸的資料裡面,我們可以看到函式名為 Add ,它在類 RemotingSample.Remotemath 中,並且被放在 RemotingLibrary.dll 檔案中。透過這些條件,我們可以在 dnSpy 中快速找到 Add 函式。

p10

這種情況下,我們會本能的在 Add 函式處下個斷點,並繼續執行 Client.exe 。但是,要注意,這個斷點永遠不會被觸發。因為在 .NET Remoting 機制中,呼叫函式的例項是在客戶端建立的,但是是在服務端執行的。

繼續分析

現在我們可以使用 dnSpy 的分析功能繼續分析,右擊 Add 函式點選 Analyze。此時會出現一個新的皮膚,有兩個重要的功能, Uses 和 Used By。 Uses 功能可以列出 Add 函式所呼叫的函式。Used By 功能會列出呼叫 Add 函式的其他函式。

p11

從圖中我們可以看到 Main 函式呼叫了 Add 函式,雙擊 Main 函式會跳回原始的入口點。

.NET Remoting 除錯實戰

接下來可以單步進入(Setp Into)呼叫 Add 函式的地方,如果你沒有改變預設的設定,將會進入 mscorlib.dll 準確的說是 CommonLanguageRuntimeLibrary.System.Runtime.Remoting.Proxies.RealProxy.PrivateInvoke() 函式中。dnSpy 的預設設定會跳過一些其他的程式碼(如設定/獲取屬性的 set/get 操作等等)。可以在選單 View (menu) > Options (menu item) > Debugger (tab) > DebuggerBrowsable 和 Call string-conversion 裡面設定。

p12

可以按下 Alt+4 快捷鍵開啟 Locals 視窗,檢視本地變數的值。

p13

跟蹤 .NET Remoting 的呼叫過程最終都會跳到此處。因為此處剛好是訊息被髮送的地方,因此我們能看到傳送的內容。在 dnSpy 1.3 版本中,在此處下斷點是不會被觸發的。在第 404 行(RemotingProxy remotingProxy = null;)處下斷點,也就是 if (1 == type) 語句之前下斷點,之後就可以跟蹤除錯檢視訊息內容。

Type == 1 語句為真,仔細來看看這塊的程式碼:

#!csharp
if (1 == type)
{
  Message expr_14 = new Message();

  // msgData 是函式的引數,其包含了訊息的資訊
  // 我們可以看到它填充了 expr_14 變數,而這個變數是一個臨時的 Message 物件
  expr_14.InitFields(msgData);

  // 將 expr_14 賦值給了 message 變數
  message = expr_14;            // 在此處下個斷點

  num = expr_14.GetCallType();
}

在第 409 行(num = message = expr_14;)處下個斷點,可以單步步過中間的呼叫過程,直接執行到 410 行,此時就可以看到 message 變數的值了。由於在啟動 dnSpy 時沒有改變預設的設定,所以在此處下斷點是會觸發的。 按下 Alt+4 可以檢視 message 變數的值。

p14

此時,我們可以看到 message 的很多資訊。轉到 474 行( RealProxy.HandleReturnMessage(message, message2);) ,在第 475 行處下個斷點,點選 Continue。此時可以看到服務端列印出來函式中的文字資訊。在 message2 變數中儲存了返回值。

p15

此時執行 Setp Out ,跳過子函式的執行過程後,返回到了 Main 函式中。可以同樣的方法對 Sub 函式進行除錯分析。

現在我們已經找到了 .NET Remoting 傳遞訊息和返回值的地方了,在實際操作中,也可以使用 WireShark 和 .NET Remoting 代理工具進行分析訊息的內容。

0x05 漏洞重現


用於演示的應用程式叫 Remoting Expanded 它有兩個元件。服務端是一個開機自啟動的 Windows 服務程式,執行許可權為系統許可權。客戶端程式由一個標準的使用者執行。客戶端使用 .NET Remoting 技術在服務端執行一些函式並執行一些標準使用者不可執行的操作。基本上,利用上述除錯方法就可以摸清 .NET Remoting 的呼叫過程。在分析了反編譯的程式碼後,我發現 DLL 檔案裡面包含了可遠端呼叫的物件和許多暴露出來的函式,但是這些函式客戶端是不可以呼叫的。其中有一個名為 StartProcess 的函式是使用系統許可權執行的,用於啟動一個程式。

為了演示漏洞,我們需要修改之前的程式碼,在 RemotingLibrary DLL 檔案裡新增一個函式。客戶端和服務端程式不需要修改,但是要注意生成新的解決方案後,會在客戶端和服務端的資料夾裡生成新的 DLL 檔案。我建立了一個新的工程,名為:RemotingLibraryExpanded。

#!csharp    
namespace RemotingLibraryExpanded
{
  public class RemoteMathExpanded : MarshalByRefObject
  {
    public int Add(int a, int b)    // 加法
    {
      Console.WriteLine("Add({0},{1}) called", a, b);
      return a + b;
    }

    public int Sub(int a, int b)    // 減法
    {
      Console.WriteLine("Sub({0},{1}) called", a, b);
      return a - b;
    }

    // 啟動一個程式
    public void StartProcess(string processPath)
    {
      Console.WriteLine("Starting process {0}", processPath);
      Process.Start(processPath);
    }
  }
}

重新生成解決方案,像之前一樣執行客戶端和服務端。此時,我們可以利用新增的方法進行許可權提升(此處使用系統許可權進行演示)。可以透過編寫程式碼或者使用 dnSpy 等工具修改客戶端程式。為了方便演示,我直接編寫了新的程式碼。

0x06 使用 dnSpy 修改 IL 指令並打補丁


修改呼叫 StartProcess 的程式碼的方式很簡單,可以直接修改為執行一個指定的程式(如:C:\Windows\System32\calc.exe)。在 dnSpy 中開啟客戶端程式,右擊呼叫 Add 函式的地方。選擇 “編輯 IL 指令”。

p16

CIL(通用中間語言)也被稱作 IL 和 Java 的位元組碼類似。修改細節如下:

#!bash
// 將 remoteMathObject 從堆中彈出並賦值給本地變數 1
12  0028    stloc.1

// 將字串壓入堆中
13  0029    ldstr   "Result of Add(1, 2): {0}"

// 將本地變數 1 壓入堆中。此時 remoteMathObject 被壓入了堆中
// 此時執行 remoteMathObject.Add
14  002E    ldloc.1

// 將 0x1 壓入堆中
// 在IL 中的 ldc.i4.1 到 ldc.i4.8 會對 ldc.i4 <Int32> 進行分割並將一個整數壓入堆中
15  002F    ldc.i4.1

// 將 0x2 壓入堆中
16  0030    ldc.i4.2

// 呼叫 Add
17  0031    callvirt    instance int32 [RemotingLibrary]RemotingSample.RemoteMath::Add(int32, int32)

// box 將一個值型別轉換為了物件引用型別
// I assume it means that we are converting the result of add to Int32
18  0036    box [mscorlib]System.Int32

// 呼叫 console.writeline (引數是壓入堆中格式化的字串和 Add 的返回值)
19  003B    call    void [mscorlib]System.Console::WriteLine(string, object)

現在需要把 Add 改為 StartProcess。單擊 Add 會出現右鍵選單。
選擇 方法(Method)會彈出一個新的標籤頁,此時你可以將它修改為任意一個已經載入的程式集裡的方法。

p17

方法名稱已經修改了,但是 Add 方法有兩個整數型別的引數,而 StartProcess 方法只有一個字串型別的引數。如果不做修改, 在“新”方法呼叫前,Int32 將被壓入堆中。此時點選 OK,可以看到下圖的糟糕情況:

p18

不要緊,我們可以編輯 IL 指令修復它。在瞭解了 IL 指令後,可以這樣修改它,刪掉 Console.WriteLine ,因為 StartProcess 沒有返回值。

修改後如下圖:

#!bash
12  0028    stloc.1
13  0029    ldloc.1
14  002A    ldstr   "c:\\windows\\system32\\calc.exe"
15  002F    callvirt    instance void [RemotingLibraryExpanded]RemotingLibraryExpanded.RemoteMathExpanded::StartProcess(string)
16  0034    nop

p19

在這可以用另外一個工具——LINQPad 5.0 進行修改。只有標準版是免費的,但是也滿足需求了。將客戶端的程式碼複製到工具裡面,像在 VS 裡面一樣新增引用並匯入名稱空間。

  1. 選擇語言為 C# 程式
  2. 右擊並選擇 引用和屬性
  3. 在 引用 標籤頁點選 新增 並搜尋 System.Runtime.Remoting.dll
  4. 單擊 瀏覽 選擇 RemotingLibraryExpanded.dll
  5. 選擇 匯入額外的名稱空間 標籤頁
  6. 單擊 來自程式集
  7. 選擇 RemotingLibraryExpanded.dll 並新增其名稱空間
  8. 選擇 System.Runtime.Remoting.dll 並新增 System.Runtime.Remoting.Channels 和 System.Runtime.Remoting.Channels.Tcp

p20

p21

現在就可以在 LINQPad 裡面修改程式碼了,修改 Console.WriteLine 為 StartProcess("c:\windows\system32\calc.exe"),並點選 執行 。如果執行失敗,不用管它。點選 IL 按鈕,可以在下面看到生成的 IL 程式碼,和前面在 dnSpy 中編寫的一樣。

p22

使用 dnSpy 儲存已修改過的模組,使用選單 File (menu) > Save Module 儲存為新的可執行程式 Client1.exe。 執行 Client1.exe。可以看到成功執行了前面指定的程式(calc.exe)。

p23

此程式現在有兩個缺陷:

  1. 提權漏洞——服務端使用系統許可權執行
  2. RCE——服務端監聽的地址為 0.0.0.0

0x07 缺陷修復


在 MSDN 中有針對 .NET Remoting 的漏洞缺陷的安全設定方法 —— Security in Remoting 以及 .NET Remoting 配置檔案的格式說明

在上面的演示場景中,監聽的地址需要修改為 localhost 。檢視 TcpChannel的屬性 有一個 bindTo 屬性,可以設定繫結的地址。

#!csharp    
using System.Collections;

IDictionary tcpChannelProperties = new Hashtable();
tcpChannelProperties["port"] = 8888;
tcpChannelProperties["bindTo"] = "127.0.0.1";

TcpChannel remotingChannel = new TcpChannel(tcpChannelProperties, null, null);


<channels>
  <channel ref="tcp" port="8888" bindTo="127.0.0.1" />
</channels>

也可以使用客戶端驗證

通道的加密和驗證

通道有一個 Secure 屬性,可以設定為 true 增強安全性,但是客戶端和服務端都必須將 tcpChannelProperties 的 secure 設定為 true 才能起到增強作用。

#!csharp    
using System.Collections;

IDictionary tcpChannelProperties = new Hashtable();
properttcpChannelPropertiesies["port"] = 8888;
tcpChannelProperties["bindTo"] = "127.0.0.1";
tcpChannelProperties["secure"] = true

TcpChannel remotingChannel = new TcpChannel(tcpChannelProperties, null, null);


<channels>
    <channel ref="tcp" port="8888" bindTo="127.0.0.1" secure="true" />
</channels>

如果只把服務端的 secure 設定為 true ,抓取的資料包如下圖所示:

p24

客戶端建立了 TCP 連線,傳送的訊息為純文字,但是服務端未作任何響應。對客戶端做如下設定:

#!csharp
using System.Collections;

IDictionary tcpChannelProperties = new Hashtable();
tcpChannelProperties["secure"] = true;

<channels>
    <channel ref="tcp" secure="true" />
</channels>

此時抓取資料包,通道就被加密了。

p25

本文章來源於烏雲知識庫,此映象為了方便大家學習研究,文章版權歸烏雲知識庫!

相關文章