How an indie Mac app gives away Pro for free, and what hardening actually changes

Current public pricing is on fortifie.xyz: Solo is $39/month ($390/year), Studio is $149/month ($1,490/year), and Business is custom (contact us). This essay is not a price page, so treat the site as the source of truth.

Most indie Mac apps are not broken into. The Pro unlock is just sitting there. It is a boolean in a preference, a plist value, a string a decompiler finds in a couple of minutes, or a license check that returns the same answer whether or not you paid. You do not need an exploit for any of that. You need the standard tools that ship with macOS plus a few that anyone can install from Homebrew.

I build Fortifie, which hardens the license path of a shipped Mac app and keeps watch for clones after release. Before I say anything about what hardening does, I want to show the problem honestly, on a binary I own, with real tools and real output. Then I will show what changes after hardening, and I will be just as clear about what does not change.

Everything below runs on a fixture I wrote myself, licensed_tool, a small Mach-O arm64 binary with a license check and one planted secret. I never run these tools against anyone else's app. The point is to measure the shape of the problem, not to unlock someone's software.

The before: the secret is right there

The unprotected build is a normal, optimized Mach-O. Here is what the free tools see.

strings on the binary prints the planted secret and the license messages in cleartext:

LICENSE-OK token=%d feature=%s
FORTIFIE-MK-7f3a9c2e5b18d04a
DENIED

nm lists the app's own functions by name, exported and ready to read:

_compute_token
_derive_feature_fnv
_main

radare2 (aaa; afl) recovers 9 functions, and the ones that matter are named and sized, so an analyst can jump straight to the license logic:

main
sym._compute_token
sym._derive_feature_fnv

That is the whole story of most casual cracks. The secret is a visible string. The check is a named function. You find the function, you see what it compares, and you make it always say yes. No creativity required. A decompiler turns that same binary into near source, and the license routine reads like a recipe.

This is not a dig at the developers who ship apps like this. It is the default. If you compiled a license check the obvious way and shipped it, this is what your build looks like right now, and you can check it yourself with the three commands above.

strings and nm on the unprotected baseline fixture: the planted secret is a cleartext string and the license function is a named, exported symbol.
strings and nm on the unprotected baseline fixture: the planted secret is a cleartext string and the license function is a named, exported symbol.

The after: same behavior, the cheap reads are gone

Now the same fixture after Fortifie's native packer (ChaCha20 payload encryption, anti-debug, integrity self-check, symbol stripping). I ran the identical commands on the protected build.

strings filtered for the secret and the license messages returns nothing. The planted secret and the format strings are gone from the static image. What remains is loader noise, including the ChaCha20 constant:

expand 32-byte k

nm no longer shows a single app function. The 30 symbols that remain all belong to the loader stub, not the app:

_ptrace  _sysctl  _mkstemp  _CC_SHA256  _PAYLOAD  _FORTIFIE_PACK_MARKER

radare2 recovers 24 functions, and every one of them is loader-stub code. Zero app functions. The real code is an encrypted payload the disassembler cannot see.

So the three moves that made the casual crack trivial are gone. The secret is not a visible string. The check is not a named function. The decompiler does not get source, it gets a loader and a blob.

Crucially, the app still works. The protected build produces the same output and the same exit codes as the original (measured: it prints a valid LICENSE-OK token on a clean run, byte for byte in the same shape as the unprotected build). Hardening that changes how the app behaves is not hardening, it is a bug. Keeping the protected app's runtime behavior and steady-state performance effectively unchanged is a hard requirement for everything I ship, not an afterthought.

I also timed the default packer on this fixture directly. In an interleaved A/B of 400 runs each, with warmup, the unprotected build ran at about 5.0 ms median per invocation (min about 2.8 ms), and the default packed build at about 95 ms median (min about 65 ms). So the default packer adds roughly 90 ms of one-time work at launch, for the decrypt, anti-debug, and child-exec steps. That cost is paid once when the app starts, not on every action inside it, and after launch the app runs its original native code, so steady-state performance is unchanged. A command-line fixture invoked fresh on every run pays that startup cost each time; a normally long-running Mac app pays it once.

The same commands on the default ChaCha20-packed build: the secret and license format strings are absent, no app functions remain, and only loader-stub symbols are left.
The same commands on the default ChaCha20-packed build: the secret and license format strings are absent, no app functions remain, and only loader-stub symbols are left.

The runtime layer, measured

Static resistance is only half of it. The other half is what happens when someone attaches a debugger to pull the decrypted code out of memory.

On this same fixture, under Apple's lldb, the protected build aborts with a non-zero exit and never decrypts or prints its unlock. A clean run with no debugger exits normally. The anti-debug check fires, and under the debugger no decrypted temp file is ever written, because the decrypt step runs only after the anti-debug check passes. That behavior was measured on this machine, not assumed.

What this does not do, stated plainly

I do not believe any honest tool can promise software that cannot be cracked, so I will not promise that. Here is what the protected fixture still gives up.

The loader stub describes its own method. nm shows ptrace and sysctl, so the anti-debug technique is visible. It shows mkstemp and execv, which tells a reader that the packer decrypts to a temporary file and runs it as a child process. On macOS there is no fexecve, /dev/fd execution is denied, and macOS can kill processes whose signed image disappears or changes, so the decrypted payload has to touch disk briefly. That window is minimized (decrypt happens only after anti-debug passes, the plaintext goes to a private owner-only directory, and it is securely wiped on teardown), but it is not zero. An attacker with owner-level access who defeats the anti-debug check can still dump the running child from memory. That is inherent to any packer on macOS, and I am not going to pretend otherwise.

So the honest summary of the default packer, which is what the commands and screenshots above measured, is this: it turns the cheap, five-minute, decompiler-and-a-flipped-boolean crack into a job that needs a skilled attacker, a debugger, defeating the anti-debug, and dumping memory at the right moment. It raises the cost and the skill required. It does not make reverse engineering impossible, and anyone who tells you their tool does is selling you something.

Beyond the default packer

The evidence above is the default packer: the whole binary is encrypted, symbols are stripped, and a debugger is refused before decrypt. That is the layer I am showing in detail because it is what every Fortifie-protected Mac build gets, and because I can show it with the same three commands you can run yourself. The timing and the screenshots on this page are of that default packer, not of the optional layers below.

The license check itself can also be rewritten, so that even after someone dumps the decrypted image, the next cheap step (find the compare, flip it) is no longer a single obvious function. I have been measuring those license-path layers on owned fixtures. They keep the check's output identical to the original. They stay off the default path when they change launch or per-call cost in a way a user would notice. I am not going to publish a catalog of the methods. A public inventory would only help the attacker.

The residual does not change. A skilled attacker who defeats anti-debug and dumps memory still has a path. The extra layers make the automated and static follow-up more expensive. They do not close the dynamic path, and I am not going to claim they do.

What this measurement is, and is not

Everything above is one owned fixture, measured with public tools, for static recovery only. It is not a third-party audit, and it is not a claim that cracking is impossible. It shows how much harder the first and cheapest attack becomes on that build. I am not publishing a broader number until it is re-run on the platforms Fortifie actually offers, with the raw log kept alongside it.

Why I am writing this

If you ship a paid Mac app, two things are probably already true. First, your license check looks more like the "before" than the "after", because that is the default and nobody has time to hand-roll packing and anti-debug on top of actually building the product. Second, if your app is any good, a crack of it may already be circulating. I keep seeing the same pattern in public: a developer finds their app pre-activated on a piracy site, asks around for how to make the next version harder, and gets told that desktop DRM never works so they should just compete on price. That advice is half right. You cannot stop a determined attacker. You can absolutely make the cheap crack stop working, notice when a clone shows up, and keep honest paying customers working offline.

That is the bar I build to. Not magic, measured.

If you want a reading of your own build

If your Mac app already has a crack floating around, you do not need a pitch. You need to know what your actual shipped build gives away and what it would take to close it.

Create a free account and run one free scan on your own build, no card needed. You will see the same kind of static look shown above, on your real binary. Want a human reading or a Business plan? Contact us.

Get a free scan

After the scan, Solo is $39/mo if you want to harden from the website or your editor. Business or a larger team? Contact us.


Method note: all static output above was produced on 2026-11-11 by compiling the owned fixture licensed_tool to Mach-O arm64 and running strings, nm, and radare2 against the baseline and the ChaCha20-packed build. Runtime and lldb results were measured on 2026-11-04. Before/after static evidence images (strings and nm on the owned fixture, baseline vs default ChaCha20 packer build) are shown above. Performance figure: on 2026-11-12, on this Apple Silicon Mac in a quiet window, I measured an interleaved A/B of 400 per-invocation runs each (plus 40 warmup pairs) with a monotonic clock, running the unprotected and default ChaCha20-packed builds of the owned fixture. The unprotected build ran at about 5.0 ms median (min about 2.8 ms) per invocation; the packed build ran at about 95 ms median (min about 65 ms), i.e. roughly +90 ms of one-time launch overhead for decrypt, anti-debug, and child-exec. Variance on the packed path is high (stdev about 60 ms) because the decrypt-to-temporary-file-and-exec path is I/O bound, while the median held steady across repeated runs. These are per-invocation launch numbers, not per-call steady-state, which is unchanged because the decrypted payload is the unmodified original binary. The timed and screenshotted build is the default ChaCha20 packer only. It does not include optional license-path layers.