7 minutes
Unlocking the Vault: A Deep Dive into macOS ARM64 Trial Enforcement and Binary Patching
Introduction
In the course of analyzing a decompiled macOS productivity application—a calendar and meeting alert utility with a time-limited trial—we encountered a multi-layered enforcement mechanism governing trial expiry, feature gating, and application termination. This practice is common in commercial software: when the trial expires, the app displays warnings, disables premium features, and eventually terminates unless the user purchases or restores a license.
This article documents the complete reverse engineering journey: from initial string reconnaissance through call-graph tracing, assembly-level analysis, and ultimately the development of six binary patches that bypass trial termination, re-enable gated features, and customize the UI—all without modifying the application’s source code or runtime environment.
Overview & Goals
Target: A macOS calendar/meeting alert application with a trial period, distributed as a Mach-O 64-bit executable (ARM64, with optional universal x86_64+ARM64 support).
Observed behavior: When the trial expires, the app shows “Trial expired - Alerts disabled” and eventually displays a modal: “Your trial is complete. Purchase [the app] to continue using the app, or the app will close.” If the user does not click “Restore,” the application terminates.
Goals:
- Trace the trial validation flow from UI strings to enforcement logic
- Identify the critical branch that triggers app termination
- Re-enable alerts and other license-gated features
- Customize banner text and UI elements
- Create reproducible binary patches for both single-arch and universal binaries
Tools & Environment
| Tool | Purpose |
|---|---|
| Hopper Disassembler | Primary analysis (ARM64 decompilation, cross-references, call graphs) |
| HopperPyMCP (optional) | MCP integration for AI-assisted analysis |
| xxd | Hex dump for binary inspection and patch verification |
| otool | Mach-O structure inspection (segments, slices) |
| PlistBuddy | UserDefaults/plist inspection (alternative bypass vectors) |
Binary path: [App].app/Contents/MacOS/[App]
Architecture: ARM64 (Mach-O 64-bit executable)
Base address: 0x100000000 (standard macOS PIE)
Important: For Mach-O executables, the __TEXT segment typically has vmaddr 0x100000000 and fileoff 0, so virtual addresses map directly to file offsets within each architecture slice.
Step 1: Initial Reconnaissance
Locating the Binary
file [App].app/Contents/MacOS/[App]
# Output: Mach-O 64-bit executable arm64
# Or: Mach-O universal binary with 2 architectures: [x86_64, arm64]
String Hunting
strings [App].app/Contents/MacOS/[App] | grep -iE "trial|expired|purchase"
Key strings found:
Trial expired - Alerts disabledTrial not started1h left in trialYour trial is complete. Purchase [App] to continue using the app, or the app will close.app_quit_after_trial_expirytrialStartDateKeytrialValidationKeypurchased_product_ids
Data Storage
The application is sandboxed. Preferences and purchase state live in the app container:
ls ~/Library/Containers/[bundle_id]/Data/Library/
plutil -p ~/Library/Containers/[bundle_id]/Data/Library/Preferences/[bundle_id].plist
Relevant keys: purchased_product_ids, trial_duration_migration_v1, app_install_date
Step 2: Tracing String References
Cross-References in Hopper
- Open the binary in Hopper Disassembler
- Search for the string
Trial expired - Alerts disabled - Right-click → References to this address
“Trial expired - Alerts disabled” is referenced by two procedures. One of them—the banner text getter—selects which trial message to display based on trial state.
Call Graph (Backward)
TrialBannerView → sub_1000cd914 → sub_1000ccde0 (banner text getter)
Key Components Identified
| Component | Purpose |
|---|---|
| PurchaseManager | Trial state, StoreKit integration, UserDefaults keys |
| BannerNotificationManager | Notification banner UI |
| TrialBannerView | SwiftUI trial banner view |
| NotificationPreferenceManager | Alert preferences and gating |
Step 3: Understanding the Banner Display Logic
Decompiled Logic (Banner Text Getter)
The banner text getter receives a TrialState enum and returns the appropriate string:
Input: TrialState enum (case + payload)
- var_38 = case selector (0 = active, 1 = expired/notStarted)
- x22 = hours remaining (for active) or 0 for expired
If case == 1 (expired/notStarted):
If x22 == 0: return "Trial expired - Alerts disabled"
Else: return "Trial not started"
If x22 == 1:
return "1h left in trial"
If x22 <= 23:
return "{x22}h left in trial"
If x22 >= 24:
days = x22 / 24
return "{days} day(s) left in trial"
TrialState Enum (from Hopper metadata)
enum PurchaseManager.TrialState {
case active(hoursRemaining: Int)
case notStarted
case expired
}
Step 4: Locating the Trial Expiry Handler
Search for Termination Strings
The string “Your trial is complete” references a procedure that displays an NSAlert and calls [NSApp terminate]. The analytics event app_quit_after_trial_expiry is logged immediately before termination.
Call Chain to Trial Expiry
sub_1001165a8 → sub_1001156a0 → sub_100115738 → sub_100115a18
sub_100115a18 is the trial expiry alert handler:
- Creates
NSAlertwith “Trial Complete - Upgrade to Continue” and “Your trial is complete…” - Adds buttons: “Restore” and “Purchase”
- Runs modal:
[alert runModal] - If return value == 0x3e8 (1000): User clicked “Restore” → call restore flow, do not terminate
- Else: Log
app_quit_after_trial_expiry, call[NSApp terminate:0]
Step 5: Assembly Analysis of the Critical Branch
The Critical Instruction
| Address | Instruction | Bytes (hex) | Purpose |
|---|---|---|---|
0x100115b5c |
cmp x0, #0x3e8 |
0a 09 00 d1 (LE) |
Compare runModal return value to 1000 (Restore button) |
Branch behavior:
- If
x0 == 0x3e8(Z=1): Branch to Restore path → no terminate - If
x0 != 0x3e8(Z=0): Fall through to terminate block → app quits
Patch Strategy: Option B
Goal: Make the branch always take the Restore path.
Chosen approach: Replace cmp x0, #0x3e8 with cmp x0, x0. This always sets Z=1 (equal), so the subsequent b.eq (branch if equal) will always branch to the Restore block.
ARM64 encoding:
- Original:
cmp x0, #0x3e8→0a 09 00 d1 - Patch:
cmp x0, x0→1f 00 00 eb
Step 6: Alert Gating — Why Patches 1 Alone Isn’t Enough
Patch 1 prevents termination, but alerts remain disabled. The alert scheduler checks a function we call has_valid_license before showing any alert:
alert_scheduler
if (has_valid_license())
→ show alert
else
→ log "alert_blocked_no_license"
→ skip alert
has_valid_license returns 1 if: lifetime purchased OR annual/monthly purchased OR trial still active. Returns 0 when trial expired and no purchase.
Patch 2: Replace the first 8 bytes of has_valid_license with mov w0, #1; ret — always return true. Alerts are then shown regardless of trial state.
ARM64 encoding:
mov w0, #1→20 00 80 52ret→c0 03 5f d6
Step 7: Two Banner Systems
The application has two distinct banner systems:
-
Trial banner (orange) — Uses the banner text getter. Shows “Trial expired - Alerts disabled”, “1h left in trial”, etc. Patch 3 modifies the
adrp/addinstructions to load “Your lifetime license is active” instead of “Trial expired - Alerts disabled” when trial is expired. -
License status banner (green checkmark) — Shown when
has_valid_license()returns true (Patch 2 enables this). Calls a separate text getter that returns:- If lifetime purchased → “Your lifetime license is active”
- If annual/monthly → subscription message
- Else → “X days remaining” (trial)
Patch 4: Replace the prologue of the license status text getter with movz x0, #0x1f; movk x0, #0xd000, lsl #48; ret — always return the Swift string for “Your lifetime license is active”.
Step 8: Universal Binary Considerations
For universal binaries (x86_64 + ARM64), each architecture occupies a separate slice. The Mach-O fat header stores the file offset of each slice. Patches must be applied at:
file_offset = slice_offset + virtual_offset_within_slice
For example, if the ARM64 slice starts at 0x2e4000, Patch 1 (virtual offset 0x115b5c) is applied at file offset 0x2e4000 + 0x115b5c.
String patches (e.g., overwriting “Upgrade to restore alerts” with “@jayluxferro”) may need to be applied to both slices if the string exists in both.
Step 9: Patch Summary
| Patch | Purpose | Technique |
|---|---|---|
| 1 | Terminate bypass | cmp x0, #0x3e8 → cmp x0, x0 |
| 2 | Alerts enable | Prologue of has_valid_license → mov w0, #1; ret |
| 3 | Trial banner text | adrp/add → load “Your lifetime license is active” |
| 4 | License status banner | Prologue of text getter → return Swift string directly |
| 5 | Trial banner subtitle | Overwrite “Upgrade to restore alerts” with “@jayluxferro” |
| 6 | Hide “About” in settings | Overwrite “About” strings with null bytes |
Step 10: Applying the Patches
Manual Application (ARM64-only binary)
# Backup first
cp [App].app/Contents/MacOS/[App] [App].bak
# Patch 1 — Terminate bypass
printf '\x1f\x00\x00\xeb' | dd of="[App]" bs=1 seek=$((0x115b5c)) conv=notrunc
# Patch 2 — Alerts enable
printf '\x20\x00\x80\x52\xc0\x03\x5f\xd6' | dd of="[App]" bs=1 seek=$((0x12c990)) conv=notrunc
Verification
xxd -s 0x115b58 -l 16 [App]
# Expected at 0x115b5c: 1f 00 00 eb
Code Signing
After patching, macOS may reject the modified binary. Re-sign with:
codesign -f -s - [App].app/Contents/MacOS/[App]
Alternative Bypass: UserDefaults Injection
If the application does not perform server-side verification, injecting a fake purchase into UserDefaults may suffice:
PLIST="$HOME/Library/Containers/[bundle_id]/Data/Library/Preferences/[bundle_id].plist"
/usr/libexec/PlistBuddy -c "Delete :purchased_product_ids" "$PLIST"
/usr/libexec/PlistBuddy -c "Add :purchased_product_ids array" "$PLIST"
/usr/libexec/PlistBuddy -c "Add :purchased_product_ids:0 string '[product_id]'" "$PLIST"
Quit the application before modifying; relaunch after. This approach may not persist if StoreKit verifies purchases server-side.
Conclusion
This reverse engineering exercise demonstrated a systematic approach to analyzing trial enforcement in a macOS ARM64 application:
- String hunting — Identify UI and analytics strings as entry points
- Cross-reference tracing — Follow references from strings to procedures
- Call graph analysis — Map the flow from UI to enforcement logic
- Assembly-level understanding — Identify critical branches and comparison instructions
- Minimal patching — Apply the smallest possible changes to achieve the desired behavior
- Universal binary awareness — Account for slice offsets when patching fat binaries
The techniques described—instruction substitution, prologue replacement, and string overwriting—are applicable to similar analyses of commercial macOS applications. Understanding how trial and license enforcement is implemented helps security researchers assess robustness and developers improve their protection strategies.
For educational and authorized security research only.