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