Starting Point
A real-world problem one day: I had a macOS Universal Mach-O app that needed to be made runnable without source code, without a developer certificate, and without server-side access. This kind of scenario is actually not uncommon in real offensive and defensive work — you obtain a binary, want to understand its internal decision paths, and make a certain branch go the way you want without changing the semantics.
This article does not discuss what app was modified; it only discusses the method. All of the tricks can be directly transferred to everyday security auditing, debugging, and gray-box testing.
Prerequisite: This article assumes you have legal reverse-engineering rights for the binary — your own product, a CTF challenge, an authorized security audit target, or old software that has been delisted but that you purchased. Using this skill set against someone else’s commercial product is not the intended scenario for this article.
Spoiler: In this 8-hour hands-on session, 6 hours were lost on soft methods, and 90% of the final 18 patches were completed in 2 hours. The 5 lessons below are a review of those 8 hours. If you have used Ghidra at least once but have not yet landed a patch on an app that actually runs, this article is for you.
First Lesson: Exhaust Soft Methods First, but Know When to Give Up
Before formally starting to modify the binary, I spent several hours on “soft methods” — changing userland configuration, changing the Keychain, changing hosts, and attaching an outbound firewall. Every one of these attempts failed, but their failure modes were different, and each failure taught me something:
- Check the crash log first: The first action before touching the binary is not decompilation, but
~/Library/Logs/DiagnosticReports/<app>-*.ips. Early dyld errors (Library not loaded: @rpath/...,Symbol not found,unknown library ordinal -8,bundleIdentifierVerification) are usually written very plainly in thetermination reasonsandexceptionfields, and can directly tell you whether to dig into framework integrity, the symbol export table, or the receipt verification path next. This is even “softer” than all the soft methods below, and should be step 0 in the soft-method lineage. - Modify plist preferences: Many keys that look like feature flags are actually just i18n localization keys. One signal for distinguishing i18n keys from real state keys: after the app gets this string, does it display it directly, or use it as a conditional branch? Looking at the naming conventions in reflection metadata can usually identify this immediately.
- Forge Keychain entries: Modern Swift apps commonly sign persistent data with Ed25519. Without the private key, you cannot generate a valid signature. Even if you write the entry in, it gets discarded once signature verification runs during loading, and is logged as
hasCachedXxx=false— but you think it accepted it. - Block via
/etc/hosts: On machines using Cloudflare WARP / iCloud Private Relay / any userland DNS resolver,/etc/hostsis not queried at all. This is especially worth remembering, because it can leave you staring at your own hosts file wondering whynslookupreturns the real IP — the appearance and the reality are completely separated. - Outbound firewall (LuLu): It can work, but it is not portable. Move to another Mac and you have to redo it.
The lesson is not “soft methods are useless.” The lesson is — each soft method has its own problem domain, and when the target’s entire verification chain is outside the scope of what you can modify, stacking more soft methods is just noise. At that point, decisively switch to the binary layer.
There is a rough signal for judging this threshold: you already understand how the target’s state machine works, but every state entry is cross-guarded by signatures / server / cache. Continuing to patch from the outside at that point is racing against the verification chain.
Once you pass the judgment point after exhausting soft methods, you enter toolchain selection.
Second Lesson: Toolchains Are Stacked, Not Substitutes
The toolset actually used after entering the binary layer:
- Ghidra 11.3.2 headless mode — The main decompiler. Swift binaries are not exactly easy for Hopper/IDA either, but Ghidra’s built-in Swift demangler + decompiler can finish a full AutoAnalysis on a roughly 10MB Mach-O in about 7–8 minutes. It is worth running once, then letting your own Java scripts repeatedly query the already-analyzed
.gprproject instead of rerunning Analysis every time. xcrun llvm-objdump --disassemble --start-address=X --stop-address=Y— When Ghidra’s pseudo-C decompilation is inaccurate, go back to asm. Specifically for spot-checking the immediate of acbnz/tbnz, or an inline Swift string assembled from a sequence ofmovz/movk.xcrun swift-demangle— Pipenmoutput into it, and you can immediately tell which class and method_$s10AppName14SomeManagerC...corresponds to. Swift hides too much structural information in mangling; without demangling, you cannot work effectively.rabin2 -zz(radare2) — Dumps all cstrings / objc methnames / Swift reflstrs in one pass, with vaddrs. Much stronger thanstrings -a.lldb -b -s commands.txt— Batch mode verification that the patch really entered the running image. Attach +memory read --size 4 --count 1 --format x <addr>, then compare against the bytes you expect. This is the only way to distinguish between “I modified the file” and “the running process is actually executing the new code” — and the latter also has to account for the ASLR slide.otool -l/lipo -detailed_info— Used to get the offset of the arm64 slice from the fat header of a Universal binary. The universal offset and the arm64-slice offset differ by a fixed base, and that number varies by app. You have to check it each time withlipo -detailed_info <bin> | grep -A4 arm64; theoffsetfield in the output is the base. I tripped over a wrong base calculation early on, which made the patch appear to write successfully while the bytes landed in a completely unrelated location. This is the easiest pitfall for newcomers: when writing scripts, strictly distinguish universal offsets and slice offsets with different variable names.dyld_info -fixups <bin>/dyld_info -exports <bin>— Built into macOS 12+. What makes it stronger thannmis that it shows the chained fixups and export trie view that dyld actually sees. The “exported symbols” seen bynmmay not match the export trie dyld actually uses for binding, a common trap when build scripts half-rename classes. This is something you can never discover fromnmalone.codesign --force --deep --sign -— After changing bytes, you must re-sign. macOS sealed-resource verification in deep mode will also inspect small files underContents/. Recognition pattern: whencodesign --verify --verbose /Applications/<app>.appreportssealed resource is missing or invalid, the error message will point to the exact path, usually some embedded framework / bundle resource. Move that file out of the.app, sign, then restore it. Each app has different “offending files,” but the localization pattern is fixed.hdiutil create -format UDZO— Packages the modified.appback into a DMG as a distributable carrier for “other machines.”
Third Lesson: Find the “Killer Patch,” Don’t Walk Every Callsite
The most valuable mindset shift in those 8 hours of reversing: instead of patching 36 callsites one by one, find the function they all call in common.
Concretely: badge rendering, modal display, feature gates, menu item click handling — on the surface, these look like 36 independent UI/action callsites. But after disassembling a few of them, you’ll notice they all follow the same pattern:
bl <some_fn> ; returns a bitmask
mvn wN, w0 ; invert
and wN, wN, MASK ; keep the bits we care about
cbnz xN, deny_path ; if not all 0, deny
Only the MASK differs. All callsites share the same <some_fn> decision function. At that point, you can rewrite that function’s prologue directly to movn x0, #0; ret (returning 0xFFFFFFFFFFFFFFFF), and every callsite will simultaneously see “MASK fully matches, allow.” One change beats 36.
This idea also applies on the defensive side: when auditing code, identify these “global decision point” functions. They are both the highest-value targets for attackers and the places you should prioritize protecting (multi-source validation, runtime integrity checks, name obfuscation).
One More Caller-Side Safety Rule
The trick of “changing the callee’s prologue to movn x0, #0; ret” is only safe for functions returning primitive types / integer bitmasks. If the callee is a Swift function returning a retain-counted class instance / Optional, the caller will almost certainly do things like swift_retain(x0), bl _objc_release, store x0 on the stack as a log argument, or even later call swift_release_n(x0, #2). In that case, if you make the callee return 0/nil, the caller immediately treats nil as an object and dereferences it, causing a SIGSEGV.
Practical rules:
- Before touching the callee, first check how the caller uses the return value. A
mov x28, x0immediately followed bybl _swift_retainis a danger sign. - If the return value is reused, bypassing the entire call from the caller side is safer — change a
tbnz/cbzso it always takes the “condition already satisfied” branch, preventing the callee from being called at all, or allowing it to be called while the caller takes a path that “does not consume the return value.” - Directly returning from the callee entrypoint is only safe when: the callee is effectively void-ish, or the caller does not retain/release/dereference
x0.
This sounds obvious, but I hit a SIGSEGV on my very first attempt, so I’m spelling out the pitfall explicitly.
Fourth Lesson: Reproducibility Is the Baseline
Any patch project with even a little ambition must come with a reapply script from day one. The reasons:
- System upgrade / app update / you accidentally overwrote something — in all of these cases, you need to get back to the patched state within 30 seconds.
- When copying the work to a second Mac, having “which bytes I manually changed” as the only documentation is a disaster.
- For retrospectives and team sharing, the script is the best narrative of “what was done” — every line is one explicit action.
Recommended script format:
- Table-driven, not a hex blob. Each entry is
(univ_offset, expected_old_hex, new_hex, description). - Before writing each patch, check whether the original bytes match the expected value. If they do not match, skip it and log the event, rather than aborting the whole script — this makes the script idempotent and safe to run repeatedly.
- After patching, run
codesign --verifyonce to confirm the re-signed state. - Finally, reassert the environment-side hardening measures as well (such as disabling auto-updates), because those are per-user defaults and are easy to lose during migration.
Fifth Lesson: Verification Takes More Time Than the Patch Itself
Writing the patch file, signing it, and starting the process — that is only the first step. Actually confirming that it works requires the following:
- Static confirmation: Use
xxdto verify that the bytes on disk really changed. - Re-signing confirmation: Use
codesign -dvto confirm the signature is new.codesignwill not complain just because you changed a few bytes, but it will complain if you forget to re-sign. - Load confirmation: After the process starts, use
vmmap <pid>to get the Load Address, calculate the ASLR slide, then attach withlldband read the patched location in runtime memory. Seeing themovnencoding instead of the originalstpproves that the patch is actually on the CPU execution path. - Behavior confirmation: Trigger the UI / API path that should go through the patch, then confirm the behavior change visually or through logs.
- Regression confirmation: Let the app run long enough to confirm that what you patched is not some one-time initialization path, but something that remains stably effective.
All five steps are indispensable. Several times, steps 1 and 2 looked perfectly fine, but step 3 showed that I had calculated the ASLR slide incorrectly — the bytes inside the process were still the old ones. The reasons:
- File mismatch: The file you modified is not the actual copy being launched from
/Applications/.... - Signature cache: After
codesign, the macOS kernel has a cache. The old app instance’sCode Signing Identifierdoes not match the newly signed one, but the kernel does not know immediately. - Incorrect ASLR slide calculation: For a Mach-O 64-bit executable, the
__TEXTvmaddr base is0x100000000(this is set at link time; confirm it with thevmaddrfield fromotool -l <bin> | grep -A4 'segname __TEXT'). For a dylib / framework, the base is usually0x0.slide = LoadAddress - static_base, not the LoadAddress itself. Use0x100000000when calculating the slide for an executable, and0x0for a framework — do not mix them up. - TCC permissions invalidated by CDHash changes: Re-signing with
codesignchanges the binary’s CDHash, and macOS TCC (Privacy & Security) uses the CDHash as the permission key. Any previously granted Accessibility / Input Monitoring / Screen Recording / Full Disk Access permissions silently become invalid — the toggle in the UI may still appear enabled, but the app effectively has no permission. Symptom: ⌥d / global shortcuts and similar features suddenly stop responding. Fix: System Settings → Privacy & Security → Accessibility (or the relevant category), remove the app and then add it again — do not just toggle it off and on.
Each one of these has cost me 20–30 minutes.
What to cover in a team share
If turning this work into an internal share, I’d suggest structuring it like this:
- "How to identify the turning point from soft methods to hard methods" — this is experience, not knowledge. Walk through 5 concrete soft-method failures and what each failure revealed. Newcomers often spend an entire day stubbornly pushing on one category of soft method; seeing other people’s failure patterns can save them time.
- "Toolchains are not parallel substitutes; they stack" — use Ghidra decompilation to understand the big picture,
llvm-objdumpto inspect local asm, andlldbto verify runtime behavior. I’ve seen colleagues use only IDA or only r2, then get stuck in certain scenarios. - "How to find the killer patch" — use a callgraph tool to list the functions with the highest in-degree. Combine that with static analysis to locate decision points. The same pattern is also effective on the defensive side for identifying "critical protection objects."
- "Reapply scripts and reproducibility discipline" — this is the most easily underestimated point. Pair it with a demo: take a practice app through the full flow, including the final script, so team members can directly experience the moment of "ah, the patch is gone — run the script — it’s back."
- "The five levels of verification" — for the 5 steps above, pair each one with a real pitfall encountered in practice.
The theme doesn’t have to land on "how to crack things," but rather "observability and reproducibility at the binary layer." This skill set is useful for investigating production crashes, doing security audits, and sanity-checking third-party SDKs.
Closing
In these 8 hours, 6 were spent trying soft approaches, and 2 produced 90% of the final 18 patches. That sounds inefficient — but those 6 hours were not wasted. Every failed soft approach taught me something about the target’s internal structure, and all of that information later fed into the final judgment calls on where to patch. If I had jumped straight into Ghidra and brute-forced the decompilation from the start, I likely would have spent even more time in the wrong functions without any prior hypotheses.
Reverse engineering is essentially a kind of "information-density optimization" work — the binary you receive is high-entropy, and your job is to use tools to compress that entropy within limited time, answering the question of "which lines of code determine which user-visible behavior." In this sense, the training value of an attack-defense exercise is not in the final patch, but in the muscle memory of "inferring structure under constrained information."
That muscle memory is useful for people who write code and build products, too.
Comments
Replies are public immediately and may be moderated for policy violations.