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:

  1. csrutil disable (disable SIP entirely)
  2. 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:

  1. Broken developer toolchains: JDK and Azure Functions Core Tools have runtime dependencies on system integrity protection. Disabling SIP directly breaks daily development workflows.
  2. 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:

  1. Unsigned pages fail before CS_DEBUGGED is set.
  2. Hardened runtime constrains dynamic loading.
  3. 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:

  1. task_for_pid (csrutil enable --without debug + root)
  2. mach_vm_allocate fresh pages in amfid
  3. temporary debug attach to get CS_DEBUGGED
  4. thread hijack to execute class_replaceMethod in 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.”

  1. Run original validateWithError: first.
  2. Let original logic perform parsing/hash/state setup.
  3. 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:

  1. _dp_slot at the end of code page is runtime-patched to data-page absolute address.
  2. SLOT_DATA_PAGE_PTR is extracted from hook.o via nm, not hardcoded.
  3. IVAR_CODE_OFFSET is extracted at build time via ivar_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:

  1. verify recorded PID still maps to amfid
  2. task_for_pid
  3. read DP_ALLOWLIST_PTR / DP_ALLOWLIST_SIZE
  4. fetch existing allowlist
  5. merge paths
  6. allocate/write new allowlist page
  7. 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

  1. Platform binaries effectively close most familiar injection paths.
  2. Do not attempt to reimplement opaque closed-source internal state when call-through is possible.
  3. Always account for both branch directions and architectural range limits.
  4. Injector compile arch is not equal to target execution mode for raw machine code stubs.
  5. CS_DEBUGGED enables execution of new unsigned pages, not tampered signed pages.
  6. class_replaceMethod is generally safer than brittle inline patching in this target class.
  7. Build-time extraction is more robust than hardcoded offsets across OS updates.
  8. System logs are your primary AMFI debugging tool.