《Evading EDR》— FUNCTION-HOOKING_DLLS

JICEY發表於2024-04-16

最近一直在做EDR相關的工作,雖然略有了解EDR的機制,但是並未深究其完整的工作框架和可能的繞過機制,借工作空閒時間依靠智譜清言閱讀一下《Evading EDR The Definitive Guide to Defeating Endpoint Detection Systems》一書。

在眾多現代端點安全產品的元件中,最常部署的是負責函式掛鉤的DLL。這些DLL向防禦者提供了程式碼執行的關鍵資訊,如傳遞給目標函式的引數及其返回結果。目前,供應商通常利用這些資料來增強其他更穩固的資訊渠道。然而,函式掛鉤依然是端點檢測與響應系統(EDR)的核心部分。在本章中,我們將探討EDR通常如何擷取函式呼叫,以及作為攻擊者,我們可以採取哪些策略來繞過或干擾這些機制。
本章重點關注Windows檔案ntdll.dll中的函式掛鉤,我們將在稍後介紹其功能,但現代EDR也會掛鉤其他Windows函式。實現這些其他掛鉤的過程與本章描述的工作流程非常相似。

How Function Hooking Works

要理解端點安全產品如何運用程式碼掛鉤技術,首先需要掌握使用者模式下程式碼與核心互動的方式。使用者模式下的程式碼在執行時通常會藉助Win32 API來實現主機上的某些功能,如請求其他程序的處理控制代碼。然而,許多操作無法完全在使用者模式下透過Win32 API完成,例如記憶體和物件管理,這些操作屬於核心的職責範疇。
在x64系統中,為了將執行流程轉移到核心,會使用syscall指令。但Windows並非在每一個需要與核心互動的函式中都直接實現syscall指令,而是透過ntdll.dll中的函式來提供這些功能。使用者模式的函式只需將必要的引數傳遞給這些匯出的函式,後者則會將控制權轉交給核心,並返回操作結果。例如,圖2-1展示了當使用者模式應用程式呼叫Win32 API函式kernel32!OpenProcess()時的執行流程。

5e32e961-106b-4e3f-8b55-6bddd3f80937
為了有效檢測惡意活動,安全產品供應商通常會掛鉤特定的Windows API。例如,EDR檢測遠端程序注入的一種方法是透過掛鉤那些用於開啟其他程序控制代碼、分配記憶體區域、向該記憶體寫入資料以及建立遠端執行緒的函式。
在早期Windows版本中,供應商(以及惡意軟體作者)經常將他們的掛鉤放置在系統服務排程表(SSDT)上。SSDT是核心中的一個表,它包含了用於syscall呼叫的核心函式指標。安全產品透過覆蓋這些函式指標,替換為自己的核心模組中的函式指標,用於記錄函式呼叫資訊,然後執行目標函式,並將返回值傳回源應用程式。
然而,自2005年Windows XP推出以來,微軟引入了名為核心補丁保護(KPP)的機制,旨在防止對SSDT及其他關鍵結構的修改。因此,在現代64位Windows版本上,這種技術變得不可行。這導致傳統的掛鉤必須在使用者模式下進行。由於ntdll.dll中執行syscall的函式是在使用者模式下觀察API呼叫的最後機會,EDR通常會掛鉤這些函式以監控其呼叫和執行。一些常見的掛鉤函式在表格2-1中有詳細說明。
ce14fd52-79d0-4f8d-b510-6951059acdcd
透過攔截對這些API的呼叫,EDR能夠監控傳遞給原始函式的引數,以及返回給呼叫API的程式碼的值。代理程式隨後可以分析這些資料,以判斷活動是否具有惡意性。例如,為了檢測遠端程序注入,一個代理可以監控以下行為:記憶體區域是否以讀寫執行許可權分配,是否在新分配的記憶體中寫入資料,以及是否使用指向該寫入資料的指標建立了一個執行緒。

Implementing the Hooks with Microsoft Detours

儘管有許多庫使實現函式掛鉤變得簡便,但大多數庫在幕後都採用了相似的技術。這是因為,本質上,所有函式掛鉤都涉及到修改無條件跳轉(JMP)指令,以此將執行流從被掛鉤的函式重定向到EDR開發人員指定的函式。
Microsoft Detours是其中一個最常用於實現函式掛鉤的庫。在技術層面,Detours透過用一個無條件JMP指令替換要掛鉤函式的前幾個指令,從而將執行流重定向到開發人員定義的函式,這個函式通常被稱為detour。detour函式執行開發人員指定的操作,例如記錄傳遞給目標函式的引數。之後,它將執行傳遞給另一個函式,通常稱為trampoline,該函式執行目標函式幷包含最初被覆蓋的指令。目標函式執行完畢後,控制權返回到detour。detour可能會執行額外的處理,例如記錄原始函式的返回值或輸出,然後再將控制權返回到原始程序。
圖2-2展示了正常程序的執行與新增了detour的程序之間的比較。實線箭頭表示預期的執行流程,而虛線箭頭則表示掛鉤後的執行流程。
f801e67a-06cf-425d-af7d-2b090736d862
在此示例中,EDR選擇掛鉤ntdll!NtCreateFile()函式,這是一個用於建立新的I/O裝置或開啟現有裝置的控制代碼的syscall。在正常操作中,這個syscall會直接轉移到核心,由其核心模式對應函式繼續執行操作。然而,當EDR的掛鉤被部署後,執行流程會在注入的DLL中暫停。這個edr!HookedNtCreateFile()函式將代表ntdll!NtCreateFile()執行syscall,從而允許它收集傳遞給syscall的引數以及操作的結果。
在偵錯程式中檢查掛鉤函式,例如WinDbg,可以清楚地展示掛鉤函式與非掛鉤函式之間的差異。列表2-1展示了在WinDbg中未掛鉤的kernel32!Sleep()函式的樣子。

fd8aa9ab-484a-4c6c-8be7-2ade7123e23c
該函式的反彙編展示了我們預期的執行流程。當呼叫者呼叫kernel32!Sleep()時,執行首先跳轉到跳轉樁kernel32!SleepStub(),然後透過長跳轉(JMP)到kernel32!_imp_Sleep(),後者提供了呼叫者所期望的真實Sleep()功能。
在利用Detours進行DLL注入並掛鉤該函式後,其外觀和結構將發生顯著變化,如列表2-2所示。
f67b8c67-0dc9-4627-acac-462f7da82c93
與直接跳轉到kernel32!_imp_Sleep()不同,反彙編程式碼現在包含了一系列的JMP指令。第二個JMP指令將執行流程跳轉到trampoline64!TimedSleep(),如列表2-3所示。
838e65b8-7390-441d-bd1c-4f86f5f95043
為了收集關於掛鉤函式執行的度量資料,這個trampoline函式透過其內部的trampoline64!TrueSleep()包裝函式呼叫合法的kernel32!Sleep()函式,以評估其睡眠的CPU時鐘滴答數,並透過彈出訊息顯示這些資料。
雖然這是一個人為構造的例子,但它展示了每個EDR的函式掛鉤DLL的核心功能:代理目標函式的執行並收集有關如何呼叫它的資訊。在這個案例中,我們的EDR簡單地測量了掛鉤程式的睡眠時間。在真實的EDR應用中,與對手行為相關的重要函式,例如ntdll!NtWriteVirtualMemory()用於將程式碼複製到遠端程序,也會以類似的方式進行代理,但掛鉤可能會更加關注傳遞的引數和返回的值。

Injecting the DLL

一個掛鉤函式的DLL在未載入到目標程序之前並不特別有用。一些庫提供了透過API建立程序並注入DLL的能力,但這對於EDR來說並不實用,因為它們需要能夠在使用者隨時建立的程序中注入它們的DLL。幸運的是,Windows提供了一些方法來實現這一點。
在Windows 8之前,許多供應商選擇使用AppInit_Dlls機制將他們的DLL載入到每個互動式程序(那些匯入user32.dll的程序)中。不幸的是,惡意軟體作者經常濫用這種技術來實現持久化和資訊收集,而且它還因引起系統效能問題而臭名昭著。微軟不再推薦使用這種方法進行DLL注入,從Windows 8開始,在啟用了安全啟動的系統上完全阻止了這種方法。
將功能掛鉤DLL注入程序的最常用技術是利用驅動程式,它可以利用核心級別的功能非同步過程呼叫(KAPC)注入將DLL插入程序。當驅動程式接收到新程序建立的通知時,它將為程序分配一些記憶體用於APC例程和要注入的DLL名稱。然後它會初始化一個新的APC物件,負責將DLL載入到程序中,並將其複製到程序的地址空間。最後,它會更改執行緒的APC狀態中的一個標誌,以強制執行APC。當程序恢復其執行時,APC例程將執行載入DLL

Detecting Function Hooks

在網路安全實踐中,攻擊者經常需要確定他們計劃使用的函式是否已被掛鉤。一旦識別出被掛鉤的函式,他們就可以將它們列入清單,並限制或完全避免使用這些函式。這樣攻擊者可以繞過EDR(端點檢測與響應系統)的函式掛鉤DLL的檢查,因為其檢查功能將永遠不會被呼叫。檢測掛鉤函式的過程通常很簡單,特別是對於ntdll.dll匯出的本地API函式。
ntdll.dll中的每個函式都包含一個syscall樁。構成這個樁的指令在列表2-4中顯示。
8bd2a62c-f65e-4cb4-b143-eeb1f7730ac2
你可以透過在WinDbg中反彙編ntdll.dll匯出的函式來檢視這個樁,如列表2-5所示。
71bfb40e-20c4-4b07-a84d-7d856ea77802
在ntdll!NtAllocateVirtualMemory()的反彙編中,我們可以觀察到syscall樁的基本組成部分。這個樁首先在R10暫存器中保留了RCX暫存器的值,然後將對應於NtAllocateVirtualMemory()的syscall編號(在這個版本的Windows中為0x18)移入EAX。接下來,MOV指令之後的TEST和條件跳轉(JNE)指令是所有syscall樁中都存在的檢查。這些檢查在受限制的使用者模式下使用,當核心模式程式碼啟用了Hypervisor程式碼完整性,而使用者模式程式碼未啟用時。在這種情況下,可以安全地忽略這些檢查。最後,執行syscall指令,將控制權轉移到核心以處理記憶體分配。當函式完成後,控制權返回給ntdll!NtAllocateVirtualMemory(),後者僅簡單地返回。
由於所有本地API的syscall樁結構都是相同的,任何對這些樁的修改都表明可能存在函式掛鉤。例如,列表2-6展示了被篡改的ntdll!NtAllocateVirtualMemory()函式的syscall樁。

0:013> u ntdll!NtAllocateVirtualMemory
ntdll!NtAllocateVirtualMemory
00007fff`fe90c0b0 e95340baff                jmp         00007fff`fe4b0108
00007fff`fe90c0b5 90                                  nop
00007fff`fe90c0b6 90                                  nop
00007fff`fe90c0b7 90                                  nop
00007fff`fe90c0b8 f694259893fe7f01  test         byte        ptr [SharedUserData+0x308],1
00007fff`fe90c0c0 7503                             jne          ntdll!NtAllocateVirtualMemory+0x15
00007fff`fe90c0c2 0f05                              syscall

00007fff`fe90c0c4 c3                                 ret
00007fff`fe90c0c5 cd2e                            int             2Eh
00007fff`fe90c0c7 c3                                 ret

在這裡,請注意,ntdll!NtAllocateVirtualMemory()的入口點處並沒有syscall樁,而是存在一個無條件JMP指令。EDR通常使用這種型別的修改來重定向執行流到它們的掛鉤DLL。
因此,為了檢測EDR放置的掛鉤,我們可以簡單地檢查當前載入到我們程序中的ntdll.dll副本中的函式,將它們的入口點指令與未修改的syscall樁的預期操作碼進行比較。如果我們發現我們想要使用的函式上有掛鉤,我們可以嘗試使用下一節中描述的技術來規避它。

Evading Function Hooks

在端點安全軟體中使用的所有感測器元件中,函式掛鉤在規避方面是最受研究的。攻擊者可以使用多種方法來規避函式攔截,這些方法通常可以歸結為以下幾種技術(還有更多其他技術):

  • 直接執行未修改的syscall樁中的指令
  • 重新對映ntdll.dll以獲取未掛鉤的函式指標或覆蓋當前對映在程序中的掛鉤ntdll.dll
  • 阻止非Microsoft DLL在程序中載入,以防止EDR的函式掛鉤DLL放置其detour

Making Direct Syscalls

迄今為止,最常被濫用的規避ntdll.dll函式掛鉤的技術是直接執行syscall。如果我們自己執行syscall樁中的指令,我們可以模仿一個未修改的函式。要做到這一點,我們的程式碼必須包含所需函式的簽名、包含正確syscall編號的樁以及目標函式的呼叫。這個呼叫使用簽名和樁來傳遞所需的引數並以函式掛鉤無法檢測的方式執行目標函式。列表2-7包含了我們需要建立的第一個檔案來實現這一技術。
f56550de-984c-4ee4-a1ba-5c285694736b
在我們的專案中,第一個檔案包含了ntdll!NtAllocateVirtualMemory()的重新實現。該檔案中的唯一函式將填充EAX暫存器以執行syscall編號,然後執行syscall指令。這段彙編程式碼將儲存在自己的.asm檔案中,Visual Studio可以配置為使用Microsoft宏彙編器(MASM)編譯它,並與專案中的其他程式碼一起編譯。
儘管我們已經構建了自己的syscall樁,但我們仍然需要一種方法從我們的程式碼中呼叫它。列表2-8展示了我們如何做到這一點。
50f9b764-e3ae-4b05-867c-cf0ed4f8cdd5
這個函式定義包含了所有必需的引數及其型別,以及返回型別。它應該在我們的標頭檔案syscall.h中,並在我們的C原始檔中包含,如下2-9所示。
4c19379f-67d0-40f0-adae-a616edc641c7
這個檔案中的wmain()函式呼叫NtAllocateVirtualMemory()以在當前程序中分配一個具有讀寫許可權的0x1000位元組緩衝區。這個函式不在微軟向開發者提供的標頭檔案中定義,所以我們必須在自己的標頭檔案中定義它。當這個函式被呼叫時,呼叫的是我們專案中的彙編程式碼,而不是ntdll.dll,有效地模擬了未掛鉤的ntdll!NtAllocateVirtualMemory()的行為,而不會執行EDR掛鉤的風險。
這種技術的主要挑戰之一是微軟經常更改syscall號碼,因此任何硬編碼這些號碼的工具可能只適用於特定版本的Windows。例如,在Windows 10構建1909上,ntdll!NtCreateThreadEx()的syscall號碼是0xBD。在接下來的版本20H1上,它是0xC1。這意味著針對構建1909的工具不會在Windows的後續版本上工作。
為了幫助解決這個限制,許多開發人員依賴於外部資源來跟蹤這些更改。例如,Google Project Zero的Mateusz Jurczyk維護了一個包含每個Windows版本中函式及其關聯syscall號碼的列表。在2019年12月,Jackson Thuraisamy釋出了工具SysWhispers,它使攻擊者能夠動態地為他們的進攻工具包中的syscall生成函式簽名和彙編程式碼。列表2-10顯示了SysWhispers為Windows 10構建1903至20H2上的ntdll!NtCreateThreadEx()函式生成的彙編程式碼。
2e67e481-42e7-478f-9928-7f42934d32e2
這段彙編程式碼從程序環境塊1中提取構建號,然後使用該值將適當的syscall號碼移動到EAX暫存器,在進行syscall之前。雖然這種方法可行,但它需要大量的努力,因為攻擊者必須在每次微軟釋出新的Windows構建時更新他們的資料集中的syscall號碼。

Dynamically Resolving Syscall Numbers

在2020年12月,一位名為@modexpblog的Twitter研究人員發表了一篇部落格文章,題為《繞過使用者模式掛鉤和直接呼叫系統呼叫》。這篇文章介紹了一種新的函式掛鉤規避技術:在執行時動態解析syscall號碼,從而使攻擊者無需為每個Windows構建硬編碼值。
這種技術的工作流程如下,用於建立一個函式名稱和syscall號碼的字典:

  1. 獲取當前程序對映的ntdll.dll的處理控制代碼。
  2. 列舉所有以Zw開頭的匯出函式,以識別系統呼叫。需要注意的是,以Nt(更常見)開頭的函式(在使用者模式下呼叫時工作方式相同)在這裡似乎是隨機的。
  3. 儲存匯出函式名稱及其關聯的相對虛擬地址。
  4. 根據相對虛擬地址對字典進行排序。
  5. 將函式的syscall號碼定義為排序後字典中的索引。
    使用這種技術,攻擊者可以在執行時收集syscall號碼,將它們插入到樁中的適當位置,然後像靜態編碼方法中通常那樣呼叫目標函式。

Remapping ntdll.dll

另一種常見的規避使用者模式函式掛鉤的技術是向程序中載入ntdll.dll的新副本,並用新載入檔案的正文覆蓋現有掛鉤版本,然後呼叫所需的函式。這種策略之所以有效,是因為新載入的ntdll.dll不包含之前載入副本中實現的掛鉤,所以當它覆蓋受汙染的版本時,實際上清除了EDR放置的所有掛鉤。列表2-11顯示了這個技術的初級示例。為了簡潔省略了一些行。
a706109e-db5a-4fc3-a7da-643f38b5dacf
80a9baac-d8fe-4479-b60f-eb154175d7e2
我們的程式碼首先獲取當前載入的(掛鉤的)ntdll.dll的基址。然後,我們從磁碟讀取ntdll.dll的內容並將其對映到記憶體。此時,我們可以解析掛鉤的ntdll.dll的PE頭部,尋找.text節區的地址,該節區包含影像中的可執行程式碼。一旦我們找到它,我們更改該記憶體區域的許可權,以便我們可以寫入它,從“乾淨”檔案複製.text節區的內容,並恢復記憶體保護。完成這一系列事件後,EDR最初放置的掛鉤應該已經被移除,開發人員可以安全地呼叫ntdll.dll中的任何函式,而不用擔心執行被重定向到EDR注入的DLL。
雖然從磁碟讀取ntdll.dll似乎很簡單,但它確實帶來了一個潛在的權衡。這是因為將ntdll.dll載入到單個程序中多次是非典型行為。防禦者可以使用Sysmon,這是一個免費系統監控實用程式,提供了與EDR相同的許多遙測收集功能。幾乎每個非惡意程序都有一對一的程序GUID與載入的ntdll.dll的對映。當我查詢大型企業環境中的這些屬性時,大約有3700萬程序在一個月內載入了ntdll.dll超過一次,佔比僅約0.04%。
為了避免基於這種異常的檢測,你可能會選擇在一個暫停狀態中生成一個新的程序,獲取新程序中對映的未修改ntdll.dll的處理控制代碼,並將其複製到當前程序。然後,你可以像以前一樣獲取函式指標,或者替換現有的掛鉤ntdll.dll,以有效地覆蓋EDR放置的掛鉤。列表2-12演示了這個技術。
這個最小示例首先開啟對我們程序當前對映的ntdll.dll副本的一個處理控制代碼,獲取其基址,並解析其PE頭部。接下來,它建立了一個暫停的程序,並解析了這個程序的ntdll.dll副本PE頭部,該副本還沒有機會被EDR掛鉤。這個函式的其餘流程與前一個示例完全相同,當它完成後,掛鉤的ntdll.dll應該已經恢復到乾淨狀態。
就像所有事情一樣,這裡也存在權衡,因為我們的新暫停程序為檢測提供了另一個機會,例如透過掛鉤的ntdll!NtCreateProcessEx()、驅動程式或ETW提供程式。在我的經驗中,很少看到一個程式為了合法原因建立一個臨時暫停的程序。

Conclusion

函式掛鉤是端點安全產品可以監控其他程序執行流的一種原始機制。雖然它為EDR提供了非常有用的資訊,但它非常容易繞過,因為其常見實現的固有弱點。因此,大多數成熟的EDR現在將其視為輔助遙測源,並依賴更健壯的感測器。