Call-stack spoofing — metadata primitives
← evasion area README · docs/index
TL;DR
EDRs walk the stack of a suspicious thread and ask "who called
VirtualAllocEx?". evasion/callstack builds the synthetic
return-frame metadata (RUNTIME_FUNCTION + ImageBase + ReturnAddress)
required to fake a benign thread-init lineage
(RtlUserThreadStart → BaseThreadInitThunk → …). The asm pivot that
executes a call through the chain (SpoofCall) ships as an
experimental scaffold; the metadata helpers (StandardChain,
FindReturnGadget, LookupFunctionEntry, Validate) are
production-ready.
Primer
Modern EDR and DFIR tooling routinely walks the stack of a suspicious
thread to see who called that VirtualAllocEx / CreateRemoteThread /
NtUnmapViewOfSection. The walker uses RtlVirtualUnwind (or its
kernel-mode sibling), which reads the PE .pdata table to locate the
RUNTIME_FUNCTION for the current RIP, then follows the stored
unwind info to climb up one frame at a time.
A spoofed call stack replaces the top frames of that walk with
addresses that look like a vanilla thread-init sequence. The walker
cannot distinguish the injected frames from a genuine execution path
unless it cross-validates RIP against ETW Threat-Intelligence or
performs its own control-flow reconstruction.
This package ships the metadata primitives required to build such
a chain. Every helper returns either a Frame (return-address +
ImageBase + RUNTIME_FUNCTION row, copied by value) or a []Frame,
and Validate performs structural sanity checks before the chain
hits a stack walker.
How It Works
sequenceDiagram
participant G as "Caller (Go)"
participant S as "Spoof pivot (asm)"
participant T as "Target fn"
participant W as "RtlVirtualUnwind"
participant N as "ntdll RET gadget"
Note over G: Build chain via StandardChain + FindReturnGadget
G->>S: SpoofCall(target, chain, args)
S->>S: Plant fakeRet then realRet on stack
S->>T: JMP target (not CALL)
Note over T: Executes body. RIP inside target.
W->>T: Snapshot RIP at sampling moment
W->>T: Lookup RUNTIME_FUNCTION RIP
W-->>W: Unwinds via target .pdata
W->>N: Lands on fakeRet, ntdll RET gadget
W->>N: Lookup RUNTIME_FUNCTION fakeRet
W-->>W: Walks ntdll frame metadata
W-->>G: Reports BaseThreadInitThunk then RtlUserThreadStart
T-->>N: RET pops fakeRet
N-->>G: RET pops realRet, back to Go
Steps:
StandardChainresolves the canonical thread-init lineage:kernel32!BaseThreadInitThunk(inner caller) andntdll!RtlUserThreadStart(outer caller). Both are looked up viaRtlLookupFunctionEntryso the returnedFrame[i]carries a validRUNTIME_FUNCTIONrow from the legitimate module's.pdata.FindReturnGadgetscansntdll.dll's.textfor a loneRET(0xC3followed by alignment padding). The address is used as the fake return at the top of the chain — when the target's ownRETfires, the CPU jumps into ntdll's image, which has full.pdatacoverage.- The asm pivot (
SpoofCallscaffold, gated behindMALDEV_SPOOFCALL_E2E=1) plants[fakeRet, ...chain]on the thread's stack, thenJMPs (notCALLs) into the target. When the target returns, it popsfakeRetand the CPU lands inside ntdll. A walker that samplesRIPat any point above the target walks ntdll's metadata and reports a benign thread-init sequence. Validateconfirms structural consistency before any of this: non-zero return / image base / unwind-info;ControlPcbounded by theRUNTIME_FUNCTION [Begin, End)window.
API Reference
type Frame struct {
ReturnAddress uintptr
ImageBase uintptr
RuntimeFunction RuntimeFunction
}
type RuntimeFunction struct {
BeginAddress uint32
EndAddress uint32
UnwindInfoAddress uint32
}
func LookupFunctionEntry(addr uintptr) (Frame, error)
func StandardChain() ([]Frame, error)
func FindReturnGadget() (uintptr, error)
func Validate(chain []Frame) error
// Experimental — gated behind MALDEV_SPOOFCALL_E2E=1
func SpoofCall(target unsafe.Pointer, chain []Frame, args ...uintptr) (uintptr, error)
Sentinel errors: ErrUnsupportedPlatform,
ErrFunctionEntryNotFound, ErrGadgetNotFound,
ErrEmptyChain, ErrTooManyArgs.
LookupFunctionEntry(addr uintptr) (Frame, error)
Wraps ntdll!RtlLookupFunctionEntry. Given any instruction address
inside a loaded PE, returns a Frame populated with
ReturnAddress + ImageBase + RUNTIME_FUNCTION (copied by value).
Parameters:
addr— any in-image RIP value. Out-of-image addresses returnErrFunctionEntryNotFound.
Returns:
Frame—ReturnAddress=addr,ImageBase+RuntimeFunctionpopulated from ntdll.error—ErrFunctionEntryNotFoundifaddris outside any loaded module's.pdatacoverage;ErrUnsupportedPlatformon non-amd64 / non-Windows.
Side effects: none — pure read.
OPSEC: silent. No syscall, no allocation, no telemetry trail.
Required privileges: unprivileged.
Platform: windows amd64.
StandardChain() ([]Frame, error)
Returns a cached 2-frame chain rooted at the Windows thread-init
sequence: [0] kernel32!BaseThreadInitThunk (inner — direct
caller of target), [1] ntdll!RtlUserThreadStart (outer — thread
entry point). Both frames carry full RUNTIME_FUNCTION metadata.
Parameters: none.
Returns:
[]Framelength 2. Returned by reference; do not mutate.error—ErrFunctionEntryNotFoundwhen either symbol cannot be resolved (e.g.,kernel32/ntdllnot yet mapped); cached on first success.
Side effects: caches the chain on first call; subsequent calls return the cached slice in O(1).
OPSEC: silent — only RtlLookupFunctionEntry reads.
Required privileges: unprivileged.
Platform: windows amd64.
FindReturnGadget() (uintptr, error)
Scans ntdll.dll's .text for the first lone RET (0xC3
followed by int3 / nop padding) and returns its absolute
address. Callers planting a fake return on the stack point there so
the target's RET jumps into a well-known ntdll address.
Parameters: none.
Returns:
uintptr— address of an ntdll RET gadget. Cached.error—ErrGadgetNotFoundonly if ntdll's.textis hooked out of recognition (very unusual).
Side effects: caches the address on first call.
OPSEC: silent — single in-process memory walk.
Required privileges: unprivileged.
Platform: windows amd64.
Validate(chain []Frame) error
Checks structural consistency of the supplied chain.
Parameters:
chain— caller-built[]Frame. May be the result ofStandardChain()plus operator-added frames.
Returns:
error— non-nil when any frame has zeroReturnAddress/ImageBase/UnwindInfoAddress, or whenControlPcfalls outsideRuntimeFunction.[Begin, End). Nil on a valid chain.
Side effects: none.
OPSEC: silent.
Required privileges: unprivileged.
Platform: windows amd64 (struct alignment relies on amd64
RUNTIME_FUNCTION layout).
SpoofCall(target, chain, args...) (uintptr, error) (experimental)
Asm pivot. Plants [fakeRet, ...chain] on the stack, sets up Win64
ABI register passing for up to 4 args, and JMPs into target. The
target's eventual RET lands on fakeRet (an ntdll RET gadget); a
walker sampling RIP anywhere above the target sees ntdll-resident
addresses with valid .pdata.
Parameters:
target—unsafe.Pointerto the function to invoke.chain— pre-validated[]FramefromStandardChain(+ caller-supplied frames as needed).args...— up to 4uintptrarguments mapped to RCX/RDX/R8/R9.
Returns:
uintptr— the value the target left in RAX.error—ErrEmptyChain/ErrTooManyArgson caller-side violations. Pivot itself does not return errors mid-flight (any fault is a crash).
Side effects: mutates the caller goroutine's stack frame for the duration of the pivot; restored on return.
OPSEC: the spoofed walker view is the goal — sampling at the target's RIP shows a benign thread-init lineage. Reflection-based walkers that re-derive frames from CFG can still flag.
Required privileges: unprivileged.
Platform: windows amd64. Gated behind
MALDEV_SPOOFCALL_E2E=1 until the
lastcontinuehandler-on-Go-runtime crash is root-caused.
Examples
Simple — build + validate a chain
chain, err := callstack.StandardChain()
if err != nil {
log.Fatal(err)
}
if err := callstack.Validate(chain); err != nil {
log.Fatalf("chain invalid: %v", err)
}
gadget, err := callstack.FindReturnGadget()
if err != nil {
log.Fatal(err)
}
log.Printf("chain frames=%d gadget=%#x", len(chain), gadget)
Composed — chain + injection landing-site spoof
The chain is one piece of the deception. Pair it with
evasion/unhook and an indirect-syscall caller so a walker that
lands on any of the hot calls sees ntdll-resident addresses with
valid .pdata.
import (
"github.com/oioio-space/maldev/evasion/callstack"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
stdChain, _ := callstack.StandardChain()
_ = callstack.Validate(stdChain)
gadget, _ := callstack.FindReturnGadget()
gadgetFrame, _ := callstack.LookupFunctionEntry(gadget)
full := append([]callstack.Frame{gadgetFrame}, stdChain...)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
inj, _ := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
_ = inj.Inject(shellcode)
_ = full // hand off to operator's own pivot OR callstack.SpoofCall
Advanced — SpoofCall (gated)
// MALDEV_SPOOFCALL_E2E=1 must be set; the asm pivot is debug-only.
chain, _ := callstack.StandardChain()
gadget, _ := callstack.FindReturnGadget()
gadgetFrame, _ := callstack.LookupFunctionEntry(gadget)
full := append([]callstack.Frame{gadgetFrame}, chain...)
target := unsafe.Pointer(windows.NewLazyDLL("ntdll.dll").
NewProc("RtlGetVersion").Addr())
ret, err := callstack.SpoofCall(target, full /* no args */)
if err != nil {
log.Fatalf("spoofcall: %v", err)
}
log.Printf("RtlGetVersion returned %#x", ret)
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
RtlLookupFunctionEntry reads | not logged | none needed |
| Synthetic frame on the thread stack | reflection-based walkers may flag | live with the residual; pair with HW-BP variant (P2.6) on hardened targets |
| ETW Threat-Intelligence | cross-references RIP against legitimate call graph | EDRs subscribing to TI can still flag — evasion/callstack makes the chain plausible, not indistinguishable |
| ntdll RET-gadget address | static — same value across calls within a process | randomise gadget pick from FindReturnGadget candidates (future enhancement) |
D3FEND counters: D3-PSA (Process Spawn Analysis) — when paired with a benign thread-init RIP sequence the spawn / call chain appears legitimate.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1036 | Masquerading | call-stack metadata | D3-PSA |
| T1027 | Obfuscated Files or Information | runtime stack obfuscation | D3-EAL |
Limitations
- x64 only. x86 uses frame-pointer walking rather than
.pdata-based unwind, which requires a different spoof strategy. - Synthetic frames detected by ETW Threat-Intelligence. Some EDRs (especially those consuming the TI provider) cross-check every stack frame RIP against the current call graph.
- Module relocations.
StandardChaincaches the frames after first call; if the target module unmaps + remaps at a new base (unusual but possible under ASLR-stressed environments), the cached frames become stale. Spawn a fresh process or build a one-shot chain viaLookupFunctionEntry. - No hardware-breakpoint variant yet. The fortra-style HWBP pivot (HW-BP on RET gadget for stronger obfuscation) is tracked under backlog row P2.6.
SpoofCallis experimental. The pivot occasionally crashes through Go'slastcontinuehandlerdue to runtime M:N scheduling. Promotion to a tagged release waits on a clean root-cause.
See also
- Evasion area README
evasion/sleepmask— pair with sleep-mask so spoofed frames are also wiped between callbackswin/syscall—MethodIndirectreturns into ntdll, complementary stack-stealth pathrecon/hwbp— companion HW-BP variant tracked under backlog P2.6- package godoc