當前位置:
首頁 > 新聞 > 如何使用C#在Windows中利用系統調用執行Shellcode注入

如何使用C#在Windows中利用系統調用執行Shellcode注入

前言

在通過Sektor7惡意軟體開發必修課程學習了如何使用C語言編寫Shellcode注入工具之後,我希望嘗試如何在C#中執行相同的操作。實際上,使用P/Invoke來運行類似的Win32 API調用,並編寫一個與Sektor7類似的簡單注入工具非常容易。我注意到其中的最大區別是,沒有直接等效的方法來混淆API調用。在對BloodHound Slack頻道進行了一些研究和提問之後,在@TheWover和@NotoriousRebel的幫助下,我發現有兩個主要的領域可以進行研究,一個是使用本地Windows系統調用(syscall),另一個是使用動態調用。這兩種方法都各有其優缺點,針對系統調用,Jack Halon和badBounty曾經解釋過其工作原理,並進行過大量的研究工作。我們這篇文章和PoC的思路都是建立在他們的成果基礎上。我也知道,目前TheWover和Ruben Boonen正在研究D/Invoke,我也想緊隨其後對這部分進行研究。

本篇文章將主要探討如何利用系統調用來實現Shell注入,闡述我自己的理解,並提出概念證明。在這篇文章中,我已經盡最大努力確保此處的信息準確無誤,但並不能確保萬無一失,其中的代碼已經經過驗證是可以正常工作的。

我們的代碼可以在這裡找到:https://github.com/SolomonSklash/SyscallPOC 。

本地API和Win32 API

首先,我們要解釋為什麼會選擇使用系統調用,原因是在於反病毒或終端檢測與響應(AV/EDR)產品通常會對API進行掛鉤。這兩類防禦產品通常都會在執行Win32 API調用之前對其進行檢查,確認其是否屬於可疑或惡意,並判斷是否允許調用繼續進行。為實現這一點,需要稍微更改常見被濫用的API調用的程序集以跳轉到AV/EDR控制的代碼來完成的,然後在這裡進行檢查,並假設允許該調用,跳回原始API調用的代碼。例如,將Shellcode注入本地或遠程進程時,經常需要使用CreateThread和CreateRemoteThread Win32 API。實際上,我會在嚴格使用Win32 API的注入演示過程中稍微使用一下CreateThread。這些API都是在Windows DLL文件中定義,根據MSDN文檔,這些API的定義具體是位於Kernel32.dll中,這意味著它們可以被正在運行的用戶應用程序訪問,並且實際上沒有直接與操作系統或CPU進行交互。Win32 API本質上是Windows本地API上的抽象層,被認為是內核模式,因為這些API更接近於操作系統和底層硬體。實際上,有比實際執行內核模式功能更低的級別,但是這些級別不會直接公開。本地API是仍然可以被用戶應用程序公開和訪問的最低級別,可以充當用戶代碼與操作系統之間的一種橋樑或粘合劑。下圖很好地解釋了其結構:

我們可以看到,儘管Kernel32.dll的名稱具有誤導性,但它實際上處於比ntdll.dll更高的級別,而ntdll.dll位於用戶模式和內核模式之間的邊界。

那麼,為什麼Win32 API會存在呢?之所以存在的一個重要原因,是需要調用本地API。當我們調用Win32 API時,它會依次調用一個本地API函數,該函數會越過邊界進入內核模式。用戶模式代碼從來不會直接接觸硬體或操作系統。因此,它需要通過本地PI來訪問更低級別的功能。但是,如果本地API仍然必須調用較低級別的API,為什麼不直接使用本地API以減少額外的步驟呢?一種答案是,Microsoft可以在不影響用戶模式應用程序代碼的情況下更改本地API。實際上,本地API中的特定功能通常會在不同Windows版本之間有所更改,但這些更改並不會影響用戶模式代碼,因為Win32 API是保持不變的。

那麼,如果我們只想注入一些Shellcode,為什麼所有這些層、級別和API對我們來說都很重要?就我們的目標而言,Win32 API與本地API之間的主要區別在於反病毒/終端檢測與防護系統可以直接掛鉤Win32調用,但不能掛鉤本地調用。這是因為本地調用被視為內核模式,用戶代碼無法對其進行更改。但也有一些例外情況,例如驅動程序,我們在這裡不對這部分例外情況進行過多討論。至此,我們最大的收穫是,防禦者無法掛鉤本地API調用,但我們卻可以調用它們。這樣一來,我們就可以在不被防禦產品發現的前提下實現相同的功能,這也就是系統調用的一大作用。

系統調用

本地API調用的另一個名稱是系統調用。與Linux類似,每個系統調用都有一個代表它的特定數字,該數字表示系統服務調度表(SSDT)中的一個條目,這是一個內核中的表格,其中包含對各種內核級別函數的各種引用。每個命名的本地API都有一個匹配的系統調用(syscall)編號,該編號對應著一個SSDT條目。為了利用系統調用,我們僅知道API的名稱(例如:NtCreateThread)是不夠的,我們必須還知道它的系統調用號。除此之外,我們需要了解是在哪個版本的Windows上運行,因為在不同版本的操作系統上同一個系統調用的編號可能會發生變化。我們有兩種方式可以找到這些數字,一種比較簡單,另一種涉及到比較複雜的調試過程。

第一種簡便的方法是使用Mateusz 「j00ru」 Jurczyk創建的便捷Windows系統調用表。假設我們已經知道要查找的API,這個過程將會讓查找所需系統調用編號的過程變得非常簡單,我們會在後面詳細介紹。

查找系統調用編號的第二種方法就是直接在ntdll.dll中進行查找。我們要實現注入,需要的第一個系統調用是NtAllocateVirtualMemory。因此,我們可以啟動WinDbg,並在ntdll.dll中尋找NtAllocateVirtualMemory函數。這個過程比聽起來容易得多。首先,我打開一個目標進程進行調試,具體是哪個進程都沒有關係,因為基本上所有進程都會映射ntdll.dll。在這裡,我們選擇了比較友好的記事本。

我們將Shellcode附加到記事本進程中,然後在命令提示符中輸入以下命令:

x ntdll!NtAllocateVirtualMemory

這樣一來,我們就可以檢查ntdll.dll DLL中的NtAllocateVirtualMemory函數。該函數返回函數的內存位置,我們使用u命令對其進行檢查或反彙編。

現在,我們就可以看到有關調用NtAllocateVirtualMemory的準確彙編語言說明。在彙編中如果需要系統調用,通常傾向於遵循一種模式,也就是在堆棧中設置一些參數,例如mov r10, rcx語句所示,然後將系統調用編號移動到eax寄存器中,這裡顯示為mov eax,18h。eax是系統調用指令用於每個系統調用的寄存器。因此,現在我們知道NtAllocateVirtualMemory的系統調用編號是十六進位表示的18,恰好與Mateusz表格中列出的值相同。到目前為止,一切都進行得很順利,我們再重複兩次上述過程,一次用於NtCreateThreadEx,另一次用於NtWaitForSingleObject。

如何獲得這些本地函數

到目前為止,查找本地API調用的系統調用編號的過程非常簡單。但是到目前為止,我還遺漏了一個關鍵的信息——如何知道我需要哪些系統調用呢?我之所以這樣做,是因為想在C#中使用Win32 API調用(GitHub存儲庫名稱為Win32Injector,已經存放在本文的GitHub存儲庫中)運行一個基本能運行的Shellcode注入工具。

這是一個簡單的Shellcode注入工具示例,它會執行一些Shellcode以顯示一個彈出框。

從代碼中可以看到,通過P/Invoke使用的三個主要Win32 API調用是VirtualAlloc、CreateThread和WaitForSingleObject,它們分別為我們的Shellcode分配內存,創建指向我們Shellcode的線程,並啟動該線程。由於它們是正常的Win32 API,因此它們在MSDN中都有非常詳細的文檔說明。但是由於本地API被認為是未記錄的,因此我們可能不得不尋找其他地方。起初,我們找不到API文檔的真實來源,但經過一些搜索後,我就能找到我需要的一切了。

對於VirtualAlloc,我們進行了一些簡單的搜索,發現其在底層的本地API是NtAllocateVirtualMemory,實際上已經在MSDN中進行了記錄。

但遺憾的是,MSDN文檔中沒有關於NtCreateThreadEx的說明,這是CreateThreat的本地API。幸運的是,badBounty的directInjectorPOC中已經包含可用的函數定義,並且是以C#語言實現的。這個項目在我的研究過程中提供了巨大的幫助,因此要對badBounty表示敬意。

最後,我需要找到NtWaitForSingleObject的文檔說明,我們猜測其可能是WaitForSingleObject調用的本地API。我們可能會注意到一個共同點,其中許多本地API調用都是以「Nt」前綴開頭,這會使得從Win32調用映射它們變得更加容易。我們可能還會看到「Zw」前綴,這也是一個本地API調用,但通常是從內核調用的。這部分有時是相通的,如果在WinDbg中執行x ntdll!ZwWaitForSingleObject和x ntdll!NtWaitForSingleObject,我們可以再次看到它們。幸好我們使用了這個API,ZwWaitForSingleObject在MSDN上已經有了詳細的記錄。

最後,我想說明一些信息,這些信息對於如何將Win32映射到本地API調用可能有幫助。首先是ReactOS的源代碼,這是Windows的開源重新實現。在他們的代碼庫GitHub鏡像中,有很多我們可以直接搜索的系統調用。接下來,就是jthuraisamy的SysWhispers,這是一個可以用於查找和實施系統調用的項目,效果非常不錯。最後,要說明一個API Monitor工具。我們可以運行一個進程,並觀察哪些API會被調用,查看其參數以及其他內容。我並沒有大量使用這個函數,因為我只需要3個系統調用,並且查找現有文檔的速度更快。但是,我可以看到Sysinternals的產品具有類似的功能,但我沒有對這部分進行太多的測試。

好的,現在我們將Win32 API映射到系統調用,我們就可以開始編寫C#代碼了。

解決語言問題

在這個過程中,我們發現,在這些文檔中都包含著C/C 實現。那麼我們如何將它們轉換為C#語言呢?答案是——封送(marshaling)。這就是P/Invoke的本質。封送處理是一種利用非託管代碼的方式,例如C/C 語言,這部分代碼可以在託管上下文(即C#)中使用。對於Win32 API,可以通過P/Invoke輕鬆完成。只需要導入DLL,在pinvoke.net的幫助下指定函數的定義,然後就可以使用了。我們可以在Win32Injector的演示代碼中證實這一點。但是,由於系統調用沒有在文檔中被記錄,因此Microsoft不會提供與之交互的簡便方法。

通過委託(delegate)的方式,實際上是有可能實現的,Jack Halon曾經針對這一方面發表過一篇文章,因此我在這裡就不做過多引述了。我建議各位讀者首先閱讀上述文章,以更好地了解它們,同時了解使用syscall的進程。但是為了完整起見,委託實際上是函數指針,讓我們能夠將函數作為參數,再傳遞給其他函數。我們在這裡利用它們的方式是定義一個委託,其返回類型和函數簽名與我們要使用的系統調用相一致。我們使用封送處理來確保C/C 數據類型與C#兼容,定義一個實現系統調用的函數,包括其所有參數和返回類型,然後就可以了。

但實則不然,我們實際上不能調用本地API,因為我們唯一的實現使用的是彙編語言。我們知道其函數定義和參數,但實際上無法像使用Win32 API那樣對其直接進行調用。編譯對我們來說非常好,使用C/C 執行彙編相當簡單,但是如果要使用C#就有難度了。幸運的是,我們有辦法克服這個問題,並且我們已經有了WinDbg擴展中的彙編。不用擔心,用戶在使用系統調用之前是不需要了解彙編語言的。下面是NtAllocateVirtualMemory系統調用的彙編語言:

從注釋中可以看到,我們在堆棧上設置了一些參數,將系統調用編號轉移到eax寄存器中,並使用了神奇的系統調用操作。在足夠低的級別上,這只是一個函數調用。還記得我們在前面提過,委派為什麼只是函數指針嗎?希望這一切都變的有意義。為了調用本地API,我們需要獲得一個指向該程序集的函數指針,以及一些與C/C 格式兼容的參數。

組合利用

現在,我們差不多快完成了,我們已經有了系統調用、它們的編號、調用它們的程序集以及在委託中調用它們的方法。接下來,我們來看看C#語言的實際外觀:

從最上面開始,我們可以看到NtAllocateVirtualMemory的C/C 定義,以及系統調用本身的程序集。從第38行開始,我們有了NtAllocateVirtualMemory的C#定義。請注意,如果我們要讓C#中的每種類型與非託管類型相匹配,可能要花費一定的時間。我們可以在不安全的塊中創建一個指向程序集的指針。這樣一來,我們就可以在C#中執行操作,例如對原始內存執行操作,這些操作通常對於託管代碼來說是不安全的。我們還使用了fixed關鍵字,來確保C#垃圾回收工具不會無意間移動內存並更改指針。

一旦有了指向Shellcode內存位置的原始指針,就需要將其內存保護更改為可執行文件,以便可以直接運行它,因為其形式將會是函數指針,而不僅僅是數據。在這裡,我將會使用Win32 API VirtualProtectEx來更改內存保護。我不知道通過系統調用實現這種操作的方式,因為這有點像雞生蛋的問題,需要獲取可執行的內存才能運行系統調用。如果有朋友知道如何在C#中執行此操作,歡迎與我取得聯繫!在這裡,還需要注意的另外一件事是,將內存設置為RWX通常會引起懷疑,但是由於這裡展示的是PoC,我就暫時沒有顧慮這一點。我們現在關心的是掛鉤問題,而不是內存掃描。

現在,就是見證奇蹟的時刻。下面是我們的委託被聲明的結構:

請注意,委託的定義只是函數簽名和返回類型。只要實現與委託定義相匹配,我們就可以決定實現的方式,這也就是我們在C#語言的NtAllocateVirtualMemory函數中實現的內容。在上面的第65行,我們創建了一個名為assembledFunction的委託,該委託利用了特殊的封送處理函數Marshal.GetDelegateForFunctionPointer。這種方法可以讓我們從函數指針獲取委託。在這種情況下,我們的函數指針是指向名為memoryAddress的系統調用程序集的指針。現在,assembledFunction是指向彙編語言函數的函數指針,也就意味著我們現在可以執行系統調用。就像調用任何常規函數一樣,我們使用參數來完成對assembledFunction委託的調用,然後獲取NtAllocateVirtualMemory系統調用的結果。因此,在我們的return語句中,使用傳入的參數調用assembledFunction,然後返回結果。讓我們看看在Program.cs中實際調用此函數的位置:

在這裡,我們調用了NtAllocateMemory,而不是Win32Injector使用的Win32 API VirtualAlloc。我們使用所有需要的參數來設置函數調用(第43-48行),然後調用NtAllocateMemory。這將會為我們的Shellcode返回一個內存塊,就如同VirtualAlloc一樣。

其餘的步驟類似:

我們將Shellcode複製到新分配的內存中,然後在當前進程中通過另一個系統調用NtCreateThreadEx來替代CreateThread在該內存中創建一個指向該內存的線程。最後,我們通過調用NtWaitForSingleObject系統調用來啟動線程,而不是WaitForSingleObject。下面是最後的結果:

終於,我們得到了一個通過系統調用顯示的Hello World。假如這是在啟用了API掛鉤的系統上運行的某種Payload,我們將繞過它,並且成功運行了我們的Payload。

後續需解決的問題

我們還有一些難題需要解決,比如為保證系統調用正常運行所需的所有本地結構、枚舉和定義。如果我們觀察上面的屏幕截圖,我們會發現在C#中沒有實現的類型,例如所有系統調用的NTSTATUS返回類型,或者AllocationType和ACCESS_MASK位掩碼。這些類型通常會在各種Windows標頭和DLL中聲明,但是要使用系統調用,我們就需要自己實現它們。我發現它們的方法是,查找任何比較複雜的類型,然後嘗試找到其對應的定義。在這裡,Pinvoke.net網站非常有幫助,將其與MSDN、ReactOS源代碼等其他資源組合,我們就可以找到並添加所需的一切。我們可以在GitHub項目的Native.cs類中找到上述代碼。

總結

實現系統調用是一個非常有趣的過程,我們幾乎很少會在一個程序中結合三種不同的語言、結合託管和非託管代碼、結合多個級別的Windows API。實際上,要實現系統調用,並不是一件容易的事情,我們需要使用一些示例的代碼,而這些示例代碼散落在各處,就如同沒有頭緒的尋寶遊戲一樣。在託管和非託管代碼進行轉換的過程中,調試也許會比較複雜。最後,系統調用編號經常會更改,因此我們必須針對所使用的平台進行自定義。D/Invoke似乎可以很好地解決其中一些問題,因此我將繼續深入研究這一部分的內容,希望能儘快與大家深入探討這些問題。

參考及來源:https://www.solomonsklash.io/syscalls-for-shellcode-injection.html

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!


請您繼續閱讀更多來自 嘶吼RoarTalk 的精彩文章:

一個 gif 就可以黑掉Zoom接收者系統
韓國黑客聲稱黑掉了印度視頻服務提供商ZEE5