Skip to content

PE: Read the ARM64 and ARMNT exception directory - #756

Open
zardus wants to merge 2 commits into
masterfrom
feature/fix-cle-pe-arm-unwind-hints
Open

zardus wants to merge 2 commits into
masterfrom
feature/fix-cle-pe-arm-unwind-hints

Conversation

@zardus

@zardus zardus commented Aug 17, 2026 •

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

An ARM64 or ARMNT PE reaches CFGFast with no function hints at all. On
binaries/tests/aarch64/windows/pe_reloc_arm64.exe and
binaries/tests/armel/windows/pe_reloc_armnt.exe:

ARM64 pe_reloc_arm64.exe: 0 function hints
ARMNT pe_reloc_armnt.exe: 0 function hints

Both ABIs require an unwind entry for every non-leaf function, so the exception
directory is the most complete function-start table these images carry, and on a
stripped one it is the only one. The data is in the file: the exception data
directory of the ARM64 image is 0x18 bytes at rva 0x4000, three eight-byte
records, and the ARMNT image's is 0x30, six records.

Root cause

_handle_seh reaches the directory only through pefile:

if hasattr(self._pe, "DIRECTORY_ENTRY_EXCEPTION"):

pefile 2024.8.26 builds DIRECTORY_ENTRY_EXCEPTION from
IMAGE_RUNTIME_FUNCTION_ENTRY records, which it parses for x86-64 and Itanium
only. On both fixtures the attribute is absent although the directory is
populated:

pe_reloc_arm64.exe machine=0xaa64 has DIRECTORY_ENTRY_EXCEPTION: False exception dd rva=0x4000 size=0x18
pe_reloc_armnt.exe machine=0x1c4  has DIRECTORY_ENTRY_EXCEPTION: False exception dd rva=0x4000 size=0x30

The hasattr is therefore false for every ARM image and the hint loop never runs.

Fix

Dispatch on FILE_HEADER.Machine and read the eight-byte ARM64/ARMNT record
directly: function start rva, then either a packed unwind word or an .xdata
pointer, told apart by the low two bits. The function length comes from the
packed word or from the .xdata header, and a record describing a fragment of a
function that begins elsewhere is skipped. An ARMNT start address keeps its Thumb
bit, as an ARM function address does everywhere else in cle, because that bit
selects the decoder.

ARM64 pe_reloc_arm64.exe: 3 function hints
    0x140001000  size=312  source=EH_FRAME
ARMNT pe_reloc_armnt.exe: 6 function hints
    0x401001  size=232  source=EH_FRAME

Testing

tests/test_pe_function_hints.py pins the hints of both fixtures --
test_aarch64, test_armnt and test_armnt_matches_the_function_pointer_table,
which cross-checks the ARMNT starts against the image's own function pointer
table -- and test_x86_64_matches_pefile checks the x86-64 path still agrees with
pefile's parse. The three ARM tests see 0 hints on the merge base. The macos and windows jobs check out binaries master,
so they stay red until the fixture pull request merges.
Corrected: both jobs do
resolve the referenced fixture pull request -- the windows log records
Checking out angr/binaries at refs/pull/183/head -- and both are green.

Fixes #753. Validation: #756 (comment)

sync: angr/binaries#183

session: sharpen

@zardus

zardus commented Aug 17, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head c13817984506251059e969ce9ea1fea556e1e21c against baseline eac0e5540516b9199dd6a91933e80dc774ea3eac.

  • Regression: pytest tests/test_pe_function_hints.py — 4 tests; against baseline cle the three ARM tests fail with an empty hint list and test_x86_64_matches_pefile passes; all 4 pass on head
  • Focused: pytest tests/ in cle — 229 passed, 9 skipped on head; 225 passed, 9 skipped on baseline
  • Lint/type: ruff check, ruff format --check, and run-ci-diff-checks.py --repository cle (the merge-base pylint and pyright comparison the hosted jobs apply) — all changed files improve or hold
  • Workspace gate: run-all-tests.sh --jobs 2 in an ANGR_FEATURE shell with cle, binaries and angr adopted — all selected suites passed: workspace checks, the test-inputs fixture check, every configured pre-commit hook, the per-feature instancing suite, cle 235 passed 9 skipped, angr 2,471 passed 46 skipped 2 xfailed 260 subtests, angr Rust 35 passed. archinfo, pypcode, pyvex, claripy and angr-management are skipped as unadopted and untouched. A first run at the default four workers lost tests/procedures/libc/test_strtol.py to SIGKILL; memory.events recorded exactly one new oom_kill, and the test passes alone in 140s, so the run was repeated at two workers

Corpus A/B, 1,557 PE objects, one process per object per side, with the
exception directory also parsed straight out of each file's bytes as the oracle:

machine objects entries in the file fragments hints on baseline hints on head
AMD64 469 228,581 3,688 228,291 228,291
ARM64 306 123,866 0 0 123,866
ARMNT 176 29,737 84 0 29,653
  • Every difference: 119 ARM64 and 57 ARMNT objects gain function hints; nothing else changes. Loaded memory, relocations, symbols and entry points are byte-identical on all 1,557 objects, x86-64 hints are byte-identical, no load newly fails, no run times out.
  • Load errors: 11 on both sides, unchanged — 10 ArchNotFound on LOONGARCH64 and one IndexError in _meta_iat that Stop indexing PE and ELF header tables past their declared length #732 fixes.
  • The addresses are not merely counted. Over the 176 objects with an exception directory: no entry is out of order, none overlaps its predecessor, and capstone decodes an instruction at every claimed start in the mode the Thumb bit selects. 121,614 of 123,866 ARM64 and 29,443 of 29,737 ARMNT ranges decode as a clean stream ending exactly on the declared length; the rest stop early on an encoding capstone does not know, which is a limit of the check.
  • Every one of the 29,737 ARMNT entries has the Thumb bit set on its start address.

The issue proposed masking that bit off. Measured, keeping it is better on every
axis, because angr reads bit 0 of an address as "decode this as Thumb" and its
mode-switch retry does not run for a job seeded from a function hint. On the two
ARMNT drivers with the most entries, CFGFast with defaults:

2,043 entries, masked kept 1,457 entries, masked kept
hints reaching a CFG node 1,230 2,038 722 1,452
of those, decoded as ARM 349 0 406 0
blocks 67,815 69,765 68,414 71,459
functions 7,396 3,470 6,294 2,656
functions at an even address 800 218 716 217
bytes covered 1,098,638 1,141,938 807,874 860,954
seconds 314 299 92 87

Both images are Thumb only, so an ARM-mode node at a hint address is a
mis-decode; masking produced 349 and 406 of them and about twice as many
functions, most of them fragments of mis-decoded code. CLE therefore keeps the
bit, which is also how it already names an ARM function from an ELF symbol
table.

CFG effect of the hints themselves. CFGFast with defaults, one process per
object per side, over the 109 objects of a 247-object ARM population that gain
hints and change nothing else:

Baseline Head
objects 109 109
objects whose block set changed — 22
blocks 253,960 278,731
functions 21,312 24,411
bytes covered 6,693,968 7,360,316
errors 0 0
timeouts 0 0

That gain is ARM64, and it concentrates: the four largest are ARM64 images whose
sha256 begins 315c2b6e9d9971dc, 6e0719160f4bc825, 35189997a98437d1 and
f7293a5cf2e69dde, with 1,147, 834, 327 and 252 exception directory entries
and 230,128, 147,668, 100,260 and 85,068 more bytes of recovered code each.

On ARMNT the hints are close to neutral for CFGFast with
defaults: on the two drivers with 2,043 and 1,457 entries it recovers 69,760
and 71,457 blocks without them and 69,765 and 71,459 with them. The same two
images under the masked spelling recover 67,815 and 68,414, which is why the
Thumb bit is kept — the wrong spelling is worse than no hints at all.

Nine of the 22 lose at most four blocks or six functions, which is a hint at a
real function entry splitting a block that spanned two functions. The other 138
objects of the 247 are ones #755 moves or that gain nothing; they
are excluded above so that this table describes only the hints.

CFGFast is deterministic on this population: two independent runs of the same
revision over all 247 objects agreed on every block address, function address,
block count and byte count, so every difference above is the change.

Caveats: the corpus is a private dataset, so objects are named by machine type and count rather than by path; angr/binaries#183 reproduces both architectures publicly. angr.Project on an ARM Windows image raises KeyError: 'Win32' until angr/angr#6794 lands, so the CFG measurement registers a Windows syscall calling convention for those architectures to reach CFGFast; CFGFast never reads it. On x86-64, entries whose UNWIND_INFO sets UNW_FLAG_CHAININFO are fragments too — 3,688 of 228,581 — and are still emitted as hints; changing that reaches every Windows x64 target and is left for its own change.

CI on this PR: all 20 checks are green at head c13817984506251059e969ce9ea1fea556e1e21c,
read 2026-09-03, with the fixture pull request angr/binaries#183 still open.
Test macos-15 and Test windows-2022 are among them, green in run
33276907944.

Correction, 2026-09-03. An earlier version of this comment said that
Test macos-15 fails and Test windows-2022 is cancelled with it because
cle's own matrix job checks out angr/binaries with no ref: and so gets
master. That described run
32006701299 of 2026-08-17 and no
longer holds: cle master gained the missing resolution in 5125f1ba ("Check
out a referenced angr/binaries pull request on macOS and Windows", 2026-08-18).

Re-keyed 2026-08-28. The figures above were measured at e4169b696b49d700c8116217fe2720b465a673ac on baseline 45c6509c753d07f740099035cd41f7f473dc6f31, which is the head the opening line named until now; the branch is at 69a3907376d879e745b6553fab647c6ff9d1f7e8 on 46a37333f4f59b0facf8774ee743ebc4cc074e9b. git range-diff 45c6509c753d07f740099035cd41f7f473dc6f31..e4169b696b49d700c8116217fe2720b465a673ac 46a37333f4f59b0facf8774ee743ebc4cc074e9b..69a3907376d879e745b6553fab647c6ff9d1f7e8 reports every commit unchanged and git diff e4169b696b49d700c8116217fe2720b465a673ac 69a3907376d879e745b6553fab647c6ff9d1f7e8 differs only by master's own advance (12 files changed, 353 insertions(+), 44 deletions(-)). Master touched none of the files this change touches between the two baselines, so every figure above still describes this head.

CI status, head 69a3907376d879e745b6553fab647c6ff9d1f7e8, read 2026-08-27.

ci / Test (0) was red for a stale sibling, not for anything in this diff and not because the sibling is unmerged. The failing test was tests/analyses/cfg/test_cfgfast_soot.py::TestCfgfastSoot::test_invokespecial_on_an_interface, which belongs to angr and to no file here; it died at construction with Exception: Not a valid binary file: '.../binaries/tests/java/interface_default.jar'. The referenced fixture branch is checked out at its tip rather than merged with master, so its staleness came into the run: angr/binaries#183 sat one commit behind angr/binaries master, and that one commit — de38bc3, merged 2026-08-26T23:33Z — is the one that adds tests/java/interface_default.jar. Querying the pinned revision for the file the test wanted returned 404; querying master returned the blob. That is the discriminator, and it predicts the failure exactly.

The repair belongs on the sibling. feature/pe-loader-fixtures has been rebased onto angr/binaries master, 25318d666b6464c5aa91f701e091a82d80c625dd to 8159566adc4c1ae5128d6f4da0533be399486cb2, with git range-diff reporting the commit unaltered, and run 33021253016 re-run in full — not --failed, because the dependency snapshot is taken in Build, which --failed skips.


CI disposition, head 7b7746eb.

ci / Typecheck was red on cle/backends/pe/pe.py. This branch really did add two diagnostics -
exc_dd.VirtualAddress and exc_dd.Size in _handle_seh_arm - on top of the 52 that master's copy
of the file already carries, taking badness to 54/1269 = 0.4255 against the bar 52/1242 = 0.4187.
(The bar is master's tip, not the merge base: typecheck.py master <head> checks master out to
score the base side, and only the file list is merge-base relative.)

Those 39 of the 52 pre-existing errors come from PE._meta_dd, whose -> pefile.Structure | None
annotation discarded the data-directory entry type the pefile stub already knows. The fix drops that
annotation and records the reason in the docstring: inference then gives the entry type where the stub
is installed and an unknown type where it is not, so every call site type-checks, none of them moves,
and nothing changes at run time. The identical eight-line hunk is on #732, which needed it for
the same reason, and the two merge cleanly.

cle/backends/pe/pe.py: badness 0.425531914893617 -> 0.10196078431372549. pylint stays at
10.00/10 for every changed file. tests/test_pe_function_hints.py tests/test_pe.py: 18 passed, with
cle imported from the branch worktree.

The macOS and Windows legs failed on tests/test_pe.py::TestPEBackend::test_uefi_image_is_not_windows
needing binaries/tests/riscv64/uefi/HighMemDxe.efi. That is a test on cle master, not this branch,
and the platform jobs check the referenced fixture branch out at its tip; angr/binaries#183 has since
been brought onto binaries master, which carries the image.


Re-keyed 2026-08-29. The branch was rebased from 7b7746ebc71ae4dec65f25bbe4a2cd79e5c6c29c on 46a37333f4f59b0facf8774ee743ebc4cc074e9b to c13817984506251059e969ce9ea1fea556e1e21c on eac0e5540516b9199dd6a91933e80dc774ea3eac, to pick up master's eac0e554 ("PE: read the loading environment from the optional header (#800)"). Without it every PE loads as os = "windows", and angr master's tests/simos/test_uefi.py then fails test_aarch64_image_uses_the_architecture_default, test_ia32_image_uses_the_microsoft_convention and test_riscv64_image_does_not_become_windows on this branch's checkout of cle. Measured directly: at 7b7746eb, tests/riscv64/uefi/HighMemDxe.efi reports os = "windows" and the two te_sections.te images report os = None; at c1381798 all three report os = "uefi".

git range-diff 46a37333f4f59b0facf8774ee743ebc4cc074e9b..7b7746ebc71ae4dec65f25bbe4a2cd79e5c6c29c eac0e5540516b9199dd6a91933e80dc774ea3eac..c13817984506251059e969ce9ea1fea556e1e21c reports both commits unchanged (=), and diff <(git diff <old-base>..<old-head>) <(git diff <new-base>..<new-head>) differs only in blob ids and hunk headers, so the patch is the same one on a new base.

The 2026-08-28 note above argued the figures carried over because master had touched none of this change's files. That argument does not hold at this baseline: eac0e554 edits cle/backends/pe/pe.py, the file this change edits. What it adds is a module-level image_os helper and one self.os = image_os(...) assignment in PE.__init__; nothing on the path to function_hints reads self.os, and the two hunks do not touch each other. The regression bullet was re-derived at the new baseline rather than assumed: with only tests/test_pe_function_hints.py added to eac0e554, the three ARM tests still fail with an empty hint list and test_x86_64_matches_pefile passes; all 4 pass at c1381798. The corpus and CFG tables above were not re-measured and still name e4169b696b49d700c8116217fe2720b465a673ac as the revision they were taken at.

At the new head: run-ci-diff-checks.py reports pylint 10.00/10 on both changed files and pyright badness on cle/backends/pe/pe.py falling 0.6280 to 0.3072, with no regression; pytest tests/test_pe.py tests/test_pe_function_hints.py tests/test_te.py is 25 passed. The sync: sibling angr/binaries#183 was rebased onto binaries master (fe84f5d7, behind_by 0), so the run resolves a fixture branch that carries tests/i386/deep_sp_chain as well as the two ARM PE images. The hosted run at this head is 33276907944; its result and the dec-snapshots comparison for c1381798 are recorded below when it reaches a terminal state.

@angr-bot

Copy link
Copy Markdown
Member

Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_756

@zardus
zardus force-pushed the feature/fix-cle-pe-arm-unwind-hints branch 2 times, most recently from b205ac2 to 340bbea Compare August 22, 2026 15:30
@zardus
zardus force-pushed the feature/fix-cle-pe-arm-unwind-hints branch from 340bbea to 69a3907 Compare August 26, 2026 22:46
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

The function hints cle derives from the exception directory of the two ARM PE fixtures, before and after this change. The fixtures come from the angr/binaries pull request linked from the description.

the reproducer
import logging, os
logging.getLogger("cle").setLevel(logging.CRITICAL)
import cle

BIN = os.environ["BINARIES"]   # a checkout of angr/binaries
T = lambda *p: os.path.join(BIN, "tests", *p)

for name, path in (("ARM64", T("aarch64", "windows", "pe_reloc_arm64.exe")),
                   ("ARMNT", T("armel", "windows", "pe_reloc_armnt.exe"))):
    obj = cle.Loader(path, auto_load_libs=False).main_object
    hints = obj.function_hints
    print(f"{name} {os.path.basename(path)}: {len(hints)} function hints")
    names = {0: "EH_FRAME", 1: "EXTERNAL_EH_FRAME", 2: "EXPORT_TABLE"}
    for h in hints:
        print(f"    {h.addr:#x}  size={h.size}  source={names[int(h.source)]}")

Before — the ARM64 and ARMNT exception directories are never parsed, so CFGFast gets nothing to seed from:

cle master at 46a37333f4f59b0facf8774ee743ebc4cc074e9b
ARM64 pe_reloc_arm64.exe: 0 function hints
ARMNT pe_reloc_armnt.exe: 0 function hints

After — three and six entries, at the addresses the images' own function pointer tables name:

with this change, at 69a3907376d879e745b6553fab647c6ff9d1f7e8
ARM64 pe_reloc_arm64.exe: 3 function hints
    0x140001000  size=312  source=EH_FRAME
    0x140001138  size=100  source=EH_FRAME
    0x14000119c  size=28  source=EH_FRAME
ARMNT pe_reloc_armnt.exe: 6 function hints
    0x401001  size=232  source=EH_FRAME
    0x4010e9  size=60  source=EH_FRAME
    0x401125  size=26  source=EH_FRAME
    0x40113f  size=12  source=EH_FRAME
    0x40114b  size=16  source=EH_FRAME
    0x40115b  size=12  source=EH_FRAME

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Two red attempts on run 33021253016, two unrelated causes, neither of them in this diff.

Attempt 2 -- Test windows-2022 (job 98558249316): infrastructure flake

53 collection errors, every one AttributeError: module 'pyvex' has no attribute 'vex_ffi'. The originating frame is pyvex's FFI parser cache:

.venv\Lib\site-packages\pyvex\native.py:42: in _parse_ffi_str
    os.replace(temp_file_name, cache_location)
E   PermissionError: [WinError 5] Access is denied:
    'C:\...\Temp\tmptseeutwf' ->
    'C:\...\Temp\pyvex_ffi_parser_cache.runneradmin.c10af7f119ced7e82cf18ff9edd6648a'

os.replace onto a path another xdist worker holds open fails on Windows, so import pyvex half-completes and every module that imports archinfo then dies at arch.py:207 on _pyvex.vex_ffi. No test executed. The same job passed on attempts 1 and 3 with nothing changed on the branch.

Attempt 3 -- ci / Test (8) (job 99062999835): a stale pinned sibling

The failing assertion is not in cle:

src/angr/tests/simos/windows/test_windows_fastfail.py::TestWindowsFastfail::test_fastfail_on_a_real_windows_arm_binary
src/angr/tests/simos/windows/test_windows_fastfail.py::TestWindowsFastfail::test_fastfail_runs_on_a_real_windows_arm_binary
Exception: Not a valid binary file: '.../binaries/tests/aarch64/windows/fastfail_arm64.exe'

raised at angr/project.py:166, where the guard is os.path.exists -- the fixture is absent from the checkout, not malformed.

ci / Build fetched refs/pull/183/head for angr/binaries, as the sync: line asks. A referenced pull request is checked out at its branch tip, unmerged, so this run got binaries at 8159566 -- three commits behind master. tests/aarch64/windows/fastfail_arm64.exe and tests/armel/windows/fastfail_armnt.exe reached binaries master in a87538b (angr/binaries#191) at 2026-08-28T15:16:37Z, and the angr test consuming them landed nine seconds later in angr/angr#6794. This was the first cle run after that pair, which is also why #755 and #757, pinning the same stale revision, are still green: their last runs predate it.

The prediction is exact rather than argued: those two fixtures are consumed by exactly two tests, and exactly those two failed.

Repair

It belongs on the sibling. angr/binaries#183 is rebased onto master (8159566 -> 507020d; git range-diff reports the commit unaltered), so its tip now carries the fastfail fixtures alongside its own. Both tests pass against that tree locally: 6 passed, 9 subtests passed.

No commit on this branch; the run is being re-run in full so it resolves the rebased sibling.

zardus and others added 2 commits August 29, 2026 21:40
_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.
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) <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/fix-cle-pe-arm-unwind-hints branch from 7b7746e to c138179 Compare August 29, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PE function hints from the exception directory are x86-64 only, so an ARM64 or ARMNT image gives CFGFast none

2 participants