☰
从RWX到权限分离:ShellCode加载器的现代免杀实践
2026/9/29 12:06:54 网站建设 项目流程

最近在和一些做安全研究的朋友交流时,发现一个挺有意思的现象:很多刚接触免杀的同学,拿到一段 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 有机会在函数执行前后进行检查。它们会关注:

  1. 内存权限的申请与变更:申请PAGE_EXECUTE_READWRITE(RWX) 权限的内存,本身就是一个高危行为。合法软件极少需要同时具备可写和可执行权限的内存块。更多情况下,代码段是RX(可读可执行),数据段是RW(可读可写)。
  2. 权限的“升格”操作:先申请RW(可读可写)内存,写入数据,然后通过VirtualProtect将其改为RX(可读可执行),这个“从数据到代码”的转变过程,同样可疑。
  3. 内存内容的来源与执行:将一块非映像文件(比如来自网络、资源段或解密缓冲区)的数据,设置成可执行并跳转过去,这违背了操作系统的典型内存布局原则。

RWX加载器之所以“经典”,是因为它简单直接,一次性完成了内存申请、写入、执行的所有准备工作。但这种简单,恰恰是它最大的弱点——它创造了一个在正常软件中极为罕见的行为模式,一个完美的检测特征。

1.2 正常软件的内存使用模式

反观一个正常的软件,比如一个文本编辑器:

  • 它的.text代码段在磁盘上就是可执行的,加载到内存后,操作系统会将其映射为RX权限。
  • 它的.data数据段用于存储变量,权限是RW。
  • 它动态申请内存(如malloc/new)用于处理数据,这些内存默认是RW,绝不会是X(可执行)。
  • 整个生命周期中,几乎没有理由去创建一块同时可写又可执行的内存。

因此,EDR 的策略非常清晰:将“申请或创建 RWX 内存区域”作为一个高权重威胁指标(IoC)。一旦触发,即使你的文件本身静态免杀做得再好,也会在运行时被重点关照甚至直接终止。

2. 权限分离的核心思想:拆解“写入”与“执行”

既然同时做“写入”和“执行”太显眼,那我们就把它拆开,分步进行,并且每一步都尽量模仿正常行为。这就是“权限分离”加载器的核心思路。其流程可以抽象为三个阶段:

  1. 分配阶段:申请内存,但只给予数据权限(如PAGE_READWRITE)。
  2. 填充阶段:将 ShellCode(通常是解密后)写入这块内存。此时内存仍是数据属性。
  3. 转换与执行阶段:改变这块内存的权限,使其变为可执行(如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 使用更底层的 API

VirtualAlloc和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内存。

改进的远程注入流程:

  1. 在目标进程申请RW内存 (VirtualAllocExwithPAGE_READWRITE)。
  2. 写入 ShellCode (WriteProcessMemory)。
  3. 更改内存权限为RX(VirtualProtectEx)。
  4. 创建远程线程执行 (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 加载器时,不妨先问自己几个问题:

  1. 我申请内存的方式,和一个文本编辑器申请缓冲区的方式有区别吗?
  2. 我修改内存权限的理由,在合法的软件生态中是否存在?
  3. 我的整个加载流程,在时间序列上是否显得过于“紧凑”和“目的明确”?

通过权限分离,我们迈出了模仿正常行为的第一步。但这仅仅是开始。后续还有更多的挑战,例如如何更隐蔽地注入到其他进程、如何对抗内存扫描、如何混淆执行流等等。掌握“权限分离”这一课,是为后续所有这些更复杂的对抗技术,打下坚实的行为基础。记住,最好的隐藏,就是成为背景噪音的一部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询