限分離:ShellCode加載器的現(xiàn)代免殺實踐)
最近在和一些做安全研究的朋友交流時發(fā)現(xiàn)一個挺有意思的現(xiàn)象很多剛接觸免殺的同學拿到一段 ShellCode 后第一反應(yīng)就是去找一個“加載器”然后照著教程用VirtualAlloc申請RWX可讀可寫可執(zhí)行內(nèi)存把 ShellCode 拷進去最后跳轉(zhuǎn)執(zhí)行。流程跑通了在虛擬機里彈個計算器成就感滿滿。但一旦放到稍微有點防護的環(huán)境里比如裝了主流 EDR終端檢測與響應(yīng)的機器上幾乎瞬間就被檢測出來。問題出在哪不是 ShellCode 本身不夠隱蔽也不是加密算法不夠強而是那個最不起眼的加載步驟——申請RWX內(nèi)存。在今天的終端安全視野里一個進程突然申請一塊可寫又可執(zhí)行的內(nèi)存然后把一段不明數(shù)據(jù)寫進去并執(zhí)行這個行為本身就是一個極其強烈的惡意信號。它幾乎是在對 EDR 大喊“快來看我在這里干壞事”所以免殺的入門遠不止是給 ShellCode 加個殼或換個編碼。真正的第一課是理解現(xiàn)代 EDR 的檢測邏輯并學會“像正常程序一樣做事”。而“權(quán)限分離”就是這第一課里最核心的原則。它要求我們把“數(shù)據(jù)寫入”和“代碼執(zhí)行”這兩個動作從時間和空間上拆分開用更符合正常軟件行為模式的方式來加載 ShellCode。這篇文章我們就來徹底拆解這個從“RWX 全家桶”到“權(quán)限分離”的思維轉(zhuǎn)變與實踐路徑。1. 為什么“RWX”成了EDR眼中的頭號嫌疑犯要繞過檢測首先得知道別人是怎么抓你的。EDR 對進程行為的監(jiān)控是立體的它不僅僅看你的文件靜態(tài)特征哈希、簽名更關(guān)注你的運行時行為。其中內(nèi)存操作是行為監(jiān)控的重中之重。1.1 EDR 的內(nèi)存行為監(jiān)控維度一個典型的 EDR 驅(qū)動或鉤子Hook會監(jiān)控一系列關(guān)鍵的系統(tǒng) API尤其是內(nèi)存管理相關(guān)的。當你調(diào)用VirtualAlloc、VirtualProtect、WriteProcessMemory等函數(shù)時EDR 有機會在函數(shù)執(zhí)行前后進行檢查。它們會關(guān)注內(nèi)存權(quán)限的申請與變更申請PAGE_EXECUTE_READWRITE(RWX) 權(quán)限的內(nèi)存本身就是一個高危行為。合法軟件極少需要同時具備可寫和可執(zhí)行權(quán)限的內(nèi)存塊。更多情況下代碼段是RX可讀可執(zhí)行數(shù)據(jù)段是RW可讀可寫。權(quán)限的“升格”操作先申請RW可讀可寫內(nèi)存寫入數(shù)據(jù)然后通過VirtualProtect將其改為RX可讀可執(zhí)行這個“從數(shù)據(jù)到代碼”的轉(zhuǎn)變過程同樣可疑。內(nèi)存內(nèi)容的來源與執(zhí)行將一塊非映像文件比如來自網(wǎng)絡(luò)、資源段或解密緩沖區(qū)的數(shù)據(jù)設(shè)置成可執(zhí)行并跳轉(zhuǎn)過去這違背了操作系統(tǒng)的典型內(nèi)存布局原則。RWX加載器之所以“經(jīng)典”是因為它簡單直接一次性完成了內(nèi)存申請、寫入、執(zhí)行的所有準備工作。但這種簡單恰恰是它最大的弱點——它創(chuàng)造了一個在正常軟件中極為罕見的行為模式一個完美的檢測特征。1.2 正常軟件的內(nèi)存使用模式反觀一個正常的軟件比如一個文本編輯器它的.text代碼段在磁盤上就是可執(zhí)行的加載到內(nèi)存后操作系統(tǒng)會將其映射為RX權(quán)限。它的.data數(shù)據(jù)段用于存儲變量權(quán)限是RW。它動態(tài)申請內(nèi)存如malloc/new用于處理數(shù)據(jù)這些內(nèi)存默認是RW絕不會是X可執(zhí)行。整個生命周期中幾乎沒有理由去創(chuàng)建一塊同時可寫又可執(zhí)行的內(nèi)存。因此EDR 的策略非常清晰將“申請或創(chuàng)建 RWX 內(nèi)存區(qū)域”作為一個高權(quán)重威脅指標IoC。一旦觸發(fā)即使你的文件本身靜態(tài)免殺做得再好也會在運行時被重點關(guān)照甚至直接終止。2. 權(quán)限分離的核心思想拆解“寫入”與“執(zhí)行”既然同時做“寫入”和“執(zhí)行”太顯眼那我們就把它拆開分步進行并且每一步都盡量模仿正常行為。這就是“權(quán)限分離”加載器的核心思路。其流程可以抽象為三個階段分配階段申請內(nèi)存但只給予數(shù)據(jù)權(quán)限如PAGE_READWRITE。填充階段將 ShellCode通常是解密后寫入這塊內(nèi)存。此時內(nèi)存仍是數(shù)據(jù)屬性。轉(zhuǎn)換與執(zhí)行階段改變這塊內(nèi)存的權(quán)限使其變?yōu)榭蓤?zhí)行如PAGE_EXECUTE_READ然后跳轉(zhuǎn)執(zhí)行。這個“先數(shù)據(jù)后代碼”的過程雖然最終結(jié)果同樣是執(zhí)行了外來代碼但其行為模式更接近于一些合法的場景例如即時編譯JIT運行時生成機器碼并執(zhí)行。軟件保護殼解密原始代碼后執(zhí)行。某些腳本引擎的動態(tài)代碼生成。這些場景在受信任的軟件中是被允許的因此 EDR 對單一RW-RX轉(zhuǎn)換的判定會比直接RWX寬松一些或者說需要結(jié)合更多上下文如進程信譽、父進程、調(diào)用鏈來判斷。這就給了我們操作的空間。3. 實戰(zhàn)構(gòu)建一個基礎(chǔ)的權(quán)限分離加載器讓我們用 C 語言實現(xiàn)一個最基礎(chǔ)的權(quán)限分離加載器并與傳統(tǒng)的 RWX 加載器進行對比。假設(shè)我們已經(jīng)通過某種方式如 XOR 加密、AES 解密獲得了一段明文的 ShellCode 字節(jié)數(shù)組shellcode[]及其長度shellcode_len。3.1 傳統(tǒng) RWX 加載器高危示例#include windows.h int main() { // 1. 申請 RWX 內(nèi)存 LPVOID execMem VirtualAlloc(NULL, shellcode_len, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (execMem NULL) { return -1; } // 2. 寫入 ShellCode memcpy(execMem, shellcode, shellcode_len); // 3. 跳轉(zhuǎn)執(zhí)行 ( (void(*)()) execMem )(); return 0; }問題分析VirtualAlloc直接使用PAGE_EXECUTE_READWRITE標志。這是最容易被檢測的簽名之一。3.2 權(quán)限分離加載器基礎(chǔ)版#include windows.h int main() { // 1. 申請 RW 內(nèi)存數(shù)據(jù)權(quán)限 LPVOID pMem VirtualAlloc(NULL, shellcode_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (pMem NULL) { return -1; } // 2. 寫入 ShellCode此時是數(shù)據(jù) memcpy(pMem, shellcode, shellcode_len); // 3. 更改內(nèi)存權(quán)限為 RX代碼權(quán)限 DWORD oldProtect 0; if (!VirtualProtect(pMem, shellcode_len, PAGE_EXECUTE_READ, oldProtect)) { VirtualFree(pMem, 0, MEM_RELEASE); return -1; } // 4. 跳轉(zhuǎn)執(zhí)行 ( (void(*)()) pMem )(); // 5. 執(zhí)行后可以恢復(fù)權(quán)限或釋放內(nèi)存可選 // VirtualProtect(pMem, shellcode_len, oldProtect, oldProtect); // VirtualFree(pMem, 0, MEM_RELEASE); return 0; }關(guān)鍵改進VirtualAlloc使用PAGE_READWRITE這是一個非常普通的數(shù)據(jù)內(nèi)存申請操作。寫入操作發(fā)生在內(nèi)存仍是數(shù)據(jù)屬性時。VirtualProtect將內(nèi)存屬性從RW改為RX。這個操作雖然仍會被監(jiān)控但其普遍性遠高于直接申請RWX。這個基礎(chǔ)版已經(jīng)能夠繞過一些僅依賴靜態(tài)特征或簡單RWX檢測的初級防護。但它遠非終點EDR 同樣會監(jiān)控VirtualProtect的調(diào)用尤其是當它用于將可寫內(nèi)存改為可執(zhí)行時。4. 進階對抗模糊化與合法化內(nèi)存操作要進一步提升隱蔽性我們需要讓內(nèi)存操作更“低調(diào)”或者嫁接到合法的系統(tǒng)行為上。4.1 使用更底層的 APIVirtualAlloc和VirtualProtect是用戶層的高層API容易被鉤住。我們可以嘗試使用更底層的 NT API例如NtAllocateVirtualMemory和NtProtectVirtualMemory。這些函數(shù)是VirtualAlloc和VirtualProtect的底層實現(xiàn)有時EDR的鉤子可能只掛在高層API上。#include windows.h #include winternl.h // 需要聲明 NT API 函數(shù)原型 typedef NTSTATUS (NTAPI *pNtAllocateVirtualMemory)( HANDLE ProcessHandle, PVOID *BaseAddress, ULONG_PTR ZeroBits, PSIZE_T RegionSize, ULONG AllocationType, ULONG Protect ); typedef NTSTATUS (NTAPI *pNtProtectVirtualMemory)( HANDLE ProcessHandle, PVOID *BaseAddress, PSIZE_T NumberOfBytesToProtect, ULONG NewAccessProtection, PULONG OldAccessProtection ); // 動態(tài)獲取函數(shù)地址 pNtAllocateVirtualMemory NtAllocateVirtualMemory; pNtProtectVirtualMemory NtProtectVirtualMemory; // 初始化函數(shù)指針通常在DllMain或初始化函數(shù)中 HMODULE hNtdll GetModuleHandleA(ntdll.dll); NtAllocateVirtualMemory (pNtAllocateVirtualMemory)GetProcAddress(hNtdll, NtAllocateVirtualMemory); NtProtectVirtualMemory (pNtProtectVirtualMemory)GetProcAddress(hNtdll, NtProtectVirtualMemory); // 使用 NT API 分配 RW 內(nèi)存 PVOID baseAddr NULL; SIZE_T regionSize shellcode_len; NTSTATUS status NtAllocateVirtualMemory(GetCurrentProcess(), baseAddr, 0, ?ionSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); // ... 寫入 ShellCode ... // 使用 NT API 修改權(quán)限為 RX ULONG oldProtect; status NtProtectVirtualMemory(GetCurrentProcess(), baseAddr, ?ionSize, PAGE_EXECUTE_READ, oldProtect);注意現(xiàn)代 EDR 同樣會鉤住這些 NT API。這只是一種繞過淺層鉤子的方法并非銀彈。4.2 利用合法的可執(zhí)行內(nèi)存區(qū)域與其自己申請和轉(zhuǎn)換內(nèi)存不如“借用”系統(tǒng)中已經(jīng)存在的、具有可執(zhí)行權(quán)限的內(nèi)存區(qū)域。例如PE文件中的代碼縫隙在程序自身的.text段或其他可執(zhí)行模塊中尋找未被使用的代碼“洞穴”Code Caves將 ShellCode 填入并執(zhí)行。這需要精確計算偏移和避免破壞原有功能。內(nèi)存映射文件映射一個合法的 DLL 或 EXE 文件到內(nèi)存利用其固有的可執(zhí)行內(nèi)存頁。這種方法技術(shù)難度較高需要對PE結(jié)構(gòu)有深入理解且通用性不強。4.3 進程注入與遠程線程創(chuàng)建中的權(quán)限分離在進程注入場景中權(quán)限分離原則同樣適用且更為重要。常見的CreateRemoteThread注入配合VirtualAllocEx時也應(yīng)避免直接申請RWX內(nèi)存。改進的遠程注入流程在目標進程申請RW內(nèi)存 (VirtualAllocExwithPAGE_READWRITE)。寫入 ShellCode (WriteProcessMemory)。更改內(nèi)存權(quán)限為RX(VirtualProtectEx)。創(chuàng)建遠程線程執(zhí)行 (CreateRemoteThread)。4.4 時序與邏輯混淆將“寫入”和“權(quán)限更改”兩個操作在時間上分離或者與大量合法的內(nèi)存操作混合在一起增加EDR行為分析的難度。例如先申請一大塊RW內(nèi)存在程序運行過程中的不同時間點分多次寫入 ShellCode 的片段最后再一次性更改權(quán)限并執(zhí)行。這需要更復(fù)雜的加載器邏輯。5. 工程化考量與防御規(guī)避清單構(gòu)建一個用于實戰(zhàn)的加載器不僅僅是實現(xiàn)功能更要考慮穩(wěn)定性和對抗性。以下是一份簡化的檢查清單考量維度傳統(tǒng) RWX 加載器權(quán)限分離加載器進階目標內(nèi)存權(quán)限直接使用PAGE_EXECUTE_READWRITE始終遵循RW-RX分離原則API 調(diào)用直接調(diào)用VirtualAlloc/VirtualProtect考慮使用 NT API、系統(tǒng)調(diào)用Syscall或間接調(diào)用調(diào)用鏈調(diào)用鏈簡單直接混淆調(diào)用鏈插入無害的系統(tǒng)調(diào)用或庫函數(shù)調(diào)用內(nèi)存特征創(chuàng)建新的、孤立的 RWX 區(qū)域嘗試復(fù)用已有的可執(zhí)行內(nèi)存區(qū)域時序特征分配、寫入、執(zhí)行快速連續(xù)發(fā)生引入延遲、分階段操作模仿 JIT 或模塊加載行為靜態(tài)特征字符串、導入表可能暴露 API動態(tài)解析 API、字符串加密、消除可疑導入項環(huán)境感知無可加入沙箱檢測、調(diào)試器檢測、EDR進程檢測邏輯重要提醒Syscall系統(tǒng)調(diào)用直接通過匯編指令發(fā)起系統(tǒng)調(diào)用是繞過用戶層鉤子Userland Hook的強力手段。但實現(xiàn)復(fù)雜且需要處理不同 Windows 版本的系統(tǒng)調(diào)用號差異穩(wěn)定性挑戰(zhàn)大。動態(tài)解析使用GetProcAddress動態(tài)獲取關(guān)鍵函數(shù)地址避免在導入表中留下明顯痕跡。字符串隱藏所有敏感的 API 函數(shù)名、ShellCode 特征字符串都應(yīng)加密或混淆。6. 總結(jié)從“功能實現(xiàn)”到“行為模仿”的思維躍遷回顧整個歷程從簡單的 RWX 加載器到實現(xiàn)權(quán)限分離再到考慮各種進階對抗技巧其核心驅(qū)動力是一個思維的轉(zhuǎn)變從只關(guān)注“讓代碼跑起來”轉(zhuǎn)變?yōu)殛P(guān)注“如何讓代碼像正常軟件一樣跑起來”。免殺不是魔法不是找到一個“無敵”的工具就一勞永逸。它是一個持續(xù)的對抗過程是對操作系統(tǒng)機制、安全產(chǎn)品邏輯和軟件正常行為的深度理解。權(quán)限分離只是這個龐大課題中的第一塊基石。它告訴我們在 EDR 的視角下行為的“異常性”比代碼的“惡意性”更容易被捕捉。因此當你下次再編寫或使用一個 ShellCode 加載器時不妨先問自己幾個問題我申請內(nèi)存的方式和一個文本編輯器申請緩沖區(qū)的方式有區(qū)別嗎我修改內(nèi)存權(quán)限的理由在合法的軟件生態(tài)中是否存在我的整個加載流程在時間序列上是否顯得過于“緊湊”和“目的明確”通過權(quán)限分離我們邁出了模仿正常行為的第一步。但這僅僅是開始。后續(xù)還有更多的挑戰(zhàn)例如如何更隱蔽地注入到其他進程、如何對抗內(nèi)存掃描、如何混淆執(zhí)行流等等。掌握“權(quán)限分離”這一課是為后續(xù)所有這些更復(fù)雜的對抗技術(shù)打下堅實的行為基礎(chǔ)。記住最好的隱藏就是成為背景噪音的一部分。