背景
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 必然会拒绝它。此前的做法需要两步来绕过:
csrutil disable完全关闭 SIPnvram boot-args="amfi_get_out_of_my_way=1"完全禁用 AMFI
这等于同时拆掉了 macOS 的两道核心安全防线。实际带来的问题:
- 开发工具链崩坏:JDK 和 Azure Functions Core Tools 在 SIP 关闭后无法正常工作。这两个工具对系统完整性保护有运行时依赖,关闭 SIP 直接导致日常开发流程断裂。
- 安全性急剧下降:完全关闭 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 有一套成熟的工具链。但 amfid 是 platform 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。但:
- 你的 dylib 本身没有代码签名——在
CS_DEBUGGED设置之前,amfid 连加载都做不到(内核拒绝映射未签名的代码页) - 即使签了名,
amfid作为 hardened runtime 进程,对dlopen的来源有严格限制 - 你仍然需要 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) 验证系统文件完整性。直接改二进制完全不现实。
那还能怎么办?
所有常规手段都被封死后,唯一的路径是:
task_for_pid(csrutil enable --without debug+ root)获取 amfid 的 task portmach_vm_allocate在 amfid 中分配全新的内存页- debugserver attach → 设置
CS_DEBUGGED→ 新页面可执行 - 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_msgSend 的 braa(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 entitlements(com.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 仍然活跃,走增量路径:
task_for_pid拿到 task port- 读取 data page 中的
DP_ALLOWLIST_PTR和DP_ALLOWLIST_SIZE remote_read读出现有 allowlist 内容- 合并新路径
remote_alloc分配新页面,写入合并结果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. 关键原则总结
- Platform binary 堵死了所有常规注入路径——
DYLD_INSERT_LIBRARIES、fishhook、远程 dlopen、inline patch、磁盘修改全部失效。唯一可行路径是task_for_pid+mach_vm_allocate新页面 + ObjC runtime 元数据修改 - 不要试图模拟闭源组件的内部状态——call-through 让原厂代码干活,只改最终结果
- 两个方向都要考虑跳转范围——ARM64 B 指令 ±128MB,远距离必须用绝对地址
- inject 的编译架构 ≠ 目标进程的 CPU 模式——手写机器码在目标上下文执行
- 内核哈希校验 ≠ 代码签名校验——CS_DEBUGGED 放行未签名页面,但不放行篡改的已签名页面
method_setImplementation在 arm64e shared cache 上有坑——优先用class_replaceMethod- Build-time extraction > 硬编码偏移——跨 macOS 版本适应性
- 系统日志是调试 AMFI 的第一工具——
log show --predicate 'process == "amfid"'