Background
vphone-cli boots a real iOS 26 virtual machine on Apple Silicon Macs. It is not the Xcode Simulator (which compiles iOS apps for the host architecture); it uses Apple’s private Virtualization.framework PV=3 (Platform Version 3) APIs — the same infrastructure Apple built for Private Cloud Compute (PCC) security research VMs.
Under the hood, vphone-cli patches the entire iOS boot chain — AVPBooter, iBSS, iBEC, LLB, TXM, and the kernelcache — to bypass signature verification and allow a custom firmware to boot inside the VM. The jailbreak variant applies 127 binary patches across the boot chain and CFW installation, enabling full root/SSH/Sileo/TrollStore on the guest.
This is extremely cool. But it requires private entitlements that Apple never grants to third parties:
com.apple.private.virtualization
com.apple.private.virtualization.security-research
Because the binary is ad-hoc signed with these private entitlements, amfid will always reject it. The old workflow required two steps to get around this:
csrutil disable(disable SIP entirely)nvram boot-args="amfi_get_out_of_my_way=1"(fully disable AMFI)
That means disabling two core macOS security boundaries at the same time. In practice, this causes:
- Broken developer toolchains: JDK and Azure Functions Core Tools have runtime dependencies on system integrity protection. Disabling SIP directly breaks daily development workflows.
- Major security regression: with SIP + AMFI fully disabled, any process can tamper with system files, inject daemons, bypass filesystem protections, and execute arbitrary unsigned code. This is an unacceptable risk on a production development machine.
I tried amfidont, a Python-based tool that uses debugserver breakpoints to intercept amfid validation. But in real usage it was not stable enough — launching binaries that require signature validation would frequently hang for extended periods, as breakpoint IPC latency caused request buildup in amfid.
So I wrote amfree: a C-based injector that installs a persistent in-process hook using ObjC runtime swizzling instead of debugserver breakpoint communication. Once installed, hook logic runs natively in amfid with zero IPC overhead and no additional latency. It only needs csrutil enable --without debug, not full SIP disable.
1. The two-layer macOS code-signing architecture
Code-signing enforcement is not a single module. It is executed by two independent layers:
flowchart TD
A["User runs ./binary"] --> B["Kernel execve()"]
B --> C{"AMFI.kext\nkernel checks"}
C -->|restricted entitlements\ncom.apple.private.*| D["Direct SIGKILL\nwithout amfid"]
C -->|normal signature validation| E["MIG IPC to\namfid daemon"]
E --> F["amfid: validateWithError:"]
F --> G{"validation result"}
G -->|"YES + cdhash"| H["execution allowed"]
G -->|"NO"| I["execution denied"]
Key model:
| Layer | Component | What it checks | Bypassable? |
|---|---|---|---|
| Kernel layer | AMFI.kext | certificate chain for restricted entitlements | No (even with SIP off) |
| User-space layer | amfid daemon | code signature parsing, cdhash computation, entitlement validation | Yes, this is the attack surface in this post |
The kernel trusts amfid replies on this path. If amfid says “valid” and returns the expected cdhash context, execution proceeds.
2. Why common injection methods all fail
For normal apps, macOS offers many familiar injection options. But amfid is a platform binary (Apple-signed system daemon), so multiple protection layers shut those paths down:
flowchart TD
subgraph "Common injection approaches"
A["DYLD_INSERT_LIBRARIES"]
B["fishhook (rebind_symbols)"]
C["remote dlopen"]
D["inline code patch"]
E["on-disk Mach-O patch"]
end
subgraph "amfid defense layers"
P["Platform binary flag\nkernel ignores DYLD env"]
Q["__TEXT page hash validation\nper-page runtime checks"]
R["SIP-protected path\n/usr/libexec/ not writable"]
S["Hardened runtime\nrestrictive dlopen policy"]
T["dyld shared cache\nmax_protection=R-X"]
end
A -->|"ignored"| P
B -->|"targets GOT/lazy pointers, but Objective-C method dispatch is not GOT-based"| Q
C -->|"needs code execution first and fails before CS_DEBUGGED"| S
D -->|"one-byte patch can trigger\nSIGKILL Invalid Page"| Q
E -->|"not writable"| R
DYLD_INSERT_LIBRARIES is ineffective for platform binaries
dyld has hardcoded behavior for platform binaries: it drops all DYLD_* variables. This is not just SIP behavior. Even with SIP off, amfid still ignores your injected dylib.
if (isRestricted || isPlatformBinary) {
pruneEnvironmentVariables(envp, &apple);
}
fishhook / rebind_symbols: wrong attack surface
fishhook rewrites lazy symbol pointers in __DATA (__la_symbol_ptr) for C-level linkage. But validateWithError: is an ObjC method dispatched through objc_msgSend method cache, not PLT/GOT.
remote dlopen / dlsym: chicken-and-egg
In theory, thread hijack + dlopen could load a hook dylib in amfid. In practice:
- Unsigned pages fail before
CS_DEBUGGEDis set. - Hardened runtime constrains dynamic loading.
- You still need debugger-assisted setup anyway.
Inline patching dies on code-page integrity checks
For platform binaries, the kernel validates signed __TEXT pages at runtime. If any modified signed page executes, it can end in SIGKILL (Code Signature Invalid).
| Code page type | Hash checked? | Can CS_DEBUGGED bypass? |
|---|---|---|
| Original signed code pages | Yes | No |
| Newly allocated unsigned pages | No original hash | Yes, executable if policy allows |
CS_DEBUGGED allows execution of newly mapped unsigned pages. It does not legitimize tampering with already signed pages.
On-disk patching is blocked by system protections
/usr/libexec/amfid is under SIP-protected system locations and SSV integrity model. Direct binary patching is not a practical route.
So what is left?
The viable path is:
task_for_pid(csrutil enable --without debug+ root)mach_vm_allocatefresh pages in amfid- temporary debug attach to get
CS_DEBUGGED - thread hijack to execute
class_replaceMethodin amfid context
This edits ObjC runtime metadata (__DATA), not signed code pages.
3. Why ObjC swizzle is the final choice
Not viable: inline code patch
Patching any byte in amfid __TEXT can trigger SIGKILL (Code Signature Invalid) when that page is executed.
Usable but suboptimal: LLDB breakpoint path (amfidont style)
Breakpoint interception works, but each validation requires debugger round-trips, increasing latency and management complexity.
Chosen approach: ObjC runtime swizzle
class_replaceMethod() modifies runtime metadata in __DATA, not signed __TEXT pages. After installation, the hook runs entirely in amfid with native dispatch and no hot-path debugger IPC.
flowchart LR
subgraph "__TEXT (RX)"
A["original code pages"]
end
subgraph "__DATA (RW)"
B["ObjC method tables\nclass_rw_t"]
end
subgraph "new allocated pages (RX)"
C["hook shellcode"]
end
B -->|"IMP pointer"| C
C -.->|"blr original IMP"| A
4. Core design: call-through hook
Why direct interception failed
A first attempt forced _isSigned=YES, _isValid=YES, and returned immediately. amfid did not crash, but binaries were still denied.
Reason: kernel-amfid interaction uses MIG protocol, and downstream validation expects state produced by the original path (including cdhash-related context). Skipping original logic can leave reply state incomplete.
Call-through principle
“Let Apple’s code compute everything; only change the final verdict when needed.”
- Run original
validateWithError:first. - Let original logic perform parsing/hash/state setup.
- If original is NO and path matches allowlist, flip final result to YES.
sequenceDiagram
participant K as Kernel AMFI.kext
participant A as amfid
participant H as Hook shellcode
participant O as Original validateWithError:
K->>A: MIG validation request /path/to/binary
A->>H: objc_msgSend dispatch to hook
H->>O: blr x16 (call original IMP)
O-->>H: return NO (+ state computed)
Note over H: check allowlist path match
alt matched
H-->>A: return YES
else not matched
H-->>A: return original NO
end
A-->>K: MIG reply
Hook flow in hook.S
flowchart TD
A["_hook_entry: save registers\nx19=self, x20=_cmd, x21=err_ptr"] --> B["load data_page pointer\nadrp + ldr (runtime patched)"]
B --> C["blr x16: call original IMP\nlet Apple path compute state"]
C --> D{"original returns YES?"}
D -->|YES| E["return YES directly"]
D -->|NO| F["load _code ivar\nself + IVAR_CODE_OFFSET"]
F --> G["SecCodeCopyPath\nget binary path"]
G --> H["CFURLGetFileSystemRepresentation\nconvert to C string"]
H --> I["compare with allowlist\nline-by-line prefix match"]
I --> J{"path matched?"}
J -->|YES| K["clear error, return YES"]
J -->|NO| L["return original NO"]
5. Injection flow in detail
The full pipeline has 8 stages across injector/debugger/target:
sequenceDiagram
participant I as injector process
participant M as Mach kernel
participant D as debugserver
participant A as amfid process
Note over I: Step 1-2: locate target
I->>M: proc_listallpids -> find amfid PID
I->>M: task_for_pid(amfid_pid) -> task port
Note over I: Step 3: ObjC resolve
I->>I: dlopen(AMFI framework)
I->>I: objc_getClass -> class_getInstanceMethod -> IMP
Note over I: dyld shared cache addresses are process-consistent
Note over I,A: Step 4: remote memory build
I->>M: mach_vm_allocate x3 (code/data/allowlist pages)
I->>A: remote_write hook shellcode -> code_page
I->>A: remote_write API pointers -> data_page
I->>A: remote_write path list -> allowlist_page
I->>M: mach_vm_protect code->RX, allowlist->R
Note over I,D: Step 5: start debugserver
I->>D: fork + exec debugserver --attach=amfid
Note over A: CS_DEBUGGED set, unsigned new pages can execute
Note over I,A: Step 6-8: thread hijack
I->>D: RSP save registers
I->>D: RSP set x0=cls, x1=sel, x2=code_page, x8=class_replaceMethod
I->>D: RSP set pc -> setup_code
I->>D: RSP continue
A->>A: paciza x2
A->>A: blr x8 (class_replaceMethod)
A->>A: brk #0xfed6 (trap back)
I->>D: RSP read return (old IMP)
I->>A: remote_write old IMP -> data_page
I->>D: RSP restore registers
I->>D: RSP detach
I->>I: kill debugserver (CS_DEBUGGED is sticky)
6. Remote memory layout
After injection, amfid contains three allocated pages:
┌─────────────────────────────┐ ┌─────────────────────────────┐ ┌────────────────────────┐
│ Code Page (RX) │ │ Data Page (RW) │ │ Allowlist Page (R) │
├─────────────────────────────┤ ├─────────────────────────────┤ ├────────────────────────┤
│ hook_entry: save regs │ │ 0x00: reserved │ │ /Users/me/dev/\n │
│ adrp + ldr -> data_page │ │ 0x08: original IMP │ │ /opt/tools/\n │
│ blr original IMP │ │ 0x10: SecCodeCopyPath │ │ (newline-separated │
│ allowlist matching logic │ │ 0x18: CFURLGetFileSystemRep │ │ list) │
│ _dp_slot: .quad data_page │ │ 0x20: CFRelease │ │ │
│ --- setup trampoline --- │ │ 0x28: allowlist size │ │ │
│ paciza x2 │ │ 0x30: allowlist ptr -> ────┼──┼──> │
│ blr x8 (class_replaceMethod)│ │ 0x100: type encoding string │ │ │
│ brk #0xfed6 │ │ │ │ │
└─────────────────────────────┘ └─────────────────────────────┘ └────────────────────────┘
Important details:
_dp_slotat the end of code page is runtime-patched to data-page absolute address.SLOT_DATA_PAGE_PTRis extracted fromhook.ovianm, not hardcoded.IVAR_CODE_OFFSETis extracted at build time viaivar_getOffset()probe.
7. arm64e PAC: the hardest constraint
amfid runs in arm64e mode. Pointer Authentication (PAC) breaks naive function-pointer redirection.
flowchart TD
subgraph "arm64e objc_msgSend fast path"
A["read IMP from method cache"] --> B["braa x16, x17\n(authenticated branch)"]
B --> C{"PAC validation"}
C -->|pass| D["branch to IMP"]
C -->|fail| E["pointer zeroed\nbranch to 0x0 -> SIGSEGV"]
end
subgraph "our fix"
F["run paciza x2\nin amfid context"] --> G["sign code_page address"]
G --> H["class_replaceMethod\nstores signed IMP"]
H --> I["objc_msgSend braa passes"]
end
Only one PAC instruction is essential in setup code:
uint32_t setup_code[] = {
0xDAC123E2, /* paciza x2 */
0xD63F0100, /* blr x8 (class_replaceMethod) */
0xD43FDAC0, /* brk #0xfed6 */
};
paciza x2 signs the replacement IMP pointer in target context, so authenticated dispatch can succeed.
8. CS_DEBUGGED: why unsigned new pages can execute
code_page is allocated with mach_vm_allocate, so it has no code signature.
By default, kernel policy rejects executing unsigned pages in this context. During debug attach, CS_DEBUGGED is set and allows execution of newly allocated unsigned pages.
Crucial point: CS_DEBUGGED is effectively sticky for process lifetime in this workflow. After hook installation and detach, execution of the installed hook can continue without a persistent debugger.
stateDiagram-v2
[*] --> Normal: amfid startup
Normal --> Debugged: debugserver attach
Debugged --> Hooked: class_replaceMethod installed
Hooked --> Running: resume target
state Normal {
[*] --> normal signature validation
}
state Debugged {
[*] --> CS_DEBUGGED_set
CS_DEBUGGED_set --> unsigned_new_pages_executable
}
state Hooked {
[*] --> objc_method_table_modified
objc_method_table_modified --> hook_shellcode_executable
}
state Running {
[*] --> hook_persists
hook_persists --> debugger_can_exit
}
9. Build-time automation
Two build-time probes avoid version-fragile hardcoding:
flowchart LR
subgraph "Build-Time Probes"
P["probe_ivar.m\ncompile + run"] -->|"ivar_getOffset(_code)"| M["ivar_offset.mk\nIVAR_CODE_OFFSET=..."]
H["hook.o"] -->|"nm -> _dp_slot"| S["SLOT_DATA_PAGE_PTR=..."]
end
subgraph "Consumers"
M --> ASM["hook.S"]
M --> C1["hook_install.c"]
S --> C1
end
$(BUILD)/probe_ivar: shellcode/probe_ivar.m
$(CC) -arch arm64 -lobjc -o $@ $<
$(BUILD)/ivar_offset.mk: $(BUILD)/probe_ivar
echo "IVAR_CODE_OFFSET=$$($(BUILD)/probe_ivar)" > $@
$(BUILD)/%.o: src/%.c $(BUILD)/hook.o
DP_OFFSET=$$(nm $(BUILD)/hook.o | grep '_dp_slot' | awk '{print "0x"$$1}'); \
$(CC) -DSLOT_DATA_PAGE_PTR=$$DP_OFFSET -DIVAR_CODE_OFFSET=$(IVAR_CODE_OFFSET) ...
10. Pitfalls encountered (full log)
Pitfall 1: private framework was not auto-loaded
find_method_imp: class AMFIPathValidator_macos not found
The framework exists in shared cache but is not auto-loaded into injector process. You must dlopen it explicitly.
Pitfall 2: ARM64 B has +-128MB range
trampoline B offset out of range
Both directions (to hook and back to original) need absolute indirect branches when target distance is large.
Pitfall 3: shared cache page max protection blocks write
remote_protect failed: protection failure
Need COW-style protection with VM_PROT_COPY to patch private copy (though text-page integrity still makes inline patch route unusable later).
Pitfall 4: ivar not initialized at hook entry
Hooking too early on entry path can observe uninitialized fields, and relocating PC-relative prologue instructions can break addressing.
Pitfall 5: platform binary text integrity kills inline patch route
SIGKILL (Code Signature Invalid) - Invalid Page
This is the decisive reason to abandon inline patching.
Pitfall 6: kernel and amfid are two different enforcement layers
amfid alive but binary still denied indicates enforcement may be rejected in kernel path, not hook crash.
Pitfall 7: direct forced-YES skipped required original state
Hook was called, but final execution still denied. This directly motivated call-through.
Pitfall 8: slot offset overlap silently corrupted runtime data
Two logical slots sharing one offset can silently overwrite pointers and make hook appear ineffective.
Pitfall 9: method_setImplementation vs class_replaceMethod
In this context, class_replaceMethod behaved more reliably with cache invalidation behavior.
Pitfall 10: compiling injector as arm64 does not remove PAC needs
Setup machine code executes in target arm64e context, so PAC requirements still apply.
Pitfall 11: toll-free bridged classes can mislead swizzle testing
Some seemingly ObjC calls route into CoreFoundation fast paths.
Pitfall 12: braa authenticated branch zeros invalid pointer
Unsigned/incorrectly signed IMP may become 0x0 on branch authentication failure.
Pitfall 13: process-health diagnostics matter
Comparing amfid PID and system logs is the fastest way to distinguish crash from logic rejection.
11. End-to-end flow summary
flowchart TD
subgraph BUILD["Build stage"]
B1["probe_ivar.m -> IVAR_CODE_OFFSET"]
B2["hook.S -> hook.o"]
B3["nm hook.o -> SLOT_DATA_PAGE_PTR"]
B4["compile all with -D values"]
B5["link -> bin/amfree"]
B1 --> B2 --> B3 --> B4 --> B5
end
subgraph INJECT["First injection"]
I1["find amfid PID + task_for_pid"]
I2["dlopen AMFI + resolve ObjC IMP"]
I3["remote_alloc x3"]
I4["remote_write shellcode/API/allowlist"]
I5["start debugserver -> CS_DEBUGGED"]
I6["RSP save regs + set regs + continue"]
I7["target executes paciza -> class_replaceMethod -> brk"]
I8["RSP read result + write old IMP + restore"]
I9["detach + stop debugserver"]
I10["write state file /tmp/amfid_hook.txt"]
I1 --> I2 --> I3 --> I4 --> I5 --> I6 --> I7 --> I8 --> I9 --> I10
end
subgraph UPDATE["Incremental allowlist update"]
U1["read state file + verify PID"]
U2["task_for_pid"]
U3["read existing allowlist"]
U4["merge with new paths"]
U5["remote_alloc new allowlist page"]
U6["update data_page pointer + size"]
U1 --> U2 --> U3 --> U4 --> U5 --> U6
end
subgraph RUNTIME["Runtime"]
R1["binary validation request"]
R2["objc_msgSend -> hook"]
R3["call-through original IMP"]
R4{"original YES?"}
R5["return YES"]
R6["SecCodeCopyPath"]
R7{"path in allowlist?"}
R8["flip to YES"]
R9["keep NO"]
R1 --> R2 --> R3 --> R4
R4 -->|YES| R5
R4 -->|NO| R6 --> R7
R7 -->|YES| R8
R7 -->|NO| R9
end
BUILD --> INJECT --> RUNTIME
INJECT -.->|"state file"| UPDATE
UPDATE -.->|"takes effect immediately"| RUNTIME
12. Incremental updates without reinjection
After first install, state is persisted (example):
pid=3882
data_page=0x104c50000
code_page=0x104c54000
old_imp=0x24197ee9c
When running again with a new path, tool can update allowlist in place:
- verify recorded PID still maps to amfid
task_for_pid- read
DP_ALLOWLIST_PTR/DP_ALLOWLIST_SIZE - fetch existing allowlist
- merge paths
- allocate/write new allowlist page
- update pointer + size in data page
No reinjection, no ObjC re-resolution, and no thread hijack required for this update path.
# first install
sudo amfree --path /Users/me/dev/
# append path without reinstall
sudo amfree --path /opt/tools/
# inspect allowlist
sudo amfree --list
13. Limitations
| Limitation | Reason |
|---|---|
| Cannot bypass kernel AMFI restricted entitlement checks | Enforcement occurs in kernel layer |
| Requires relaxed debug policy | task_for_pid against system daemons needs root + debug allowance |
| Not persistent across amfid restart | Process-bound state and flags are lost when target process exits |
14. Principles learned
- Platform binaries effectively close most familiar injection paths.
- Do not attempt to reimplement opaque closed-source internal state when call-through is possible.
- Always account for both branch directions and architectural range limits.
- Injector compile arch is not equal to target execution mode for raw machine code stubs.
CS_DEBUGGEDenables execution of new unsigned pages, not tampered signed pages.class_replaceMethodis generally safer than brittle inline patching in this target class.- Build-time extraction is more robust than hardcoded offsets across OS updates.
- System logs are your primary AMFI debugging tool.