文章总结: 本文是面向红队的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 哈希解析导入、用直接/间接系统调用绕过用户态钩子,以及基于回调的执行(用 EnumWindows、CertEnumSystemStore 等)替代 CreateThread。
反射式 DLL 加载器(Stephen Fewer)
Stephen Fewer 的 反射式 DLL 注入 技术是划时代的。它不依赖 LoadLibrary(那由操作系统控制、被 EDR 监视),而是让 DLL 自己加载自己。
DLL 导出一个名为 ReflectiveLoader() 的函数,它会:
- 找到自己的基地址(从当前指令指针向前回溯)
- 解析自己的 PE 头(DOS 头 → NT 头 → 节区头)
- 为新镜像分配内存
- 把节区拷贝 到正确的虚拟偏移
- 处理重定位(修正所有绝对地址)
- 解析导入(遍历 PEB 找到已加载的 DLL 及其导出)
- 调用 TLS 回调(如果有的话)
- 调用 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 的姊妹项目。它提供:
- Crystal Palace——PIC 链接器本体
- 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 威力的关键。
简单加载器 只做三件事:
- 读取嵌入的 DLL 资源
- 把它映射进内存(分配、拷贝节区、修重定位、解析导入)
- 调用
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 里找到 Sleep、VirtualAlloc、WaitForSingleObject 等函数的地址。Crystal Palace 的钩取机制拦截这个过程。
工作原理:
- 加载器映射 beacon DLL 并开始处理它的导入地址表(IAT)
- 对每一个导入函数,加载器调用
GetProcAddress找到真实地址 - Crystal Palace 的
_GetProcAddress钩子拦截这次调用 __resolve_hook()——一个在链接时生成的链接器内联函数——检查是否为这个函数名注册了钩子- 如果钩子存在,返回钩子的地址而非真实函数地址
- 此后 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 的内存空间:
- 在磁盘上 找一个合适的 DLL(足够大、不关键)
- 用
NtCreateSection从文件 创建一个 section——这和 Windows 正常加载 DLL 用的是同一套机制 - 用
NtMapViewOfSection把 section 映射进进程 ——此时内存是 有镜像背书的 - 用 beacon 的节区 覆写被映射的 DLL
- 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 调用。对 NtCreateSection、NtMapViewOfSection、VirtualProtect、RtlAddFunctionTable 的调用——全部源自加载器那块无背书的内存。如果 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 链,依次:
- 把 beacon 的内存权限从 RX 改为 RW
- 用
SystemFunction032(RC4)加密 beacon 镜像 - 睡眠指定时长(
WaitForSingleObject) - 解密 beacon 镜像
- 恢复 RX 权限
- 通知主线程恢复
// 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)
- 钩取
WaitForSingleObject、WaitForSingleObjectEx、ConnectNamedPipe和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 睡眠掩码,在
Sleep、ConnectNamedPipe、FlushFileBuffers和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.allocator、stage.magic_pe、stage.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 规则:
- 你的 shellcode 加载器(运行 PIC blob 的那个东西)和加载器本身同等重要。一个完美的 Crystal Palace blob 被一个糟糕的 shellcode runner 执行,照样会被抓。
- 永远不要在生产中用默认反射式加载器——它是攻防安全里被签名最狠的东西。
- 要针对目标环境里的具体 EDR 做测试,而不只是 Defender。
- 没有调用栈伪造的睡眠掩码是不完整的——加密的内存配上可疑的栈,依旧可疑。
- 不做
.pdata注册的模块覆写,意味着你的 beacon API 调用没有展开数据——栈展开会失败或看起来可疑。 - 避免 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 加载器:执行的艺术》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论