Reverse Engineer a macOS App with REA: Mach-O, Swift and NIBs
On this page
A macOS application is a directory tree. Inside it sit one or more Mach-O executables, nested frameworks and helpers, property lists, compiled Interface Builder files and an asset catalog. You can understand a lot about the app before opening a disassembler: how it is assembled, what it links against, which entitlements it declares and how its UI connects to code. REA, the open-source project by morluto, reads each of those layers as Evidence and then hands specific functions to Ghidra or Hopper.
Who this is for: macOS developers debugging their own builds, people studying how open-source Mac apps are structured, and security learners who want a repeatable static workflow. You should know your way around Terminal and have seen a .app bundle from the inside.
What you will learn:
- Inventory a
.appor.dmgand project its bundle anatomy - Read
Info.plist, Mach-O headers, architectures and code-signing metadata - Trace how dyld would resolve every
@rpathload - Demangle Swift symbols and read Objective-C and Swift dispatch metadata
- Decode compiled NIBs and storyboards, keyed archives and asset catalogs
- Move from a UI action to its handler in Ghidra or Hopper
Respect the license. Use open-source apps, apps you built yourself, or apps whose license allows analysis. Many commercial EULAs restrict reverse engineering. Read the EULA first and stay within it. This article only reads metadata and code. It does not cover modifying, re-signing or patching apps, removing protections, or getting around licensing. Everything here is read-only analysis. Runtime steps launch the target with your user permissions, so run them only on apps and machines you are authorized to use. See REA’s DISCLAIMER.md.
Example target: Rectangle, the MIT-licensed window manager written in Swift. Download a release from its GitHub page. Any open-source Mac app or a project you build in Xcode works the same way.
What runs where
Some REA tools on macOS call Apple’s command-line utilities. Others parse the files in TypeScript and work on any host.
| Tool | Needs | Works off macOS? |
|---|---|---|
inspect-artifact, extract-artifact, project-apple-application-graph |
Nothing extra (DMG child inventory needs macOS) | Yes, except DMG contents |
trace-dylib-resolution |
Nothing extra; pure header parsing | Yes |
decode-interface-builder, inspect-keyed-archive |
Nothing extra; static archive parsing | Yes |
inspect-macho, list-architectures, inspect-signature, inspect-plist, demangle-swift |
Xcode command-line tools found through xcrun (lipo, otool, nm, dyld_info, vtool, codesign, plutil, swift-demangle) |
No: unsupported_host |
inspect-native-dispatch-metadata |
macOS for the Mach-O byte reader (no external tool) | Only symbol-only metadata through a provider such as Ghidra |
inspect-asset-catalog |
macOS assetutil |
No |
decompile, search, trace-native-ui-action and other function-level work |
Ghidra or Hopper (or IDA) | Depends on the provider |
Prerequisites
- REA 6.4.0 or newer.
npm install --global rea-agents, or prefix commands withnpx -y rea-agents@latest. To use it from Claude Code, Cursor or Codex, runnpx rea-agents setup(setup guide). - Xcode command-line tools on macOS:
xcode-select --install. - Ghidra or Hopper for Steps 9 and 10. Setup can detect an existing Ghidra or offer to install Hopper with your approval. Hopper is separate commercial software with a free demo mode. Check one engine with
rea doctor --provider ghidra --json(orhopper). jq. Several results here are large.
Copy the app into a working directory, for example ~/re-work/Rectangle.app. That way your analysis does not depend on the copy in /Applications being updated underneath you. All paths below must be absolute.
Step 1: Inventory the bundle
rea inspect-artifact ~/re-work/Rectangle.app --json > inspection.json
Why first: inspect-artifact returns the complete content-addressed inventory: every path, size, SHA-256 and detected format, plus integrity observations and suggested next probes. You can also pass the downloaded .dmg directly. On macOS, REA mounts it read-only, inventories the volume and cleans up the mount afterwards. Use rea extract-artifact when you want regular files on disk. REA picks a fresh temporary directory and reports it.
Step 2: Project the bundle anatomy
The inventory is a flat list. project-apple-application-graph turns it into an Apple-specific structure. Pass it the inventory Evidence that inspect-artifact nests in its result:
jq '{inventory_evidence: [.normalized_result.substeps[].evidence]}' \
inspection.json > apple-input.json
rea project-apple-application-graph ./apple-input.json --json > apple-graph.json
jq '.normalized_result.bundles[] | {path, role, layout, info_plist_path, executable_candidates}' apple-graph.json
jq '.normalized_result.runtime_families' apple-graph.json
What you get:
- Bundles and roles by path convention:
application,app-extension,xpc-service,framework,login-item,helper-application,plug-in,resource-bundleand more. Each has itsinfo_plist_pathand executable candidates. - Components: privileged helpers in
Contents/Library/LaunchServices/, launchd plists inLaunchAgents/andLaunchDaemons/, signing files and symlinks. - Runtime families: hints such as
react-native,flutterorunity, based on exact framework path segments. If you seeElectron Framework.frameworkand anapp.asar, the interesting code is JavaScript. Switch to the Electron guide.
These are path-based inferences. The projection does not read plists or prove that anything loads at runtime. The next steps check them.
Step 3: Read Info.plist and other plists
rea inspect-plist ~/re-work/Rectangle.app --json
rea inspect-plist ~/re-work/Rectangle.app \
--relative-path Contents/Library/LaunchAgents/EXAMPLE.plist --json
With no --relative-path, REA reads the bundle’s Info.plist and reports the identifier, executable, name and version. It also returns the complete decoded value, with URL schemes, usage descriptions, LSUIElement and so on. For a launchd or helper plist, use a path listed by Step 2 (the second command shows a placeholder). It runs plutil under the hood, so it needs macOS.
Step 4: Mach-O header, slices and dependencies
rea list-architectures ~/re-work/Rectangle.app --json
rea inspect-macho ~/re-work/Rectangle.app --json > macho.json
A .app path resolves to its main executable. You can also pass Contents/MacOS/<name>, or a framework binary from Step 2, directly.
inspect-macho returns file type, flags, UUID, entry points, architectures, build metadata (platform and SDK), load commands, dependencies, imports, exports and segments. Each list has items, total and an exhaustive flag. Save it to a file and slice it. On real apps this result can be very large. Issue #2473 (still open) measured one inspect_macho call at roughly 80k to 230k estimated tokens on ordinary macOS apps, because every import and export is returned inline. That is far above common MCP client result limits, so don’t paste it into an agent’s context. For example:
jq '.normalized_result.dependencies.items' macho.json
jq '.normalized_result.build_metadata' macho.json
jq '.normalized_result.imports.total, .normalized_result.exports.total' macho.json
For universal binaries, architectures.items lists every slice with its offset and size in the container file. The 6.4.0 result has no field naming the slice REA read. The selected_architecture field described in the repository docs exists only on main, not in the npm 6.4.0 release.
Step 5: Code-signing metadata
rea inspect-signature ~/re-work/Rectangle.app --json
This reads the existing signature with codesign: identifier, team identifier, CDHashes, authorities, designated requirement, timestamp, whether the hardened runtime is on, and the declared entitlements. Entitlements are often the quickest summary of what an app is allowed to do: sandboxing, network access, Apple Events, camera and so on. REA only reports the signature. It never removes or replaces it.
Step 6: Trace dylib resolution
rea trace-dylib-resolution ~/re-work/Rectangle.app --json > dylibs.json
rea trace-dylib-resolution ~/re-work/Rectangle.app \
--root Contents/MacOS/Rectangle --architecture arm64 --json
For every executable in the bundle, REA follows dyld’s rules. It expands @executable_path, @loader_path and each @rpath through the LC_RPATH stack in dependents-first order. Every candidate path gets an outcome. Each load is resolved, conditional (an earlier candidate outside the bundle, such as /usr/lib/swift, would win if present), unresolved or undetermined. Derived findings flag unresolved required loads and absent earlier @rpath candidates. System libraries stay outside-target, because on macOS 11 and later they live in the dyld shared cache. Nothing is executed, and this tool works on Linux too. The root path above assumes the executable is named Rectangle. Take the real name from Step 3.
Step 7: Swift symbols and dispatch metadata
Swift symbols are mangled. Pass the ones you want readable, for example from imports or exports in Step 4:
rea demangle-swift ~/re-work/Rectangle.app '$s4main3FooV' --json
The first argument binds the Evidence to the app. Every following argument is one symbol. REA’s own test suite uses this exact symbol and expects $s4main3FooV to become main.Foo. Symbols that are not mangled come back with status unchanged.
For the structure behind the names:
rea inspect-native-dispatch-metadata ~/re-work/Rectangle.app --json > dispatch.json
On macOS this uses a validated Mach-O byte reader. On other hosts the native macOS provider is unavailable, and a provider such as Ghidra can report only symbol-based metadata. It decodes Objective-C classes, metaclasses, superclasses, methods, ivars, properties, categories and protocols, plus simple Swift conformances, witness slots and non-generic, non-resilient class vtables. Each record has exact addresses and file offsets. Generic, resilient and async tables are reported as unsupported or partial, not guessed.
Step 8: NIBs, storyboards, archives and assets
rea decode-interface-builder ~/re-work/Rectangle.app --json > ib.json
This statically parses compiled .nib files and storyboard scenes. It produces an object, hierarchy and connection graph with outlets, actions, and controller and class names. documents lists each archive with its object and connection counts. Unrecognized classes and unresolved handlers stay explicit unknowns. Limits are 64 documents, 20,000 objects and 40,000 connections by default (--max-documents, --max-objects, --max-connections).
Other UI-adjacent resources:
rea inspect-keyed-archive ~/re-work/Rectangle.app Contents/Resources/SOME_ARCHIVE.plist --json
rea inspect-asset-catalog ~/re-work/Rectangle.app --limit 200 --json
inspect-keyed-archive reads any NSKeyedArchiver plist as its original object table, without instantiating classes. Shared and cyclic references are preserved. Select a root with --root and page with --offset and --limit. The archive path above is a placeholder, so use one from your inventory. inspect-asset-catalog reads Assets.car metadata through assetutil. It does not extract image bytes.
Step 9: Decompile with Ghidra or Hopper
Now use what you learned to pick functions:
rea search ~/re-work/Rectangle.app "applicationDidFinishLaunching" --provider ghidra --json
rea decompile ~/re-work/Rectangle.app \
'--procedure=-[AppDelegate applicationDidFinishLaunching:]' --provider ghidra --json
Use the --procedure= form for Objective-C names, because they start with - or + and would otherwise look like CLI flags. The selector above is an illustration. Copy the exact name from the search result or the dispatch metadata, including any namespace. Swift methods appear under their mangled or provider-demangled names. Over MCP, an agent can call get_objc_classes and analyze_swift_types on the open binary to get class and type inventories from the provider’s names. Both are MCP-only in 6.4.0: the package lists them in MCP_TOOLS_WITHOUT_DEDICATED_CLI, so there is no rea get-objc-classes command. The full Ghidra workflow is in /en/rea-ghidra-native-binary/.
Step 10: From a button to its handler
trace-native-ui-action connects Step 8 and Step 9. Give it an action selector or object ID from the decoded NIB graph. It follows the authored connection to the Objective-C handler and then the handler’s static callees:
rea trace-native-ui-action ~/re-work/Rectangle.app 'ACTION_SELECTOR:' \
--provider ghidra --max-depth 4 --json
The selector must be unique. Typed direct calls, resolved indirect calls, ambiguous candidates and targetless sites are kept separate. The result does not claim the path runs. To confirm what actually happens in one run, observe-native-calls launches the app under LLDB with explicit breakpoints. That is runtime work: it runs the app, may change files or use the network, and needs authorization. Hardened-runtime targets need the get-task-allow entitlement, so in practice use it on apps you build yourself. See native investigation.
Let the agent drive
Use REA on ~/re-work/Rectangle.app. Inventory it, project the Apple
application graph, read Info.plist and the signing entitlements, then trace
dylib resolution for the main executable. Summarize the bundle layout and any
unresolved loads. Do not paste the raw inspect_macho result; select the fields
you need. Cite Evidence IDs and list unknowns.
The instruction about inspect_macho matters. Without it, an agent may try to read the whole record and run out of context (issue #2473).
Troubleshooting
unsupported_host for inspect-macho, inspect-plist or demangle-swift. These need macOS. On Linux, use trace-dylib-resolution, decode-interface-builder, inspect-keyed-archive and a Ghidra provider instead.
“tool is unavailable through xcrun”. Install or repair the command-line tools with xcode-select --install, then run rea doctor --json.
Active artifact is not Mach-O. You passed a script, a plist or a non-Mach-O resource. Pass the .app or a file from executable_candidates.
DMG shows only a root hash. Child inventory needs the read-only mount on macOS. On other hosts, extract the app on a Mac first, or analyze the .app directly.
Interface Builder result is partial. The archives together exceeded the 32 MiB aggregate input budget or the 256 MiB aggregate decode budget. Earlier documents remain available, and the omitted paths are listed.
Hopper opens a window. On macOS, REA drives the Hopper application, and a first-run dialog may appear. Use Ghidra for fully headless analysis.
Next steps
- Go deeper on functions with Ghidra: /en/rea-ghidra-native-binary/
- The app turned out to be Electron: /en/reverse-engineer-electron-app/
- Compare two releases of the same app: /en/rea-evidence-snapshots-compare/
- Choosing between REA, GhidraMCP and IDA Pro MCP: /en/rea-vs-ghidramcp-ida-pro-mcp/
- Use IDA Pro as the engine instead of Ghidra or Hopper: IDA Pro MCP with REA
- Reference: Apple application analysis, native investigation