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¶
- 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 exampleUWOP_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
.xdatarecord (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¶
- Microsoft PE format specification, exception data: the RUNTIME_FUNCTION table.
- x64 exception handling (Microsoft): unwind info, unwind codes and handler flags.
