Skip to content

Exception Directory

Data directory 3

IMAGE_DIRECTORY_ENTRY_EXCEPTION: DataDirectory[3] in the Optional header. All data directories

On x64 and ARM64, Windows unwinds the stack using tables, not frame pointers. Every non-leaf function has an entry in the exception directory (the .pdata section) that describes where the function starts and ends and how its prolog changed the stack. For a reverse engineer this is a free, compiler-generated map of function boundaries. For a malware analyst, a missing or garbage table is a strong sign that the code was packed or hand-crafted.

How the structure works

An array of 12-byte RUNTIME_FUNCTION entries, sorted by address:

Field Meaning
BeginAddress RVA of the function start
EndAddress RVA just past the function end
UnwindInfoAddress RVA of its UNWIND_INFO (usually in .rdata)

UNWIND_INFO holds the version, flags (1 EHANDLER, 2 UHANDLER, 4 CHAININFO), the prolog size, the frame register, and the unwind codes (UWOP_PUSH_NONVOL, UWOP_ALLOC_SMALL/LARGE, UWOP_SET_FPREG, UWOP_SAVE_NONVOL, …). When a handler flag is set, the RVA of the language-specific exception handler follows the codes.

8-byte entries: BeginAddress plus either a packed unwind word (the common case: function length, frame size and saved registers encoded in 32 bits) or the RVA of an .xdata record with function length, epilog scopes and unwind codes.

What PPEE shows

Linux screenshot: Exception directory with unwind codes

Exception directory with unwind codes

  • Tree: DIR_ENTRY_EXCEPTION (AMD64, n) or (ARM64, n).
  • AMD64 upper list: BeginAddress - Section name · EndAddress · UnwindInfo - Section name (with the count of unique unwind blocks in the header) · Comment (function length in hex and decimal). Rows that share an unwind block get the same tint.
  • AMD64 lower list: decoded UNWIND_INFO: Version, Flags (decoded), SizeOfProlog, CountOfCodes, and one row per UnwindCode (for example UWOP_ALLOC_SMALL, OpInfo=4, Size=28h → .ALLOCSTACK 28h).
  • ARM64 upper list: BeginAddress - Section name · UnwindData - Section name · Comment. The lower list decodes the packed word or the .xdata record (function length, epilog count, code words, exception data present).
  • AMD64 Check column (x64 files): whether the entry's unwind data matches its code. See the Check column.
  • Follow in Hex View (Ctrl+H) on any address jumps to the code or unwind bytes.

The Check column

For each AMD64 entry PPEE checks the entry itself, then decodes the function's prologue and compares it with the entry's unwind codes. A compiler writes both together, so on compiled code the column is empty. A problem shows in the warning colour, with what does not match:

Entry Check
begin=000016D0 in crackme-Section_name.exe The function starts with "jmp 0x140017A41": its prologue was replaced (a hook or a patch), the unwind data still describes the original
a .NET runtime stub in coreclr.dll "push rax" changes the stack in the prologue, but no unwind code describes it

The column is computed for the rows on screen as you scroll, so a .pdata with hundreds of thousands of entries opens as fast as before.

When the whole table is unusable, the reason is said once, in the node's label, and the rows stay quiet:

DIR_ENTRY_EXCEPTION (AMD64, 19733)  -- 15361 of 19733 .pdata entries have unreadable or invalid UNWIND_INFO, as stored in the file
                                                                                                                       (Enigma Protector)
DIR_ENTRY_EXCEPTION (AMD64, 1971)   -- About 39% of the prologues do not decode at the addresses the unwind data gives, as stored in
                                       the file (packers do this, and so does Microsoft's Warbird in licensing and DRM binaries)  (ClipUp.exe)
Check Means
prolog_hooked The function starts with a jmp, but its unwind data describes a real prologue: patched or hooked after linking
prolog_mismatch An unwind code says one thing (push rbx, sub rsp, 0x28, set the frame register, save a register), the instruction at that point does another
prolog_undescribed A push or sub rsp in the prologue that no unwind code describes: the unwinder would end up 8 or more bytes off
prolog_size The prologue size is longer than the function, or ends in the middle of an instruction
prolog_invalid The prologue bytes do not decode as x64
table_order, table_overlap Entries out of order or overlapping. The unwinder uses a binary search and can miss functions
range A function range that is empty or not in an executable section
unwind_info, version The UNWIND_INFO cannot be read, or has a version other than 1 or 2
unwind_packed More than half of the entries fail the line above: the unwind data is packed or encrypted (said once, in the node's label; the rows stay quiet)
code_encrypted Many prologues do not decode at all: the code is encrypted as stored (said once, in the node's label; the rows stay quiet). Packers do this, and so does Microsoft's Warbird in licensing and DRM binaries (ClipUp.exe, GenValObj.exe)
indirect An indirect entry (UnwindData with bit 0 set, used by older Windows binaries such as Windows 7's explorer.exe) that does not point to another entry of the table
handler An exception handler outside executable code
chain A chained entry pointing to a function that has no entry of its own

The check knows what compilers legitimately do and does not report it:

  • MSVC saves registers into the caller's home area before sub rsp.
  • Shrink-wrapped functions keep an early-return epilogue inside the prologue range.
  • Chained entries describe saves made in the function body.
  • Stack-probe loops end in mov rsp, r11.
  • Some prologues realign rsp.

What a finding means

A finding says that the code and its unwind data disagree, not why. Hand-written assembly disagrees too: the .NET runtime (coreclr.dll) has 5 such stubs, and OpenSSL's libcrypto has handler entries pointing into .data. Look at where a finding is. In a single hand-tuned routine it is normal. On an exported function, the entry point or DllMain of a file that should be compiler-made, it means the file was changed after it was built.

On clean files

On clean x64 files, such as Windows system binaries, the Check column is empty. The exceptions are files whose code Microsoft's Warbird encrypts, such as ClipUp.exe and GenValObj.exe: they get only the code_encrypted verdict in the label.

The Code analysis adds what needs the code scan: hooked prologues and functions with no unwind data become patterns, and its Summary counts the entries that disagree, with a link back to this table.

Reading exception data like an analyst

Observation What it suggests
Thousands of entries, all inside .text, lengths look normal Compiler-generated. Import the boundaries into your disassembler
BeginAddress ≥ EndAddress, or addresses beyond SizeOfImage The .pdata bytes are encrypted or compressed by a packer and restored at run time
Exception directory present with 0 entries, or missing on an x64 binary with lots of code Hand-written or generated code, or a protector that registers its own tables at run time (RtlAddFunctionTable)
Entries pointing outside .text (into a writable or unnamed section) Code that lives in unusual places, often unpacked stubs
Unusual handler addresses (flags 1/2) on small functions SEH-based anti-debugging: exceptions raised on purpose to transfer control
Very large unwind allocations, or chained entries (CHAININFO) forming loops Crafted to break unwinders or analysis tools

Real samples

Sample Entries Invalid (begin ≥ end or beyond image) Reading
explorer.exe 12,411 0 Normal MSVC build
ARM64 Rust build 93,829 0 Normal; packed and .xdata records
Protected crackme 9,876 9,872 .pdata encrypted by the protector, for example begin=7032281C end=9F4D6BDC
UPX-packed build 1,613 1,613 .pdata packed; the real table exists only after unpacking
Themida-protected DLL 0 0 Directory present but empty; the protector handles its own unwinding

Function boundaries for your disassembler

Export beginAddress/endAddress pairs from JSON and feed them to your disassembler's scripting interface. For a stripped x64 binary this recovers accurate function starts, including functions never called directly.

CLI and JSON

$ ppee-cli --exception explorer.exe
Exception directory (AMD64): 12411 entrie(s)
  begin=00001008 end=000012ED unwindInfo=0040914C [version=1 flags=3 sizeOfProlog=38 countOfCodes=9]
$ ppee-cli --exception cargo-aarch64.exe
Exception directory (ARM64): 93829 entrie(s)
  begin=00001000 unwindData=01792B30 packed=no [xdata: functionLength=86 version=0 exceptionDataPresent=1 epilogCount=2 codeWords=3]

The check shows under its entry, and a whole-file verdict under the heading:

$ ppee-cli --exception crackme-Section_name.exe | grep -B1 '\[\*\]'
  begin=000016D0 end=0000181F unwindInfo=000080BC [version=1 flags=0 sizeOfProlog=8 countOfCodes=3]
    [*] prolog_hooked: The function starts with "jmp 0x140017A41": its prologue was replaced (a hook or a patch), the unwind data still describes the original

JSON: exception → present, machine, verdict (id, text; only when the whole table is packed or the code encrypted), entries[]. AMD64: beginAddress, endAddress, unwindInfoAddress, unwindInfo (version, flags, sizeOfProlog, countOfCodes, when it decodes), check (id, text; only on an entry with a problem). ARM64: beginAddress, unwindData, packed, xdata.

Hunting recipes

# Encrypted / packed .pdata detector
ppee-cli --json --exception --headers f.exe | jq '
  def h: ascii_downcase | explode | reduce .[] as $c (0; . * 16 + (if $c >= 97 then $c - 87 else $c - 48 end));
  (.headers["OptionalHeader.SizeOfImage"] | h) as $soi
  | {entries: (.exception.entries | length),
     invalid: ([.exception.entries[] | select(.endAddress != null)
               | select((.beginAddress | h) >= (.endAddress | h) or (.endAddress | h) > $soi)] | length)}'

# Function boundaries as "start end" pairs (AMD64) for a disassembler script
ppee-cli --json --exception f.exe | jq -r '.exception.entries[] | select(.endAddress) | "0x\(.beginAddress) 0x\(.endAddress)"' > funcs.txt

# Entries whose unwind data does not match the code
ppee-cli --json --exception f.exe | jq '.exception.entries[] | select(.check) | {beginAddress, check}'

# Functions with a language-specific handler (flags & 3)
ppee-cli --json --exception f.exe | jq '[.exception.entries[] | select(.unwindInfo.flags // 0 | . % 4 != 0)] | length'

Related: --exception · TLS callbacks · Load Config (EH continuation targets)

References