Understanding the x64 Windows SEH Mechanism

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

This article breaks down the low-level mechanics of x64 Windows Structured Exception Handling (SEH) from a reverse-engineering perspective. It assumes you already understand SEH from a developer’s point of view; here we go one level deeper, into the data structures and the kernel-mode dispatch path.

For SEH to work, three parties must cooperate: the compiler, the CPU, and the operating system. The concrete implementation of SEH is architecture-specific — the x64 mechanism described here is fundamentally different from the legacy x86 frame-based SEH.

The diagram below summarizes the overall Windows exception-handling framework:

Exception-handling framework

A minimal example

Here is a minimal program we’ll use as a running example:

// test.c
#include <windows.h>
#include <stdio.h>

int main() {
    __try {
        printf("__try block\n");
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        printf("__except block\n");
    }
    return 0;
}

The RUNTIME_FUNCTION structure

Unlike x86, the x64 SEH mechanism no longer places exception-handling information on the stack. Instead, the compiler emits the metadata into a dedicated PE section: .pdata. The first structure to understand is RUNTIME_FUNCTION. Several of these form an array inside .pdata:

typedef struct _RUNTIME_FUNCTION {
    DWORD BeginAddress;    // Start RVA of the SEH code chunk
    DWORD EndAddress;      // End RVA of the SEH code chunk
    DWORD UnwindData;      // RVA of an UNWIND_INFO structure that describes this code frame
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION;

The three fields:

  1. BeginAddress — function start address
  2. EndAddress — function end address
  3. UnwindData — pointer to an UNWIND_INFO structure

With a PE tool you can inspect this array. For the example program above, the exception directory (opened with CFF Explorer) looks like this:

Exception directory in a PE tool

The number of these structures does NOT depend on the number of __try blocks — it depends on the number of functions that contain a __try block (no matter how many __try blocks a single function holds). Also note that RUNTIME_FUNCTION entries in .pdata are sorted in ascending order by BeginAddress.

Why sorted? To support O(log n) binary search. When an exception occurs, the kernel exception dispatcher (RtlLookupFunctionEntry) must locate the owning function from the faulting RIP as fast as possible. Binary search is the only algorithm that does this in logarithmic time, and binary search requires an ordered dataset.

Also note all three addresses are offsets relative to ImageBase — which is why they fit in 32 bits even on a 64-bit system.

In IDA you can jump straight to main’s RUNTIME_FUNCTION entry with the g shortcut:

.pdata:000000014000400C                 dd rva main             ; FunctionStart
.pdata:0000000140004010                 dd rva byte_140001096   ; FunctionEnd
.pdata:0000000140004014                 dd rva stru_140002804   ; UnwindInfo

The first two fields are the start and end addresses of main. Next, the UNWIND_INFO structure pointed to by UnwindData.


The UNWIND_INFO structure

This structure is where the core of SEH lives:

// Unwind info flags
#define UNW_FLAG_NHANDLER 0x0
#define UNW_FLAG_EHANDLER 0x01
#define UNW_FLAG_UHANDLER 0x02
#define UNW_FLAG_CHAININFO 0x04

// UNWIND_CODE: a 3-byte structure
typedef union _UNWIND_CODE {
  struct {
    UBYTE CodeOffset;
    UBYTE UnwindOp : 4;
    UBYTE OpInfo : 4;
  };
  USHORT FrameOffset;
} UNWIND_CODE, *PUNWIND_CODE;

typedef struct _UNWIND_INFO {
  UBYTE Version : 3;          // + 0x00 - Unwind info structure version
  UBYTE Flags : 5;            // + 0x00 - Flags (see above)
  UBYTE SizeOfProlog;         // + 0x01
  UBYTE CountOfCodes;         // + 0x02 - Count of unwind codes
  UBYTE FrameRegister : 4;    // + 0x03
  UBYTE FrameOffset : 4;      // + 0x03
  UNWIND_CODE UnwindCode[1];  // + 0x04 - Unwind code array
  UNWIND_CODE MoreUnwindCode[((CountOfCodes + 1) & ~1) - 1];
  union {
    OPTIONAL ULONG ExceptionHandler;    // Exception handler routine
    OPTIONAL ULONG FunctionEntry;
  };
  OPTIONAL ULONG ExceptionData[];       // C++ scope-table structure
} UNWIND_INFO, *PUNWIND_INFO;

The definition is dense; we focus on the key parts.

The Flags field

Depending on Flags, the anonymous union field of UNWIND_INFO is interpreted differently, and whether ExceptionData is valid also depends on Flags. The following definition expresses that relationship more clearly:

#define UNW_FLAG_NHANDLER 0x0
#define UNW_FLAG_EHANDLER 0x1
#define UNW_FLAG_UHANDLER 0x2
#define UNW_FLAG_CHAININFO 0x4

typedef struct _UNWIND_INFO {
    UBYTE Version         : 3;
    UBYTE Flags           : 5;
    UBYTE SizeOfProlog;
    UBYTE CountOfCodes;
    UBYTE FrameRegister  : 4;
    UBYTE FrameOffset    : 4;
    UNWIND_CODE UnwindCode[1];
    union {
        // If (Flags & UNW_FLAG_EHANDLER)
        OPTIONAL ULONG ExceptionHandler;
        // Else if (Flags & UNW_FLAG_CHAININFO)
        OPTIONAL ULONG FunctionEntry;
    };
    // If (Flags & UNW_FLAG_EHANDLER)
    OPTIONAL ULONG ExceptionData[];
} UNWIND_INFO, *PUNWIND_INFO;

To get to the point quickly, we only discuss the case where Flags is set to UNW_FLAG_EHANDLER. A typical function whose Flags is UNW_FLAG_EHANDLER looks like this:

void ExceptFunc() { __try {} __except(1) {} }

When Flags is UNW_FLAG_EHANDLER, the ExceptionHandler field in the union is valid and filled in by the compiler. If an exception occurs and the instruction pointer satisfies >= BeginAddress and < EndAddress, that ExceptionHandler is invoked.

ExceptionHandler parses the ExceptionData field to decide how to handle the exception.

The C_SCOPE_TABLE structure

ExceptionData is a pointer (an offset, really) to a C_SCOPE_TABLE structure. Read this one carefully:

// C Scope table entry
typedef struct _C_SCOPE_TABLE_ENTRY {
  ULONG Begin;        // +0x00 - Begin of the guarded code block; offset of the first instruction of the __try block
  ULONG End;          // +0x04 - End of the target code block; offset of the instruction after the last instruction of the __try block
  ULONG Handler;      // +0x08 - Exception filter function (or "__finally" handler)
  ULONG Target;       // +0x0C - Exception handler pointer (the code inside the __except block)
} C_SCOPE_TABLE_ENTRY, *PC_SCOPE_TABLE_ENTRY;

// C Scope table
typedef struct _C_SCOPE_TABLE {
  ULONG NumEntries;               // +0x00 - Number of entries
  C_SCOPE_TABLE_ENTRY Table[1];   // +0x04 - Scope table array
} C_SCOPE_TABLE, *PC_SCOPE_TABLE;

RUNTIME_FUNCTION describes the whole range of a function that contains SEH, while SCOPE_TABLE describes each individual __try/__except block within that function.

Putting it together

VOID FrobThePointer(PUCHAR UserAddress)
{
        __try
        {
            *UserAddress = 0;
            *UserAddress = 1;
        }
        __except (EXCEPTION_EXECUTE_HANDLER)
        {
            DbgPrint("Bad Address\n");
        }
}

For the code above, the generated assembly looks roughly like this:

<00> mov     [rsp+0x8],rcx
<05> sub     rsp,0x28
<09> mov     rax,[rsp+0x30]        // Move UserAddress into RAX
<0e> mov     byte ptr [rax],0x0    // *UserAddress = 0;
<11> mov     rax,[rsp+0x30]        // Move UserAddress into RAX
<16> mov     byte ptr [rax],0x1    // *UserAddress = 1;
<19> jmp     FrobThePointer+0x28   // Success!
<1b> lea     rcx,"Bad Address\n"   // Begin of code in the except block...
                                   //   prepare to DbgPrint
<22> call    DbgPrint
<27> nop
<28> add     rsp,0x28
<2c> ret

Mapped onto the RUNTIME_FUNCTION and UNWIND_INFO structures:

RUNTIME_FUNCTION and UNWIND_INFO mapped to the function

How the exception location is found (using a divide-by-zero as the example)

At this point we can summarize how x64 SEH locates the faulting point. Let EA be the address where the exception occurred. First, binary search the RUNTIME_FUNCTION array (matching BeginAddress < EA < EndAddress) to find the faulting function. Then walk the C_SCOPE_TABLE_ENTRY array pointed to by ExceptionData to find the faulting try block (matching Begin < EA < End).

That’s the basic flow, but we can go deeper. Let’s trace a divide-by-zero (#DE) in a 64-bit Windows program through the full system-level call chain and data-structure operations — from the CPU trap all the way to executing the __except block.

When an exception occurs, the internal Windows function RtlDispatchException is called. It is implemented in NTDLL for user-mode exceptions and in NTOSKRNL for kernel-mode exceptions, with slight differences. It begins with initial checks: if a user-mode VEH (vectored exception handler) is present, that handler is invoked; otherwise standard SEH processing proceeds.

Standard SEH processing: copy the thread context at the time of the exception, then use RtlLookupFunctionEntry to perform the critical task — obtaining the PE ImageBase and the RUNTIME_FUNCTION structure.


Phase 1: hardware trap and kernel takeover

1. Triggering the exception

; executing in user mode
mov eax, 1
cdq
idiv dword ptr [rcx]  ; assume [rcx]=0, triggers #DE

2. CPU hardware operation

  • The CPU detects the divide-by-zero; interrupt vector 0 (#DE) fires.
  • The CPU automatically switches to kernel mode (loading the GS base via the preconfigured TSS or MSR).
  • The CPU pushes the key user-mode context onto the current thread’s kernel stack, forming a KTRAP_FRAME. This includes:
    • RIP — pointing at the instruction after idiv
    • RSP — the user-mode stack pointer
    • RFLAGS, CS, SS, and other segment registers
    • general registers RAX, RCX, RDX, etc.
  • The CPU looks up IDT (Interrupt Descriptor Table) entry 0 and jumps to the preset interrupt handler entry. On Windows this is KiDivideErrorFault (or a similar trap handler).

3. Kernel trap handling (KiDivideErrorFault)

// pseudocode, lives in ntoskrnl.exe
VOID KiDivideErrorFault() {
    // 1. build a fuller trap frame
    KTRAP_FRAME* pTrapFrame = GetCurrentTrapFrame();

    // 2. package the exception info into an EXCEPTION_RECORD
    EXCEPTION_RECORD ExceptionRecord = {0};
    ExceptionRecord.ExceptionCode = STATUS_INTEGER_DIVIDE_BY_ZERO; // 0xC0000094
    ExceptionRecord.ExceptionAddress = pTrapFrame->Rip;
    ExceptionRecord.NumberParameters = 0;

    // 3. call the common exception dispatch routine
    KiExceptionDispatch(&ExceptionRecord, pTrapFrame);
}

Phase 2: kernel exception dispatch (KiExceptionDispatch / KiDispatchException)

This is the central dispatcher. Its internals are complex, but the key steps are:

1. Build CONTEXT and EXCEPTION_POINTERS

// inside KiDispatchException
CONTEXT ContextRecord = {0};
// fill ContextRecord's registers from the KTRAP_FRAME
RtlpCaptureContext(&ContextRecord, pTrapFrame);

EXCEPTION_POINTERS ExceptionPointers = {0};
ExceptionPointers.ExceptionRecord = &ExceptionRecord;
ExceptionPointers.ContextRecord = &ContextRecord;

2. First attempt: user-mode exception dispatch The kernel checks the exception address and finds RIP is in user space, so it calls RtlDispatchException (the kernel entry point for user-mode exception dispatch).

BOOLEAN RtlDispatchException(PEXCEPTION_RECORD pExceptionRecord, PCONTEXT pContextRecord) {
    // [Key point 1] check VEH (vectored exception handlers)
    if (RtlCallVectoredExceptionHandlers(pExceptionRecord, pContextRecord)) {
        return TRUE; // VEH handled it
    }

    // [Key point 2] locate the RUNTIME_FUNCTION
    PRUNTIME_FUNCTION pRuntimeFunction = RtlLookupFunctionEntry(
        pContextRecord->Rip,          // control point
        &ImageBase,                   // out: module base
        NULL                          // history table
    );

    if (pRuntimeFunction == NULL) {
        // no function table; usually JIT code, cannot unwind
        return FALSE;
    }

    // [Key point 3] get UNWIND_INFO
    PUNWIND_INFO pUnwindInfo = (PUNWIND_INFO)(ImageBase + pRuntimeFunction->UnwindInfoAddress);

    // [Key point 4] call the language-specific exception handler (e.g. __C_specific_handler)
    if (pUnwindInfo->Flags & (UNW_FLAG_EHANDLER | UNW_FLAG_UHANDLER)) {
        // compute the ExceptionData address
        PVOID pHandlerData = RtlpGetHandlerData(pUnwindInfo);

        // this function is provided by the MSVC runtime; it parses SCOPE_RECORD
        EXCEPTION_DISPOSITION disposition = __C_specific_handler(
            pExceptionRecord,
            (PVOID)pContextRecord->Rip,
            pContextRecord,
            pHandlerData
        );

        if (disposition == ExceptionExecuteHandler) {
            // handler found, prepare to unwind
            return TRUE;
        }
    }

    // [Key point 5] no handler: unwind one frame (for C++ exception propagation)
    RtlUnwindEx(...);
    return FALSE;
}

3. What RtlLookupFunctionEntry does This function is the concrete implementation of the first-phase lookup.

PRUNTIME_FUNCTION RtlLookupFunctionEntry(
    IN ULONG64 ControlPc,
    OUT PULONG64 ImageBase,
    IN OUT PT_RUNTIME_FUNCTION* HistoryTable
) {
    // 1. find the PE module (DLL/EXE) that owns ControlPc
    PLDR_DATA_TABLE_ENTRY pModule = LdrFindEntryForAddress(ControlPc);
    *ImageBase = pModule->DllBase;

    // 2. locate the .pdata section from the PE header
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)(*ImageBase + ((PIMAGE_DOS_HEADER)*ImageBase)->e_lfanew);
    PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNtHeaders);
    for (WORD i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++, pSection++) {
        if (memcmp(pSection->Name, ".pdata", 6) == 0) {
            break;
        }
    }

    // 3. compute the start and end of the RUNTIME_FUNCTION array
    PRUNTIME_FUNCTION pFunctionTable = (PRUNTIME_FUNCTION)(*ImageBase + pSection->VirtualAddress);
    ULONG NumberOfFunctions = pSection->Misc.VirtualSize / sizeof(RUNTIME_FUNCTION);

    // 4. [core] binary search
    return RtlpBinarySearch(pFunctionTable, ControlPc - *ImageBase, NumberOfFunctions);
}

4. What __C_specific_handler does (a user-mode callback!) This function is provided by the MSVC runtime library (vcruntimexxx.dll). Note: the CPU is still in kernel mode at this point, but this function is user-mode code — the kernel temporarily switches to user mode to execute it.

EXCEPTION_DISPOSITION __C_specific_handler(
    PEXCEPTION_RECORD pExceptionRecord,
    PVOID EstablisherFrame,  // actually the stack-frame pointer of the faulting function
    PCONTEXT pContextRecord,
    PEXCEPTION_REGISTRATION_RECORD pDispatcherContext
) {
    // 1. extract the SCOPE_RECORD array from DispatcherContext
    PSCOPE_RECORD pScopeRecords = (PSCOPE_RECORD)pDispatcherContext;
    DWORD scopeCount = pScopeRecords->Count;
    pScopeRecords++; // point to the first SCOPE_RECORD

    // 2. compute the offset of the exception within the function
    DWORD_PTR ImageBase = ...;
    PRUNTIME_FUNCTION pRF = ...;
    DWORD offsetInFunction = (DWORD)(pContextRecord->Rip - ImageBase - pRF->BeginAddress);

    // 3. linear scan the SCOPE_RECORD array
    for (DWORD i = 0; i < scopeCount; i++) {
        if (offsetInFunction >= pScopeRecords[i].BeginAddress &&
            offsetInFunction < pScopeRecords[i].EndAddress) {
            // 4. matching try block found! call the filter
            if (pScopeRecords[i].HandlerAddress) {
                int filterResult = ((FILTER_FUNC)pScopeRecords[i].HandlerAddress)();
                if (filterResult == EXCEPTION_EXECUTE_HANDLER) {
                    // 5. set the jump target
                    pDispatcherContext->TargetIp = ImageBase + pRF->BeginAddress + pScopeRecords[i].JumpTarget;
                    return ExceptionExecuteHandler;
                }
            }
        }
    }
    return ExceptionContinueSearch;
}

Phase 3: stack unwinding and final dispatch

1. What the kernel does after receiving ExceptionExecuteHandler The kernel’s KiDispatchException receives TRUE from RtlDispatchException and knows a handler was found.

// inside KiDispatchException
if (FirstPassSuccess) { // i.e. RtlDispatchException returned TRUE
    // 1. get the target address (set by __C_specific_handler)
    ULONG64 TargetIp = GetTargetIpFromDispatcherContext();

    // 2. perform the stack unwind
    RtlUnwindEx(
        TargetFrame,          // which frame to unwind to
        TargetIp,             // jump target
        pExceptionRecord,
        ReturnValue,          // return value
        pContextRecord,
        HistoryTable
    );

    // 3. rewrite RIP in the trap frame
    pTrapFrame->Rip = TargetIp;

    // 4. [key] resume execution
    KiContinuePreviousMode(pContextRecord, pTrapFrame, PreviousMode);
}

2. The RtlUnwindEx unwind process This is the actual frame unwinder; it interprets the UNWIND_CODE array.

VOID RtlUnwindEx(...) {
    // unwind frame by frame until reaching the target frame
    while (CurrentFrame < TargetFrame) {
        // for the current frame, look up its RUNTIME_FUNCTION
        PRUNTIME_FUNCTION pRF = RtlLookupFunctionEntry(CurrentRip, &ImageBase, NULL);
        PUNWIND_INFO pUnwindInfo = (PUNWIND_INFO)(ImageBase + pRF->UnwindInfoAddress);

        // interpret the UNWIND_CODE array
        for (int i = 0; i < pUnwindInfo->CountOfCodes; i++) {
            switch (pUnwindInfo->UnwindCode[i].UnwindOp) {
                case UWOP_PUSH_NONVOL:
                    // simulate a pop to restore the register
                    Context->Rsp += 8;
                    Context->Rbx = *(PULONG64)(Context->Rsp - 8);
                    break;
                case UWOP_ALLOC_SMALL:
                    Context->Rsp += (pUnwindInfo->UnwindCode[i].OpInfo * 8) + 8;
                    break;
                // ... other unwind opcodes
            }
        }

        // if there's a termination handler (__finally), call it now
        if (pUnwindInfo->Flags & UNW_FLAG_UHANDLER) {
            CallTerminationHandler(pUnwindInfo, Context);
        }

        // move to the caller's frame
        CurrentRip = *(PULONG64)Context->Rsp;
        Context->Rsp += 8;
    }
}

Phase 4: returning to user mode and executing the handler block

1. Final return (KiContinuePreviousMode) The kernel writes the modified CONTEXT back into the trap frame, then executes iretq (or sysret) to return to user mode — but the RIP is no longer just after idiv; it is the address of the __except block.

2. User mode resumes

; user mode, but RIP now points at the __except block
; assume the __except block is at 0x140001050
0x140001050: mov rcx, str_inside_except
0x140001057: call printf
; ... exception handling code

Summary of the key data structures and call chain:

Phase Key function Key structure Operation
Hardware trap KiDivideErrorFault KTRAP_FRAME save user context
Kernel dispatch KiDispatchException EXCEPTION_RECORD, CONTEXT build exception info
User dispatch RtlDispatchException - coordinate the dispatch flow
Function lookup RtlLookupFunctionEntry RUNTIME_FUNCTION array binary-search the function
Unwind info - UNWIND_INFO, UNWIND_CODE describe the stack-frame layout
Scope lookup __C_specific_handler SCOPE_RECORD array linear-search the try block
Stack unwind RtlUnwindEx UNWIND_CODE interpret unwind instructions
Return KiContinuePreviousMode KTRAP_FRAME restore user-mode context

The overall call chain:

KiUserExceptionDispatcher // Windows Kernel Internal (KI) API
  -> RtlDispatchException // main logic for exception handling
    -> RtlpCallVectoredHandlers // call any VEH
    -> RtlLookupFunctionEntry // look for a valid PRUNTIME_FUNCTION entry in the ExceptionDirectory
      -> RtlpLookupDynamicFunctionEntry // if no valid PRUNTIME_FUNCTION, run any dynamic callbacks
    -> RtlVirtualUnwind / RtlpxVirtualUnwind // perform stack-frame unwinding
    -> RtlpExecuteHandlerForException // execute the exception handler!

The UNWIND_CODE structure

Inside UNWIND_INFO there is a UnwindCode field that points to a UNWIND_CODE structure.

UNWIND_CODE is the “unwind script” or “reverse-operation instruction set” of the x64 SEH mechanism. It does not participate in the decision of exception handling (that’s SCOPE_RECORD’s job); it handles the execution side — how to safely and correctly restore the stack frame to its pre-call state. It is the cornerstone of stack unwinding.


The core role of UNWIND_CODE: the frame’s “construction blueprint” and “disassembly manual”

Think of a function call as building blocks:

  • Prolog is the “building” phase — it allocates stack space and saves registers.
  • Epilog is the “taking apart” phase — it restores registers, releases stack space, and returns.
  • When an exception occurs, the program stops abruptly, mid-build. UNWIND_CODE is the emergency disassembly manual that tells the system how to safely take the half-built structure back to its pre-call state, no matter which step it was interrupted at.

Key point: an exception can occur at any point after the prolog and before the epilog. The system must be able to restore the stack to the pre-function state from any point. UNWIND_CODE provides the exact, step-by-step reverse operations to do that.


UNWIND_CODE in detail

Defined in winnt.h:

typedef union _UNWIND_CODE {
    struct {
        UBYTE CodeOffset;  // offset from the start of the prolog, in bytes
        UBYTE UnwindOp:4;  // unwind opcode
        UBYTE OpInfo:4;    // opcode additional info
    };
    USHORT FrameOffset;    // for some UnwindOp values, the whole USHORT is a frame offset
} UNWIND_CODE, *PUNWIND_CODE;

Field meanings:

  • CodeOffset — the most critical and elegant design. It says after which “progress point” of the prolog this unwind operation should run. The system compares the exception address (RIP) against the function start to decide which UNWIND_CODE entries to execute. This ensures the stack is correctly restored regardless of whether the exception occurred after the prolog, inside the function body, or right before the epilog.
  • UnwindOp — the unwind opcode. Defines the concrete restore operation (e.g. pop a register, add to RSP, etc.).
  • OpInfo — opcode additional info, usually a register number or a size parameter.

How UNWIND_CODE works in practice

Let’s trace a concrete example. Assume the following function:

// corresponding assembly prolog
MyFunc:
    push    rbx        ; save non-volatile register
    push    rsi
    sub     rsp, 0x20  ; allocate local-variable space
    ...                ; function body

The compiler generates the following logical UNWIND_CODE array (pseudocode):

UNWIND_CODE UnwindCode[] = {
    {CodeOffset: 0x0, UnwindOp: UWOP_PUSH_NONVOL, OpInfo: RBX}, // corresponds to push rbx
    {CodeOffset: 0x1, UnwindOp: UWOP_PUSH_NONVOL, OpInfo: RSI}, // corresponds to push rsi
    {CodeOffset: 0x2, UnwindOp: UWOP_ALLOC_SMALL, OpInfo: 4}    // corresponds to sub rsp,0x20 (4*8=0x20)
};

Note: CodeOffset is cumulative. The first entry corresponds to prolog byte 0 (push rbx), the second to byte 1 (push rsi), the third to byte 2 (sub rsp,0x20).

Scenario analysis: exceptions at different positions

Scenario 1: exception after the prolog, inside the function body (e.g. after sub rsp, 0x20)

  1. The system computes the RIP offset — say 0x05.
  2. It walks the UNWIND_CODE array and executes every entry with CodeOffset <= 0x05 (all three):
    • reverse operation: add rsp, 0x20 (undo the stack allocation)
    • reverse operation: simulate pop rsi (restore rsi from the stack into the CONTEXT structure)
    • reverse operation: simulate pop rbx (restore rbx)
  3. RSP, RBX, and RSI are restored to their function-entry state.

Scenario 2: exception during the prolog (e.g. after push rsi, before sub rsp,0x20)

  1. The RIP offset is 0x01.
  2. Only entries with CodeOffset <= 0x01 run (the first two):
    • reverse operation: simulate pop rsi
    • reverse operation: simulate pop rbx
  3. Note: the UWOP_ALLOC_SMALL operation (add rsp,0x20) is NOT executed, because the corresponding sub rsp,0x20 hasn’t run yet! This is the essence of the CodeOffset mechanism: only undo the prolog operations that have actually executed.

Scenario 3: unwinding for __finally The process is identical for __finally. UNWIND_CODE ensures the stack is correctly restored, then the system jumps to the __finally block to run cleanup code, then continues unwinding.


The main UnwindOp opcodes and their reverse operations

Opcode Meaning Example prolog instruction Reverse operation at unwind (performed by RtlUnwindEx)
UWOP_PUSH_NONVOL push a non-volatile register push rbx pop the value from the stack and restore the corresponding register in CONTEXT. RSP += 8.
UWOP_ALLOC_SMALL allocate small stack space sub rsp, 0x20 RSP += allocated size.
UWOP_ALLOC_LARGE allocate large stack space sub rsp, 0x1000 RSP += allocated size.
UWOP_SAVE_NONVOL save a non-volatile register onto the stack mov [rsp+0x10], rbx read the value at the specified stack offset and restore the corresponding register in CONTEXT.
UWOP_SAVE_XMM128 save an XMM register movaps [rsp+0x20], xmm6 restore the XMM register in CONTEXT.
UWOP_SET_FPREG establish a frame pointer mov rbp, rsp restore RBP to the caller value saved in CONTEXT.
UWOP_PUSH_MACHFRAME push a machine frame (for hardware interrupts) done automatically by the hardware interrupt special interrupt recovery.

Important: the “pop” during unwinding is logical, not literal. What actually happens:

  1. Compute the original value’s position on the stack from the current RSP and CodeOffset.
  2. Read that value into the corresponding register field of CONTEXT.
  3. Update CONTEXT’s RSP to simulate the stack-pointer movement.

The stack memory itself is not modified — only CONTEXT is updated, preparing for resumption of execution.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch