好文|Shellcode加载器:执行的艺术

admin 2026-08-22 04:37:24 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文是面向红队的Shellcode加载器完全指南,覆盖经典加载器、反射式DLL加载器、.NET程序集加载器、分阶段与无阶段加载器及PIC加载器。文章深入拆解CrystalPalace框架,包括其PIC链接器、TradecraftGarden项目、PICO架构、spec文件及IAT钩取等高级规避技术。核心结论是成熟的加载器策略需实现零落地物执行、内存规避与自我清理,以绕过现代EDR和AV检测。 综合评分: 95 文章分类: 红队,免杀,安全开发,渗透测试,实战经验


好文 | Shellcode 加载器:执行的艺术

原创

DbgMan DbgMan

赛博生存指南

2026年8月4日 22:58 浙江

在小说阅读器读本章

去阅读

原文:https://0xdbgman.github.io/posts/shellcode-loaders-the-art-of-execution/ 作者:DebuggerMan 发布时间:2026-04-06 翻译模型:GLM-5.2

这是面向红队的 Shellcode 加载器完全指南:经典 Shellcode 加载器、反射式 DLL 加载器(Stephen Fewer)、.NET 程序集加载器(CLR 托管、Assembly.Load)、分阶段与无阶段加载、PIC 加载器,并 深入拆解 Crystal Palace——Raphael Mudge 的 PIC 链接器、Tradecraft Garden、PICO 架构、spec 文件、IAT 钩取、模块覆写、NtContinue 入口转移、基于 Draugr 的调用栈伪造、Ekko 睡眠掩码、YARA 签名消除,以及真实世界的实现(Eden、KaplaStrike、StealthPalace)


为什么加载器如此重要

我是 DebuggerMan,一名红队队员。你打造了完美的载荷——自定义的 beacon 干干净净。但只要你把它落地到磁盘并双击,Windows Defender 就会吞掉它,SmartScreen 拦下它,EDR 标记进程,SOC 在你的 beacon 还没回连之前就收到了告警。

丢一个 .exe 到磁盘然后运行——这条路早就死了,而且死了好多年。

现代环境有层层防御:静态分析扫描磁盘上的每一个字节,AMSI 在内存里审查 .NET 程序集与 PowerShell 脚本,ETW 把行为遥测喂给 EDR,内核回调监控着进程创建、线程创建和镜像加载。「写到磁盘 → 执行」这条经典流程的每一步都在被监视。

加载器是连接你的原始能力(shellcode、DLL、.NET 程序集)与目标环境内执行的桥梁。它包办一切:解密、内存分配、API 解析、注入和清理——与此同时还要规避环境抛过来的每一层防御。

没有像样的加载器,就没有作战可言。

一套成熟的加载器策略只有一个目标:把你的能力以正确的权限、干净的调用栈、零落地物跑进内存里——且不触发检测。

这意味着:

  • 载荷从不以明文形式落盘
  • 内存分配不会大喊「我是恶意软件」(没有 RWX、没有无文件背书的私有提交内存)
  • API 调用拥有合法的调用栈
  • 加载器在执行后自我清理
  • 睡眠期间,beacon 是加密的、对内存扫描器不可见
  • 静态签名(YARA)匹配不到你的 PIC blob

本文覆盖操作者需要理解的每一种加载器类型,并 深入拆解 Crystal Palace——那个改变了我们看待 PIC 加载器方式的框架。


什么是加载器

本质上,加载器是一段代码,它接收一项能力(shellcode、DLL、.NET 程序集)并在内存中执行它。最简单的加载器长这样:

// The most basic shellcode loader
void* mem = VirtualAlloc(NULL, shellcode_len, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(mem, shellcode, shellcode_len);
((void(*)())mem)();

它分配一块 RWX 内存,把 shellcode 拷进去,然后跳过去执行。能用。但它也是这个星球上被检测得最狠的东西。

真实作战中的执行链路是这样的:

Operator → Shellcode Loader → Decrypts Payload → Allocates Memory →
Resolves APIs → Maps Sections → Fixes Relocations → Processes Imports →
CallsEntry Point → C2 Beacon Running

这条链路里的每一步,既是被检测的机会,也是规避的机会。


加载器类型总览

经典 Shellcode 加载器

最直白的加载器类型。接收原始 shellcode(天生位置无关)并执行它。经典模式:

// 1. Allocate memory
void* exec = VirtualAlloc(NULL, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

// 2. Copy payload
memcpy(exec, payload, payload_len);

// 3. Change permissions (avoid initial RWX)
DWORD oldProtect;
VirtualProtect(exec, payload_len, PAGE_EXECUTE_READ, &oldProtect);

// 4. Execute
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)exec, NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);

IOC:权限为 PAGE_EXECUTE_READ 的私有提交内存、无文件背书的代码执行、CreateThread 的起始地址指向私有内存、可疑的调用栈。

现代 shellcode 加载器在上面叠加了多层:对载荷做 XOR/AES 加密、执行前做沙箱检查、用 API 哈希解析导入、用直接/间接系统调用绕过用户态钩子,以及基于回调的执行(用 EnumWindowsCertEnumSystemStore 等)替代 CreateThread

反射式 DLL 加载器(Stephen Fewer)

Stephen Fewer 的 反射式 DLL 注入 技术是划时代的。它不依赖 LoadLibrary(那由操作系统控制、被 EDR 监视),而是让 DLL 自己加载自己。

DLL 导出一个名为 ReflectiveLoader() 的函数,它会:

  1. 找到自己的基地址(从当前指令指针向前回溯)
  2. 解析自己的 PE 头(DOS 头 → NT 头 → 节区头)
  3. 为新镜像分配内存
  4. 把节区拷贝 到正确的虚拟偏移
  5. 处理重定位(修正所有绝对地址)
  6. 解析导入(遍历 PEB 找到已加载的 DLL 及其导出)
  7. 调用 TLS 回调(如果有的话)
  8. 调用 DllMain,参数为 DLL_PROCESS_ATTACH

加载器和载荷在 同一个文件 里。ReflectiveLoader() 这个导出就住在 DLL 的 .text 节中。这就是 stomped(踩踏) 模型——加载器是 beacon 自身的一部分。

┌─────────────────────────────┐
│        Beacon DLL           │
│  ┌───────────────────────┐  │
│  │  ReflectiveLoader()   │  │  ← Loader code lives here
│  │  (exported function)  │  │
│  └───────────────────────┘  │
│  ┌───────────────────────┐  │
│  │    Beacon Logic       │  │  ← C2 functionality
│  │    (check-in, tasks)  │  │
│  └───────────────────────┘  │
└─────────────────────────────┘

Cobalt Strike 从一开始就用这个模型。beacon DLL 自带反射式加载器。生成载荷时,整个 DLL(加载器 + beacon)就是输出。

局限:加载器是静态的——同一份代码加载每一个 beacon,形成稳定签名;到处都是 RWX 内存;没有 IAT 钩取能力;没有睡眠掩码;没有调用栈伪造;反射式加载器和 beacon 一起留在内存里——留给扫描器的攻击面更大。

.NET 程序集加载器

这就是我们之前讨论 Rubeus 时提到的加载器类型。当一个 .NET 工具针对特定框架版本(比如 .NET 4.7)编译时,在版本不同的机器上运行会失败,因为 Windows 在启动时会做严格的版本检查。

解决办法:在一个已经在运行的 CLR 内加载这个程序集。

# PowerShell Assembly.Load
$bytes = [System.IO.File]::ReadAllBytes("C:\Tools\Rubeus.exe")
$assembly = [System.Reflection.Assembly]::Load($bytes)
$assembly.EntryPoint.Invoke($null, @(,[string[]]@("kerberoast")))

PowerShell 已经跑着一个 CLR。通过把原始字节喂给 Assembly.Load(),你绕过了操作系统层面的版本检查,由已经初始化的 CLR 来负责执行。

Cobalt Strike 的 execute-assembly 干的就是这件事——它起一个牺牲进程,注入一个 CLR 引导器,托管 CLR,然后在进程内加载 .NET 程序集。

Beacon Object File(BOF) 是它的进化——小型 COFF 对象,直接在 beacon 进程内运行,完全免去了另起进程的需要。

分阶段 vs 无阶段加载器

分阶段(Staged):初始载荷(stager)很小。它连接 C2 服务器,通过网络下载完整的 beacon。磁盘体积小,但这次网络传输本身就是一个 IOC。

无阶段(Stageless):完整的 beacon 从一开始就嵌在载荷里。文件更大,但不需要第二次网络连接。

Staged:SmallStager → Network → FullBeacon
Stageless:[Loader+FullBeacon] → Execute

在现代作战中,通常更倾向无阶段。初始载荷体积已经不是大问题,而砍掉第二个网络阶段能减少 IOC。

位置无关代码(PIC)加载器

PIC 加载器是反射式加载器的进化。加载器不再是 DLL 的一部分(stomped 模型),而是一个独立的 PIC blob,DLL 被附加在它后面(prepended 模型,也就是 Double Pulsar 风格)。

Stomped Model (Stephen Fewer):
┌──────────────────────┐
│   Beacon DLL         │
│   [Loader + Payload] │  ← Loader inside the DLL
└──────────────────────┘

Prepended Model (Double Pulsar):
┌──────────┐┌──────────┐
│  Loader  ││  Beacon  │  ← Loader is separate, DLL appended
│  (PIC)   ││  (DLL)   │
└──────────┘└──────────┘

prepended 模型意味着:

  • 加载器可以独立开发
  • 加载器可以替换,无需重新编译 beacon
  • 加载器可以在加载完 beacon 后把自己从内存里清掉
  • 不同的加载器可以套用到同一个 beacon DLL 上

这就是 Crystal Palace 登场的地方。


Crystal Palace 深度拆解

什么是 Crystal Palace

Crystal Palace 是一个 专为位置无关代码设计的链接器。由 ** Raphael Mudge (Cobalt Strike 的原作者)创建,是 ** Tradecraft Garden 项目的核心。

最简单地讲:Crystal Palace 接收编译好的目标文件(COFF),把它们链接到一起,解析所有依赖,输出一个完全位置无关、可以从任何内存地址执行的 blob。

但这个描述低估了它。Crystal Palace 是一个 面向切面编程(AOP)工具,把 tradecraft 织入 PIC 能力里。它把你的代码 ** 做什么**(能力)与 ** 如何**规避检测(tradecraft)分离开来。你用 C 写一个加载器,编译成目标文件,写一个 .spec 文件描述如何链接和配置,Crystal Palace 就产出一个可直接部署的 PIC blob。

SourceCode (.c) → Compile (mingw-w64) → Object File (.o) → Crystal Palace (link) → PIC Blob (.bin)

Crystal Palace 用 Java 写成,跑在 Linux 上(推荐 WSL),并且完全 与 C2 无关。它和 Cobalt Strike 配合得天衣无缝,但同样适用于 Havoc、Mythic(Xenon)、Adaptix、Sliver,或任何能产出 DLL 或 COFF 能力的框架。

Tradecraft Garden

Tradecraft Garden 是 Crystal Palace 的姊妹项目。它提供:

  1. Crystal Palace——PIC 链接器本体
  2. The Garden——一组预制的加载器示例,演示各种设计模式

Garden 里有循序渐进的示例:

  • simple_rdll——基础反射式 DLL 加载器
  • simple_rdll_guardrail——带环境密钥(只在正确环境下执行)的加载器
  • simple_rdll_masking——带资源掩码/加密的加载器
  • simple_rdll_stomping——带模块踩踏用于内存规避的加载器
  • simple_rdll_hooking——带 IAT 钩取用于睡眠掩码的模块化加载器
  • simpleobj——COFF 能力加载器(而非 DLL)

每个示例都建立在前一个之上,一次只教一个概念。目标是 把 PIC tradecraft 分解成自包含的单元,让进攻方和防御方都能理解。

架构:COFF、PICO 与 spec 文件

Crystal Palace 基于三个核心概念运作:

COFF(Common Object File Format,通用目标文件格式):用 mingw-w64 编译 C 代码的产物。一个标准的 .o 目标文件,包含代码、数据、重定位和符号。

PICO(Position-Independent Code Object,位置无关代码对象):Crystal Palace 的可执行 COFF 约定。一个 PICO 是经过处理成为位置无关的 COFF。PICO 与传统的 Beacon Object File(BOF)不同——BOF 支持 Beacon 专属约定(BOF C API、BeaconOutput 等),而 PICO 是独立的。

PICO 相对 DLL 的关键优势:

  • 你清楚程序里有什么,拥有完全控制
  • 体积小
  • 代码(.text)和数据可以放在 内存中彼此相隔很远的区域
  • 可以应用 Crystal Palace 的二进制变换(+mutate+optimize+disco
  • 支持 tradecraft 分离——你的能力不知道规避的存在,你的规避也不知道能力

Spec 文件(.spec):Crystal Palace 的链接脚本。它是定义 ** 各组件如何拼装**的黏合剂。spec 文件告诉 Crystal Palace 加载什么、如何处理、套用什么 tradecraft、以及如何输出最终 blob。

一个基础的 spec 文件:

x64:
  load "bin/loader.x64.o"# load the loader COFF
  make pic +gofirst              # turn it into PIC, go() is entry point
  dfr "resolve""ror13"# use ROR13 hashing for API resolution
  mergelib "libtcg.x64.zip"# merge the shared library
push$DLL# read the DLL being provided
  link "dll"# link it to the "dll" section
  export                         # export the final PIC

dfr 命令至关重要——它告诉 Crystal Palace 如何解析 PICO 用于动态函数解析的 MODULE$Function 约定(比如 KERNEL32$VirtualAlloc)。PICO 不走正常导入,而是用这种命名约定,由 Crystal Palace 在链接时用指定的哈希算法解析。

make pic 命令从 COFF 中提取 .text 和 .rdata 节,合并它们,并解析所有重定位,产出位置无关代码。+gofirst 标志确保 go() 函数被放在位置 0——即 PIC blob 的入口点。

Crystal Palace 中的加载器类型

Crystal Palace 同时支持两种加载器模型,但重点是 prepended(Double Pulsar) 模型:

Stomped(Stephen Fewer 风格):加载器代码覆写 beacon DLL 的 .text 节的一部分。DLL 中的 ReflectiveLoader() 导出被替换为你的自定义加载器。加载器和 beacon 在同一个文件里。

Prepended(Double Pulsar 风格):加载器是一个独立的 PIC blob。beacon DLL 作为资源附加在后面。运行时,加载器从自己的数据里读出 DLL,映射到内存,转移执行。

┌───────────────────────────────────┐
│         Crystal Palace Output     │
│  ┌─────────────┐ ┌─────────────┐ │
│  │ Loader PIC  │ │ Beacon DLL  │ │
│  │ (code)      │ │ (encrypted  │ │
│  │             │ │  resource)  │ │
│  └─────────────┘ └─────────────┘ │
└───────────────────────────────────┘

DLL 在链接时作为资源嵌入 PIC blob 并被加密。运行时,加载器解密它、映射它、执行它。beacon 跑起来以后,加载器可以释放自己的内存——它已经没用了。

从简单加载器到模块化加载器

从简单加载器到模块化架构的演进,是 Crystal Palace 威力的关键。

简单加载器 只做三件事:

  1. 读取嵌入的 DLL 资源
  2. 把它映射进内存(分配、拷贝节区、修重定位、解析导入)
  3. 调用 DllMain,参数为 DLL_PROCESS_ATTACH

模块化加载器 分离关注点:

  • 基础加载器:处理 PE 映射
  • setup 模块:引导 PIC 服务、初始化 tradecraft
  • hooking PICO:常驻内存,拦截 API 用于睡眠掩码
  • tradecraft PICO:可堆叠的模块(调用栈伪造、加密、guardrail)

Crystal Palace 的 run 命令让 spec 文件可以包含其他 spec 文件,实现真正的模块化:

# loader.spec
x64:
  load "bin/loader.x64.o"
  make pic +gofirst
  run "xorhooks_setup.spec"# bring in setup module
  run "xorhooks.spec"# bring in hooking tradecraft
push$DLL
link"dll"
  export

每个组件独立开发、测试和调试。在链接时,Crystal Palace 把它们合并成一个 PIC blob。关键洞见:tradecraft 和能力在源码里分离,在链接时统一。

通过 PICO 机制做 IAT 钩取

Crystal Palace 最强大的特性之一,是在导入解析时做 IAT 钩取。

当加载器把一个 DLL 映射到内存时,它需要解析导入——在已加载的 DLL 里找到 SleepVirtualAllocWaitForSingleObject 等函数的地址。Crystal Palace 的钩取机制拦截这个过程。

工作原理:

  1. 加载器映射 beacon DLL 并开始处理它的导入地址表(IAT)
  2. 对每一个导入函数,加载器调用 GetProcAddress 找到真实地址
  3. Crystal Palace 的 _GetProcAddress 钩子拦截这次调用
  4. __resolve_hook()——一个在链接时生成的链接器内联函数——检查是否为这个函数名注册了钩子
  5. 如果钩子存在,返回钩子的地址而非真实函数地址
  6. 此后 beacon 每次调用这个被钩的函数,实际调用的都是钩子

钩子在 spec 文件里注册:

# Register a hook for WaitForSingleObject
addhook"KERNEL32$WaitForSingleObject""_WaitForSingleObject"

# Filter hooks to only those needed by the current DLL
filterhooks $DLL

filterhooks 命令至关重要——它遍历目标 DLL 的导入,移除该 DLL 实际并未使用的已注册钩子,避免不必要的开销。

为什么这对睡眠掩码意义重大:当 beacon 调用 Sleep() 或 WaitForSingleObject() 时,钩子拦截它。钩子随后可以加密 beacon 的内存、设置正确权限、等待指定时间、解密内存、恢复权限,然后返回——这一切对 beacon 完全透明。beacon 根本不知道自己被做了掩码。

这正是 Crystal Palace 实现里 _WaitForSingleObject 钩子做的事:

DWORD WINAPI _WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds)
{
// 1. Encrypt beacon memory sections
// 2. Change permissions to RW (hide executable code)
// 3. Call real WaitForSingleObject (or Sleep via spoofed stack)
// 4. Decrypt beacon memory sections
// 5. Restore RX permissions
// 6. Return to beacon
return result;
}

hooking PICO 与 beacon 一起常驻内存。加载器释放自己之后,hooking PICO 会在 beacon 的整个生命周期里持续拦截调用。

要让这套机制生效,beacon DLL 必须真正通过 IAT 导入这个被钩的函数。如果 beacon 是靠手动遍历 PEB 来解析 WaitForSingleObject(而不是走正常导入),就没有 IAT 条目可供拦截。这也是为什么一些实现会强制生成一个真实导入:

// Force the compiler to generate an IAT entry
__declspec(dllimport) DWORD WINAPI WaitForSingleObject(HANDLE, DWORD);

模块覆写(NtCreateSection + NtMapViewOfSection)

经典反射式加载器用 VirtualAlloc 分配内存。这会产生 私有提交(private commit) 内存——由进程动态分配的内存。EDR 和内存扫描器会标记可执行的私有提交内存,因为合法代码通常住在 镜像提交(image commit) 内存里(由磁盘上的文件背书)。

模块覆写的解法是把 beacon 加载进一个合法 DLL 的内存空间:

  1. 在磁盘上 找一个合适的 DLL(足够大、不关键)
  2. 用 NtCreateSection 从文件 创建一个 section——这和 Windows 正常加载 DLL 用的是同一套机制
  3. 用 NtMapViewOfSection 把 section 映射进进程 ——此时内存是 有镜像背书的
  4. 用 beacon 的节区 覆写被映射的 DLL
  5. beacon 现在住在一块看起来像合法加载 DLL 的内存里
// 1. Open the DLL file
HANDLE hFile = CreateFileW(L"C:\\Windows\\System32\\mshtml.dll", ...);

// 2. Create a section (same as what LoadLibrary does internally)
HANDLE hSection;
NtCreateSection(&hSection, SECTION_ALL_ACCESS, NULL, NULL,
                 PAGE_READONLY, SEC_IMAGE, hFile);

// 3. Map it into our process
PVOID baseAddress = NULL;
NtMapViewOfSection(hSection, GetCurrentProcess(), &baseAddress, ...);

// 4. Now overwrite with beacon sections
// Copy .text, .data, .rdata, etc. from beacon into this memory

没有 LoadLibrary 调用——DLL 从不走过正常加载流程,所以不会触发 LdrLoadDll 回调,也不会产生镜像加载事件。这绕过了 LoadLibrary 本会触发的 CFG(Control Flow Guard)限制。

KaplaStrike 的实现 ** 加了一个关键的 OPSEC 改进:.pdata 注册**。覆写模块之后,它调用 RtlAddFunctionTable 注册 beacon 的异常处理数据(.pdata 节)。这意味着当 beacon 发起 API 调用时,Windows 能够正确地穿越 beacon 的栈帧展开。调用栈看起来是合法的,因为展开数据已注册并且指向有镜像背书的内存。

NtContinue 入口转移

加载器映射好 beacon DLL、修好重定位、解析完导入、装好钩子之后——它需要把执行转移到 beacon 的入口点。这是个关键时刻。

如果加载器直接调用入口点:

// Direct call  BAD
DllMain(baseAddress, DLL_PROCESS_ATTACH, NULL);

栈上的返回地址会指回加载器的内存。加载器是 PIC,住在无背书的私有提交内存里。beacon 生命周期内的任何栈检查,都会看到这个返回地址指向可疑内存。

NtContinue ** 通过执行一次 不压入返回地址的上下文切换**来解决:

CONTEXT ctx;
RtlCaptureContext(&ctx);

// Set RIP to beacon's entry point
ctx.Rip = (DWORD64)beaconEntryPoint;

// Set RSP to our synthetic stack
ctx.Rsp = (DWORD64)fakeStack;

// Set DllMain arguments
ctx.Rcx = (DWORD64)baseAddress;      // hinstDLL
ctx.Rdx = DLL_PROCESS_ATTACH;         // fdwReason
ctx.R8  = 0;                          // lpvReserved

// Transfer execution  no return address pushed
NtContinue(&ctx, FALSE);

但还有更多。合成栈必须包含 BaseThreadInitThunk 和 RtlUserThreadStart 的伪造栈帧——这两个函数通常会出现在每一个合法线程调用栈的底部。

KaplaStrike 通过读取这些函数的展开数据(.pdata)来精确计算栈帧大小,然后把它们的返回地址写到伪造栈缓冲区的正确偏移上。结果:beacon 的调用栈终结得和一个合法线程一模一样,栈上没有任何返回地址指向加载器

NtContinue 触发后,从 beacon 的执行上下文里彻底够不到加载器。加载器此时可以安全地从内存释放。

用 Draugr 做调用栈伪造

即便做了模块覆写和 NtContinue 入口转移,仍然有一个问题:加载器自身在加载过程中会发起 API 调用。对 NtCreateSectionNtMapViewOfSectionVirtualProtectRtlAddFunctionTable 的调用——全部源自加载器那块无背书的内存。如果 EDR 在这些调用发生时检查调用栈,它会看到一个无背书的调用方。

Draugr 是 Cobalt Strike 的 Sleepmask-VS 项目中的一个调用栈伪造实现。它通过合成栈帧代理 API 调用,让调用看起来源自合法代码。

Crystal Palace 让集成 Draugr 变得简单。Eden 加载器演示了这一点——它把 Draugr 的 PIC 版本直接嵌入加载器:

# Eden spec file (simplified)
x64:
  load "bin/loader.x64.o"
  make pic +gofirst

# Load Draugr as PIC (not PICO  no VirtualAlloc needed)
  load "bin/draugr.x64.o"
  make pic
link"draugr"

push$DLL
link"dll"
  export

把 Draugr 编译为 PIC(用 make pic 而非 make object),它就直接嵌入加载器 blob,无需 VirtualAlloc 来加载一个 PICO。加载器发起的每一次 API 调用都可以通过 Draugr 代理,拿到干净的调用栈。

Eden 加载器把 callgate 与加载器本身解耦——这是出于模块化的设计考量。你只需改 spec 文件,就能换上另一种栈伪造技术。思路是让加载器成为一组可互换能力的组合。

睡眠掩码(Ekko 风格)

睡眠掩码解决一个关键问题:beacon 在睡眠时(在两次 C2 回连之间等待),它的代码以明文形式留在内存里,可被扫描。

内存扫描器(Moneta、BeaconHunter、YARA 扫描)能通过 beacon 的代码模式、字符串或配置在内存中识别它。睡眠掩码在 beacon 睡眠时加密整个 beacon 镜像,在 beacon 醒来时再解密。

Ekko(作者 C5pider,灵感来自 MDSec 的 Nighthawk)用定时器队列回调配合 NtContinue 构造一条 ROP 链,依次:

  1. 把 beacon 的内存权限从 RX 改为 RW
  2. 用 SystemFunction032(RC4)加密 beacon 镜像
  3. 睡眠指定时长(WaitForSingleObject
  4. 解密 beacon 镜像
  5. 恢复 RX 权限
  6. 通知主线程恢复
// Simplified Ekko ROP chain
CreateTimerQueueTimer(&timer, queue, (WAITORTIMERCALLBACK)NtContinue,
                       &ctxProtRW,   100, 0, WT_EXECUTEINTIMERTHREAD);  // RX → RW
CreateTimerQueueTimer(&timer, queue, (WAITORTIMERCALLBACK)NtContinue,
                       &ctxEncrypt,  200, 0, WT_EXECUTEINTIMERTHREAD);  // Encrypt
CreateTimerQueueTimer(&timer, queue, (WAITORTIMERCALLBACK)NtContinue,
                       &ctxSleep,    300, 0, WT_EXECUTEINTIMERTHREAD);  // Sleep
CreateTimerQueueTimer(&timer, queue, (WAITORTIMERCALLBACK)NtContinue,
                       &ctxDecrypt,  400, 0, WT_EXECUTEINTIMERTHREAD);  // Decrypt
CreateTimerQueueTimer(&timer, queue, (WAITORTIMERCALLBACK)NtContinue,
                       &ctxProtRX,   500, 0, WT_EXECUTEINTIMERTHREAD);  // RW → RX
CreateTimerQueueTimer(&timer, queue, (WAITORTIMERCALLBACK)NtContinue,
                       &ctxSignal,   600, 0, WT_EXECUTEINTIMERTHREAD);  // Signal

整条链跑在一个 定时器线程 上——一个合法的 Windows 线程池 worker。该定时器线程拥有完美干净的调用栈,底部是 BaseThreadInitThunk 和 RtlUserThreadStart,没有任何无背书的返回地址。

在 Crystal Palace 的实现里,睡眠掩码住在一个 hooking PICO 里,通过 IAT 拦截 WaitForSingleObject。当 beacon 调用 WaitForSingleObject(或者 Sleep,后者内部会调用 WaitForSingleObject)时,钩子触发 Ekko 链。

StealthPalace(MaorSabag 的 Adaptix C2 实现)更进一步:

  • 基于 RC4 的 Ekko 睡眠掩码(不只是 XOR)
  • 钩取 WaitForSingleObjectWaitForSingleObjectExConnectNamedPipe 和 FlushFileBuffers
  • 按节区恢复权限(每个节区拿回自己正确的权限:.text = RX、.data = RW)
  • 睡眠期间做线程上下文伪造

关键 OPSEC 注意:用模块覆写实现睡眠掩码时,.pdata 节和 UNWIND_INFO 结构在加密期间必须 保留。如果它们被加密,睡眠期间的任何栈展开都会失败,因为展开数据不可读。Lorenzo Meacci 的 ** InsomniacUnwinding** 技术解决的正是这个问题——在加密其他一切的同时,精确地保留 PE 头、.pdata 和抽取出的 UNWIND_INFO 区域。

YARA 签名消除(+mutate、+disco、+optimize)

即便有了上面全部的运行时规避,PIC blob 本身仍带有静态签名。安全产品用 YARA 规则检测已知的加载器模式。Crystal Palace 提供三种二进制变换来破坏这些签名:

+optimize:从 PIC blob 中移除未使用的函数。如果你的加载器链接了一个含 50 个函数的库但只用了 10 个,其余 40 个会被剥掉。输出更小,签名的攻击面更小。

+disco:随机化 PIC blob 中的函数顺序。每次链接,函数都被放到不同的顺序。依赖函数邻近性或顺序的模式签名被破坏。

+mutate:Crystal Palace 的代码变形器。它变换机器码本身——插入垃圾指令、重排相互独立的指令、替换为等价的指令序列。最终代码功能相同但结构不同。

# Apply all transformations
x64:
  load "bin/loader.x64.o"
  make pic +gofirst +mutate +disco +optimize
  ...

这三种变换组合起来意味着:每次构建加载器,输出在结构上都是 独一无二的。针对特定字节序列的 YARA 规则永远无法稳定命中。

Crystal Palace 还能 为 PIC blob 中不可变的部分生成高保真 YARA 规则——即那些无法被变换改变的部分。这对防御方很有用:你能识别出到底哪些字节足够稳定,可以写检测。对操作者而言,它告诉你加载器里还有哪些部分需要继续打磨。

真实世界的实现

Eden 加载器(Cobalt Strike 团队)

Eden 是 Cobalt Strike 用 Crystal Palace 构建的官方 UDRL PoC。它组合了:

  • 分页流式加载(Raphael Mudge 把 beacon DLL 流式送进内存的技术)
  • Draugr 调用栈伪造(来自 Sleepmask-VS,移植为 PIC)

Eden 演示了核心思路:用 Crystal Palace 组合不同的「执行单元」来创造新型加载器。Draugr PICO 以 PIC 形式嵌入加载器,而不是在运行时作为独立 COFF 加载,这就消除了加载 PICO 本会需要的 VirtualAlloc 调用。

Eden 有意做得不是完全规避的加载器——它用 RWX 内存,也不追踪 beacon 的堆。它是一个教学工具和概念验证。

# Building Eden
make clean; make
# Copy crystalpalace.jar to Cobalt Strike
# Load eden.cna into the client
# Payloads are now generated with Eden automatically

KaplaStrike(Lorenzo Meacci)

KaplaStrike 是一个产品级的、面向 Cobalt Strike 的 Crystal Palace 加载器,实现了:

  • 通过 NtCreateSection + NtMapViewOfSection 做 模块覆写(无 LoadLibrary、无 CFG 问题)
  • 通过 RtlAddFunctionTable 做 .pdata 注册,让 beacon 调用栈帧干净
  • 带合成 BaseThreadInitThunk / RtlUserThreadStart 栈帧的 NtContinue 入口转移
  • 通过 Draugr 为加载器 setup 阶段做 API 调用栈伪造
  • 睡眠掩码 完全由加载器处理(不靠 Cobalt Strike 自带的 sleepmask)
  • 通过 Crystal Palace 变换做 静态签名消除

Malleable C2 profile 配置极简,因为加载器包办了一切:

stage {
set cleanup "true";
set sleep_mask "false";    # Loader handles this
set obfuscate "false";
}
post-ex {
set cleanup "true";
}

一个 CNA 脚本(NOUDRL.cna)在 beacon DLL 进入流水线之前,先把默认反射式加载器剥掉。从 BEACON_RDLL_SIZE 返回 "0",你就拿到一个干净的、前面什么都没附加的原始 DLL。然后 Crystal Palace 的 spec 文件接管这个原始 DLL,把它和 KaplaStrike 加载器链接起来,输出最终 PIC blob。

make x64
./link spec/loader.spec cobalt_strike_raw.dll output.bin
# output.bin is the final PIC blob
# Execute with any shellcode loader

KaplaStrike 绕过了市面上顶级的某款 EDR。它里面的每一项技术,都是为了反制某个具体的检测而存在的。

StealthPalace(MaorSabag,Adaptix C2)

StealthPalace 证明 Crystal Palace 与 C2 无关的特性。它为 Adaptix C2 构建,实现了:

  • 载荷在链接时用随机 128 字节密钥 通过 Crystal Palace 指令加密
  • 运行时:XOR 解密到一个临时 VirtualAlloc 缓冲区,映射成布局正确的镜像,然后安全擦除
  • 基于 RC4 的 Ekko 睡眠掩码,在 SleepConnectNamedPipeFlushFileBuffers 和 WaitForSingleObjectEx 上触发
  • 基于 PICO 的 IAT 钩取,在加载时拦截 GetProcAddress,把目标 API 重定向经过钩子表
  • 睡眠后 按节区恢复内存权限

StealthPalace 还接入了 Adaptix 的 Service Extender 系统。一个安装脚本钩住 agent 构建流水线,使得每个 agent DLL 在编译时自动被 Crystal Palace RDLL 包裹。

chmod +x install.sh
./install.sh--ax/path/to/AdaptixServer
# Restart teamserver  every agent now uses StealthPalace

Crystal Palace 与 Cobalt Strike 集成(UDRL)

用户自定义反射式加载器(UDRL) 在 Cobalt Strike 4.4 中引入,给操作者完全掌控 beacon 反射式加载过程的能力。Crystal Palace 通过 Aggressor Script 与之集成。

两个关键钩子:

BEACON_RDLL_SIZE:返回 "0",把默认反射式加载器从 beacon DLL 中完全剥除。

BEACON_RDLL_GENERATE:每次生成无阶段载荷时被调用。Crystal Palace 在这里处理原始 beacon DLL 并套用自定义加载器。

# CNA Integration (simplified)
set BEACON_RDLL_SIZE {
return"0";
}

set BEACON_RDLL_GENERATE {
local('$beacon $arch');
$beacon = $2;
$arch = $3;

# Load spec file
$spec = [new CrystalPalace: script_resource("loader.spec")];

# Apply spec to raw beacon
$payload = [$spec run: $beacon, $null];

return$payload;
}

当你导出一个载荷时,脚本控制台会显示:

[14:55:55] [*] GeneratingPayload: HTTP -- Arch: x64
[14:55:56] [LOADER] ApplyingCrystalPalace spec...
[14:55:56] [LOADER] PayloadSize: 387060 bytes
[14:55:56] [*] Using user modified reflective DLL!

重要的 Malleable C2 注意事项:使用 UDRL 时,像 stage.allocatorstage.magic_pestage.obfuscate 和 stage.sleepmask 这类设置会被写入 beacon 的运行时配置,但 不会自动生效。UDRL 必须自己处理这些行为。如果你的 Crystal Palace 加载器已经处理了睡眠掩码,就在 profile 里把 stage.sleepmask 设为 false

避免 fork and run:Cobalt Strike 的 post-ex 能力(比如 powerpick)会起一个牺牲进程,并用 默认 反射式加载器注入一个 DLL。这会把你整场加载器工作消除掉的所有 IOC 全部重新带回来。在较新版本中,Crystal Palace 可以被加载进客户端,以改变 post-ex DLL 的注入和加载方式。但作为通则:优先用 BOF 做内联执行,而非任何进程外方案。

Crystal Palace 不止于 Cobalt Strike(与 C2 无关)

Crystal Palace 并不绑定 Cobalt Strike。任何能产出 DLL 或 COFF 的 C2 框架都能用它。

Havoc:开源 C2,自带反射式加载器。你可以直接改加载器源码,或者把 Crystal Palace 作为外部链接步骤。

Mythic(Xenon agent):Xenon 把 Crystal Palace 作为默认反射式 DLL 加载器。Xenon agent 代码里的 spec 文件告诉 Crystal Palace 如何构建 PIC:

x64:
  load "bin/loader.x64.o"
  make pic +gofirst
  dfr "resolve""ror13"
  mergelib "libtcg.x64.zip"
  push $DLL
link"dll"
export

操作者可以在构建过程中为 shellcode 输出类型换成自己的 Crystal Palace 加载器。

Adaptix:StealthPalace(见上文)通过 Service Extender 演示了完整的 Crystal Palace 集成。

模式永远一样:从你的 C2 框架拿到原始 DLL,连同 spec 文件一起送进 Crystal Palace,得到一个 PIC blob,用任何 shellcode runner 执行它。


检测与 OPSEC 注意事项

防御方在看什么,以及每一位操作者都应当心的事:

内存指标

  • 权限为 PAGE_EXECUTE_READ 或 PAGE_EXECUTE_READWRITE 的私有提交内存——合法代码住在有镜像背书的内存里
  • 无文件背书的可执行内存区域(没有磁盘文件为这块内存背书)
  • RWX 内存分配(在合法进程里几乎不应存在)
  • 高熵节区(等待被解密的加密载荷)
  • 有镜像背书的内存中被修改的代码(覆写后 SharedOriginal 标志丢失)

调用栈指标

  • 返回地址指向私有提交或无背书内存
  • 调用栈底部缺少 BaseThreadInitThunk / RtlUserThreadStart
  • 调用栈与 .pdata 中的展开数据不匹配
  • 线程等待原因(直接 Sleep 调用对应 DelayExecution,而 WaitForSingleObject 对应 UserRequest

行为指标

  • 对异常 DLL 调用带 SEC_IMAGE 标志的 NtCreateSection
  • 从无镜像背书的内存发起 RtlAddFunctionTable 调用
  • 以 NtContinue 为回调的定时器队列创建(Ekko 签名)
  • 睡眠周期内的 SystemFunction032(RC4)调用
  • 同一内存区域上快速的权限切换(RX → RW → RX)

静态指标

  • 针对已知加载器字节模式的 YARA 规则
  • 内存中的 PE 头残留(即便踩踏之后,也 may 残余痕迹)
  • 已知工具字符串(BokuLoader 硬编码值、Cobalt Strike 配置标记)

OPSEC 规则

  1. 你的 shellcode 加载器(运行 PIC blob 的那个东西)和加载器本身同等重要。一个完美的 Crystal Palace blob 被一个糟糕的 shellcode runner 执行,照样会被抓。
  2. 永远不要在生产中用默认反射式加载器——它是攻防安全里被签名最狠的东西。
  3. 要针对目标环境里的具体 EDR 做测试,而不只是 Defender。
  4. 没有调用栈伪造的睡眠掩码是不完整的——加密的内存配上可疑的栈,依旧可疑。
  5. 不做 .pdata 注册的模块覆写,意味着你的 beacon API 调用没有展开数据——栈展开会失败或看起来可疑。
  6. 避免 fork and run 的 post-ex——它把你加载器消除掉的所有 IOC 重新带回。

参考与资源

Crystal Palace 与 Tradecraft Garden

  • Tradecraft Garden Documentation —— Raphael Mudge
  • Harvesting the Tradecraft Garden —— Daniel Duggan(@_RastaMouse)
  • Modular PIC C2 Agents —— Daniel Duggan
  • Debugging the Tradecraft Garden —— Daniel Duggan
  • Playing in the (Tradecraft) Garden of Beacon: Finding Eden —— Cobalt Strike Team
  • Revisiting the UDRL Part 1 —— Cobalt Strike Team
  • COFFing out the Night Soil —— Raphael Mudge
  • Tradecraft Orchestration in the Garden —— Raphael Mudge

实现

  • Eden Loader(GitHub)—— Cobalt Strike Team
  • KaplaStrike(GitHub)—— Lorenzo Meacci
  • Bypassing EDR in a Crystal Clear Way —— Lorenzo Meacci
  • Unwind Data Can’t Sleep: InsomniacUnwinding —— Lorenzo Meacci
  • Crystal-Loaders(GitHub)—— Daniel Duggan
  • StealthPalace / Adaptix-StealthPalace(GitHub)—— MaorSabag
  • BokuLoader(GitHub)—— Bobby Cooke

反射式加载

  • Reflective DLL Injection —— Stephen Fewer
  • UDRL Update in Cobalt Strike 4.5 —— Cobalt Strike

睡眠掩码与内存规避

  • Avoiding Memory Scanners —— Black Hills InfoSec
  • Ekko Sleep Obfuscation —— C5pider
  • Module Stomping —— Nigerald
  • Process Injection in 2023 —— Vincent Van Mieghem

调用栈伪造

  • ETW Threat Intelligence and Hardware Breakpoints —— Praetorian

Mythic 集成

  • Xenon Agent OPSEC —— MythicAgents

在 X 上关注我:@0XDbgMan在 Telegram 上关注我:@DbgMan

本文由作者采用 CC BY 4.0 许可发布。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:赛博生存指南 DbgMan DbgMan《好文 | Shellcode 加载器:执行的艺术》

评论:0   参与:  0