背景

vphone-cli 能在 Apple Silicon Mac 上启动一个真正的 iOS 26 虚拟机。它不是 Xcode Simulator(那只是把 iOS 应用编译到宿主架构上跑),而是使用了 Apple 私有的 Virtualization.framework PV=3(Platform Version 3)API——与 Apple 为 Private Cloud Compute (PCC) 安全研究搭建的 VM 基础设施完全相同。

在底层,vphone-cli 对 iOS 的整条启动链做了二进制 patch——AVPBooter、iBSS、iBEC、LLB、TXM、kernelcache——绕过签名验证,使自定义固件能在 VM 中启动。越狱变体在启动链和 CFW 安装阶段一共施加了 127 个二进制 patch,可以在客户机内获得完整的 root/SSH/Sileo/TrollStore 环境。

这个项目非常硬核。但它需要 Apple 从不向第三方授予的私有 entitlement:

com.apple.private.virtualization
com.apple.private.virtualization.security-research

因为该 binary 是用这些私有 entitlement 做 ad-hoc 签名的,amfid 必然会拒绝它。此前的做法需要两步来绕过:

  1. csrutil disable 完全关闭 SIP
  2. nvram boot-args="amfi_get_out_of_my_way=1" 完全禁用 AMFI

这等于同时拆掉了 macOS 的两道核心安全防线。实际带来的问题:

  1. 开发工具链崩坏:JDK 和 Azure Functions Core Tools 在 SIP 关闭后无法正常工作。这两个工具对系统完整性保护有运行时依赖,关闭 SIP 直接导致日常开发流程断裂。
  2. 安全性急剧下降:完全关闭 SIP + AMFI 意味着任何进程都可以修改系统文件、注入系统 daemon、绕过文件系统保护、运行任意未签名代码。这在生产开发机上是不可接受的风险。

于是尝试了 amfidont,它通过 debugserver 断点拦截 amfid 的验证逻辑。但 amfidont 基于 Python 实现,实际使用中发现它不够稳定——运行需要签名验证的 binary 时经常出现长时间无响应,断点通信的延迟导致 amfid 积压请求、软件卡死。

因此写了 amfree:用 C 实现完整的注入流程,通过 ObjC runtime swizzle 安装一个持久化的进程内 hook,而不是依赖 debugserver 断点通信。hook 安装后以原生方法调度运行,零 IPC 开销,不会引入额外延迟。同时只需要 csrutil enable --without debug,不需要完全关闭 SIP。


1. macOS 代码签名的双层架构

macOS 的代码签名并不是单一模块,而是内核 + 用户态守护进程两层独立执行:

flowchart TD
    A["用户执行 ./binary"] --> B["内核 execve()"]
    B --> C{"AMFI.kext
内核层检查"} C -->|restricted entitlements
com.apple.private.*| D["直接 SIGKILL
不经过 amfid"] C -->|普通签名验证| E["MIG IPC →
amfid daemon"] E --> F["amfid: validateWithError:"] F --> G{"验证结果"} G -->|"YES + cdhash"| H["允许执行"] G -->|"NO"| I["拒绝执行"]

关键认知:

组件 检查内容 能否绕过?
内核层 AMFI.kext restricted entitlements 的证书链 不行(SIP off 也不行)
用户态 amfid daemon 解析 code signature、计算 cdhash、验证 entitlements 可以,本文的攻击面

内核信任 amfid 的回复——只要 amfid 说 “valid” 并附上正确的 cdhash,内核就放行。这就是攻击面。


2. 为什么常规注入手段全部失效

在对普通 app 做注入时,macOS 有一套成熟的工具链。但 amfidplatform binary(Apple 签名的系统守护进程),它享受多层特权保护,把常规手段封得死死的:

flowchart TD
    subgraph "常规注入手段"
        A["DYLD_INSERT_LIBRARIES"]
        B["fishhook (rebind_symbols)"]
        C["远程 dlopen"]
        D["inline code patch"]
        E["Mach-O on-disk 修改"]
    end

    subgraph "amfid 的防御层"
        P["Platform Binary 标志
内核忽略 DYLD 环境变量"] Q["__TEXT 代码页哈希校验
运行时逐页验证"] R["SIP 保护系统路径
/usr/libexec/ 不可写"] S["Hardened Runtime
dlopen 策略限制"] T["dyld shared cache
max_protection=R-X"] end A -->|"被忽略"| P B -->|"需要改 __DATA,__la_symbol_ptr
但 amfid 的目标方法不走 GOT"| Q C -->|"需要先获得代码执行权
且 CS_DEBUGGED 前无法执行"| S D -->|"改 1 字节触发
SIGKILL Invalid Page"| Q E -->|"不可写"| R

逐个分析:

DYLD_INSERT_LIBRARIES — 对 platform binary 无效

macOS dyld 对 platform binary(即 Apple 自签名的系统可执行文件)有硬编码逻辑:直接忽略所有 DYLD_* 环境变量。这不是 SIP 的限制——即使 SIP 关了,amfid 作为 platform binary 依然不会加载你注入的 dylib。

// dyld 源码中的检查逻辑(简化)
if (isRestricted || isPlatformBinary) {
    // 清除所有 DYLD_ 环境变量
    pruneEnvironmentVariables(envp, &apple);
}

fishhook / rebind_symbols — 攻击面不匹配

fishhook 的原理是修改 __DATA 段中的 __la_symbol_ptr(lazy symbol pointer),让 GOT 条目指向 hook 函数。但 validateWithError: 是 ObjC 方法,不走 PLT/GOT——它通过 objc_msgSend 的方法缓存分发。fishhook 能 hook SecCodeCopyPath 这样的 C 函数,但对于 ObjC 方法调度完全无效。

而且即使你想用 fishhook hook 底层 C 函数,你也需要先把代码加载到 amfid 进程内——这又回到了 “如何获得代码执行权” 的问题。

远程 dlopen / dlsym — 先有鸡还是先有蛋

理论上可以通过 task_for_pid + thread hijacking 在 amfid 中调用 dlopen 加载一个包含 hook 的 dylib。但:

  1. 你的 dylib 本身没有代码签名——在 CS_DEBUGGED 设置之前,amfid 连加载都做不到(内核拒绝映射未签名的代码页)
  2. 即使签了名,amfid 作为 hardened runtime 进程,对 dlopen 的来源有严格限制
  3. 你仍然需要 debugserver 来设置 CS_DEBUGGED——那不如直接在 thread hijack 中做完所有事

Inline code patch — 代码页哈希校验致死

这是开发过程中亲身验证的死路(见后文踩坑记录)。amfid 是 platform binary,内核对其 __TEXT 段的每一个代码页维护哈希值。执行到任何一个被修改过的页面时,内核会验证 hash→不匹配→SIGKILL (Code Signature Invalid)

关键区分:

代码类型 是否受哈希校验 CS_DEBUGGED 能否绕过
已签名的原始代码页 受校验 不能——改了就死
新分配的未签名代码页 不涉及(没有原始哈希) 可以执行

CS_DEBUGGED 只是告诉内核 “允许执行没有签名的新页面",但对 “已签名页面被篡改” 零容忍。

修改磁盘上的 Mach-O — SIP 保护

/usr/libexec/amfid 在 SIP 保护的路径下。即使关闭 SIP,重启后 macOS 仍然会从 Signed System Volume (SSV) 验证系统文件完整性。直接改二进制完全不现实。

那还能怎么办?

所有常规手段都被封死后,唯一的路径是:

  1. task_for_pidcsrutil enable --without debug + root)获取 amfid 的 task port
  2. mach_vm_allocate 在 amfid 中分配全新的内存页
  3. debugserver attach → 设置 CS_DEBUGGED → 新页面可执行
  4. thread hijack → 在 amfid 上下文中执行 class_replaceMethod

修改的是 ObjC runtime 元数据(__DATA 段中的方法表),不修改任何代码页。新分配的 hook 代码页没有原始签名哈希,靠 CS_DEBUGGED 获得执行权限。这是在所有防御层的夹缝中唯一可行的路径。


3. 方案选型:为什么是 ObjC Swizzle?

不可行:inline code patch

修改 amfid __TEXT 段的任何一个字节→哈希不匹配→SIGKILL (Code Signature Invalid).

amfid 是 platform binary,内核对其代码页做运行时哈希校验。这不是 CS_DEBUGGED 能绕过的——它只放行未签名的新页面,不放行被篡改的已签名页面

次优:LLDB 断点(amfidont 方案)

amfidont 用 debugserver 在 validateWithError: 设断点,每次命中时读写寄存器来"改判”。能用,但:

  • 每次验证都要走 RSP 协议来回通信(性能开销)
  • hook 逻辑在注入进程而非 amfid 内部运行
  • 断点管理复杂度高

选定:ObjC runtime method swizzle

class_replaceMethod() 修改的是 ObjC 运行时元数据__DATA 段),不碰代码页。内核的哈希校验不覆盖 __DATA。安装后 hook 以正常方法调度运行在 amfid 进程内部,零 IPC 开销。

flowchart LR
    subgraph "__TEXT 段(RX)"
        A["原始代码页"]
    end
    subgraph "__DATA 段(RW)"
        B["ObjC 方法表
class_rw_t"] end subgraph "新分配页(RX)" C["hook shellcode"] end B -->|"IMP 指针"| C C -.->|"blr original IMP"| A

4. 核心设计:Call-Through Hook

为什么不能直接拦截?

最初的设计是:hook 入口直接设置 _isSigned=YES_isValid=YES,然后 ret。结果:amfid 没 crash,但 binary 照样被 kill

原因:amfid 和内核之间用 MIG 协议通信。内核要求 amfid 的 reply 中必须包含正确计算的 cdhash(代码目录哈希)。直接跳过原函数→cdhash 没算→内核认为签名无效。

Call-Through 的哲学

“让它算,我们只改判。”

让 Apple 原厂的 validateWithError: 把复杂的 Mach-O parsing、cdhash 计算、内部状态填充全部跑完。hook 只在最后出手——把 NO 翻转成 YES。

sequenceDiagram
    participant K as 内核 AMFI.kext
    participant A as amfid
    participant H as Hook Shellcode
    participant O as 原始 validateWithError:

    K->>A: MIG: 验证 /path/to/binary
    A->>H: objc_msgSend dispatch → hook
    H->>O: blr x16(调用原始 IMP)
    O-->>H: return NO(+ cdhash 已计算)
    Note over H: 检查路径是否在 allowlist
    alt 路径匹配 allowlist
        H-->>A: return YES(cdhash 保留)
    else 不匹配
        H-->>A: return NO(原样返回)
    end
    A-->>K: MIG reply(YES + cdhash)
    K->>K: 放行

hook.S 的完整流程

flowchart TD
    A["_hook_entry: 保存寄存器
x19=self, x20=_cmd, x21=err_ptr"] --> B["加载 data_page 指针
adrp + ldr(运行时 patch)"] B --> C["blr x16:调用原始 IMP
让 Apple 代码计算 cdhash"] C --> D{"原始返回 YES?"} D -->|YES| E["直接返回 YES
不干扰合法 app"] D -->|NO| F["ldr _code ivar
从 self + IVAR_CODE_OFFSET"] F --> G["SecCodeCopyPath
获取 binary 的文件路径"] G --> H["CFURLGetFileSystemRepresentation
转成 C 字符串"] H --> I["逐行比对 allowlist
前缀匹配"] I --> J{"路径匹配?"} J -->|YES| K["清除 error,返回 YES"] J -->|NO| L["返回原始 NO"]

5. 注入流程详解

整个注入过程分 8 个步骤,跨越两个进程:

sequenceDiagram
    participant I as inject 进程
    participant M as Mach 内核
    participant D as debugserver
    participant A as amfid 进程

    Note over I: Step 1-2: 定位目标
    I->>M: proc_listallpids → 找到 amfid PID
    I->>M: task_for_pid(amfid_pid) → task port

    Note over I: Step 3: ObjC 解析
    I->>I: dlopen(AMFI framework)
    I->>I: objc_getClass → class_getInstanceMethod → IMP
    Note over I: dyld shared cache 地址
在所有进程一致 Note over I,A: Step 4: 构建远程内存 I->>M: mach_vm_allocate × 3
(code/data/allowlist pages in amfid) I->>A: remote_write: hook shellcode → code_page I->>A: remote_write: API 指针 → data_page I->>A: remote_write: 路径列表 → allowlist_page I->>M: mach_vm_protect: code→RX, allowlist→R Note over I,D: Step 5: 启动 debugserver I->>D: fork + exec debugserver --attach=amfid Note over A: CS_DEBUGGED 标志位被设置
允许执行未签名代码页 Note over I,A: Step 6-8: 线程劫持 I->>D: RSP: 保存所有寄存器 I->>D: RSP: 设置 x0=cls, x1=sel,
x2=code_page, x8=class_replaceMethod I->>D: RSP: pc → setup_code I->>D: RSP: "c"(继续执行) A->>A: paciza x2(PAC 签名 code_page) A->>A: blr x8(class_replaceMethod) A->>A: brk #0xfed6(陷阱,交还控制权) I->>D: RSP: 读取返回值(old IMP) I->>A: remote_write: old IMP → data_page I->>D: RSP: 恢复寄存器 I->>D: RSP: "D"(detach) I->>I: kill debugserver(CS_DEBUGGED 是 sticky 的)

6. 内存布局

注入完成后,amfid 地址空间中有三块我们分配的内存:

┌─────────────────────────────┐  ┌─────────────────────────────┐  ┌────────────────────────┐
│      Code Page (RX)         │  │      Data Page (RW)         │  │  Allowlist Page (R)    │
├─────────────────────────────┤  ├─────────────────────────────┤  ├────────────────────────┤
│ hook_entry: 保存寄存器       │  │ 0x00: (reserved)            │  │ /Users/me/dev/\n       │
│ adrp + ldr → data_page     │  │ 0x08: Original IMP          │  │ /opt/tools/\n          │
│ blr 原始 IMP                │  │ 0x10: SecCodeCopyPath       │  │ (newline-separated     │
│ 路径比对逻辑                 │  │ 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 str    │  │                        │
│ brk #0xfed6                 │  │                             │  │                        │
└─────────────────────────────┘  └─────────────────────────────┘  └────────────────────────┘

关键设计点

  • _dp_slot 是 code page 末尾的一个 .quad,在注入时被 patch 为 data page 的绝对地址。shellcode 通过 adrp + ldr 定位它来获取 data page 指针。
  • SLOT_DATA_PAGE_PTR 的偏移不是硬编码的——Makefile 在编译 hook.o 后用 nm 提取 _dp_slot 符号的地址。
  • IVAR_CODE_OFFSET 也不是硬编码的——probe_ivar.m 在 build time 用 ivar_getOffset() 从 AMFI framework 动态获取。

7. arm64e PAC:最硬的骨头

amfid 运行在 arm64e 模式下,Pointer Authentication Code (PAC) 使得传统的函数指针跳转处处碰壁:

flowchart TD
    subgraph "arm64e objc_msgSend 快速路径"
        A["从方法缓存取出 IMP"] --> B["braa x16, x17
(认证跳转)"] B --> C{"PAC 验证"} C -->|通过| D["正常跳转到 IMP"] C -->|失败| E["指针被清零
跳到 0x0 → SIGSEGV"] end subgraph "我们的解决方案" F["在 amfid 上下文中
执行 paciza x2"] --> G["code_page 地址
获得 PAC 签名"] G --> H["class_replaceMethod
存储已签名的 IMP"] H --> I["objc_msgSend → braa
验证通过"] end

最终方案只需要一条 PAC 指令

// hook_install.c — setup code(手写机器码字节)
uint32_t setup_code[] = {
    0xDAC123E2, /* paciza x2  — 签名 code_page 地址 */
    0xD63F0100, /* blr    x8  — class_replaceMethod  */
    0xD43FDAC0, /* brk    #0xfed6                    */
};

paciza x2(PAC Instruction Address, key A, modifier=0)给 code_page 地址打上 IA 签名。class_replaceMethod 把签名后的 IMP 存入方法缓存,之后 objc_msgSendbraa(authenticated branch)能验证通过。

不需要 pacdb(PAC Data, key B)——那是早期用 method_setImplementation 时给 Method* 参数签名用的。换成 class_replaceMethod 后,它内部自己处理 Method* 的认证。

关键:inject 二进制编译为 -arch arm64,但 setup code 是手写的机器码字节uint32_t 数组),以 raw bytes 写入 amfid 内存。amfid 是 arm64e 进程,Apple Silicon 硬件上 paciza 是真实的 PAC 操作——与 inject 的编译架构无关。


8. CS_DEBUGGED:让未签名页面可执行

code_page 是通过 mach_vm_allocate 分配的——它没有代码签名。内核默认拒绝执行未签名的代码页。

CS_DEBUGGED 标志位是 debugserver attach 时内核自动设置的,它告诉内核:“这个进程正在被调试,允许执行未签名代码页。”

关键:CS_DEBUGGED 是 sticky flag。 在 XNU 内核中,ptrace(PT_ATTACHEXC) 调用 cs_allow_invalid() 设置 p->p_csflags |= CS_DEBUGGED,但 ptrace(PT_DETACH) 不会清除这个标志。bsd/kern/kern_cs.c 中只有 set 操作,没有对应的 clear。也就是说,一旦设置,CS_DEBUGGED 在进程生命周期内持续有效,即使 debugger detach 或被杀掉。

stateDiagram-v2
    [*] --> Normal: amfid 启动
    Normal --> Debugged: debugserver attach
    Debugged --> Hooked: class_replaceMethod
    Hooked --> Running: 恢复执行

    state Normal {
        [*] --> 正常签名验证
    }

    state Debugged {
        [*] --> CS_DEBUGGED_set: 内核设置 sticky flag
        CS_DEBUGGED_set --> 未签名页面可执行
    }

    state Hooked {
        [*] --> ObjC方法表已修改
        ObjC方法表已修改 --> hook_shellcode可执行
    }

    state Running {
        [*] --> hook持续生效
        hook持续生效 --> debugserver可安全退出: CS_DEBUGGED 是 sticky 的
    }

实测验证:修改代码让成功路径发送 "D"(detach)并 kill debugserver,hook 依然正常工作——test_ent PASS,amfid 存活。debugserver 只需在注入阶段短暂 attach,之后可以安全退出。


9. 构建时自动化

Makefile 实现了两个 build-time extraction,避免硬编码随 macOS 版本变化的值:

flowchart LR
    subgraph "Build-Time Probes"
        P["probe_ivar.m
编译 + 运行"] -->|"ivar_getOffset(_code)"| M["ivar_offset.mk
IVAR_CODE_OFFSET=8"] H["hook.o
编译后"] -->|"nm → _dp_slot"| S["SLOT_DATA_PAGE_PTR=0xB0"] end subgraph "消费方" M --> ASM["hook.S
ldr x0, [x19, #IVAR_CODE_OFFSET]"] M --> C1["hook_install.c"] S --> C1 end
# 1. 编译运行 probe,拿到 ivar 偏移
$(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)" > $@

# 2. 从 hook.o 提取 _dp_slot 符号偏移
$(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. 踩坑全记录

开发过程中踩的 13 个坑,按 “你以为 → 实际上” 的模式整理。

坑 1:私有框架不会自动加载

find_method_imp: class AMFIPathValidator_macos not found

AppleMobileFileIntegrity.framework 在 dyld shared cache 中,但不会自动加载到 inject 进程。必须手动 dlopen。build-time probe 里写了这行,正式代码里漏了。

坑 2:ARM64 B 指令 ±128MB 范围限制

trampoline B offset out of range: 1327864731

hook page 和 dyld shared cache IMP 相隔 ~5GB。ARM64 B 指令只支持 ±128MB PC-relative 跳转。两个方向都要用绝对地址间接跳转(ldr x16, #8; br x16; .quad addr)。

最初只在 IMP→hook 方向做了绝对跳转,忘了 hook→IMP 回跳同样超范围。

坑 3:dyld shared cache 的 max_protection 不含 WRITE

remote_protect failed: (os/kern) protection failure

IMP 所在页面属于 shared cache,max_protection 只有 R-X。必须用 VM_PROT_COPY(0x10)触发 COW(copy-on-write)

mach_vm_protect(task, page, size, FALSE,
                VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY);

坑 4:hook 入口时 ivar 未初始化

[+] hook installed!  → test_ent: killed → amfid PID 变了

validateWithError: 本身就是设置 _isSigned 的方法。hook 替换了它的入口——执行 hook 时 ivar 还没被赋值。检查 _isSigned 永远走 fallback。

更致命的是:displaced 的前 4 条原始指令包含 ADRP(PC-relative),搬到 5GB 之外的 trampoline 后算出错误地址→crash。

这个坑促成了从 “inline hook with displaced instructions” 到 “ObjC swizzle with call-through” 的根本架构转变。

坑 5:Platform binary 代码页完整性——inline patch 彻底不可行

SIGKILL (Code Signature Invalid) — Invalid Page

VM_PROT_COPY COW 了 IMP 所在页面并写入 trampoline。虽然 COW 成功了,但修改后的页面哈希不匹配代码签名→内核在 amfid 下次执行该页面上任何函数时 SIGKILL。

结论:inline code patch 对 macOS platform binary 不可行。 这就是最终选择 ObjC runtime swizzle 的根本原因。

坑 6:内核 vs amfid 双重验证

hook installed! → test_ent: killed → amfid PID 没变(没 crash)

系统日志:Code has restricted entitlements, but the validation of its code signature failed.

内核 AMFI.kext 独立于 amfid 检查 restricted entitlementscom.apple.private.*)。我们成功 hook 了 amfid 层,但内核层在 amfid 之前/之外就拒绝了。

教训:pgrep -x amfid 对比 PID 是最快的诊断手段——PID 变了=crash,没变=hook 正常但问题在别处。

坑 7:直接拦截 → cdhash 缺失

hook was CALLED → test_ent exited 137

Hook 被调用了,但直接跳过原函数返回 YES→cdhash 没有被计算→内核收到空 cdhash 的 MIG reply→kill。

这是 call-through 设计的直接原因:“让它算,我们只改判”。

坑 8:内存槽位偏移覆盖——静默失效

#define SLOT_DATA_PAGE_PTR    0x60
#define SLOT_POST_HOOK_ADDR   0x60  // ← 罪魁祸首!

两个宏定义了相同偏移。后写的 memcpy 覆盖了先写的值。shellcode 读到错误的 data_page 地址,hook 静默失效(没 crash 也没效果)。

坑 9:method_setImplementation vs class_replaceMethod

method_setImplementation 在 arm64e 上成功存储了新 IMP,method_getImplementation 也确认了。但 objc_msgSend 仍然跳到 0x0

原因:objc_msgSend 快速路径从 preoptimized cache 获取 IMP。method_setImplementation 对 shared cache 的 small method 缓存清除不够彻底。

修复:换用 class_replaceMethod,它正确处理 preopt cache 的失效。

坑 10:arm64 编译 ≠ 不需要 PAC

将 Makefile 改为 -arch arm64 并移除 PAC 指令→amfid 内 crash。

setup code 不是编译产物——是手写的 uint32_t 机器码字节,在 amfid(arm64e 进程)的 CPU 上下文执行。paciza 在 Apple Silicon 上是真实的 PAC 操作,与 inject 二进制的编译架构无关。

坑 11:Toll-Free Bridging 绕过 objc_msgSend

测试用 [NSString length] 验证 swizzle→method_getImplementation 返回新 IMP 但实际调用走旧路径。

NSString 是 toll-free bridged 到 CFString[str length] 通过桥接直接到 CFStringGetLength()绕过 ObjC 方法缓存。测试目标方法要避开 toll-free bridged 类。

坑 12:braa 认证跳转——未签名的 IMP 被清零

class_replaceMethod 存储了新 IMP,method_getImplementation 确认了。但 objc_msgSend 跳到 0x0 → SIGSEGV。

arm64e 的 objc_msgSend 快速路径用 braa(authenticated branch) 跳转到 IMP。braa 会验证 IMP 指针的 PAC 签名——验证失败则指针被清零,跳到 0x0。我们的 code_page 是 mach_vm_allocate 分配的未签名内存,CS_DEBUGGED 允许执行未签名页面,但 PAC 认证发生在 CPU 流水线级别,与代码签名无关。

修复:在 setup code 中加 paciza x2,在 amfid 上下文中给 code_page 地址打 IA 签名。


11. 端到端流程总览

flowchart TD
    subgraph BUILD["构建阶段"]
        B1["probe_ivar.m → IVAR_CODE_OFFSET"]
        B2["hook.S → hook.o"]
        B3["nm hook.o → SLOT_DATA_PAGE_PTR"]
        B4["编译所有 .c(注入 -D 宏)"]
        B5["链接 → bin/amfree"]
        B1 --> B2 --> B3 --> B4 --> B5
    end

    subgraph INJECT["首次注入(sudo amfree --path /dir/)"]
        I1["find_amfid_pid + task_for_pid"]
        I2["dlopen AMFI → resolve ObjC IMP"]
        I3["remote_alloc × 3 in amfid"]
        I4["remote_write: shellcode + API ptrs + allowlist"]
        I5["fork debugserver → CS_DEBUGGED"]
        I6["RSP: save regs → set regs → continue"]
        I7["amfid 执行 setup:
paciza → class_replaceMethod → brk"] I8["RSP: read result → write old IMP → restore"] I9["detach + kill debugserver
CS_DEBUGGED 是 sticky 的"] I10["写入状态文件 /tmp/amfid_hook.txt"] I1 --> I2 --> I3 --> I4 --> I5 --> I6 --> I7 --> I8 --> I9 --> I10 end subgraph UPDATE["增量更新(sudo amfree --path /new/)"] U1["读取状态文件 + 验证 PID"] U2["task_for_pid"] U3["读取现有 allowlist"] U4["合并新路径"] U5["remote_alloc 新页面"] U6["更新 data_page 指针"] U1 --> U2 --> U3 --> U4 --> U5 --> U6 end subgraph RUNTIME["运行时(hook 持续生效)"] R1["有 binary 需要验证"] R2["objc_msgSend → hook"] R3["call-through 原始 IMP"] R4{"原始返回 YES?"} R5["直接 YES"] R6["SecCodeCopyPath → 取路径"] R7{"路径匹配 allowlist?"} R8["翻转为 YES"] R9["保持 NO"] R1 --> R2 --> R3 --> R4 R4 -->|YES| R5 R4 -->|NO| R6 --> R7 R7 -->|YES| R8 R7 -->|NO| R9 end BUILD --> INJECT --> RUNTIME INJECT -.->|"状态文件"| UPDATE UPDATE -.->|"即时生效"| RUNTIME

12. 增量更新:不重新注入的前提下追加路径

hook 安装完成后,状态写入 /tmp/amfid_hook.txt

pid=3882
data_page=0x104c50000
code_page=0x104c54000
old_imp=0x24197ee9c

再次运行 amfree --path /new/dir/ 时,程序先检查状态文件中的 PID 是否仍是 amfid(通过 proc_name 验证)。如果 hook 仍然活跃,走增量路径:

  1. task_for_pid 拿到 task port
  2. 读取 data page 中的 DP_ALLOWLIST_PTRDP_ALLOWLIST_SIZE
  3. remote_read 读出现有 allowlist 内容
  4. 合并新路径
  5. remote_alloc 分配新页面,写入合并结果
  6. remote_write 更新 data page 的指针和长度

整个过程不需要 debugserver、不需要 ObjC 解析、不需要线程劫持。只通过 task_for_pid + mach_vm_* 操作即可完成。

# 首次安装
sudo amfree --path /Users/me/dev/

# 追加路径(即时生效,无需重新注入)
sudo amfree --path /opt/tools/

# 查看当前 allowlist
sudo amfree --list

这之所以可行,核心原因是:

  • hook shellcode 在运行时读取 data page 的指针——不是硬编码的 allowlist 地址,而是每次执行时从 data page 加载 DP_ALLOWLIST_PTR
  • CS_DEBUGGED 是 sticky 的——新分配的页面不需要 debugserver 重新 attach
  • data page 保持 RW 权限——可以通过 mach_vm_write 直接修改

13. 局限性

限制 原因
不能绕过内核 AMFI restricted entitlements 在内核层被拒绝,amfid 根本不参与
需要放开 debug 限制 task_for_pid 对系统 daemon 需要 root + csrutil enable --without debug
不跨 amfid 重启持续 reboot 或 killall amfid 后需要重新注入(CS_DEBUGGED 随进程消亡)

14. 关键原则总结

  1. Platform binary 堵死了所有常规注入路径——DYLD_INSERT_LIBRARIES、fishhook、远程 dlopen、inline patch、磁盘修改全部失效。唯一可行路径是 task_for_pid + mach_vm_allocate 新页面 + ObjC runtime 元数据修改
  2. 不要试图模拟闭源组件的内部状态——call-through 让原厂代码干活,只改最终结果
  3. 两个方向都要考虑跳转范围——ARM64 B 指令 ±128MB,远距离必须用绝对地址
  4. inject 的编译架构 ≠ 目标进程的 CPU 模式——手写机器码在目标上下文执行
  5. 内核哈希校验 ≠ 代码签名校验——CS_DEBUGGED 放行未签名页面,但不放行篡改的已签名页面
  6. method_setImplementation 在 arm64e shared cache 上有坑——优先用 class_replaceMethod
  7. Build-time extraction > 硬编码偏移——跨 macOS 版本适应性
  8. 系统日志是调试 AMFI 的第一工具——log show --predicate 'process == "amfid"'