Skip to content

Load ARM64 and ARMNT COFF objects - #724

Open
zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-coff-machines
Open

zardus wants to merge 1 commit into
masterfrom
feature/fix-cle-coff-machines

Conversation

@zardus

@zardus zardus commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

An ARM64 or ARMNT COFF object fails autodetection, and naming the backend raises something a caller cannot catch as a CLEError and that does not say what it saw:

D aarch64/coff_reloc_arm64.obj: RAISED CLECompatibilityError: Unable to find a loader backend
C mips/coff_r4000.obj with backend=COFF: RAISED NotImplementedError: Unsupported machine type

The addend defect underneath it is silent, and reaches x86-64 too. A .pdata RUNTIME_FUNCTION is two ADDR32NB relocations against the same section symbol, told apart only by the addends left in the patched fields, so on tests/x86_64/fauxware.obj every record collapses to a zero-length range:

A x86_64/fauxware.obj .pdata ADDR32NB in-place addends: 1 RUNTIME_FUNCTION records, all begin<end = False
    ['[0x14e8,0x14e8) len=0x0']

That is the table the unwinder and any exception-handling analysis reads.

Root cause

CoffParser._parse gated the whole backend on two machines, and Coff.is_compatible repeated the pair independently:

if self.header.Machine not in {IMAGE_FILE_MACHINE.I386, IMAGE_FILE_MACHINE.AMD64}:
    raise NotImplementedError("Unsupported machine type")

Separately, CoffRelocationADDR32NB.value was return self.resolvedby.relative_addr and CoffRelocationADDR64.value was return self.resolvedby.rebased_addr. Neither reads the field it is about to overwrite, so the producer's addend is discarded.

Fix

COFF_MACHINE_TO_ARCH_NAME becomes the one machine table _parse and is_compatible both consult, gaining ARMNT and ARM64, and an unsupported machine raises CLECompatibilityError naming it. ADDR32NB and ADDR64 add the resolved address to the field's existing contents. New relocation classes cover the ARM64 BRANCH26/PAGEBASE_REL21/PAGEOFFSET_12A/PAGEOFFSET_12L set and the ARMNT ADDR32/MOV32T/BRANCH24T set; ARMNT code is Thumb-2 only, so its function symbols and extern stubs carry the Thumb bit.

A .pdata: 1 RUNTIME_FUNCTION records, all begin<end = True   ['[0x14e8,0x1506) len=0x1e']
C mips/coff_r4000.obj with backend=COFF: CLECompatibilityError: Unsupported machine type 0x0166
D aarch64/coff_reloc_arm64.obj: backend=Coff arch=AARCH64
    BRANCH26 reaches ext_fn: True   PAGEOFFSET_12L(ldr q, scale 16) == aligned: True
    PAGEOFFSET_12A with addend == target+4: True   ADDR64 == target+8: True
E armel/coff_reloc_armnt.obj: backend=Coff arch=ARMEL
    thumbfn == .text+1 (Thumb bit): True   MOV32T with addend == thumbfn+4: True

The ARM64 relocation types the fixtures do not carry stay unimplemented, and BRANCH24T raises rather than synthesising a veneer for an out-of-range target.

Testing

tests/test_coff.py::TestCoff::test_x86_64_pdata_addends asserts begin < end on every .pdata record; ::test_arm64 and ::test_armnt load tests/aarch64/coff_reloc_arm64.obj and tests/armel/coff_reloc_armnt.obj and check each relocation's patched word against the symbol it names; ::test_unsupported_machine pins the new error on tests/mips/coff_r4000.obj. All four fixtures are on angr/binaries master, so nothing here waits on a fixture change. angr.Project on these objects additionally needs a Win32 syscall convention angr lacks for AArch64 and ARM; that gap is not addressed here and cle.Loader is unaffected.

Validation: #724 (comment)

session: sharpen

@zardus

zardus commented Aug 9, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 90846328c6e9c42ef8957f0572d6edc2968a5973 against baseline d2ecea068794d20b1f14d90eecc1bc4bc4cfa431.

  • Focused: python -m pytest tests/test_coff.py — 9 passed on head.
  • Regression: reverting cle/backends/coff.py to the baseline and keeping the tests fails 4 of those 9. test_arm64 and test_armnt with CLECompatibilityError: Unable to find a loader backend for .../tests/aarch64/coff_reloc_arm64.obj and .../tests/armel/coff_reloc_armnt.obj, test_unsupported_machine with NotImplementedError: Unsupported machine type raised from CoffParser._parse where a CLEError is expected, and test_x86_64_pdata_addends with assert 5352 < 5352 on the first RUNTIME_FUNCTION of tests/x86_64/fauxware.obj. Restoring the file makes all 9 pass again.
  • Inputs: the tests load tests/aarch64/coff_reloc_arm64.obj, tests/armel/coff_reloc_armnt.obj, tests/mips/coff_r4000.obj and the three i386 COFF objects, all of them already on angr/binaries master, which is what cle CI checks out. Nothing in this PR assembles a container at run time.
  • Lint/type: pre-commit run --all-files — all hooks pass, no file rewritten. Merge-base comparison on the two changed files: pylint 10.00 -> 10.00, pyright badness 0.0 -> 0.0.
  • Workspace gate: cle only; no other repository is touched, and the test-input check passes for cle.

Reproducer, using clang 21.1.8 rather than a fixture so it can be rerun from source:

cat > t.c <<'EOF'
void ext_fn(void);
static void loc_fn(void) { }
void (*const table[])(void) = { ext_fn, loc_fn };
void caller(void) { ext_fn(); loc_fn(); }
void *get_ext(void) { return (void *)ext_fn; }
void *get_loc(void) { return (void *)loc_fn; }
EOF
clang --target=aarch64-unknown-windows-msvc -c -O1 -ffreestanding t.c -o arm64.obj
clang --target=thumbv7-unknown-windows-msvc  -c -O1 -ffreestanding t.c -o armnt.obj
python -c "import cle; cle.Loader('arm64.obj')"

On the baseline that last line raises CLECompatibilityError: Unable to find a loader backend, and so does the ARMNT object. On head both load, and every relocated field decodes to the right target:

Object Site Relocation Result
arm64.obj .text+0x4 BRANCH26 b to 0x500000, the extern ext_fn
arm64.obj .text+0x8, +0xc PAGEBASE_REL21, PAGEOFFSET_12A adrp/add pair forms 0x500000
arm64.obj .text+0x14, +0x18 PAGEBASE_REL21, PAGEOFFSET_12A pair forms 0x400104, the local loc_fn
arm64.obj .rdata+0x0, +0x8 ADDR64 0x500000 and 0x400104
armnt.obj .text+0x14 BRANCH24T displacement resolves to 0x500000
armnt.obj .text+0x1e, +0x30 MOV32T 0x500001 and 0x400105, Thumb bit set
armnt.obj .rdata+0x0, +0x4 ADDR32 0x500001 and 0x400105, Thumb bit set

The bit patterns follow lld-link's applyRelARM and applyRelARM64, including reading the in-place addend and scaling PAGEOFFSET_12L by the access size. The two objects the tests load reach further than the clang output above: between them they cover both PAGEOFFSET_12A and 12L, including the 128-bit vector encoding's scaling, non-zero in-place addends on ADD, ADDR64 and ADDR32NB, and a function whose address is only taken, which must still carry the Thumb bit.

Caveats:

  • Whether an undefined symbol is Thumb is decided from its COFF symbol type. MSVC types a referenced external as a function — strcmp in tests/x86_64/fauxware.obj is 0x20 — but clang leaves it untyped, so a clang-built object that only takes the address of an extern function still gets a pointer without the Thumb bit. Nothing in such an object distinguishes that symbol from data.
  • IMAGE_REL_ARM64_SECREL_LOW12A/HIGH12A and the other unlisted types are still logged and skipped, as unhandled x86 types already are.
  • Coff.is_compatible now also claims files beginning c4 01 or 64 aa; the only files in angr/binaries that do are the two COFF objects these tests load.
  • angr.Project on these objects raises KeyError: 'Win32' from SimWindows, which has no syscall convention for AArch64 or ARM. That is an angr-side gap and is not addressed here; cle.Loader is unaffected.
  • Found by a corpus sweep of Windows COFF objects, in which every ARM64 and ARMNT unit stopped at this check.

Corpus measurement of the open queue, 2026-08-15 — a prerequisite, not an independent recovery

Correcting the record. The open pull-request queue was scored against 733 objects drawn from a sweep's own failing units (35 error classes, 49 architectures, 16 containers), with each repository's current master as the baseline rather than the revisions the sweep pinned. Each object is loaded with auto_load_libs=False, use_sim_procedures=False and then run through CFGFast(normalize=True, data_references=False, resolve_indirect_jumps=True, force_complete_scan=False) with a 120-second timeout.

The class here is NotImplementedError from CoffParser._parse, 758 corpus units; 24 were measured.

Branches applied Objects that complete CFGFast
#724 alone 0 of 24
#724 + angr#6794 24 of 24

Alone, all 24 move from NotImplementedError to KeyError: 'Win32' out of SimWindows — precisely the angr-side gap the last caveat names, now with a count against it. With angr#6794 applied the whole sample completes, recovering between 1 and 22 blocks per object.

So the corpus effect of this change is real but conditional, and the record should say so: this PR is a prerequisite for the class rather than a change that clears any of it by itself. Loading is a separate claim from analysis, and the loading claim above — that cle.Loader opens these objects and every relocated field decodes to the right target — is unaffected by any of this.


Corpus follow-up, and a dependency worth knowing before merge.

A CFGFast sweep over a large mixed-architecture corpus groups its failures by
(package, exception_type, file, function). NotImplementedError: Unsupported machine type at coff.py:_parse is 1,833 units, and it still reproduces on
current cle master. This branch loads that object.

Loading it is not sufficient on its own, though. With this branch alone the
object gets further and then fails in angr instead:

project.py:249   self.simos = os_mapping[self.loader.main_object.os](self)
windows.py:58    self.fastfail.cc = SYSCALL_CC[self.arch.name]["Win32"](self.arch)
KeyError: 'Win32'

CLE labels every COFF/PE object os = "windows", and SYSCALL_CC has a Win32
entry only for X86 and AMD64, so an ARM64 object reaches a key that does not
exist. That is angr/angr#6794, which adds the ARM and AArch64 Windows syscall
conventions.

With both applied, the same object goes from NotImplementedError to a recovered
CFG: arch=AARCH64, simos=SimWindows, 6 nodes, 6 edges, and one function
tgoto of 6 blocks.

So these two are complementary rather than alternatives, and neither alone
changes the outcome for an ARM64 COFF object. Nothing here needs to change in
this branch; it is recorded so the pair is not reviewed as if either were
sufficient.

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

@angr-bot

angr-bot commented Aug 9, 2026

Copy link
Copy Markdown
Member

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

The COFF backend rejected every machine type but I386 and AMD64. CoffParser
raised NotImplementedError, which is not a CLEError, and Coff.is_compatible
applied the same two-machine filter, so autodetection never reached the backend
either and loading an ARM64 or ARMNT object ended in "Unable to find a loader
backend". Both are ordinary output of Windows-on-ARM toolchains, and archinfo
resolves both to the architectures the PE backend already uses for them.

Both are accepted now. The machine check and is_compatible read the same
COFF_MACHINE_TO_ARCH_NAME table, and an unsupported machine raises
CLECompatibilityError naming the type it rejected instead of NotImplementedError
naming nothing.

Accepting the header alone does not make the object useful, so this also
implements the relocation types those objects carry. ADDR32, ADDR32NB, ADDR64,
REL32, SECREL and SECTION are the patch shapes the existing classes already
handle. BRANCH26, PAGEBASE_REL21 and PAGEOFFSET_12A/12L patch an immediate field
inside a single ARM64 instruction, and MOV32T and BRANCH24T patch the two
halfwords of a Thumb-2 instruction.

ARMNT code is Thumb-2 only, so function symbols carry the Thumb bit the way CLE
already reports Thumb code elsewhere, and so do the extern stubs that stand in
for undefined functions. Nothing in the object states this, so without it every
function address the backend reports is an ARM address.

ADDR32NB and ADDR64 ignored the addend held in the field they patch, unlike
DIR32, DIR32NB and REL32 beside them. The .pdata of the x86_64 object in the
binaries repository shows the cost: the second ADDR32NB of a RUNTIME_FUNCTION
record holds the function's end offset as its addend, so BeginAddress and
EndAddress were relocated to the same address. Both apply the addend now, which
the ARM64 objects need as well.

The tests load the ARM64, ARMNT and R4000 objects the binaries repository now
carries, relocate each one through cle.Loader and decode the patched fields back
out, rather than assembling a COFF container of their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/fix-cle-coff-machines branch from 410339c to 9084632 Compare August 26, 2026 23:10
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full COFF load report before and after this change. Row A counts the .pdata RUNTIME_FUNCTION records of tests/x86_64/fauxware.obj and prints their ranges; rows B and C try tests/mips/coff_r4000.obj by autodetection and with main_opts={"backend": "coff"}; rows D and E load tests/aarch64/coff_reloc_arm64.obj and tests/armel/coff_reloc_armnt.obj and check each relocated word against the symbol it names.

Before — the .pdata record is an empty range, the ARM objects are not loadable at all, and an unsupported machine raises a bare NotImplementedError:

cle at the merge base, d2ecea0
cle: <cle at the merge base>/cle/__init__.py
A x86_64/fauxware.obj .pdata ADDR32NB in-place addends: 1 RUNTIME_FUNCTION records, all begin<end = False
    ['[0x14e8,0x14e8) len=0x0']
B mips/coff_r4000.obj autodetected: RAISED CLECompatibilityError: Unable to find a loader backend for <workspace>
B mips/coff_r4000.obj autodetected:   at loader.py:_load_object_isolated  |  raise CLECompatibilityError(
C mips/coff_r4000.obj with backend=COFF: RAISED NotImplementedError: Unsupported machine type
C mips/coff_r4000.obj with backend=COFF:   at coff.py:_parse  |  raise NotImplementedError("Unsupported machine type")
D aarch64/coff_reloc_arm64.obj: RAISED CLECompatibilityError: Unable to find a loader backend for <workspace>
D aarch64/coff_reloc_arm64.obj:   at loader.py:_load_object_isolated  |  raise CLECompatibilityError(
E armel/coff_reloc_armnt.obj: RAISED CLECompatibilityError: Unable to find a loader backend for <workspace>
E armel/coff_reloc_armnt.obj:   at loader.py:_load_object_isolated  |  raise CLECompatibilityError(

After — the record spans 0x1e bytes, both ARM objects load with every relocation landing on its symbol, and the unsupported machine is named in a CLECompatibilityError:

with this change, 9084632
cle: <cle with this change>/cle/__init__.py
A x86_64/fauxware.obj .pdata ADDR32NB in-place addends: 1 RUNTIME_FUNCTION records, all begin<end = True
    ['[0x14e8,0x1506) len=0x1e']
B mips/coff_r4000.obj autodetected: RAISED CLECompatibilityError: Unable to find a loader backend for <workspace>
B mips/coff_r4000.obj autodetected:   at loader.py:_load_object_isolated  |  raise CLECompatibilityError(
C mips/coff_r4000.obj with backend=COFF: RAISED CLECompatibilityError: Unsupported machine type 0x0166
C mips/coff_r4000.obj with backend=COFF:   at coff.py:_parse  |  raise CLECompatibilityError(f"Unsupported machine type {self.header.Machine:#06x}")
D aarch64/coff_reloc_arm64.obj: 
    backend=Coff arch=AARCH64
    BRANCH26 reaches ext_fn: True
    PAGEOFFSET_12A(add) == target: True
    PAGEOFFSET_12L(ldr, scale 4) == target: True
    PAGEOFFSET_12L(ldr q, scale 16) == aligned: True
    PAGEOFFSET_12A with addend == target+4: True
    ADDR64 == target+8: True
    ADDR32NB == target-base+4: True
E armel/coff_reloc_armnt.obj: 
    backend=Coff arch=ARMEL
    thumbfn == .text+1 (Thumb bit): True
    MOV32T == thumbfn: True
    MOV32T with addend == thumbfn+4: True
    BRANCH24T reaches ext_fn: True
    ext_ptr is odd (Thumb): True
    ADDR32 at .text+0x18 == ext_ptr: True

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

How much of a corpus's failure surface this removes, measured rather than
argued.

Sample. 12,000 objects drawn uniformly at random, from a seeded permutation,
out of a 624,920-object internal corpus of compiler- and vendor-produced
binaries; 11,989 were retrievable and probed, 468 of them COFF. Rates carry 95%
Wilson intervals.

Method. Each object is loaded with the catalogue's declared load recipe and
CFGFast is taken as far as the failure under test. A 1,241-object subset — the
whole of every failure class under study plus 722 objects that already reach CFG
— is probed against master (eac0e5540516b9199dd6a91933e80dc774ea3eac) and
against this branch's head (90846328c6e9c42ef8957f0572d6edc2968a5973) in one
environment, so before and after are the same objects. The comparison is keyed
on exception type and function, not on file:line, because a patch that edits
the failing file moves every line below its hunk.

Before. 36 / 11,989 = 0.30% of the sample (CI 0.22–0.42), and 7.7% of its
468 COFF objects, fail with NotImplementedError: Unsupported machine type at
coff.py:_parse — which does not say which machine type. All 36 are Windows
relocatable objects: 26 AARCH64 and 10 32-bit ARM, built by clang (24), clang++
(9) and MSVC (3), from four unrelated collections.

After. This head clears all 36, and all 36 go on to reach CFG — the
whole class, 0.30% of the sample. Nothing in the class is left behind, so there
is no residual to report for it.

Control. 722 objects that already reached CFG on master are unchanged on
this head — 0 of 722 differ.

Three other open changes touch this backend without overlapping this class:
#764, #775 and #761 clear none of these 36 on their own, measured the same way.
All three edit coff.py in the same region this change moves
COFF_MACHINE_TO_ARCH_NAME through, so whichever lands first will need the
others rebased.

The corpus is not redistributable, so its objects are described by architecture,
format and OS rather than named; none of the 36 is byte-identical to anything
tracked in angr/binaries.

session: sharpen

zardus added a commit that referenced this pull request Sep 1, 2026
The backend maps an object at its own file offsets and gives each section
vaddr = PointerToRawData. That offset is only as aligned as the file's packing
leaves it, and MSVC packs raw data with no padding between sections, so a
section commonly begins where the IMAGE_SCN_ALIGN_* in its own header does not
allow. All eight .text$mn sections in tests/x86/fauxware.obj state 16-byte
alignment and six of them start at a file offset that is not a multiple of 16.

x86 and AMD64 never notice, because their instructions have no alignment
requirement. ARM64 and ARMNT, whose machine types #724 adds, do: a .text$mn
placed on an odd address holds no instruction anything can decode, every
function symbol in it lifts to a zero-length block, and CFGFast recovers
nothing from it. Over 66 ARM64 COFF objects, 298 of 880 function symbols went
unrecovered and 16 objects recovered none of their own; with the sections
placed where their headers ask, all 880 are recovered.

A section whose file offset does not satisfy its alignment now gets space of
its own past the image with its bytes copied in, which is the treatment a
section with no bytes in the file already gets. A header that states no
alignment states no requirement and its section is left where it is; the 16
_section_alignment returns for that case is only where to put a section that
has to be placed somewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zardus added a commit that referenced this pull request Sep 2, 2026
The backend maps an object at its own file offsets and gives each section
vaddr = PointerToRawData. That offset is only as aligned as the file's packing
leaves it, and MSVC packs raw data with no padding between sections, so a
section commonly begins where the IMAGE_SCN_ALIGN_* in its own header does not
allow. All eight .text$mn sections in tests/x86/fauxware.obj state 16-byte
alignment and six of them start at a file offset that is not a multiple of 16.

x86 and AMD64 never notice, because their instructions have no alignment
requirement. ARM64 and ARMNT, whose machine types #724 adds, do: a .text$mn
placed on an odd address holds no instruction anything can decode, every
function symbol in it lifts to a zero-length block, and CFGFast recovers
nothing from it. Over 66 ARM64 COFF objects, 298 of 880 function symbols went
unrecovered and 16 objects recovered none of their own; with the sections
placed where their headers ask, all 880 are recovered.

A section whose file offset does not satisfy its alignment now gets space of
its own past the image with its bytes copied in, from the same cursor and under
the same MAX_IMAGE_SIZE ceiling as the section marked
IMAGE_SCN_CNT_UNINITIALIZED_DATA that already goes there. Where that ceiling
would stop the move, the section keeps its file offset, which is where its
bytes are. A header that states no alignment states no requirement and its
section is left where it is; the 16 _section_alignment returns for that case is
only where to put a section that has to be placed somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants