From b59fd03c2ef6aaaca01ab7a21760e5f0cd52363d Mon Sep 17 00:00:00 2001 From: Yan Date: Mon, 17 Aug 2026 05:34:34 +0000 Subject: [PATCH 1/2] PE: Read the ARM64 and ARMNT exception directory _handle_seh took the exception directory from pefile, which parses it for x86-64 and Itanium only, so an ARM64 or ARMNT image produced no function hints at all even though its ABI requires an unwind entry for every non-leaf function. Read the eight-byte record for those two machines directly: the function's start RVA, then either an .xdata RVA or unwind data packed into the second word, which is where the function length comes from. Records that describe a fragment of a function beginning elsewhere are not function starts and are skipped, and an ARMNT start address keeps its Thumb bit, which is what selects the decoder. --- cle/backends/pe/pe.py | 56 ++++++++++++++++++++++- tests/test_pe_function_hints.py | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/test_pe_function_hints.py diff --git a/cle/backends/pe/pe.py b/cle/backends/pe/pe.py index e41b8b62a..974cc9c84 100644 --- a/cle/backends/pe/pe.py +++ b/cle/backends/pe/pe.py @@ -470,7 +470,13 @@ def _handle_exports(self, coff_symbol_types: dict[int, set[SymbolType]]): self.deps.append(forwardlib) def _handle_seh(self): - if hasattr(self._pe, "DIRECTORY_ENTRY_EXCEPTION"): + assert self._pe.FILE_HEADER is not None + machine = self._pe.FILE_HEADER.Machine + if machine == pefile.MACHINE_TYPE["IMAGE_FILE_MACHINE_ARM64"]: + self._handle_seh_arm(arm64=True) + elif machine == pefile.MACHINE_TYPE["IMAGE_FILE_MACHINE_ARMNT"]: + self._handle_seh_arm(arm64=False) + elif hasattr(self._pe, "DIRECTORY_ENTRY_EXCEPTION"): for entry in self._pe.DIRECTORY_ENTRY_EXCEPTION: self.function_hints.append( FunctionHint( @@ -480,6 +486,54 @@ def _handle_seh(self): ) ) + def _handle_seh_arm(self, arm64: bool): + """ + Read the ARM64 and ARMNT exception directory, which pefile parses for x86-64 and Itanium + only. Each entry is two words: the function's start RVA, then either the RVA of an .xdata + record or unwind data packed into the word itself, distinguished by its low two bits. + Described in Microsoft's ARM and ARM64 exception handling references. + """ + exc_dd = self._meta_dd("IMAGE_DIRECTORY_ENTRY_EXCEPTION") + if exc_dd is None: + return + try: + table = self._pe.get_data(exc_dd.VirtualAddress, exc_dd.Size) + except pefile.PEFormatError: + log.warning("PE exception directory lies outside the image") + return + + instruction_unit = 4 if arm64 else 2 + for offset in range(0, len(table) - 7, 8): + begin, unwind_data = struct.unpack_from("> 2) & 0x7FF) * instruction_unit + else: + # Flag 2 describes a piece of a function that begins elsewhere, so its start + # address is not a function entry. Flag 3 is reserved. + continue + # Bit 0 of an ARMNT start address marks Thumb code. CLE names an ARM function by the + # address with that bit set, as an ELF symbol table does, because it is what selects + # the decoder; the function's first instruction is at the address without it. + self.function_hints.append( + FunctionHint( + begin + self.linked_base, + length, + FunctionHintSource.EH_FRAME, + ) + ) + def _parse_meta_regions(self): """ Walk pefile's parsed data directories and build meta_regions describing the locations and layouts of PE diff --git a/tests/test_pe_function_hints.py b/tests/test_pe_function_hints.py new file mode 100644 index 000000000..6e7946db9 --- /dev/null +++ b/tests/test_pe_function_hints.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +from __future__ import annotations + +import os +import unittest + +import pefile + +import cle +from cle.backends.backend import FunctionHintSource + +TEST_BASE = os.path.join(os.path.dirname(os.path.realpath(__file__)), os.path.join("..", "..", "binaries")) + +ARM64_PE = os.path.join(TEST_BASE, "tests", "aarch64", "windows", "pe_reloc_arm64.exe") +ARMNT_PE = os.path.join(TEST_BASE, "tests", "armel", "windows", "pe_reloc_armnt.exe") +AMD64_PE = os.path.join(TEST_BASE, "tests", "x86_64", "windows", "sioctl.sys") + + +def load(path): + return cle.Loader(path, auto_load_libs=False, main_opts={"backend": "pe"}).main_object + + +def exception_directory_hints(obj): + return sorted((h.addr, h.size) for h in obj.function_hints if h.source == FunctionHintSource.EH_FRAME) + + +# pylint: disable=no-self-use +class TestPEFunctionHints(unittest.TestCase): + """ + Function hints taken from the exception directory, whose entries have a different shape on + each architecture. + """ + + def test_aarch64(self): + obj = load(ARM64_PE) + base = obj.linked_base + # the last entry uses the packed form, the first two point at an .xdata record + assert exception_directory_hints(obj) == [ + (base + 0x1000, 0x138), + (base + 0x1138, 0x64), + (base + 0x119C, 0x1C), + ] + + def test_armnt(self): + obj = load(ARMNT_PE) + base = obj.linked_base + # every function in the image is Thumb, so every start address carries the Thumb bit + assert exception_directory_hints(obj) == [ + (base + 0x1001, 0xE8), + (base + 0x10E9, 0x3C), + (base + 0x1125, 0x1A), + (base + 0x113F, 0xC), + (base + 0x114B, 0x10), + (base + 0x115B, 0xC), + ] + + def test_armnt_matches_the_function_pointer_table(self): + obj = load(ARMNT_PE) + base = obj.linked_base + # the three entries of the function pointer table name three of the same functions the + # exception directory does, Thumb bit and all + table = [obj.memory.unpack_word(0x2000 + i * 4, size=4) for i in range(3)] + assert sorted(table) == [base + 0x113F, base + 0x114B, base + 0x115B] + hint_addrs = {addr for addr, _ in exception_directory_hints(obj)} + assert hint_addrs.issuperset(table) + + def test_x86_64_matches_pefile(self): + obj = load(AMD64_PE) + pe = pefile.PE(AMD64_PE, fast_load=True) + pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_EXCEPTION"]]) + entries = getattr(pe, "DIRECTORY_ENTRY_EXCEPTION") # pefile only sets this for x86-64 and Itanium + expected = sorted( + (obj.linked_base + e.struct.BeginAddress, e.struct.EndAddress - e.struct.BeginAddress) for e in entries + ) + assert exception_directory_hints(obj) == expected + + +if __name__ == "__main__": + unittest.main() From c13817984506251059e969ce9ea1fea556e1e21c Mon Sep 17 00:00:00 2001 From: Yan Date: Sat, 29 Aug 2026 19:13:27 +0000 Subject: [PATCH 2/2] Let _meta_dd return the data directory entry's own type The Typecheck job scores each changed file against master's copy of it: badness is (10*errors + warnings)/lines and must not increase. Its environment installs types-pefile, whose stub types OPTIONAL_HEADER.DATA_DIRECTORY as a list of entries carrying VirtualAddress and Size. _meta_dd declared pefile.Structure, the base class that has neither, which threw that away for every caller: 39 of pe.py's 52 errors are a caller reading VirtualAddress or Size off the result. _handle_seh_arm reads exc_dd.VirtualAddress and exc_dd.Size to find the exception directory, so it added the 53rd and 54th. Leaving the return type to inference gives the entry type where the stub is installed and an unknown one where it is not, so the callers type-check and nothing about them changes at run time. pe.py goes from 54 errors to 13. Co-Authored-By: Claude Opus 5 (1M context) --- cle/backends/pe/pe.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cle/backends/pe/pe.py b/cle/backends/pe/pe.py index 974cc9c84..39c04190d 100644 --- a/cle/backends/pe/pe.py +++ b/cle/backends/pe/pe.py @@ -561,8 +561,14 @@ def _meta_pe_context(self) -> tuple[pefile.PE, int, bool, int]: ptr_size = 8 if is_64 else 4 return pe, base, is_64, ptr_size - def _meta_dd(self, name: str) -> pefile.Structure | None: - """Return a data directory entry if it has a nonzero VirtualAddress and Size, else None.""" + def _meta_dd(self, name: str): + """ + Return a data directory entry if it has a nonzero VirtualAddress and Size, else None. + + The return type is inferred rather than declared: pefile fills a data directory entry in from the format + string it parsed, so the pefile.Structure this used to promise declares neither VirtualAddress nor Size and + hides both from every caller. + """ idx = pefile.DIRECTORY_ENTRY[name] dd = self._pe.OPTIONAL_HEADER.DATA_DIRECTORY[idx] if dd.VirtualAddress and dd.Size: