Skip to content

Add MachO support for kext and bundle file types - #674

Open
adamdoupe wants to merge 5 commits into
masterfrom
macho-kext-bundle-support
Open

Add MachO support for kext and bundle file types#674
adamdoupe wants to merge 5 commits into
masterfrom
macho-kext-bundle-support

Conversation

@adamdoupe

@adamdoupe adamdoupe commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds support for loading MH_KEXT_BUNDLE (filetype 11) and MH_BUNDLE (filetype 8) Mach-O binaries, enabling CLE to load macOS kernel extensions from KDKs.
  • Fixes the dyld chained fixups chain walker to use the correct next bitfield and stride per pointer format — the old code always used generic64.rebase.next (12 bits at bit 52), which reads garbage for Arm64e formats like DYLD_CHAINED_PTR_ARM64E_KERNEL where next is 11 bits at bit 51.
  • Handles file-offset-to-vaddr shift in segments where vmaddr != fileoff (common in kexts' __DATA_CONST), which previously crashed with an assertion that only __ETC segments could have this.
  • Adds missing _fields_ to dyld_chained_ptr_arm64e_bind24 struct and adds defensive bounds checks for chain walks.
  • Fixes PIC detection from buggy filetype & MH_DYLIB (bitwise AND of ints) to proper filetype in (...).
  • Universal2: when no arch= is specified, load only the first slice (with a warning) for the main binary, and pick the slice matching the main binary's arch when loaded as a dependency. Avoids address collisions and downstream breakage from multiple is_main_bin objects.

Test binary lives in angr/binaries#166. The macOS and Windows workflows in this PR pick up a same-named branch from angr/binaries when present (falling back to master); the linux CI uses the body reference above.

Test plan

  • All 27 MachO/Universal2 unit tests pass (10 new kext tests, helper test, 8 macho tests, 9 universal2 tests)
  • Successfully loads all 907 kext binaries in KDK_26.4.1_25E253.kdk/System/Library/Extensions/
  • Successfully loads 699/699 Mach-O binaries in macOS /bin + /usr/bin
  • Successfully loads 88/90 Mach-O binaries from an extracted iPhone filesystem (2 remaining: dyld itself which is filetype 7, and a dangling symlink to a shared-cache-only dylib)
  • Pre-commit hooks pass

🤖 Generated with Claude Code

Also fixes several bugs in the dyld chained fixups walker that were
exposed by kext binaries:

- Use correct `next` field and stride per pointer format. Arm64e packs
  `next` as 11 bits at bit 51; Generic64 packs it as 12 bits at bit 52.
  The old code always used generic64.rebase.next, producing garbage
  chain walks for ARM64E_KERNEL (stride 4) kexts.

- Handle file-offset-to-vaddr shift in segments where vmaddr != fileoff
  (common in kexts' __DATA_CONST, previously only allowed for __ETC).

- Add bounds checks: stop chain walks that exceed page boundaries or
  produce out-of-range bind ordinals instead of crashing.

- Add missing _fields_ to dyld_chained_ptr_arm64e_bind24 struct.

- Fix PIC detection to use `filetype in (...)` instead of bitwise AND.

Tested against all 907 kext binaries in KDK_26.4.1_25E253.kdk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@angr-bot

Copy link
Copy Markdown
Member

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

adamdoupe and others added 2 commits April 11, 2026 20:44
When a universal binary contains multiple architecture slices and no
arch= is passed, loading all slices causes address collisions (e.g.
multiple MH_EXECUTE slices all mapping to 0x400000). Pick the first
slice and log a warning telling the user how to select a specific one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add test_macho_kext.py with 10 tests covering MH_KEXT_BUNDLE loading
using the IPwnKit kext binary: filetype detection, PIC, base address,
segments, sections, symbols (including IOKit class names), relocations,
and code readability.

Update test_universal2.py to match the new default behavior of loading
only the first architecture slice when no arch= is specified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@rhelmot

rhelmot commented Apr 12, 2026

Copy link
Copy Markdown
Member

Wrt the "only load the first slice" commit, it looks like you've found an issue that I found before and have been procrastinating on fixing. Can you make sure your fix aligns with the fix I describe here?

Per @rhelmot's review feedback: when a fat binary is loaded as a
dependency rather than as the main object, select the slice that
matches the main binary's arch instead of the first slice. This
keeps dependency loading consistent with the main binary's arch.

Also extracts the slice filter into a static helper and adds a
unit test for it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@adamdoupe

Copy link
Copy Markdown
Contributor Author

Thanks @rhelmot — pushed f49c805 aligning with your design:

  • Main binary: pick a single arch (first slice by default, overridable via arch=), with a warning when there's ambiguity.
  • Loaded as a dependency: pick the slice matching the main binary's arch (via the new _filter_slices_by_arch helper), falling back to the first slice only if there's no main object yet.

Added a unit test for the filter helper. There's a separate pre-existing issue where loading a fat binary containing MH_EXECUTE slices as a dependency hits an is_main_bin assertion in the MachO backend — that's orthogonal to this change and would affect normal fat-dylib dependency loading the same way regardless. Happy to address it in a follow-up if you'd like.

@rhelmot

rhelmot commented Apr 12, 2026

Copy link
Copy Markdown
Member

@fmagin I don't seem to be able to request a review from you, but can you take a look at this?

@fmagin

fmagin commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Doesn't look wrong in any way that I noticed by reading it. I can run this on a larger dataset at work next week to catch regressions on the data that I care about

@adamdoupe

Copy link
Copy Markdown
Contributor Author

I tested on all kexts in KDK 26.4 and a bunch of user space binaries, they all at least load. Also added test case kext that I wrote to binaries.

@fmagin

fmagin commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

FWIW I'm fine with merging this now, it does look good to me.
I'm updating the angr version we use at work currently anyway, and as part of that I am running acceptance tests for a dataset of a few thousand apps. So that would catch things like some symbol address changing, symbols disappearing or appearing, etc

- macho.py: drop duplicate DyldChainedPtrFormats import alias and use the
  full enum name in _CHAIN_STRIDE; add docstring to _ChainStride to fix
  pylint missing-class-docstring.
- test_macho_kext.py: assert isinstance(MachO)/isinstance(MachOSegment)
  so the type checker can resolve segname/sections attributes.
- macos.yml + windows.yml: check out a same-named branch from angr/binaries
  when one exists, falling back to master. Lets cross-repo PRs picked up
  on macOS and Windows runners (linux CI already does this via PR-body
  references).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@rhelmot

rhelmot commented Apr 13, 2026

Copy link
Copy Markdown
Member

It looks like your edits to the workflows just didn't work at all, so I've merged the binaries branch. Please remove those lines and we'll see what CI says.

@zardus

zardus commented Aug 16, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Measurement in support of the "only load the first slice" part of this PR, from
a block-level sweep that scores CFGFast against what a file says about itself.
It is only about that hunk; nothing here is a review of the kext and bundle
changes.

The defect this hunk fixes, on a fixture already in angr/binaries

tests/multi_arch/fauxware_macho_multiarch is an x86_64 + arm64 fat binary.
On cle master, both slices load into one address space and project.arch comes
from child_objects[0], so CFGFast decodes the arm64 slice as AMD64:

import angr, archinfo

FAT = "binaries/tests/multi_arch/fauxware_macho_multiarch"
for opts in ({}, {"arch": archinfo.ArchAMD64()}, {"arch": archinfo.ArchAArch64()}):
    p = angr.Project(FAT, auto_load_libs=False, main_opts=opts)
    cfg = p.analyses.CFGFast(normalize=True)
    per = {c.arch.name: sum(1 for n in cfg.model.nodes()
                            if n.size and c.min_addr <= n.addr <= c.max_addr)
           for c in p.loader.main_object.child_objects}
    print(opts, p.arch.name, per, len(p.kb.functions))
{}                          AMD64    {'AMD64': 40, 'AARCH64': 39}  28 functions
{'arch': ArchAMD64()}       AMD64    {'AMD64': 40}                 20 functions
{'arch': ArchAArch64()}     AARCH64  {'AARCH64': 42}               17 functions

Half of the blocks in the default project are x86_64 decodings of AArch64
bytes. Two of them, disassembled both ways:

0x400516  4 bytes   as AMD64:   add cl, bh ; loope 0x400529
                    as AArch64: bfmlalb v0.4s, v8.8h, v1.h[6]
0x400513  3 bytes   as AMD64:   clc ; loopne 0x400529
                    as AArch64: (not an instruction boundary -- 0x400513 is not
                                 4-byte aligned, so capstone decodes nothing)

They are not filtered out downstream: they become kb.functions entries, and an
UnresolvableCallTarget appears that neither single-slice project produces.

At scale

Over a corpus sweep, 1,946 fat Mach-O containers have been scored so far. Every
one of them carries exactly two slices, x86_64 and arm64. Splitting the CFG of
122 of them by the object each block landed in — all 25 real-world containers
available at the time plus 97 smaller generated ones, 1,191,935 blocks in total:

blocks executable bytes recovered
slice whose arch is project.arch 1,038,640 18,871,898 86.6%
every other slice 153,295 16,948,525 7.5%

The slice that does not get project.arch has 7.5% of its code recovered
against 86.6% for the one that does
, and 12.9% of all blocks recovered from
these binaries are decoded with the wrong instruction set. Restricted to the 25
real-world containers the split is the same: 86.7% against 7.1%, 12.3% of blocks
wrong-arch pooled and 10.5% on the median object. One 1.7 MB arm64 + x86_64
dylib: 29,992 blocks and 83.3% coverage on the x86_64 slice, 3,514 blocks and
6.0% on the arm64 one.

Nothing downstream notices, which is why this is easy to miss — the wrong-arch
blocks still land inside __text and __stubs, so they are inside an
executable range, inside a section marked as holding instructions, and inside
the object's own bounds. Every structural check passes on them. Of 122 objects
scored against their own load commands, exactly one block landed outside every
executable section, and it is a run of zero fill in __TEXT alignment padding.

On the design

main_opts={"arch": ...} already produces a coherent project today, as the
table above shows, so the behaviour this PR makes the default is behaviour cle
can already reach — the change is which one you get when you say nothing. The
measurement has no opinion on the tie-break between "first slice" and "host
arch"; it only shows that the current default of "all of them" is not a working
option for any consumer that has a single project.arch.

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.

5 participants