穿透静态检测:EDR对抗技术的分层实现
作者:1571199704841171
https://xz.aliyun.com/news/91978
文章转载自 先知社区
穿透静态检测:EDR 对抗技术的分层拆解
本文仅用于安全研究与防御对抗学习。引用的开源项目均来自公开仓库,目的是让安全从业者理解攻击面、反过来指导防御建设。请遵守当地法律法规。
EDR 并不是一块铁板。它是由很多检测层叠起来的一张网——磁盘扫描、内存扫描、行为监控、云端比对、ETW、内核回调……每一层都有自己的假设和盲区。
攻防双方真正在做的事情,其实不是”绕过 EDR”这么简单,而是在操纵样本在不同检测层的可见形态。换个角度看,问题从来都不是”能不能被检测到”,而是”以什么形式被看到”。
这篇想把”静态检测”这一层说清楚。它是整条检测链的起点,也是对抗成本最低、技术演化最充分的一层。后面会有单独的一篇讲动态对抗。
静态对抗到底在对抗什么
静态层面的检测逻辑很朴素:样本还没跑起来,引擎就已经试图从 PE 结构、字节序列、常量数据、导入表等线索中识别出恶意意图。从 YARA 规则到机器学习分类器,底层的假设都是一样的——恶意样本在静态特征上总会留下可辨识的痕迹。
这句话反过来就是红队的出发点:只要能在不破坏语义的前提下,把这些可辨识的痕迹抹掉或稀释掉,静态层就失去意义。但这件事不存在”一招制敌”。实战中看到的样本,往往是多种手段叠加——每一层单独看效果有限,合在一起才开始让静态引擎的成本难以承受。
粗略梳理一下常见的几个方向:
- 文件级:把整个文件包起来让引擎看不见内部(整体加密/packer)
- 编译级:在 IR 层改变代码结构,让最终二进制的特征彻底换一套(LLVM 混淆)
- 数据级:保护字符串、哈希等常量,避免直接被提取(编译期字符串加密)
- 特征级:针对 PE 本身的结构特征做伪装和重排(导入表、段位置)
- 内存级:解决”运行起来之后内存里是什么”的问题(sleep mask / payload 实时加解密)
整体加密:最朴素,也最容易被误解
加载器 + 加密 payload 的结构是最古早的思路。主程序负责解密,payload 以密文形式躺在磁盘上,静态引擎看到的就是一堆看不出规律的字节。
这个思路听起来没什么技术含量,但它解决了一个很实在的问题:样本分发和更新不需要重新”磨”免杀。只要加载器本身过了静态检测,后面换 payload、换密钥就是小事。从工程角度讲,这种解耦带来的收益比单纯的”加密”本身更重要。
问题在于,它只解决磁盘上的可见性。payload 终究要在内存里还原——如果不叠加内存层的保护(后面会讲的 sleep mask 之类),解密之后的那一刻就是最大的暴露面。
站在防守一侧看,这也是为什么现代 EDR 在内存扫描上投入的资源反而比磁盘扫描更多:磁盘是可以被加密的,内存迟早要吐出真相。加密壳这种东西对老旧的签名引擎很有效,但对一个行为 + 内存扫描做得到位的 EDR,它更多是延迟而非免疫。
编译期混淆:改变”代码长什么样”
如果说整体加密是在文件外面套一层皮,那 LLVM Pass 混淆就是直接改皮肤。原理不复杂——LLVM 有个中间 IR 层,所有高级语言代码编译到这一层之后是统一形态的。在这一层做变换,最终生成的二进制在语义上完全等价,但在字节层、控制流图层、数据层全都换了模样。
几种常见的 Pass 其实思路各不相同:
控制流平坦化是最经典的一种。正常的 if/else、循环在汇编层有清晰的形态,IDA 反编译出来控制流图(CFG)一目了然。平坦化会把所有基本块拆到同一层级,用一个大 switch-case 配合状态变量来串起来。看起来像”蜘蛛网”——单条路径的逻辑被彻底打散,人工逆向和模式匹配都会变得很痛苦。
虚假控制流则是在真路径旁边插假路径。假路径靠不透明谓词保证永远不会执行(比如 x*(x+1)%2==0 恒为真),但静态分析器没法轻易判断出它是假的,只能当真路径去分析。代码量膨胀,分析复杂度也膨胀。
指令替换更琐碎一些。把 a + b 换成 a - (-b),把 x ^ y 换成 (x & ~y) | (~x & y)。单看没意义,组合起来之后字节层的特征基本全变了。
字符串加密其实就是下一节的事,只不过很多 LLVM 混淆框架会把它打包进去。
这些 Pass 的组合,用的基本都是 OLLVM 的 fork——Hikari、Pluto 这些项目都在继续维护。C/C++ 项目只需要把编译器换成改过的 clang,几乎不用改源码。
参考:gmh5225/awesome-llvm-security
这里有个容易踩的坑:编译期混淆对动态检测几乎无效。代码再怎么换形态,最终还是要调 NtAllocateVirtualMemory、还是要写内存、还是要创建线程。行为层的特征不会因为控制流变复杂而消失。所以它更像是一个”把静态检测往后拖”的手段——让签名、YARA、机器学习模型都失效,但 hook、ETW、内核回调这些东西它帮不上忙。
从防守视角看,面对重度混淆的样本,静态引擎基本是残废的。有经验的防御团队会直接放弃在静态层较劲,转而重仓行为链分析和内存扫描——反正混淆再狠,VirtualAlloc + WriteProcessMemory + CreateRemoteThread 这种三连依然会触发行为规则。
实践中还要注意成本:编译时间和二进制体积都会显著膨胀。所以通常的做法是只对核心敏感函数做深度混淆,外围代码用轻量混淆就够。
字符串常量:静态检测最喜欢的礼物
未加密的字符串是静态分析中最方便的突破口。.rdata 里躺着 "VirtualAlloc"、"CreateRemoteThread"、C2 的 URL、硬编码的 User-agent——任何一个 strings 工具、任何一条 YARA 规则都能秒提取。这些字符串本身就是强特征命中点。
解决思路也很直观:让字符串在编译后的二进制里以密文形态存在,运行时再解密。关键是”编译期完成加密”,否则明文依然会在源码编译过程中短暂出现在 .rdata 里。
C++ 的 constexpr 机制刚好适合干这件事。编译器会在编译期就把构造函数跑完,结果直接嵌进二进制。一个极简的原型:
template <int N>
class ObfuscatedString {
char encrypted[N];
char key;
constexpr char enc(char c, int i) const {
return c ^ (key + i);
}
public:
constexpr ObfuscatedString(const char (&str)[N], char k)
: encrypted{}, key(k) {
for (int i = 0; i < N; i++)
encrypted[i] = enc(str[i], i);
}
const char* decrypt(char* buf) const {
for (int i = 0; i < N; i++)
buf[i] = encrypted[i] ^ (key + i);
return buf;
}
};
#define OBFS(str) ObfuscatedString<sizeof(str)>(str, __TIME__[7])
使用起来就是:
auto s = OBFS("VirtualAlloc");
char buf[32];
s.decrypt(buf);
// ... 用完立刻清零
SecureZeroMemory(buf, sizeof(buf));
加密前二进制里赤裸裸地躺着 56 69 72 74 75 61 6C 41 6C 6C 6F 63——直接能被 strings 抓到。加密后对应位置变成乱码,签名规则找不到参照物。
真正要用的时候一般不会自己造轮子。这个领域最知名的开源实现是 JustasMasiulis/xorstr——密钥 64 位编译期生成、数据块 16 字节对齐、加密数据直接嵌进代码段而不是 .rdata、加解密完全内联、甚至支持 AVX/SSE 向量化。兼容 Clang 5.0+、GCC 7.1+、MSVC v141。
其他几个可以参考的:
| 项目 | 特点 |
| skCrypter | C++11 即可,兼容性好 |
| ADVobfuscator | 老牌项目,Pass 思路丰富 |
| obfuscate | 轻量级,header-only |
有几个容易忽略的点。第一是优化等级必须开到 -O1 以上,否则编译器可能不会在编译期求值,密钥和加密逻辑反而会在二进制里留下完整形态,暴露意图比不加密还糟。第二是 Release 构建要 strip 符号——如果调试符号里还留着 ObfuscatedString 这个类名,它本身就变成了一个特征。第三,同样的思路不只能用来加密字符串,整数常量、shellcode 字节数组、API 哈希值都可以。
话说回来,字符串加密只解决”磁盘上能不能看到明文”这一件事。运行时你调用 GetProcAddress(hMod, decrypted_buf)——第二个参数依然是明文字符串,任何 hook 或 ETW 都能原样捕获。单独看它的防护面是很窄的。通常要跟 API 哈希化调用(不传函数名、传哈希、自己遍历导出表匹配)一起用,才算把整条链路盖住。
PE 特征对抗:把结构本身改造一遍
安全引擎不仅看代码内容,也看 PE 文件的结构。导入表、段名、段偏移、Rich Header、甚至每个函数在文件中的物理位置——这些都可以成为签名的一部分。
导入表:敏感 API 的名字是最硬的特征
IAT 里出现 GetProcAddress、LoadLibrary、VirtualAllocEx、CreateRemoteThread、WriteProcessMemory 这一类函数,几乎等于在样本额头上写了”我想干点什么”。新项目尽量从一开始就避开,老项目则要通过自定义实现来替代。
几种常见的替代思路:
自己实现 GetProcAddress/GetModuleHandle。思路是通过 PEB → PEB_LDR_DATA → InMemoryOrderModuleList 找到目标 DLL 基址,再解析它的 IMAGE_EXPORT_DIRECTORY,遍历导出函数名匹配。这么做的好处是双重的:导入表里不会出现敏感 API,同时也能避开用户态 hook(因为没走系统的那份 GetProcAddress)。
参考:Speedi13/Custom-GetProcAddress-and-GetModuleHandle-and-more
API 哈希。既然字符串本身是特征,干脆不存字符串——把 API 名在编译期预计算成哈希,运行时遍历导出表时对每个函数名算一次哈希再比对。二进制里见不到任何 API 名,静态分析只能看到一堆看不出用途的 4 字节常量。
参考:SolomonSlash/ApiHashing
手动 DLL 加载。绕开 LoadLibrary 本身:自己映射 PE、处理重定位、解析导入表、执行 TLS 回调、调用 DllMain。重量级,但能彻底避开系统加载器这条路径上的观察点。
参考:adamhlt/Manual-DLL-Loader
直接系统调用。用 syscall 指令直接进内核,跳过 ntdll 中被用户态 hook 的那一层代码。这已经开始进入动态对抗的地盘了。
参考:annihilatorq/shadow_syscall
站在防守这一侧看,导入表”干干净净”反而是一个异常信号。一个正常的 Win32 程序通常会有几十上百个导入项,分布覆盖 kernel32、user32、gdi32、ole32 等常见库。一个导入表只有寥寥几个函数、或者干脆只导入 ntdll 的样本,它自己就在喊”我不正常”。好几款主流 EDR 都把”导入表稀疏度”作为机器学习特征之一。
导入表伪装:反着来
既然稀疏的导入表本身就是异常,那就反过来——把导入表做得”很像一个普通的 GUI 程序”。MSVC 里可以用 #pragma comment(linker, "/include:...") 强制让链接器保留某个导入项,哪怕代码里根本不会调用它:
#pragma comment(linker, "/include:MessageBoxW")
手动一条条写太麻烦,通常的做法是写个脚本从一个”正常”的 PE 文件(比如记事本、计算器、或者某个开源应用)里批量提取导入表,生成一堆 #pragma 贴进源码,重新编译。编译出来的样本在 IAT 层面看起来就像一个有 UI 的普通应用程序,敏感 API 被淹没在一大堆”看起来无害”的导入里。
段位置和布局:打破基于偏移的签名
有一部分 YARA 规则长这样:在 .text 段偏移 0x200 处匹配某字节序列。这类规则依赖”内容在文件中的相对位置”。只要把位置打乱,签名就失效——内容都不用动。
PE 格式有一个挺好用的特性:段名支持 $ 后缀。.text$a 和 .text$b 属于同一个基名段 .text,但链接器会按后缀的字典序合并它们。利用这个机制可以精确控制函数和数据在最终文件里的排布。
几种具体做法:
// 把关键函数丢到自定义段里
#pragma code_seg(".mycode")
void DecryptPayload(BYTE* buf, int len, BYTE key) {
for (int i = 0; i < len; i++) buf[i] ^= key;
}
#pragma code_seg()
// 加密的 shellcode 放到自定义数据段
#pragma data_seg(".mydata")
unsigned char encPayload[] = { 0xFC, 0x48, 0x83, /* ... */ };
#pragma data_seg()
// 或者用 __declspec
__declspec(allocate(".hide")) char config[] = "encrypted_c2_data";
这样 PE 里会多出 .mycode 和 .mydata 段,原本在 .text 和 .rdata 里的内容偏移全都跟着变。基于固定偏移的签名直接失效。
往前推一步,还可以在关键内容前后塞”垃圾段”来推移偏移:
#pragma section(".junk", read)
__declspec(allocate(".junk"))
char junk[(__COUNTER__ + 1) * 0x100] = {0};
__COUNTER__ 每次编译值不同,垃圾段大小也不同,后面所有内容的偏移都会跟着变。同一份源码,每次编译出来的文件布局都不一样。
函数级的随机化也一样。把每个函数放到带唯一 $ 后缀的段里,修改后缀字母就能改变最终的排列顺序:
#pragma code_seg(".fn$a")
void StepOne() { }
#pragma code_seg(".fn$c")
void StepTwo() { }
#pragma code_seg(".fn$b")
void StepThree() { }
#pragma code_seg()
甚至段名本身也是一个检测点。默认的 .text、.rdata 是标准名,不少 YARA 规则会用 pe.section[0].name == ".text" 作为前置条件。自己改个奇怪的名字就能绕开这一类规则:
#pragma comment(linker, "/RENAME:.text=.code")
#pragma comment(linker, "/RENAME:.rdata=.cfg")
还有 Rich Header 和 DOS Stub——它们同样可能被当成签名的一部分。Rich Header 里记录了编译器版本、工具链信息,甚至能被当成某些 APT 样本的”指纹”。可以在链接时替换 DOS Stub,或者运行时(内存加载场景)直接覆盖 Rich Header 区域。
这一系列手段单独看都很琐碎。但综合起来之后,样本的宏观布局(段名、段顺序、段内容位置)和微观内容(字节序列)都被同时重构,基于位置的签名基本全军覆没。
不过有一点值得提醒:自定义段名和垃圾段如果用得太浮夸,本身也会变成异常信号。一个正常应用不会出现 .xyz1、.junk1234 这样的段名。如果防守方用”段名熵值”或”段名是否符合已知合法列表”来做一道辅助判断,过度定制反而适得其反。这件事是一个典型的”对抗 vs 被对抗”——红队每推进一步,蓝队就会在下一个维度建立检测,绕的方向太远本身就是一种特征。
特征定位修改
传统思路里还有一种最朴素的手法:定位被引擎标记的具体字节序列,然后手动改掉。但经过前面这些处理之后,文件特征已经被彻底重构,这种兜底手段一般都用不上了。它更多是在所有其他方法都失效、只需要针对某个具体引擎做最后一公里修复时才派上用场。
云端扫描:换个机房而已
云特征扫描听起来高大上,但它的检测逻辑和本地扫描本质上是一回事——只不过引擎搬到了云端,算力和特征库更大。样本上传的往往也不是完整文件,而是少量的特征/指纹/行为摘要。
所以针对本地特征的那一套对抗方法(整体加密、编译期混淆、常量加密、导入表处理等等),在云端面前同样有效。云端真正多出的能力其实是跨样本关联和群体行为分析——”这个哈希之前在别的客户那里关联过可疑行为”这种信号。这块属于动态对抗的范畴,后面单独说。
内存中的那一刻
讲到这里应该能看出一个规律:前面所有手段解决的都是”文件躺在磁盘上”的问题。一旦样本跑起来,内存里的形态又是另一个战场。
内存扫描的逻辑和磁盘扫描类似,但时机完全不同——EDR 可以在任何时刻(系统空闲、可疑行为发生后、定时触发)扫描进程的内存空间。这意味着即便磁盘上的样本完美无瑕,只要在某一瞬间内存里出现了明文 payload,就可能被抓。
文件整体加密、编译期混淆、段位置调整这些手段对内存是”延伸有效”的——代码结构在磁盘上是什么样,加载到内存里就是什么样。但整体加密 payload 的做法,一旦解密就会在内存里留下明文,这是最大的破绽。
Sleep Mask:让 payload 大部分时间是”乱码”
真正的转折点是 Sleep Mask 这一类技术。它的洞察很犀利:C2 beacon 的绝大部分生命周期是在休眠等待命令,真正执行命令的时间很短。如果能让 payload 在休眠期间对自己进行加密,唤醒执行前再解密,那么内存扫描绝大多数时候看到的都是密文。
这个思路最早来自 Cobalt Strike 4.4 引入的 Sleep Mask Kit。后来开源社区出现了一批公开实现。
Ekko (Cracked5pider/Ekko)是最早也最有代表性的公开 PoC。作者 C5pider 的灵感来自对 MDSec NightHawk 的分析。核心机制是用 CreateTimerQueueTimer 注册一系列定时器回调,这些回调其实是在构造一条 ROP 链:
- 先把 payload 的内存区改成 RW
- 调
SystemFunction032(RC4)加密内存内容
- 进入等待
- 等待结束后用同一密钥解密
- 把内存改回 RX
- 触发事件,唤醒主线程
整个过程是在定时器工作线程里跑的,主线程就是干等。每个回调指向的都是合法 API(比如 NtContinue),参数通过精心伪造的 CONTEXT 结构传递。这样做的好处是——从 EDR 的视角看,调用链里全都是合法 Win32/NT API,没有直接出现 RtlEncryptMemory 之类的可疑符号。
D1rkSleep (SaadAhla/D1rkSleep)是 Ekko 的优化版本,只加密 PE Image 的各个 Section 而不是整块内存。性能更好,保护面依然覆盖核心代码。
Cronos (Idov31/Cronos)换了个定时器 API——用 Waitable Timer 代替 Timer Queue Timer。思路类似,API 面换了一套。
这些变种本质上都是在”定时器回调 + RC4 + 内存权限切换”这个核心上做微调。从对抗视角看,它们共同的优势是把”加密 payload”这个动作伪装成正常的定时器调度;但反过来,这也意味着它们共同的弱点是——定时器回调、RtlExitUserThread、NtContinue 这些操作组合起来依然有辨识度。Hunt Sleeping Beacons 这类检测工具就是专门盯这种模式的。
SWAPPALA:从”加密”到”替换”
oldboy21/SWAPPALA走了另一条路。它的核心洞察是:私有内存(Private Memory)本身就是一种可疑特征。EDR 扫内存时看到一块 RWX 或 RX 的 Private Memory,和看到一个由合法 DLL 文件映射来的 Image Memory,可信度是完全不同的。
SWAPPALA 的做法是”借壳”:先加载一个合法的牺牲 DLL,然后把它 unmap,再在同一地址上创建一个新的 Section 并映射,把恶意 payload 写进去。休眠时把恶意内容换回那个合法 DLL 的原始内容;唤醒时再换回来。
这样做之后,EDR 扫描内存时看到的是一块”有文件背景的 Image 映射”——对应的磁盘文件是合法的系统 DLL。它的身份看起来比任何加密后的 Private Memory 都正常。
但这条路工程上很难走:
LoadLibrary加载后 Section 和文件句柄会被系统关闭,为了保住它们得 hookZwClose
- 恢复 Section Handle 的逻辑要参考 System Informer(原 Process Hacker)的句柄枚举
MapViewOfFileEx(指定地址映射)和标准 Ekko 的 Sleep Mask 机制不兼容,作者只好另起炉灶写了一套新的 —— 就是下面的 SLEAPING
SLEAPING:绕开 CFG 的副作用
在搞 SWAPPALA 的过程中,作者发现现有 Sleep Mask 不满足需求,顺手搞了 SLEAPING。它用的是定时器工作线程(Timer Worker Thread)来恢复预先创建的挂起线程,每个工作线程有独立的栈。额外的好处是用 ResumeThread 恢复线程的方式避免了把敏感 API 注册成回调目标,绕过 CFG(Control Flow Guard)检查,少了一个 IOC。
2024 年 9 月的更新还加了定时器回调地址欺骗——通过改 TpWorkerFactory 对象里的回调地址来对抗 Hunt Sleeping Beacons 这类工具。这场对抗还在继续。
其他几个值得看的实现
| 项目 | 思路 |
| KrakenMask | Sleep Mask 的另一种工程化 |
| effective-waffle | 基于 Waffle 思路的 Sleep Obfuscation |
从防守的角度总结一下
把前面讲的所有东西拼起来看,会发现静态对抗本质上是在做一件事:让样本在不同检测层的可见形态”看起来正常”。
- 磁盘层”看起来正常”:整体加密 + 编译期混淆 + 导入表伪装 + 段重构
- 运行时代码”看起来正常”:字符串加密 + API 哈希 + 直接系统调用
- 内存休眠时”看起来正常”:Sleep Mask + 借壳映射
这三层叠起来之后,纯静态引擎基本就没戏了。但这也正是防守方应该关注的地方——既然静态层已经被放弃作为主战场,蓝队的资源就该重仓到行为层和关联分析:
- 行为链检测:哪怕样本的代码被混淆到天花乱坠,
VirtualAlloc(RWX) → WriteProcessMemory → CreateRemoteThread这种行为序列依然是异常
- 内存扫描策略升级:不再只看字节签名,还要关注”Private Memory with EXECUTE”、”RX 权限的非 Image 页”、”定时器回调指向奇怪地址”这类结构特征
- 堆栈回溯检测:很多 Sleep Mask 在定时器回调运行时,线程调用栈会指向奇怪的位置——栈顶不在任何已知模块内,本身就是高置信度的可疑信号
- 跨进程/跨主机关联:单样本再完美,一旦在组织里大面积部署,流量模式、进程创建序列、横向移动轨迹会暴露整条杀伤链
有经验的防御团队已经不再纠结”能不能在静态层拦住它”这件事。问题的正确问法是:”即使样本完全绕过静态层,我们能在它的下一次动作、下一跳横移、下一次 C2 心跳时抓到它吗?“
静态对抗的演化已经相当成熟,也相当卷。动态对抗那边更有意思——用户态 hook 的绕过、ETW 的规避、AMSI 的破解、进程注入的各种新玩法、行为链的打断与伪装。那些东西留到下一篇说。
几条零散的思考
- 层层叠加比单点深耕更重要。一个只做字符串加密但导入表还裸奔的样本,和一个所有手段都做了 60% 的样本,后者的生存率会高很多。
- 编译期能解决的事情不要拖到运行时。运行时的每一行代码都是可以被观察、被 hook、被分析的;编译期的变换留下的只是结果。
- 暴露窗口要尽可能短。payload 明文存在的时间越短,被抓的概率越小。这是 Sleep Mask 思路最核心的哲学。
- 对抗是持续的。今天有效的技术三个月后可能就进了各家 EDR 的检测规则库。关注对抗历史比关注单个技术点更重要——因为下一轮对抗的方向往往是从上一轮的弱点里长出来的。
最后再说一句老生常谈的话:这些技术的价值在于帮我们理解 EDR 的假设边界在哪里。知道对抗方会怎么动手,防御才能落到实处。否则写规则永远是瞎子摸象。















请登录后查看评论内容