rea-reverse

Is This Mac App Universal? Check Apple Silicon and Intel Slices

Written for REA 6.4.0Updated 6 min read
On this page

Short answer: a universal (or “fat”) Mach-O file starts with the magic 0xcafebabe and a table of slices. Each slice is a complete Mach-O image for one CPU, usually arm64 for Apple silicon and x86_64 for Intel. On a Mac, lipo -archs <file> lists them. On any computer, including Windows and Linux, the ELF, PE and Mach-O viewer on this site lists the slices in your browser and lets you open each one, without uploading the file.

The important part is that every slice is a separate build. Two slices in one file can have different minimum macOS versions, different signatures and different dependencies. This guide builds a few universal binaries, shows how they differ slice by slice, and reproduces two real failures: a dependency with a missing slice and a slice that is not signed. All the output below is real, from macOS 27 on an Apple silicon Mac with Xcode installed.

Who this is for: Mac developers shipping their own app, plug-in or command-line tool, and developers on other systems who received a .app, .dylib or framework and need to know what it runs on.

What you will learn:

  • How a universal binary is laid out, and how to find the executable in an .app
  • How to list and compare slices in the browser, with lipo, file and REA 6.4.0
  • How to build one with Xcode, CMake, Go or lipo -create
  • How to diagnose “incompatible architecture” and unsigned-slice problems

Only inspect apps and libraries you build, open-source software, or files you are authorized to examine.

How a universal binary is laid out

The file has a short big-endian header: the magic FAT_MAGIC (0xcafebabe, or 0xcafebabf for the 64-bit variant), the number of slices, then one entry per slice with its CPU type, subtype, file offset, size and alignment. After that come the slices, each a thin Mach-O file starting at its offset.

Common architectures:

  • arm64: Apple silicon apps.
  • x86_64: Intel Macs, and Apple silicon under Rosetta 2.
  • arm64e: arm64 with pointer authentication. Apple’s own system binaries use it. Third-party apps normally ship arm64.

When a process starts, the kernel and dyld pick the best slice for the CPU. Every library the process loads must then contain a matching slice too.

Step 1: Find the real executable

An .app is a folder. The executable is in Contents/MacOS, named by CFBundleExecutable in Contents/Info.plist:

plutil -extract CFBundleExecutable raw Hello.app/Contents/Info.plist
Hello

The main executable is not the only Mach-O file. Frameworks, dylibs, helpers and plug-ins can have their own slices, and one Intel-only library is enough to break the app on Apple silicon. To check every Mach-O file in a bundle:

find Hello.app -type f -exec sh -c 'lipo -archs "$1" >/dev/null 2>&1 && echo "$1: $(lipo -archs "$1")"' _ {} \;

On my test bundle, which deliberately contains an Intel-only library:

Hello.app/Contents/MacOS/Hello: x86_64 arm64
Hello.app/Contents/Frameworks/libutil.dylib: x86_64

Step 2: Open the slices in the browser

Open the binary viewer and choose the executable (on Windows or Linux, extract the .app from its .zip or .dmg first). For a universal file, the report gets a Universal binary slices section with one button per slice, labelled with the architecture and size. The first slice is selected. Click another one and the whole report switches to it.

For hello-universal, built with clang -arch arm64 -arch x86_64 -mmacosx-version-min=11.0, the viewer listed:

Slice Architecture Offset Size
0 x86_64 4096 12744
1 arm64 32768 50160

The Identity section shows the SHA-256 of the whole file and a separate Slice SHA-256 for the selected slice. Use the slice hash when you compare builds: two universal files can have identical arm64 slices and still hash differently as a whole. Offsets inside the report (sections, segments) are relative to the slice, as otool prints them.

Then, for each slice, compare:

  • Platform, minimum OS and SDK, read from LC_BUILD_VERSION or the older LC_VERSION_MIN_MACOSX.
  • Linked libraries (LC_LOAD_DYLIB and friends), including @rpath names.
  • Code signature and hardened runtime in the Security features table.

Slices can disagree

I built the two halves separately and joined them:

clang -O2 -arch arm64  -mmacosx-version-min=11.0  hello.c -o hello-arm64
clang -O2 -arch x86_64 -mmacosx-version-min=10.13 hello.c -o hello-x86_64
lipo -create hello-arm64 hello-x86_64 -output hello-lipo

The viewer showed Minimum OS 10.13 on the x86_64 slice and 11.0 on the arm64 slice. otool confirms that the slices even use different load commands for it:

otool -l -arch x86_64 hello-lipo | grep -A4 LC_VERSION_MIN
otool -l -arch arm64  hello-lipo | grep -A4 LC_BUILD_VERSION
      cmd LC_VERSION_MIN_MACOSX
  cmdsize 16
  version 10.13
      sdk 27.0
--
      cmd LC_BUILD_VERSION
  cmdsize 32
 platform 1
    minos 11.0
      sdk 27.0

That is normal (Apple silicon Macs started with macOS 11), but it means “this app supports macOS 10.13” is only true for Intel Macs.

Step 3: Cross-check on a Mac

file hello-universal
lipo -archs hello-universal
lipo -detailed_info hello-universal
hello-universal: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64:Mach-O 64-bit executable arm64]
hello-universal (for architecture x86_64):	Mach-O 64-bit executable x86_64
hello-universal (for architecture arm64):	Mach-O 64-bit executable arm64
x86_64 arm64
Fat header in: hello-universal
fat_magic 0xcafebabe
nfat_arch 2
architecture x86_64
    cputype CPU_TYPE_X86_64
    cpusubtype CPU_SUBTYPE_X86_ALL
    capabilities
    offset 4096
    size 12744
    align 2^12 (4096)
architecture arm64
    cputype CPU_TYPE_ARM64
    cpusubtype CPU_SUBTYPE_ARM64_ALL
    capabilities
    offset 32768
    size 50160
    align 2^14 (16384)

The offsets and sizes match the viewer’s slice list. To run a specific slice on Apple silicon, arch -x86_64 ./hello-universal starts the Intel slice under Rosetta 2. Both slices printed hello, world.

With REA 6.4.0

REA, the open-source project by morluto, has rea list-architectures, which runs lipo -detailed_info (dist/native/NativeMacOSProvider.js). It needs macOS with the Xcode command-line tools. You can pass the file or the .app:

rea list-architectures /path/to/Hello.app --format json

Trimmed result:

"subject":{"name":"Hello", ... "architecture":"arm64","local_path":".../Hello.app/Contents/MacOS/Hello"},
"normalized_result":{"architectures":{"items":[
  {"name":"x86_64","cpu_type":"CPU_TYPE_X86_64","cpu_subtype":"CPU_SUBTYPE_X86_ALL","file_offset":4096,"size":12744,"alignment":4096},
  {"name":"arm64","cpu_type":"CPU_TYPE_ARM64","cpu_subtype":"CPU_SUBTYPE_ARM64_ALL","file_offset":32768,"size":50160,"alignment":16384}]

Two limits are worth knowing. For an .app, it reports only the main executable, not the frameworks inside it, so the bundle-wide find above is still needed. And the evidence subject.architecture said arm64 on my Mac even though the first slice is x86_64. The 6.4.0 result has no separate field naming the slice that was read. More on REA’s macOS tools in reverse engineering a macOS app with REA.

Step 4: Build a universal binary

  • Xcode: the default Architectures setting is Standard Architectures (ARCHS_STANDARD), which Xcode 27’s macOS specification defines as arm64 and x86_64. Debug builds normally set Build Active Architecture Only (ONLY_ACTIVE_ARCH) to Yes and so produce one slice; archive a Release build and check it with lipo -archs.

  • clang directly: pass -arch twice, as above.

  • CMake: configure with -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64".

  • Go: build each architecture and join them. This worked with Go 1.27.1:

    GOOS=darwin GOARCH=arm64 go build -o app-arm64 .
    GOOS=darwin GOARCH=amd64 go build -o app-amd64 .
    lipo -create app-arm64 app-amd64 -output app
  • Prebuilt libraries: every one must have both slices. lipo -create only joins thin files for different architectures. It cannot turn an Intel library into an Apple silicon one.

Common problems

“incompatible architecture (have ‘x86_64’, need ‘arm64’)”

I built app universal against a universal libutil.dylib, then replaced the library with an Intel-only build, the way a stale third-party SDK sneaks in. On Apple silicon:

dyld[92577]: Library not loaded: @rpath/libutil.dylib
  Referenced from: <196030DD-79A2-3BB0-9B35-3985D8230067> .../dep/app
  Reason: tried: '.../dep/libutil.dylib' (mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64e, arm64e.v1, arm64, arm64')), ...

The UUID in Referenced from is the arm64 slice’s LC_UUID; the viewer showed the same UUID on that slice. Linking against the Intel-only library fails earlier, at build time:

ld: warning: ignoring file './libutil.dylib': found architecture 'x86_64', required architecture 'arm64'
Undefined symbols for architecture arm64:

REA’s rea trace-dylib-resolution finds the problem without running anything. Run on app with --architecture arm64, the @rpath/libutil.dylib candidate had "outcome":"architecture-missing", and the findings said:

"kind":"required-load-unresolved", ... "explanation":"No candidate for @rpath/libutil.dylib exists in the analyzed root; dyld would fail to load this required dependency for app (arm64) unless the image is supplied elsewhere."

The fix is on the build side: get a universal (or arm64) build of the library from its vendor, or rebuild it yourself.

One slice is not signed

On Apple silicon, the linker ad-hoc signs arm64 output automatically, but not x86_64 output. So a universal binary straight from the linker had a signed arm64 slice and an unsigned x86_64 slice. The viewer showed Code signature: no (no LC_CODE_SIGNATURE) on slice 0 and adhoc,linker-signed on slice 1, and:

codesign -v hello-universal
hello-universal: code object is not signed at all

REA’s rea inspect-signature reported "signed": true, but listed "Unsigned Mach-O slices: x86_64." in its limitations. Read that field, not just the boolean. The fix is to sign the final universal file with your identity (codesign --sign "Developer ID Application: ..." --options runtime), which signs every slice.

A common belief is that lipo always breaks signatures. In my tests it did not: joining two separately ad-hoc-signed slices with lipo -create, or removing a slice from a signed universal file with lipo -remove or -thin, left files that passed codesign -v, because each slice carries its own signature. I did not test this with Developer ID signatures or notarized apps. Sign the final artifact after the last lipo step, then verify it.

FAQ

Can I check this without a Mac? Yes. The viewer runs in any modern browser and parses the fat header and each slice itself. lipo, codesign and REA’s macOS tools need macOS.

What is arm64e, and should my app ship it? It is arm64 with pointer authentication, used by Apple’s system binaries. Apps normally ship arm64.

Why are the section offsets so small? Offsets in the report are relative to the selected slice. Add the slice offset from the slice list to get the position in the file.

Does the viewer upload the file? No. Parsing and hashing happen in a Web Worker in your browser.

Next steps