COMegon

image

Dual-mode API dispatch framework that routes arbitrary Windows API calls through COM infrastructure, producing fully legitimate system callstacks with zero user code on any thread.

What It Does

COMegon leverages the COM runtime's own internal dispatch machinery to execute API calls on your behalf. From the OS perspective, every call originates from combase.dll → rpcrt4.dll → ntdll.dll — a callstack indistinguishable from normal COM RPC activity.

Modes

Sleep Mode (Batch Fire-and-Forget)

Queue multiple API calls, then execute them all in a single pump cycle. The calling thread's fiber is suspended while dispatch occurs on a dedicated pump fiber whose stack contains only system frames.

Use case: Sleep encryption chains — queue VirtualProtect → SystemFunction032 → NtDelayExecution → SystemFunction032 → VirtualProtect, then sleep. Your .text section can be encrypted because no user code exists on any active callstack.

Proxy Mode (Synchronous Dispatch)

Execute a single API call and retrieve the result. The call is dispatched through the same COM channel but returns synchronously to the caller.

Use case: Runtime stack spoofing — any sensitive API call gets a clean system-only callstack without persistent infrastructure changes.

Security Properties

Property Status
CET (Shadow Stack) compliant ✅ All returns are legitimate
HSP (Hardware Stack Protection) compliant ✅ No stack pivots or overwrites
No user code on callstack (sleep) ✅ Pump fiber is pure system frames
No user code on STA callstack (proxy) ✅ Dispatch via COM channel
.text encryption safe ✅ No return addresses into user .text during sleep
No trampolines or detours ✅ Uses COM's own vtable dispatch
Fiber-based context switching ✅ CET-compliant control flow

Architecture

COMegon abuses the COM runtime's cross-apartment RPC dispatch to execute arbitrary function pointers through a fully legitimate system callstack. No hooks, no trampolines, no shellcode — just carefully crafted MIDL metadata that tells NdrStubCall2 to call our functions as if they were COM method implementations.

Layered Design

Layer 1: RVA Resolution     — Resolve combase.dll internals by offset (ModalLoop, BlockFn,
                               CCliModalLoopCtor, PostCall, NoOpReturn0, CCliModalLoop vtable)
Layer 2: Format Strings     — Build NDR proc format strings at runtime describing each dispatch
                               slot's parameter layout (param count, stack sizes, Oi2 header)
Layer 3: MIDL Tables        — Heap-allocated MIDL_SERVER_INFO, MIDL_STUB_DESC, RPC_SERVER_INTERFACE,
                               dispatch tables, format string offsets, CStdStubBuffer, stub vtable
Layer 4: COM Plumbing       — IPSFactoryBuffer, IRpcProxyBuffer, IClassFactory, ISynchronize —
                               fake COM objects that satisfy the runtime's QueryInterface/CreateStub/
                               CreateProxy calls during CoMarshalInterThreadInterfaceInStream
Layer 5: STA Command Loop   — Dedicated STA thread: CoInitEx(APARTMENTTHREADED), registers our
                               PSFactory via CoRegisterClassObject, marshals IFiberDispatch, then
                               enters a fiber-based command loop awaiting sleep/proxy/shutdown
Layer 6: PostCall Worker    — MTA worker thread: GetBuffer → fill RPCOLEMESSAGE → PostCall for each
                               queued call. Writes SOleTlsData PID and fake ISynchronize per message.
Layer 7: SendReceive Worker — (WIP) MTA worker: GetBuffer → SendReceive → FreeBuffer for synchronous
                               single-call proxy mode
Layer 8: Public API         — init(), queue(), pump() [working], invoke() [working], deinit()

Public API

// Initialize: spins up STA thread, registers COM plumbing, marshals channel
var ctx = try comegon.init(.{ .max_slots = 256 });
defer ctx.deinit();

// Sleep mode: queue N calls, fire them all, block until done
// No return values — fire-and-forget. Last call is auto-appended SwitchToFiber(main).
ctx.queue(fn_VirtualProtect, &.{ text_base, text_size, PAGE_RW, &old_prot });
ctx.queue(fn_SystemFunction032, &.{ &img_range, &key_range });
ctx.queue(fn_NtDelayExecution, &.{ 0, &delay });
ctx.queue(fn_SystemFunction032, &.{ &img_range, &key_range });
ctx.queue(fn_VirtualProtect, &.{ text_base, text_size, PAGE_RX, &old_prot });
ctx.pump();  // blocks until all 5 calls + SwitchToFiber(main) complete

// Proxy mode (WIP): single synchronous call, captures HRESULT return value
// Dispatches via SendReceive (synchronous) + PostCall(SwitchToFiber) for cleanup
const status = ctx.invoke(fn_NtAllocateVirtualMemory, &.{ process, &base, 0, &size, MEM_COMMIT, PAGE_RW });
Function Mode Transport Returns Status
queue() + pump() Sleep PostCall (async) void ✅ Working
invoke() Proxy SendReceive (sync) HRESULT (u32) ✅ Working

The Dispatch Chain (Sleep Mode)

                    MTA Worker Thread                          STA Thread / Pump Fiber
                    ────────────────                          ──────────────────────────

                    GetBuffer(channel, &msg, IID)
                    msg.iMethod = RESERVED_METHODS + slot
                    PostCall(channel, &msg, &sync, pid)  ──►  WM_USER+0x00 posted to STA window
                       ... repeat for each queued call ...

                                                              SwitchToFiber(pump_fiber)
                                                                     │
                                                              ┌──────▼──────────────────────────┐
                                                              │ combase!ModalLoop                │
                                                              │   └─► combase!BlockFn            │
                                                              │         ├─ WaitForMultipleObjects │
                                                              │         ├─ GetQueueStatus         │
                                                              │         ├─ PeekRPCAndDDEMessage   │
                                                              │         └─ DispatchMessageW       │
                                                              │              └─► ThreadWndProc    │
                                                              │                   └─► ThreadDisp  │
                                                              │                        └─► CIFLAI │
                                                              │                   ┌────────────┐  │
                                                              │                   │CStdStub_Inv│  │
                                                              │                   │  └─NdrStub │  │
                                                              │                   │    └─YOUR  │  │
                                                              │                   │      API   │  │
                                                              │                   └────────────┘  │
                                                              └───────────────────────────────────┘

Full callstack during dispatch (what ETW/debugger sees):

ntdll!NtDelayExecution           ← your function
rpcrt4!NdrStubCall2              ← NDR unmarshals params, calls dispatch_table[method]
combase!CStdStubBuffer_Invoke    ← stub vtable[5], routes to NdrStubCall2 via MIDL_SERVER_INFO
combase!SyncStubInvoke
combase!StubInvoke
combase!ComInvokeWithLockAndIPID ← CIFLAI: finds IPID entry, resolves stub, calls Invoke
combase!ThreadDispatch
combase!ThreadWndProc            ← STA COM hidden window message handler
user32!DispatchMessageW
combase!PeekRPCAndDDEMessage     ← MyPeekMessage + DispatchMessageW on COM window
combase!CCliModalLoop::BlockFn   ← pumps messages in a wait loop
combase!ModalLoop                ← top-level RPC pump (entry point of pump fiber)
ntdll!BaseFiberStart             ← CET-compliant fiber entry

Key Internal Structures

CStdStubBuffer (fake_stub, 0x48 bytes) — The structure CStdStubBuffer_Invoke receives as this:

+0x00  pvServerObject      → fake_server_obj (whose [0] → server_dispatch_array)
+0x08  vtbl_ptr            → stub vtable (CStdStubBuffer_QI/AddRef/Connect/.../Invoke)
+0x10  ref_count           = 1
+0x18  pvServerObject2     → fake_server_obj (refreshed before each pump cycle)
+0x40  pHeader             → CInterfaceStubHeader

CInterfaceStubHeader (precedes stub vtable in memory):

+0x00  piid                → IID_IFiberDispatch
+0x08  pServerInfo         → MIDL_SERVER_INFO
+0x10  DispatchTableCount  = max_slots
+0x14  _pad                = 0
+0x18  pDispatchTable      = -1  ← CRITICAL: must be -1 for NDR path (0 = skip dispatch)

MIDL_SERVER_INFO — Tells NdrStubCall2 where to find everything:

+0x00  pStubDesc           → MIDL_STUB_DESC (alloc/free funcs, format types)
+0x08  DispatchTable       → [fn_ptr, fn_ptr, ...]  ← YOUR function pointers
+0x10  ProcString          → all_fmt buffer (NDR format strings per method)
+0x18  FmtStringOffset     → [offset, offset, ...]  ← per-method offset into ProcString
+0x20  ThunkTable          = null
+0x28  pTransferSyntax     → NDR 2.0 syntax GUID {8A885D04-1CEB-11C9-...}

Fake CSyncClientCall (0x120 bytes, passed to ModalLoop as fiber param):

+0xC0   inner_obj ptr      → fake COM object (all vtable slots → combase!NoOpReturn0)
+0x108  pump_event handle   ← signaled to make BlockFn's WaitForMultipleObjectsEx return

CCliModalLoop (constructed via CCliModalLoopCtor(buf, 0, 0x04FF, 0, 0)): Written into the STA thread's TLS chain: SOleTlsData.pCAptCallCtrl._pTopCML → cml_buf. BlockFn reads cml_buf[0x18] for return code (RPC_S_CALLPENDING = keep pumping) and cml_buf[0x108] for the pump event handle.

NDR Format Strings

Each dispatch slot gets a dynamically-built NDR Oi2 proc format string that describes the method's parameter layout. NdrStubCall2 parses this to determine stack frame size and unmarshals parameters from the RPCOLEMESSAGE buffer into the call.

Format structure per method:

[0x32, 0x48]                           ← Oi2 header (handle_type, oi_flags)
[rpc_flags: u32]                       ← 0
[method_num: u16]                      ← RESERVED_METHODS + slot
[stack_size: u16]                      ← (n_params + 1) * 8
[0, 0, 0, 0, 0, 0]                    ← excount, padding
[n_params + 1, 0]                      ← param count (including return)
Per-param (6 bytes each):
  [FC_HYPER, 0x08/0x48, stack_offset: u16, 0, 0]  ← 8-byte param at offset
Return:
  [FC_LONG, 0x52, stack_offset: u16, 0, 0]         ← HRESULT return
[0x5B, 0x5C, 0x00]                    ← FC_END, FC_PAD, terminator

COM Channel Setup

  1. STA thread initializes apartment-threaded COM, registers a fake IPSFactoryBuffer under a custom CLSID via CoRegisterClassObject, then marshals a fake IFiberDispatch object via CoMarshalInterThreadInterfaceInStream
  2. During marshal, COM calls our IPSFactoryBuffer::CreateStub — we return our CStdStubBuffer (fake_stub) which points at our MIDL tables
  3. Main thread (MTA) calls CoGetInterfaceAndReleaseStream to unmarshal — COM calls our IPSFactoryBuffer::CreateProxy, connects the IRpcChannelBuffer (which we capture for later GetBuffer/PostCall/SendReceive calls)
  4. The IRpcChannelBuffer is now a live cross-apartment channel. GetBuffer allocates an RPCOLEMESSAGE, PostCall fires it as an async message to the STA's hidden COM window

Sleep Mode Pump Cycle

  1. Caller queue()s N calls → stored as {method_slot, fn_ptr, n_params}
  2. pump() signals STA command loop → STA constructs CML, creates pump fiber (CreateFiber(ModalLoop, &fake_client_call)), switches to it
  3. MTA worker thread posts all N calls via PostCall (each writes method index into RPCOLEMESSAGE.iMethod, sets wParam via SOleTlsData PID pattern, provides fake ISynchronize)
  4. ModalLoop → BlockFn pumps the STA message queue, dispatching each WM_USER through ThreadWndProc → ComInvokeWithLockAndIPID → CStdStubBuffer_Invoke → NdrStubCall2 → dispatch_table[iMethod] → your function
  5. Last queued call is SwitchToFiber(sta_main_fiber) — returns control to STA
  6. STA destroys pump fiber (DeleteFiber on suspended fiber — safe, no resume needed), signals completion

Build

Requires Zig (tested with 0.14.x):

zig build-exe comegon.zig -target x86_64-windows -O ReleaseSmall --name comegon_test

Note: Use ReleaseSmall or Debug. ReleaseSafe triggers a Zig optimizer bug that breaks COM dispatch (segfault in NdrStubCall2 due to binary layout sensitivity). This is a known Zig LLVM backend issue with function pointer tables used by COM infrastructure.

CET-Enforced Build

Zig 0.14.x has no native --cetcompat flag. Use a two-step build to opt into CET shadow stack enforcement:

# Step 1: Compile to COFF object
zig build-obj comegon.zig -target x86_64-windows -O ReleaseSmall --name comegon

# Step 2: Link with /cetcompat (reuses Zig's bundled lld-link)
zig lld-link -lldmingw -ERRORLIMIT:0 -NOLOGO -MLLVM:-float-abi=hard \
    -STACK:16777216 -BASE:5368709120 -BUILD-ID:NO -MACHINE:X64 -BREPRO \
    -OUT:comegon_cet.exe -SUBSYSTEM:console,6.0 -NODEFAULTLIB \
    -ENTRY:wWinMainCRTStartup /cetcompat \
    comegon.obj compiler_rt.lib ntdll.lib kernel32.lib

Tip: Run a normal zig build-exe --verbose-link first to get the exact lib paths for compiler_rt.lib, ntdll.lib, and kernel32.lib from your local Zig cache.

The /cetcompat flag sets IMAGE_DLL_CHARACTERISTICS_EX_CET_COMPAT (0x1) in the PE debug directory, opting the binary into CET shadow stack enforcement on HVCI-enabled machines.

Verified: 5/5 runs stable with HVCI + CET enforcement enabled (Windows 11 x64). Both sleep mode and proxy mode pass with zero shadow stack violations.

Project Structure

comegon.zig          # Framework implementation
initial-pocs/
  omega1.zig        # Original sleep mode PoC (static MIDL tables)
  sta_com_sleep.zig # Original proxy mode PoC (SendReceive path)

Status

  • COM channel setup (STA/MTA marshal/unmarshal)
  • MIDL table construction (dynamic slot allocation, up to 249 calls)
  • Sleep mode — batch dispatch via PostCall + ModalLoop (pump())
  • Clean fiber lifecycle (ModalLoop as direct fiber proc, DeleteFiber on suspended)
  • NDR format string generation (runtime Oi2 builder, variable param counts)
  • Auto-appended SwitchToFiber(main) as final dispatch call
  • Proxy mode — synchronous dispatch via SendReceive (invoke()) — returns HRESULT
  • Debug output removal / production hardening (const DEBUG = false + log() wrapper)
  • VEH crash handler gated on DEBUG
  • CET shadow stack compatibility verified (HVCI + /cetcompat, 5/5 stable)

Requirements

  • Windows 10/11 x64
  • COM runtime (combase.dll, rpcrt4.dll) — present on all Windows installations
  • No additional dependencies

License

Private — not for redistribution.

S
Description
Automated archival mirror of github.com/nbaertsch/COMegon
Readme
1.3 MiB
Languages
Zig 100%