资讯动态

免杀链路实战:从钓鱼邮件到内存反射加载的对抗

发布时间:2026/10/19 10:34:55 来源:尧图企业网站定制
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]; // 方案AMarshal.CopyAPI调用序列均匀但显眼 Marshal.Copy(shellcode, 0, unmanagedPtr, shellcode.Length); // 方案Bunsafe指针拷贝调用链更短 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命令逻辑只有一步从某个远程地址下载一段文本内容再用IEXInvoke-Expression执行。听起来是不是很危险没错。但这是语义层面的“危险”在操作层面我做了好几个微调让它在行为监控看来没那么扎眼命令不是一次性完整出现的而是通过多次字符串拼接生成而且是分多次写入的最后才合并执行下载地址被拆成几段在代码里穿梭完成拼接避免静态特征PowerShell进程启动后先执行了一段正常的本地命令比如打印系统信息把上下文“预热”成正常业务脚本的感觉然后再执行真正的下载逻辑。这些手段谈不上高明但在行为检测模型看来活动的复杂度明显增加了。至少在我测试的版本上这个阶段没有触发任何联动告警。6.3 第三阶段内存加载的最终执行形态到这里整个链路已经顺畅地走完了三分之二文档落地、宏代码执行、PowerShell远程加载文本。真正临门一脚的问题是那段文本到底是什么怎么执行才能不被拦我用的方案是下载的内容是一段经过Base64编码的C#源码和ShellcodePowerShell先将其解码为字节数组再通过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 行为对抗的边界与底线聊到行为对抗必须反复强调边界和底线。这次测试是在授权范围内、目标明确、载体清晰的合法工作中完成的。所有对抗思路和技术方案都只用于检验目标环境的安全能力不是用来指导真实攻击的教程。技术本身没有善恶但使用技术的目的、场景和边界决定了一件事是否正当。如果你正在参与合法的攻防演练或安全评估那么这篇文章里的思路可以给你参考检测与绕过是一个动态博弈的过程没有任何一劳永逸的答案。但如果你是想拿这套东西去做不合规的事趁早打消这个念头。一方面是你一定会被抓住只是时间问题另一方面真到了那个地步后果远不是一篇文章能兜得住的。兜兜转转把整个链路跑通之后最大的感受是免杀不再是一个“点”的问题而是一个“系统”的问题。你需要同时考虑文件层的静态特征、内存层的动态行为、网络层的流量特征、进程层的整体行为序列甚至还得把熵值、时序间隔、白名单路径纳入防线。哪个环节没考虑到哪个环节就会变成突破口。如果你也在做类似方向的测试建议从简单场景开始逐步叠加对抗维度。每加一层就在目标环境里跑一遍观察告警出现的位置再反向修正自己的方案。这套方法论比任何现成的工具都要可靠得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑