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