☰
Xcode 15下MonkeyDev动态库注入调试适配指南
2026/10/27 12:32:11 网站建设 项目流程

上周把开发机上的 Xcode 升到 15,随手打开那个安静了半年的 MonkeyDev 工程,Command+R 之后红色报错一路刷到底,那种感觉就像你出差回来发现自家门锁换了。MonkeyDev 这套免越狱设备上的动态库注入调试方案,在 Xcode 15 下确实需要一批针对性的改造才能重新跑起来——它既不是简单换个版本号就能糊弄过去,也不是彻底不能用。这篇就把我这两天从零到跑通的完整过程记下来,包括 Xcode 15 到底改了哪几处底层行为、Build Settings 里必须动刀的十几个键、Run Script 的重写要点、签名顺序为什么不能乱,以及一堆只有真机跑起来才会暴露的坑。如果你手上也有这类注入调试工程,或者正在做自有 App 的插件化拆解、安全研究学习、授权范围内的测试验证,这篇应该能帮你省掉一整个下午。

1. 先搞清楚 MonkeyDev 在 Xcode 里到底干了什么

1.1 一条完整链路:模板、dylib target、Run Script、重签名

很多人用 MonkeyDev 用了很久,其实没细看它在工程里布了什么局。装好之后,Xcode 的新建工程面板里会多出几个自定义模板,选中之后生成出来的并不是一个普通 App 工程,而是「一个宿主 App target + 一个动态库 target」的组合体。宿主 target 负责把壳装到设备上,动态库 target 才是你真正写业务逻辑的地方——所有的类声明、方法替换、界面改动都写在这边。两者之间靠 Build Phases 里的一个 Run Script 连接起来,这个脚本会调用 MonkeyDev 自带的一套命令行工具,做三件固定动作:把编译出来的 dylib 拷贝进.app包内、往宿主可执行文件的 Mach-O 头里插入一条加载指令、对改动过的产物重新签名。做完这三步,Xcode 的安装流程才会把一个「自带插件」的壳推进设备。

理解这条链路非常关键,因为 Xcode 15 的所有破坏性改动,基本都打在这条链路的某一环上。第一环是模板本身,模板里塞着一堆历史遗留的 Build Settings,这些键在新版本里有的被废弃、有的默认值翻转、有的直接消失;第二环是 dylib target 的编译参数,涉及架构切片、链接器行为、模块校验;第三环是 Run Script 的执行环境,Xcode 15 给它加了一个沙盒;第四环是签名,Xcode 15 对签名的校验更严,顺序错了或者漏签任何一个 Mach-O 都会在安装阶段被拦下来。四环里任何一环断掉,你看到的都是同一个结果——真机上闪退或者压根装不进去,但根因完全不同。

我踩的第一个坑就在这里:一开始看到闪退就去查 hook 代码,折腾了两小时才发现是签名顺序导致的code signature invalid,跟业务逻辑一行关系都没有。所以后面的章节我会按这条链路的顺序来讲,方便你对照自己的工程定位断点在哪一环。

1.2 Xcode 15 掐断的四根线,和它们各自的表现

第一根线是用户脚本沙盒。Xcode 15 新建工程默认把ENABLE_USER_SCRIPT_SANDBOXING打开,一旦开启,Run Script 只能在工程目录和派生数据目录里读写,任何越界访问都会被系统直接拒绝。MonkeyDev 的脚本恰恰需要访问工程外的东西——命令行工具在安装目录下,dylib 产物在派生数据里,App 壳在另一个路径。表现就是构建日志里出现一串Sandbox: bash(...) deny(1) file-read-data,然后脚本中途退出,但 Xcode 未必给你一个显眼的红叉,很多时候只是一个 warning 级别的提示,产物看起来「构建成功」,装上去必然崩。

第二根线是位码彻底退场。老工程里ENABLE_BITCODE = YES几乎是默认配置,Xcode 15 已经不认这个键了,留着会在构建时报出明确的错误而不是警告。这一条比较好处理,删掉即可,但它会掩盖其他问题——因为构建一旦在早期失败,你就看不到后面链接阶段的报错了。

第三根线是架构收敛。VALID_ARCHS这个键已经废弃,ARCHS里如果还写着armv7,Xcode 15 会直接告诉你该架构不再支持。更隐蔽的是模拟器切片问题,如果你的工程里手动配了EXCLUDED_ARCHS或者依赖了只有 x86_64 的静态库,在新架构组合下会报building for iOS Simulator, but linking in object file built for iOS,这个错看起来像库的问题,实际是架构没对齐。

第四根线是链接器换代。Xcode 15 换上了新链接器,行为和老链接器有一批差异:重复链接同一个库会报警告、-lstdc++的查找路径变了、对LC_LOAD_DYLIB的处理更严格、对 arm64 切片的合并规则也不同。这些差异单独看都不致命,但叠在一起会让老工程的构建产物悄悄变形——比如插入了加载指令但符号表对不上,运行时 dyld 直接拒绝加载。

2. 动手前先把环境矩阵定死

2.1 版本组合与踩雷概率

适配这种事情,最忌讳的就是「边改边猜」。先把版本组合确定下来,能规避一半的玄学问题。下面这张表是我实测下来比较稳的搭配,以及几个常见组合的雷点:

组合项推荐选择备注与雷点
Xcode 版本15.2 或 15.415.0 早期版本对新链接器的兼容略差;15.3 之后部分工具链路径有调整
macOS 版本14.x13.x 上部分命令行工具的签名校验行为不一致
部署目标iOS 13.0 及以上低于 12.5 会撞上静态运行库缺失的问题,13.0 更保险
真机系统iOS 15 及以上过老的系统对新签名策略支持不好,调试反而更麻烦
工具链版本与工程安装目录保持一致多版本共存时务必确认脚本调用的是哪一个
架构仅 arm64真机 arm64,模拟器按需额外处理

关于部署目标那一条,值得多说两句。Xcode 从 14.3 开始,当部署目标低于 12.5 时,链接阶段会去找一个早已不在工具链里的静态运行库,报的错误是「file not found」加一长串路径。这个错看起来像缺文件,很多人第一反应是去装库、去改搜索路径,其实只要你把部署目标提到 12.5 以上,问题自动消失。我在老工程上就是先被这个错误带偏了半小时,最后发现改一个数字就完事。

另外要提醒的是工具链版本。如果你机器上装了多个 Xcode,xcode-select -p指向的和你在图形界面里打开的未必是同一个,而 Run Script 里的命令行工具是按绝对路径调用的。我见过一个案例,图形界面用的是新版本,脚本里写死了老版本的路径,结果编译用的是旧工具链、签名用的是新工具链,两边的签名策略不一致,产物在安装时被判为无效。适配期一定要把所有路径统一,别留这种隐形不一致。

2.2 工程骨架选型:dylib target 还是 framework target

老模板默认产出的是 dylib,新版本 Xcode 对 framework 的支持更完整(比如资源打包、模块导出、@rpath的处理都更规范)。但改骨架的代价不小,你要重做 target、改注入脚本、调整加载指令的指向,还要处理 framework 内嵌签名的问题。

我的建议是:除非你有明确的资源打包需求,否则不要换骨架。dylib 的路径简单、签名逻辑少、注入脚本好写,适配成本明显更低。只有当你的插件需要带图片、带 xib、带独立配置文件时,framework 才值得考虑,因为 framework 内嵌资源是官方支持的标准做法,而 dylib 想带资源得自己想办法塞进主 bundle,很容易和宿主 App 的资源冲突。

如果确实要走 framework 路线,两个额外的注意点:一是INSTALL_PATH要设成@executable_path/Frameworks,二是宿主 target 的LD_RUNPATH_SEARCH_PATHS要包含@executable_path/Frameworks,否则运行时找不到。这两个键名字很像但作用完全不同,前者决定「装到哪」,后者决定「去哪找」,写反了就是典型的「dyld: Library not loaded」。

2.3 一份可以直接粘的 xcconfig

与其在图形界面里一个个点,不如抽出公共配置做成 xcconfig,多人协作和重建工程时都省事。下面这份是我在 dylib target 上验证过的,注释里写了每个键的作用:

// 关掉脚本沙盒,允许访问工程外路径 ENABLE_USER_SCRIPT_SANDBOXING = NO // 架构只保留 arm64,去掉所有遗留架构 ARCHS = arm64 VALID_ARCHS = // 部署目标抬高,绕开静态运行库缺失 IPHONEOS_DEPLOYMENT_TARGET = 13.0 // 关闭模块校验的严格模式,避免第三方头文件报 module not found CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES CLANG_ENABLE_MODULES = NO // 新链接器的重复库只警告,不中断构建 OTHER_LDFLAGS = $(inherited) -Wl,-no_warn_duplicate_libraries // 签名方式统一为手动,避免自动签名把 dylib 漏掉 CODE_SIGN_STYLE = Manual CODE_SIGN_IDENTITY = Apple Development // 关闭严格警告,老代码的告警太多会淹没真正的错误 GCC_TREAT_WARNINGS_AS_ERRORS = NO // 库的安装路径 INSTALL_PATH = @executable_path/

这份配置里最容易被忽略的是CODE_SIGN_STYLE = Manual。自动签名在 Xcode 15 下会尝试给 bundle 内的所有 Mach-O 重签,但它对 dylib 的处理逻辑和主程序不同,经常出现「主程序签了、dylib 没签」或者「签名用的证书不一致」的情况,最终表现是安装成功但一启动就退出。手动签名虽然要多写几行脚本,但每一步在不在你的掌控之中,排查起来心里有底。

3. 逐项改造:Build Settings 里的十几处硬修改

3.1 沙盒、位码、架构三件套

这三个是最先要处理的,因为它们决定了构建能不能走完。

沙盒这一项在 target 的 Build Settings 里搜sandbox就能找到,把User Script Sandboxing改成NO。改完之后要清理一次派生数据再构建,因为 Xcode 会缓存脚本的执行策略,不清理的话改了也不生效,你会以为没改对。清理的方式是Product → Clean Build Folder,或者直接删掉派生数据目录里对应的工程文件夹。

位码这一项,除了在 Build Settings 里删掉Enable Bitcode,还要检查工程的 project.pbxproj 文件里有没有残留。有些从老版本迁移过来的工程,图形界面里看不到这个键了,但文件里还留着,构建时依然会读。稳妥的做法是用文本编辑器搜一遍ENABLE_BITCODE,把整行删掉。顺带一提,.pbxproj文件在改之前一定要备份或者提交一次,这个文件一旦改坏,工程打不开就麻烦了。

架构这一项,除了ARCHS设成arm64,还要看一眼EXCLUDED_ARCHS。有些人为了在模拟器上跑,会在这里加一堆排除规则,比如EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64,这在 Intel 机器上是合理的,但在 Apple Silicon 机器上就会导致模拟器跑不起来。判断标准很简单:你的机器是什么芯片,模拟器就用什么架构,别硬套别人的配置。真机调试不受这一项影响,如果只是在真机上跑,直接把模拟器相关的排除规则都删干净最省事。

3.2 链接器与模块校验的两处关键开关

新链接器带来的第一个麻烦是重复库。老工程里经常出现同一个库被间接链接多次的情况,比如-lz在多个依赖里都写了,老链接器默默去重,新链接器会给你一条ignoring duplicate libraries的警告。这条警告本身不致命,但如果你的工程开了「警告视为错误」,构建就断了。所以要么加-Wl,-no_warn_duplicate_libraries抑制它,要么关掉「警告视为错误」,我倾向于后者,因为老代码里的警告本来就不可能清干净。

第二个麻烦是模块校验。老工程里的第三方头文件大多不是模块化的,Xcode 15 对@import和#import的混用更敏感,经常报module '某库' not found或者include of non-modular header inside framework module。这类错误的根源不是头文件真的找不到,而是编译器试图把非模块化头文件塞进模块化上下文。解法就是把CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES打开,同时把CLANG_ENABLE_MODULES关掉——后者会让编译器完全走传统的#import路径,虽然编译速度慢一点,但兼容性最好。注入调试工程的代码量通常不大,这点编译时间可以忽略。

还有一个容易被忽略的键是ENABLE_TESTABILITY。Xcode 15 在 Debug 配置下默认把它打开,而它会让编译产物带上一些额外的符号信息,某些 hook 框架在解析符号表时会因为多了这些信息而行为异常。如果你的插件在 hook 之后出现莫名其妙的方法找不到,可以试着把它关掉看看。

3.3 签名参数与 Info.plist 的配套改动

签名这块的核心原则是「一处配置,全链路一致」。证书名、描述文件、签名方式,三个地方必须完全对齐,主程序、dylib、内嵌的其他 Mach-O 都要签同一个证书。

CODE_SIGN_IDENTITY的值在 Xcode 15 里叫Apple Development,老工程里写的iPhone Developer虽然在部分场景下还能识别,但建议统一改过来。PROVISIONING_PROFILE_SPECIFIER要填描述文件的名称,注意是名称不是 UUID,填 UUID 在命令行构建时可能不生效。另外CODE_SIGN_ENTITLEMENTS要指向正确的文件,如果你没有特殊需求,主程序和 dylib 都要在这个文件里声明同样的能力,尤其是涉及调试相关的能力,dylib 缺了会直接导致附加进程失败。

Info.plist 这边,有两个键需要确认:CFBundleExecutable必须和实际的可执行文件名一致,MinimumOSVersion要和部署目标对齐。我见过一个案例,工程的部署目标改成了 13.0,但 Info.plist 里还写着 11.0,结果在较新的系统上安装时被拒,报的错误信息完全不指向这个字段,纯靠猜。

还有一个跟签名相关但和 Build Settings 无关的设置——设备的开发者模式。较新版本的系统在首次连接调试时需要手动在设备上开启开发者模式,不开启的话,Xcode 会显示「已连接」但安装阶段总是失败,而且错误提示非常笼统。这个不是 Xcode 15 引入的,但适配期很容易撞上,先确认一下能省很多时间。

4. Run Script 重写:注入流程的核心

4.1 脚本执行时机与沙盒的正确处理

脚本的执行时机决定了它能看到什么产物。Xcode 的构建分阶段,Run Script 如果放在Compile Sources之前,dylib 还没编译出来,脚本自然拷不到东西;放在Copy Bundle Resources之后,资源已经打包完,再改产物就需要重新签名整个 bundle。正确的位置是所有编译和资源阶段之后、签名阶段之前。老模板一般放在最后,但 Xcode 15 的签名阶段被提前了,需要手动把脚本往前挪一格。

脚本内容的开头建议加三行:

#!/bin/bash set -e set -o pipefail

set -e让脚本在任何一步失败时立即退出,而不是继续往下跑出一个半成品。这一点很重要,因为注入流程有严格的先后依赖,前一步失败后一步继续跑,产出的东西必然不可用,但构建日志里可能只有一个不起眼的 warning,你会在真机上排查半天才发现源头在构建阶段。

脚本里用的路径变量,Xcode 15 下有两个推荐用:$TARGET_BUILD_DIR指向最终产物目录,$WRAPPER_NAME是.app的目录名,拼起来就是完整的 App 路径。$BUILT_PRODUCTS_DIR也可以,但它指向的是构建中间目录,在多配置构建设置下可能和最终产物不在同一个地方。老脚本里经常写死Debug-iphoneos,这种写法在新构建系统下有时会失效,因为 DerivedData 的目录结构变了。

沙盒关闭之后,脚本就可以正常访问安装目录了。但有个细节:脚本里调用的命令行工具最好写绝对路径,或者在最前面export PATH=$PATH:/your/tool/path。因为 Xcode 执行脚本时的 PATH 是一个精简版本,不包含你自己安装目录下的可执行文件,直接写工具名会报command not found。

4.2 注入路径与加载指令的处理

dylib 拷贝进.app之后,还要让主程序知道「启动时该加载它」。这一步靠往 Mach-O 头里插入一条LC_LOAD_DYLIB指令完成,路径一般写成@executable_path/你的库名.dylib,表示相对主程序所在目录查找。

这里有个实操细节:拷贝的时候要确认拷贝的是正确架构的切片。如果你的 dylib target 同时产出了 arm64 和模拟器切片,直接cp整个 fat 文件过去也能用,但体积会大一倍,而且可能夹带不该有的切片。稳妥做法是用lipo -thin arm64抽出来再拷。同理,往主程序里插指令的工具对 fat 二进制的支持程度不一,老工具处理 fat 文件时可能只改第一个切片,导致真机架构上根本没有加载指令,运行时看起来就是「代码完全不生效」。

插入指令之前最好做个备份,或者至少在脚本里判断一下指令是否已经存在,避免重复执行时插进去两条。有些工具自带/D之类的去重参数,有些没有,没有的话就自己用otool -l检查一下再决定要不要插。这个判断逻辑写起来不复杂,但能省掉「为什么我的库被加载了两次」这种玄学问题。

4.3 重签名顺序不能乱

签名顺序是整条链路里最容易出错的一环,而且错误的表现极具迷惑性。正确的顺序是从内到外:先签 dylib,再签 bundle 内的其他 Mach-O,然后是整个.app目录,最后如果有外层容器再签外层。顺序反过来的话,签外层的时候会把内层的签名状态一起固化进去,之后再改内层,外层就失效了,系统加载时直接报签名无效。

脚本里的签名命令大致长这样:

codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" \ --timestamp=none \ "$APP_PATH/你的库名.dylib" codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" \ --timestamp=none \ "$APP_PATH"

--timestamp=none是必须的,加时间戳会去联网请求,构建时网络不通就卡住,而且调试用途也不需要时间戳。--force允许覆盖已有签名。$EXPANDED_CODE_SIGN_IDENTITY是 Xcode 提供的环境变量,值就是你在 Build Settings 里配的证书,用它比自己写死字符串更安全。

还有一个坑:如果你的.app里有Frameworks目录或者PlugIns目录,里面的内容必须先单独签完,再签整个 bundle。脚本里用一个find加循环处理就行,注意跳过.dSYM和隐藏文件。签完可以用codesign -dv --verbose=4检查一下每个 Mach-O 的签名信息,确认Authority字段一致、Identifier没有重复。

5. 验证与排错:产物到底对不对

5.1 otool 三连自查

构建完之后别急着装,先对产物做三项检查,能提前发现八成问题。

第一项看加载指令:otool -L 主程序路径,输出里应该能看到你的 dylib 并且路径是@executable_path/...。如果看不到,说明插入失败或者插到了别的切片上;如果路径是绝对路径,说明插入时写错了,装到设备上一定找不到。

第二项看架构:lipo -info分别看主程序和 dylib,确认两边都只包含 arm64。如果一边有 arm64 另一边只有 x86_64,那就是架构没对齐,链接阶段会出问题。

第三项看签名:codesign -dv --verbose=4 路径,重点看Authority、TeamIdentifier、Identifier三个字段。主程序和 dylib 的TeamIdentifier必须一致,Identifier必须不同(否则系统会认为是同一个 bundle 的重复实例)。

这三项检查加起来不到一分钟,但能把后面的排查时间压缩十倍。我现在的习惯是把它写成一个 shell 脚本,构建完成后自动跑一遍,有问题直接报出来,不用等到真机闪退才回头翻。

5.2 启动即崩的六种典型与定位方法

第一种,dyld 报Library not loaded。原因通常是 dylib 没拷进包里,或者加载路径写错。定位方法是在设备上用log stream抓系统日志,崩溃瞬间会打出完整的查找路径,照着路径去find一下就知道文件到底在不在。

第二种,code signature invalid。签名顺序错了,或者某个 Mach-O 漏签。用codesign -v全量校验一遍,它会告诉你是哪个文件、哪一项校验没过。

第三种,进程被Killed: 9。这种情况多见于证书问题,比如用了免费开发者证书且已经过期(有效期七天),或者描述文件和设备不匹配。表现是完全没有崩溃日志,进程直接消失。

第四种,走到 hook 点就EXC_BAD_ACCESS。类名或方法名拼错是最常见的原因,其次是参数类型对不上。这类问题要把日志级别调高,让 hook 框架把每次替换的类、方法都打出来,逐条比对。

第五种,objc_msgSend附近崩溃。通常是 hook 框架没有正确加载,或者加载了但初始化没跑。检查一下库的入口函数有没有被执行,可以在入口打一行日志验证。

第六种,界面起来了但代码完全不生效。这种最隐蔽,往往是因为 dylib 被加载了,但加载的是错误架构的切片,或者加载指令插在了错误的 Mach-O 头里。回到上一节的三项检查,基本能定位。

6. 高频报错速查表与几条血泪经验

6.1 报错对照表

报错关键词真实原因处理方式
Sandbox: bash deny file-read-data脚本沙盒没关把 User Script Sandboxing 设为 NO,清派生数据
building for armv7 is no longer supported架构里有遗留值ARCHS 只留 arm64,清掉 VALID_ARCHS
file not found: ...libarclite 相关部署目标过低IPHONEOS_DEPLOYMENT_TARGET 提到 13.0
module '某库' not found模块化头文件混用关 CLANG_ENABLE_MODULES,开非模块化包含
ignoring duplicate libraries新链接器重复库警告加 -Wl,-no_warn_duplicate_libraries
command not found: 某工具脚本 PATH 精简脚本里写绝对路径或前置 export PATH
dyld: Library not loaded拷贝或路径错误otool -L 看指令,检查包内文件是否存在
code signature invalid签名顺序或漏签从内到外重签,codesign -v 全量校验
进程 Killed: 9证书或描述文件问题检查证书有效期与设备绑定关系
加载了但代码不生效架构切片不对lipo -info 核对两边架构

这张表建议直接贴到工程 README 里,团队里谁再撞上,对着查就行。里面每一条我都是真机踩出来的,尤其最后一条,当时反复确认代码没错、签名没错、包内文件也在,最后发现是插入指令的工具只处理了 fat 文件的第一个切片。

6.2 几条踩出来的经验

第一,改造之前先把老工程的.pbxproj完整备份一份。这个文件是纯文本,但结构复杂,改错一个括号工程就打不开。我吃过一次亏,删ENABLE_BITCODE那一行的时候不小心删多了,工程直接无法解析,只能从 git 里回滚重来。

第二,别相信「构建成功」这四个字。Xcode 15 的构建日志里,脚本阶段的失败很多时候只报 warning,界面照样显示绿色的成功提示。真正要确认的是脚本有没有跑完——可以在脚本最后加一句echo "inject done",日志里没看到这句话,就说明没走到最后。

第三,真机调试第一次跑通之后,立刻把整套配置提交并打一个 tag。注入调试工程的配置太脆弱,任何一个 Xcode 小版本更新都可能打破平衡,有个可回退的基线非常重要。我现在的做法是给每个能跑通的组合打一个 tag,名字里带上 Xcode 版本和系统版本,出问题直接切回去对比。

第四,尽量把改动收敛到 xcconfig,少在图形界面里点。图形界面的改动藏在.pbxproj里,diff 出来全是一堆十六进制看不懂的东西;xcconfig 是纯文本,改了什么一目了然,多人协作时冲突也好处理。

第五,也是我个人体会最深的一点:这类工具链适配,最大的时间消耗不在「改」,而在「确认改对了没有」。所以每一步改完都要立刻做一次最小验证——改完架构就lipo -info看一眼,改完脚本就单独跑一次脚本,改完签名就codesign -v校验一次。别想着全部改完再一起跑,那样一旦失败,你不知道是哪一处引起的,只能从头二分。我这两天真正编码的时间加起来不到一小时,剩下的全在验证和确认上,这个比例在工具链适配里是正常的,心里有数就不会急躁。

如果你后续还想继续深挖,其实这套流程可以往上抽象成一层自动化:把构建、注入、签名、校验写成一条流水线,每次改配置自动跑一遍输出报告,比手动点快得多。我打算下次有空把这部分整理出来,到时候再说。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询