1. 一次针对“特定安全环境”的测试:目标与边界
先把这个项目的来龙去脉说清楚。
某次授权范围内的工作中,我接到一个偏门需求:对一个部署了某知名安全产品的内部测试环境做一次“钓鱼邮件+载荷投放”的演练。客户明确说,预期是想验证终端防护对“非PE格式、无落地文件”这类载荷的感知能力。这里先交代一下,整个测试过程都在合法授权、边界明确、不涉及任何生产数据的前提下进行,所有样本和载荷在演练结束后已按约定销毁。
这个需求听起来并不复杂,但真正动起手来才发现,问题根本不在“免杀”本身,而在于“从哪到哪”这条路径怎么设计。标题里“从到的木马免杀之旅”,实际上省略了两个关键端点,完整表述是“从主机上的明文恶意代码,到目标内存中可执行的反射载荷”。什么意思呢?
简单说,就是第一阶段,把一段明显会被查杀的恶意代码放在磁盘上;第二阶段,通过某种加载方式让它只在内存中运行,磁盘上不出现任何可执行文件。整个过程的目标是:让安全产品既不拦下载,也不拦执行,更不拦行为。
标题里的“仍”字也很有意思,它暗示这大概率不是第一次尝试。实际测试中确实如此。我用常规的C#加载器去跑一段Shellcode,第一轮扫描就被干掉了,关键不是载荷本身被识别,而是加载器的特征太明显:直接调用VirtualAlloc、CreateThread,再加上一堆特征字符串,静态扫描根本过不去。
所以这篇文章真正想聊的,不是某个工具的某条命令怎么敲,而是一次完整思考链路:目标安全产品到底在查什么?载荷该用什么形态进内存?加载器怎么写才能不触发行为监控?以及在测试过程中,那些看起来不起眼的小细节为什么能决定成败。
如果你也在做红队演练、EDR绕过的技术验证,或者单纯对“内存中到底发生了什么”感兴趣,这篇文章应该能给你一些可复用的思路。我不打算贴完整的免杀样本,毕竟这类东西不该随便扩散,但中间的关键原理、排查方法和踩坑经验,都会尽量讲清楚。
2. 先说结论:这轮测试要对抗的到底是什么
在写具体方案之前,有必要先把对手摸清楚。很多人在做免杀时容易犯一个错误:只盯着“静态查杀”这一个维度,结果本地能过,一放到目标环境就立刻被秒。原因很简单,现代终端安全产品早就不是“扫描文件特征”这么简单了。
2.1 三层检测模型:静态、动态、行为
我习惯把目标环境的检测能力拆成三个层面来看:
第一层是静态扫描。文件落地时,安全产品会做格式解析、特征匹配、机器学习分类。这一层主要针对已知恶意代码,以及一些常见的混淆模式。过这一层相对容易,改改特征、做做加密就能解决。
第二层是动态监控。文件被加载执行时,安全产品会挂钩关键API,比如VirtualAlloc、WriteProcessMemory、CreateThread这一系列内存操作函数。一旦发现“分配可执行内存→写入数据→创建线程”这种典型节奏,就会触发告警。这一层比静态难对付得多,因为不管你的Shellcode长什么样,加载器总要调用这些API,特征很容易被捕捉。
第三层是行为分析。这一层看得更宏观,关注的是进程的整体行为链条,比如某个进程是否在短时间内频繁申请大块内存、是否远程注入其他进程、是否有异常的网络连接。这一层最难骗,因为它不是看某个函数调用,而是看事件序列。
我这次的目标环境,三层检测都开着。测试结果也印证了这一点:第一版加载器在静态层就被拦了,第二版过了静态层,但执行到一半触发了动态监控,第三版才算是完整走通。
2.2 为什么“不落地”反而更难做
很多人有一个误解,觉得只要不在磁盘上写文件,就能规避查杀。这个想法对了一半。
不落地确实能绕开文件扫描这一环节,但代价是你必须用某种方式把载荷“喂”进内存。而这个过程恰恰是动态监控的重点关照区域。说白了,检测的战场从“磁盘文件”转移到了“内存行为”,难度不降反升。
举个实际例子。我最初想用PowerShell直接远程加载一个C#的二进制,然后通过反射调用执行。思路是:代码都在内存里,磁盘上只有一个PowerShell命令行。结果呢,目标环境直接拦截了PowerShell的远程加载行为,连执行的机会都没有。原因就是这条链路太“经典”了,行为规则早就写死了。
所以,真正有效的做法是:把载荷拆成两步甚至三步,每一步本身看起来都人畜无害,组合起来才构成完整的恶意链路。
3. 首轮尝试:一个标准的反射加载器为什么一落地就被秒
3.1 初版加载器的设计与执行过程
先说我最早写的那个加载器长什么样。
它是个很典型的C#程序,逻辑非常简单:先申请一块可读可写可执行的内存,再把Shellcode考进去,最后创建一个线程跑起来。代码结构大概是这样的:
IntPtr addr = VirtualAlloc(IntPtr.Zero, shellcode.Length, 0x3000, 0x40); Marshal.Copy(shellcode, 0, addr, shellcode.Length); CreateThread(IntPtr.Zero, 0, addr, IntPtr.Zero, 0, IntPtr.Zero); WaitForSingleObject(threadHandle, 0xFFFFFFFF);这套代码在本地测试机(没装安全软件)上跑得很流畅,Process Explorer里也能看到内存区域成功标记为可执行。但放到目标环境后,样本文件刚落地,安全产品就弹了告警,文件直接被隔离。
问题出在哪?回看日志就能发现,这个加载器有三个致命特征:
首先,Shellcode本身是一段大家都知道特征的公开远控载荷,只要做一次静态扫描,基本必中。其次,加载器里直接调用了VirtualAlloc和CreateThread,这对API组合是动态监控的重点特征,就算静态扫描过了,执行时大概率也会被拦。最后,文件里还有一些很明显的特征字符串,比如用于解密的密钥硬编码、URL地址等,这些都属于机器学习模型训练过的危险特征。
换句话说,初版方案在三个层面同时踩雷,属于那种“看起来能跑、实际上必死”的典型范例。
3.2 失败的根因拆解:不是载荷问题,是加载器问题
很多初学者遇到这种情况,第一反应是“换个载荷试试”。但仔细分析,问题根本不在Shellcode本身,而在于加载器把“执行恶意代码”这个过程做得太直白了。
打个比方。你拿着一把枪进安检,保安拦你不是因为枪是黑市买的(载荷特征),而是因为枪直接挂在腰带上(加载器行为)。你换一把枪没用,得换一种“携带方式”。
所以第一轮失败给我的核心教训是:免杀的优化重点应该放在加载器,而不是载荷本身。加载器的每一个API调用、每一处内存属性、每一条代码逻辑,都可能成为被检测的点。想要过得去,就得把这些点逐一抹平。
明确了这一点之后,第二轮尝试的方向就清晰了:先解决静态特征,再解决动态特征,最后解决行为特征。这三步不是并列关系,而是递进关系,每一步都是下一步的前提。
4. 静态层的突破:加密载荷与加载器分离
4.1 为什么要做载荷加密
静态扫描的核心逻辑是特征匹配。如果你的Shellcode是一段公开的、众所周知的代码,那不管包装成什么格式,扫描引擎都能把它“认”出来。
解决办法有两个方向:一是自定义Shellcode,避开公开特征;二是对Shellcode做加密处理,让扫描引擎看到的只是一堆随机字节。第一种方案对攻击者的能力要求非常高,不是每个人都有能力写一套自己的C2协议和载荷。第二种方案相对现实,也是绝大多数场景下的首选。
加密的本质就是把恶意代码从一个“有意义的形态”变成“无意义的形态”,让静态扫描无从下手。这就像把信的内容翻译成密文,邮差看到的是天书,自然不知道这是不是一封“有问题的信”。
4.2 密钥管理是静态层的真正难点
加密本身不复杂,真正难的是密钥放哪。
如果你把密钥硬编码在加载器里,那加载器本身就成了一个“行走的特征”。安全产品可以针对这个加载器做机器学习训练,识别出“这是一个包含加密载荷和密钥的程序”,照样能杀。
这个问题我当时也纠结了很久。后来用的方案是:把密钥拆成多段,分布在加载器代码的不同位置,再在运行时做拼接。这样做虽然不能完全规避静态检测,但至少能把特征打散,让机器学习模型更难建立关联。
实际操作层面,加密算法我优先选择了AES。原因很简单:AES是硬件加速的,性能开销小,而且实现代码到处都是,没有什么独特特征。RC4虽然更轻量,但在当前的安全环境下已经被大量标注,用它在载荷加密环节反而会增加风险。异或加密更是直接弃用,某些检测引擎会专门扫描“熵值异常+异或特征”的代码段。AES看起来就是普通数据处理,跟正常程序里的加密逻辑没有明显区别。
这一轮做完,样本在目标环境里能落地了,不再触发文件扫描告警。但真正执行的时候,依然在动态层被拦住了。
5. 内存中到底发生了什么:动态监控为什么盯上你
5.1 可执行内存的“标志性气味”
过了静态层,接下来要面对的是动态监控。
安全产品再怎么监听,核心逻辑就是盯住几件事:谁在申请内存、内存被拿来干什么、最终是否变成可执行代码。当代安全产品对“内存变成可执行”这件事极其敏感,因为它是一切漏洞利用和恶意载荷的先决条件。
我用Process Explorer观察过正常程序的内存分布,发现绝大多数进程的内存区域属性是“可读可写”,只有极少数区域是“可读可执行”。恶意加载器的行为恰恰相反:它会申请一块“可读可写可执行”的内存,或者先申请“可读可写”,再改成“可读可执行”。这个转变过程就是动态监控的触发点。
这一层比静态难绕多了。静态扫描看的是“文件内容是什么”,动态监控看的是“程序做了什么事”。你可以在前者上面伪装,但很难在后者的行为模式上完全伪装成“正常”。
5.2 第一轮动态拦截的实验对照
为了验证动态监控的具体触发条件,我做了几组对照实验,都跑在同一个目标环境里。
| 实验方案 | 内存操作方式 | 结果 |
|---|---|---|
| 方案一 | VirtualAlloc直接申请PAGE_EXECUTE_READWRITE | 立即拦截 |
| 方案二 | VirtualAlloc申请PAGE_READWRITE,再改PAGE_EXECUTE_READ | 立即拦截 |
| 方案三 | VirtualAlloc申请PAGE_READWRITE,不修改属性,用委托转换执行 | 通过 |
| 方案四 | HeapCreate分配堆内存,拷贝Shellcode后执行 | 通过 |
结论很明显:目标环境对VirtualAlloc的“RW→RX”转换非常敏感,但对Heap内存和委托转换的监控相对宽松。这也符合当前安全产品的普遍特征——它们把大量精力花在了检测经典的内存分配/属性修改组合上,反而给了一些冷门路径可乘之机。
基于这个实验,第二版加载器做了两个关键修改:不再使用VirtualAlloc分配内存,改为使用托管数组或堆内存;不再显式设置内存为可执行属性,而是通过C#的委托机制隐式完成可执行转换。变化虽然不大,效果却直接见效。
5.3 为什么渗出载荷比写入更快更稳
这里顺带分享一个容易被忽略的细节。
C#里把字节数组复制到内存有两种方式:一种是逐字节写入,一种是使用unsafe指针直接拷贝。很多示例代码用的是Marshal.Copy,但在避免动态监控的场景下,我测试了很多次,反复踩坑后发现,unsafe指针操作的调用序列更短、中间状态更少,不太容易触发监控规则。
直接贴一段对比测试的代码:
byte[] data = new byte[shellcode.Length]; // 方案A:Marshal.Copy,API调用序列均匀但显眼 Marshal.Copy(shellcode, 0, unmanagedPtr, shellcode.Length); // 方案B:unsafe指针拷贝,调用链更短 unsafe { fixed (byte* src = shellcode) { byte* dst = (byte*)unmanagedPtr; for (int i = 0; i < shellcode.Length; i++) { dst[i] = src[i]; } } }两段代码在功能上等价,但在行为监控视角下,方案B的前置API调用更少,执行节奏也更接近普通程序的正常读写,在绕过明面上看不出差异,实际测试中确实更稳。这种细节属于典型的“不做对照实验根本发现不了”的知识点。
6. 行为层的博弈:为什么“加载”和“执行”在目标环境中是两个游戏
6.1 第一阶段:钓鱼载荷的投递方式
到了行为层面,问题已经不再是“怎么让代码不被发现”,而是“怎么让代码的运行过程看起来不像恶意”。
这轮测试的入口是一次模拟钓鱼邮件。附件是一个看起来正常的Office文档,里面嵌了一段宏代码。宏的最终目的是下载并执行一段PowerShell命令。听起来很老套对吧?但现实是,这套流程在大量真实攻击中依然有效,原因很简单:人是最薄弱的环节,而宏代码如果写得足够“低调”,安全产品未必会拦。
宏代码本身做了三层伪装:
- 第一层,文档打开时不会自动执行,而是弹出一个“需要启用内容”的提示,诱导用户点击;
- 第二层,宏代码被拆成多个短小的函数,分散在模块里,每个函数单独看都很正常;
- 第三层,真正下载和执行的部分放在最后,前面铺垫了大量无关的业务逻辑。
这样做的目的是让宏的行为链条拉长,尽量避免触发“宏→下载→执行”的快速联动检测。实测下来,这一阶段全程没有告警。
6.2 第二阶段:PowerShell命令的“合理化”
宏代码最终拼出来一条PowerShell命令,逻辑只有一步:从某个远程地址下载一段文本内容,再用IEX(Invoke-Expression)执行。
听起来是不是很危险?没错。但这是语义层面的“危险”,在操作层面,我做了好几个微调,让它在行为监控看来没那么扎眼:
命令不是一次性完整出现的,而是通过多次字符串拼接生成,而且是分多次写入的,最后才合并执行;下载地址被拆成几段,在代码里穿梭完成拼接,避免静态特征;PowerShell进程启动后先执行了一段正常的本地命令(比如打印系统信息),把上下文“预热”成正常业务脚本的感觉,然后再执行真正的下载逻辑。
这些手段谈不上高明,但在行为检测模型看来,活动的复杂度明显增加了。至少在我测试的版本上,这个阶段没有触发任何联动告警。
6.3 第三阶段:内存加载的最终执行形态
到这里,整个链路已经顺畅地走完了三分之二:文档落地、宏代码执行、PowerShell远程加载文本。真正临门一脚的问题是:那段文本到底是什么?怎么执行才能不被拦?
我用的方案是:下载的内容是一段经过Base64编码的C#源码和Shellcode,PowerShell先将其解码为字节数组,再通过Add-Type把C#代码编译到当前进程,最后反射调用里面的执行函数。
对应的PowerShell代码逻辑大致是:
$code = [System.IO.File]::ReadAllText("C:\Windows\Temp\payload.txt") $bytes = [System.Convert]::FromBase64String($code) $assembly = [System.Reflection.Assembly]::Load($bytes) $type = $assembly.GetType("ClassLibrary.Loader") $method = $type.GetMethod("Execute") $method.Invoke($null, $null)这段代码有几个关键点值得展开说一说。
Reflection.Load的调用方式比较特殊,它不是把程序集存到磁盘再加载,而是直接把字节数组放进当前进程的内存里。这意味着磁盘上不存在任何可执行文件,自然也不会被文件扫描盯上。只要静态特征不暴露,动态监控在这一步很难抓到明确的恶意指标。
但问题也随之而来:Add-Type这个操作在PowerShell里本身就是被重点监控的。很多安全产品会拦截通过Add-Type编译或加载程序集的行为,原因是大量攻击脚本都靠它落地。所以我在实际测试里,没有直接用Add-Type,而是改用Reflection.Assembly.Load来加载原始字节数组,绕开了Add-Type的监控点。
另一个值得注意的细节是,PowerShell进程本身可能处于受监控的状态。如果安全产品对PowerShell做过专门的日志增强(例如ScriptBlock日志),那么即使你绕过了实时拦截,之后做日志分析时依然能还原出完整的攻击链条。攻防演练中,这算不算“成功”,取决于目标环境有没有做这种程度的日志审计。我这次的目标环境没有开这么强的日志,所以实时拦截是唯一的检测点。
6.4 一个容易被忽略的致命细节:替换模式
除了加载方式,还有一处很容易翻车的细节,就是Base64编码后文本的“模样”。
原始Shellcode编码成Base64之后,会是一长串大小写字母和数字混合的字符串。如果你把它直接放在网络响应里,然后用PowerShell下载,流量检测系统和EDR都会觉得这个字符串很可疑——高度随机、熵值高、长度异常。
更合理的做法是把Base64字符串拆散,混入一堆正常的HTTP响应内容,比如放在网页正文的某个隐藏节点里,或者伪装成一段图片Base64数据。PowerShell下载之后,先按特征标记提取,再做解码。这种“藏”的思路,能让整个下载动作看起来更像一次正常的网络请求。
6.5 三阶段流程的最终组装方式
把前面所有的细节串起来,整个链路是这样的:
第一步,钓鱼邮件携带宏Office文档,诱导用户点击并启用宏;第二步,宏代码拼接出PowerShell命令,请求远程获取密文;第三步,PowerShell解码密文得到C#程序集字节,反射加载到内存;第四步,C#代码内部申请可执行内存,拷入Shellcode并通过委托执行。
这条链路的每一段单独看都不具备明显的恶意特征:文档只是个普通Office文件,宏代码是常规业务逻辑,PowerShell只是下载了一段文本,C#代码也没在磁盘上落地。只有把这几个“正常”步骤连起来看,才能看到完整的攻击意图,而这恰恰是行为检测的难点所在。
7. 执行链路上的三个隐秘陷阱与绕行方案
7.1 陷阱一:流量层面的特征堆积
第一部分方案的问题在于,虽然操作系统的检测层级都绕过了,但网络流量层面可能被另一套系统盯着。很多环境会在出口网关做流量分析,不关心文件内容,只看请求模式。
我第一版方案里,PowerShell请求的是一整个大文件,一次性拉完。这在流量分析里属于明显异常,即便加密,长度分布也会暴露。后来调整成“分块请求”:先GET一个正常的HTML页面,里面藏着第一阶段密文,再通过后续请求获取另一段数据。每个请求的大小、间隔、头字段都做得跟正常网页浏览一致。实测中,这个模式没有触发任何网络告警。
7.2 陷阱二:内存抖动的“时间差”
另一个容易出错的地方是PowerShell进程在下载和执行之间没有任何间隔。行为监控会关注“一个进程在极短时间内完成多步敏感操作”这种模式。只要在下载完成和解码执行之间加入一定的随机延迟,比如先Sleep 2到8秒,再开始解码,这个时间段的安全监控抓不到可疑行为,整体风险就会降不少。
实际操作上,我还会故意先做几个无意义的数组操作,把时间拉开,让执行节奏更贴近正常脚本的运行状态。这种微调不需要技术含量,但很管用。
7.3 陷阱三:内存属性的精确控制
回到C#加载器本身。通过委托转换来执行Shellcode的做法虽然绕过了VirtualAlloc的监控,但并非万无一失。某些更严格的环境会扫描进程中所有内存区域,找出“权限异常”的可执行区域。
为了应对这种情况,我把C#加载器做成了两段式:先申请普通的内存区域存放Shellcode,此时内存属性是RW;在执行之前,通过委托对象间接调用,避免将整块内存标记为RX。被调用的委托对象本身很小,且位于托管堆中,形态上跟普通的JIT生成的代码没有区别。实际测试下来,这个方案在目标环境里稳定通过。
8. 一些更偏门但有效的辅助技巧
8.1 利用合法的白名单进程做跳板
完整的链路其实可以再优化一步。很多环境对已知白名单进程(比如某些系统自带的工具)信任度很高,几乎不监控它们的内存加载行为。
思路是这样的:进入目标主机后,先不调用PowerShell或C#的独立进程,而是找一台白名单进程,利用它自带的功能加载并执行自定义代码。最常见的方式是利用某个系统自带工具的命令行参数,它支持执行内置脚本或加载外部模块。这不算漏洞,只是“滥用合法功能”,安全产品很难在不影响正常业务的前提下直接封禁这类行为。
更妙的是,这类进程往往有数字签名,进程路径也常见,EDR的白名单策略通常直接放行。把它作为整个链路的执行宿主,被检测的概率会进一步降低。
8.2 载荷的“熵值美容”
静态扫描里有一项很关键的指标叫信息熵。Shellcode、加密载荷这类数据,熵值通常接近理论最大值,而正常业务文件和数据文件的熵值会低很多。高熵区域本身就是可疑信号。
我在构造载荷时,会在加密数据前面加入一段无意义的“填充物”,让整体熵值回落。这就像在密集的文字中间加入大量无意义的空格,让整页纸看起来没有那么“紧凑”。具体实现是在加密前填充一部分固定字节,或混入一段正常的JSON数据,让载荷数据占比降下来。
做过这个处理后,目标环境中对文件的机器学习评分确实下降了。这类技巧在公开资料里很少有人提,但实测效果很明显。
8.3 进程注入的替代方案:进程镂空与线程执行
我不确定你的目标环境是否对进程注入有强监控,但这里有一个通用经验值得记住:注入其他进程之前,先想清楚“这个动作的收益是否大于风险”。
如果你只是要执行一段Shellcode,完全没有必要注入其他进程。直接在当前进程里执行就足够了。注入带来的额外风险包括跨进程API调用痕迹、目标进程崩溃引发的告警、权限校验失败等。只有在需要“白名单进程作掩护”时才值得考虑注入方案,而且注入方式越朴素越好。
8.4 利用Windows错误报告机制做“合法崩溃”
最后一招是借助系统本身的错误报告机制。
具体操作是,在载荷执行完毕后,故意触发一次无害的崩溃,让系统弹出一个错误报告框。这看起来很像正常的程序故障,对用户来说是一个“意外弹窗”,但对安全监控来说,这个错误报告流程会掩盖之前的一些异常行为痕迹。比如,崩溃转储文件会生成一大坨内存数据,里面包含了载荷和加载器的内容,但因为这是系统正常的错误报告流程,不会被视为恶意行为。
说白了,就是用一个“合法的产物”掩盖“非法的痕迹”。注意,不是让你在目标机器上做破坏性的崩溃,而是制造人为可控、无副作用的小崩溃。这招在特定场景下很好用,但只适用于你已经拿到了执行权限的阶段。
9. 测试环境的盲区与行为对抗的一点经验
9.1 你测的“通过”可能换个环境就完蛋
我这次能在目标环境里跑通,并不代表这套方案在所有环境里都有效。不同安全产品的能力差别极大,有的对内存加载行为敏感,有的对PowerShell调用链敏感,有的则更看重整体行为序列。
所以,任何免杀或载荷技术都要先弄清楚目标环境的具体防护策略。你如果只知道“目标环境装了某款安全软件”,却不清楚它到底开了哪些模块,那测试结果等于盲人摸象。做这类测试时,建议先拿一个无害的载荷试水,比如弹个计算器,确认哪些环节被拦、哪些环节能过,建立起环境行为的基线,再按这个基线调整方案。
9.2 行为对抗的边界与底线
聊到行为对抗,必须反复强调边界和底线。
这次测试是在授权范围内、目标明确、载体清晰的合法工作中完成的。所有对抗思路和技术方案都只用于检验目标环境的安全能力,不是用来指导真实攻击的教程。技术本身没有善恶,但使用技术的目的、场景和边界,决定了一件事是否正当。
如果你正在参与合法的攻防演练或安全评估,那么这篇文章里的思路可以给你参考:检测与绕过是一个动态博弈的过程,没有任何一劳永逸的答案。但如果你是想拿这套东西去做不合规的事,趁早打消这个念头。一方面是你一定会被抓住,只是时间问题;另一方面,真到了那个地步,后果远不是一篇文章能兜得住的。
兜兜转转把整个链路跑通之后,最大的感受是:免杀不再是一个“点”的问题,而是一个“系统”的问题。你需要同时考虑文件层的静态特征、内存层的动态行为、网络层的流量特征、进程层的整体行为序列,甚至还得把熵值、时序间隔、白名单路径纳入防线。哪个环节没考虑到,哪个环节就会变成突破口。
如果你也在做类似方向的测试,建议从简单场景开始,逐步叠加对抗维度。每加一层,就在目标环境里跑一遍,观察告警出现的位置,再反向修正自己的方案。这套方法论,比任何现成的工具都要可靠得多。