Skip to content

Mach-O Fat Binary Abuse

A universal Mach-O binary bundles multiple architecture slices under one file; hiding a payload in an unusual slice, or exploiting how tools disagree on which slice is 'the' binary, can desync what a reviewer sees from what runs.

A "fat" or universal Mach-O binary starts with a fat_header listing several architecture slices (typically x86_64 and arm64 today) inside a single file, each with its own offset and size, so one binary runs natively on either CPU family. Because each slice is a fully independent Mach-O image, an attacker can populate slices unevenly: ship a clean, harmless x86_64 slice a manual reviewer or an automated tool defaults to inspecting, while the arm64 slice (the one that will actually execute on the large and growing population of Apple Silicon Macs) carries the real payload — or vice versa, depending on which architecture a given analysis pipeline treats as canonical. A related variant abuses inconsistencies in how different tools parse the fat header itself (byte order, slice count, alignment padding) so two tools looking at the same file disagree about which slice is "the" binary at all.

How it works

lipo -detailed_info enumerates every slice a fat binary actually contains:

text
$ lipo -detailed_info suspicious_app
Fat header in: suspicious_app
fat_magic 0xcafebabe
nfat_arch 2
architecture x86_64
    offset 16384
    size 45056
architecture arm64
    offset 65536
    size 98304

If a static-analysis pipeline is configured (or defaults) to extract and scan only one architecture — commonly x86_64, for historical Intel-Mac-era tooling — an attacker who puts the malicious logic in the arm64 slice evades that pipeline entirely while the file still runs fully maliciously on any Apple Silicon victim. lipo -thin arm64 -output evil_arm64 suspicious_app extracts and lets you inspect the slice a naive x86_64-first pipeline would have skipped.

Detection & analysis

Static analysis:

  • Always enumerate every slice with lipo -detailed_info (or otool -f) before analysis, and independently disassemble/scan each one — never assume the first or the x86_64 slice represents the whole binary's behavior.
  • Compare slice sizes and hashes against what's expected for the claimed application; a slice that is unusually small, unusually large, or has a hash not matching any known-good build of that app for that architecture warrants its own separate analysis.
  • Cross-check fat-header parsing between two independent tools (e.g. lipo and a scripted parser using pyobjc/manual struct parsing); a mismatch in reported slice count or offsets between tools is itself suspicious and may indicate a header crafted to desync analysis tooling from the OS loader.

Dynamic analysis:

  • Execute (in an isolated lab matching the victim's actual CPU architecture) and confirm which slice dyld actually loads and runs — compare that against whichever slice your static pipeline analyzed, and flag any case where they differ.
  • On Apple Silicon hosts, explicitly force execution under Rosetta 2 (arch -x86_64) as well as natively, to observe both slices' runtime behavior rather than only the one the OS would pick by default.

Detection rule hint:

Flag any fat Mach-O binary where slices differ meaningfully in behavior (different imported symbols, different embedded strings/network indicators), where slice count or reported architecture disagrees between lipo and an independent parser, or where the code-signature covers a different set of slices than the fat header actually contains.

Votes

Comments(0)