Module 5: Gadget Discovery & Selection
Finding the right instruction sequences in a sea of legitimate code.
Why This Module?
SilentMoonwalk's ROP chain is only as good as the gadgets it uses. Unlike exploit ROP where any gadget will do, SilentMoonwalk requires gadgets that reside inside functions with valid RUNTIME_FUNCTION entries, have matching unwind codes, and produce predictable side effects. This module covers how to find and evaluate suitable gadgets in ntdll.dll and kernel32.dll.
Gadget Sources: Why ntdll and kernel32?
SilentMoonwalk restricts gadget searches to specific system DLLs for good reasons:
| Module | Why It's Ideal | Key Properties |
|---|---|---|
| ntdll.dll | Always loaded in every process, mapped at the same base per boot session (ASLR applied per-boot). Contains thousands of functions with rich unwind data. | First DLL loaded, provides native API layer, trusted by all EDRs |
| kernel32.dll | Nearly always loaded, provides Win32 API wrappers. Return addresses here are completely normal for any process. | Contains BaseThreadInitThunk (thread entry), common call chain terminal |
| kernelbase.dll | Backend implementation for many kernel32 and advapi32 functions. Also always loaded. | Large code base with many gadget candidates |
Avoid Application-Specific Modules
Using gadgets from application-specific DLLs or the main executable creates two problems: (1) the gadget addresses change between targets, making the tool less portable, and (2) return addresses in uncommon DLLs may themselves trigger heuristics. EDRs expect to see ntdll, kernel32, and kernelbase on every call stack — these are invisible by nature.
The Gadget Search Algorithm
SilentMoonwalk scans the .text section of target modules looking for specific byte patterns. The search must also verify that any candidate gadget falls within a function that has valid unwind metadata:
C++// Simplified gadget search: find all JMP [RBX] gadgets in a module
// Byte pattern for JMP QWORD PTR [RBX]: FF 23
// Byte pattern for JMP [RBX] with SIB: FF 63 XX (with displacement)
struct Gadget {
PVOID address; // Address of the gadget instruction
DWORD functionRva; // RVA of containing function
DWORD offsetInFunction; // Offset from function start
DWORD frameSize; // Frame size from UNWIND_INFO
PRUNTIME_FUNCTION pRtFunc; // Pointer to RUNTIME_FUNCTION entry
};
std::vector<Gadget> FindJmpRbxGadgets(HMODULE hModule) {
std::vector<Gadget> results;
DWORD64 imageBase = (DWORD64)hModule;
// Get .text section bounds
PIMAGE_NT_HEADERS pNt = RtlImageNtHeader(hModule);
PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);
PBYTE textStart = NULL;
DWORD textSize = 0;
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
if (memcmp(pSec[i].Name, ".text", 5) == 0) {
textStart = (PBYTE)imageBase + pSec[i].VirtualAddress;
textSize = pSec[i].Misc.VirtualSize;
break;
}
}
// Scan for FF 23 (jmp [rbx]) pattern
for (DWORD offset = 0; offset < textSize - 1; offset++) {
if (textStart[offset] == 0xFF && textStart[offset + 1] == 0x23) {
PVOID gadgetAddr = textStart + offset;
DWORD rva = (DWORD)((DWORD64)gadgetAddr - imageBase);
// CRITICAL: verify this address has a RUNTIME_FUNCTION
DWORD64 imgBase;
PRUNTIME_FUNCTION pFunc = RtlLookupFunctionEntry(
(DWORD64)gadgetAddr, &imgBase, NULL
);
if (pFunc != NULL) {
Gadget g;
g.address = gadgetAddr;
g.functionRva = pFunc->BeginAddress;
g.offsetInFunction = rva - pFunc->BeginAddress;
g.pRtFunc = pFunc;
g.frameSize = ComputeFrameSize(
(PUNWIND_INFO)(imageBase + pFunc->UnwindData)
);
results.push_back(g);
}
}
}
return results;
}
Key Gadget Patterns
SilentMoonwalk needs several specific gadget types. Each serves a distinct purpose in the spoofing chain:
1. JMP [RBX] — The Trampoline Gadget
This is the most critical gadget. It transfers control to a function pointer stored at the address in RBX without pushing a return address. This allows the target API call to be made with a fully controlled stack.
x86-64 ASM; Pattern: FF 23
; Disassembly: jmp qword ptr [rbx]
;
; Before execution:
; RBX = pointer to memory containing target API address
; RSP = points to our fake return address on the spoofed stack
;
; After execution:
; RIP = target API (e.g., NtWaitForSingleObject)
; RSP = unchanged (no push occurred)
; When target API hits RET, it pops our controlled return address
Why JMP [RBX] Specifically?
RBX is a non-volatile register on x64 Windows. This means it survives across function calls — if you set RBX before the ROP chain executes, it will still contain that value when the JMP [RBX] gadget fires. Volatile registers (RAX, RCX, RDX, R8-R11) may be clobbered by intermediate gadgets. Other non-volatile registers (RBP, RSI, RDI, R12-R15) could also work, but JMP [RBX] gadgets are common in ntdll because many functions use RBX as a pointer to a function table or dispatch structure.
2. ADD RSP, N; RET — Stack Frame Gadgets
These gadgets advance RSP by a fixed amount and then return. They serve as the "body" of each synthetic frame, consuming exactly the right number of stack bytes to match a target function's unwind allocation:
x86-64 ASM; Pattern: 48 83 C4 XX C3 (ADD RSP, imm8; RET)
; Or: 48 81 C4 XX XX XX XX C3 (ADD RSP, imm32; RET)
; Example: add rsp, 0x28; ret
; Bytes: 48 83 C4 28 C3
;
; This gadget creates a "frame" of 0x28 bytes:
; RSP += 0x28 (skip over frame data)
; RET (pop next gadget address, RSP += 8)
; Total RSP change: 0x30 bytes
; For SilentMoonwalk, the ADD RSP value must match the
; UWOP_ALLOC_SMALL or UWOP_ALLOC_LARGE in the containing
; function's UNWIND_INFO.
3. POP REG; RET — Register Setup Gadgets
Used to load values into specific registers from the stack before the target API call:
x86-64 ASM; pop rcx; ret --> Bytes: 59 C3
; pop rdx; ret --> Bytes: 5A C3
; pop r8; ret --> Bytes: 41 58 C3
; pop r9; ret --> Bytes: 41 59 C3
; These are essential for setting up function arguments.
; The x64 calling convention passes the first 4 integer args
; in RCX, RDX, R8, R9. Before calling the target API via
; JMP [RBX], these registers must contain the correct values.
Gadget Validation Criteria
Not every instruction sequence that matches a byte pattern is a usable gadget. SilentMoonwalk applies strict validation:
| Criterion | Why It Matters | How to Check |
|---|---|---|
| Has RUNTIME_FUNCTION | Without unwind metadata, the gadget's frame breaks stack walking | RtlLookupFunctionEntry() returns non-NULL |
| Unwind codes match behavior | ADD RSP, N in the gadget must align with UWOP_ALLOC_SMALL/LARGE in UNWIND_INFO | Parse UNWIND_INFO and compare allocation size |
| No harmful side effects | Gadget shouldn't write to unexpected memory, trigger exceptions, or clobber critical state | Manual review or emulation of gadget bytes |
| No chained unwind info | UNW_FLAG_CHAININFO adds complexity; simpler functions are preferred | Check UNWIND_INFO.Flags & UNW_FLAG_CHAININFO |
| Gadget at valid offset | The instruction must be at an offset where unwind codes apply correctly | Gadget offset must be ≥ SizeOfProlog in UNWIND_INFO |
| Inside module code section | Must be in executable, file-backed memory | Verify address falls within .text section bounds |
The Offset Constraint
This is subtle but critical. A gadget at offset 5 in a function whose SizeOfProlog is 20 bytes would be inside the prologue. When RtlVirtualUnwind processes this frame, it only applies unwind codes whose CodeOffset is less than or equal to the instruction's offset within the function. If the gadget is inside the prologue, only a subset of unwind codes apply, changing the computed frame size. The gadget must be at an offset past the end of the prologue for all unwind codes to be in effect.
Building a Gadget Database
SilentMoonwalk pre-scans target modules at initialization to build a database of usable gadgets, indexed by type and frame size:
C++// Gadget database structure
struct GadgetDB {
// JMP [RBX] gadgets indexed by frame size
std::map<DWORD, std::vector<Gadget>> jmpRbxByFrameSize;
// ADD RSP, N; RET gadgets indexed by N value
std::map<DWORD, std::vector<Gadget>> addRspBySize;
// POP REG; RET gadgets indexed by register
std::map<int, std::vector<Gadget>> popRegByReg;
void Build(HMODULE hModule) {
// Scan .pdata to enumerate all functions with unwind info
PIMAGE_NT_HEADERS pNt = RtlImageNtHeader(hModule);
DWORD64 imageBase = (DWORD64)hModule;
// Get .pdata (exception directory)
DWORD pdataRva = pNt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION].VirtualAddress;
DWORD pdataSize = pNt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION].Size;
PRUNTIME_FUNCTION pFuncs = (PRUNTIME_FUNCTION)(imageBase + pdataRva);
DWORD numFuncs = pdataSize / sizeof(RUNTIME_FUNCTION);
for (DWORD i = 0; i < numFuncs; i++) {
PRUNTIME_FUNCTION pFunc = &pFuncs[i];
PUNWIND_INFO pUnwind = (PUNWIND_INFO)(
imageBase + pFunc->UnwindData
);
// Skip chained unwind info for simplicity
if (pUnwind->Flags & UNW_FLAG_CHAININFO) continue;
DWORD frameSize = ComputeFrameSize(pUnwind);
DWORD funcSize = pFunc->EndAddress - pFunc->BeginAddress;
// Scan function body (after prologue) for gadgets
PBYTE funcStart = (PBYTE)(imageBase + pFunc->BeginAddress);
DWORD scanStart = pUnwind->SizeOfProlog;
for (DWORD off = scanStart; off < funcSize - 1; off++) {
// Check for JMP [RBX]: FF 23
if (funcStart[off] == 0xFF && funcStart[off+1] == 0x23) {
Gadget g = { funcStart + off, pFunc->BeginAddress,
off, frameSize, pFunc };
jmpRbxByFrameSize[frameSize].push_back(g);
}
// Check for ADD RSP, imm8; RET: 48 83 C4 XX C3
if (off + 4 < funcSize &&
funcStart[off] == 0x48 &&
funcStart[off+1] == 0x83 &&
funcStart[off+2] == 0xC4 &&
funcStart[off+4] == 0xC3) {
BYTE addVal = funcStart[off+3];
Gadget g = { funcStart + off, pFunc->BeginAddress,
off, frameSize, pFunc };
addRspBySize[addVal].push_back(g);
}
}
}
}
};
Practical Considerations
ASLR and Gadget Addresses
ntdll.dll is randomized per boot session, but its base address is the same across all processes within a single boot. SilentMoonwalk resolves gadget addresses at runtime by scanning the loaded ntdll in the current process. Since the spoofer runs in-process, the addresses are always correct. However, gadget byte offsets within functions remain stable across processes in the same boot session.
Windows Version Sensitivity
Function layouts and unwind info can change between Windows builds. A gadget that exists at offset 0x42 in ntdll on Windows 10 21H2 may be at offset 0x45 on Windows 11 22H2, or may not exist at all. SilentMoonwalk's runtime scanning approach handles this automatically — it discovers whatever gadgets are available in the currently loaded modules rather than relying on hardcoded offsets.
Gadget Chain Assembly Order
Once the gadget database is built, SilentMoonwalk assembles gadgets in a specific order for the complete spoofing chain. Here is the logical sequence:
- Parameter setup gadgets — POP RCX/RDX/R8/R9 to load API arguments
- RBX setup — Load address of target API into memory pointed to by RBX
- Frame body gadgets — ADD RSP, N; RET gadgets matched to target function frame sizes
- Trampoline gadget — JMP [RBX] to invoke the target API with the spoofed stack active
- Recovery gadgets — After API returns, restore original stack state
Pop Quiz: Gadget Discovery
Q1: Why must gadgets reside inside functions with valid RUNTIME_FUNCTION entries?
Q2: Why must a gadget be located AFTER the function's prologue (offset ≥ SizeOfProlog)?
Q3: Why is RBX preferred over RCX for the JMP [REG] trampoline?