源码级调试 App Store 包

有些时候我们会遇到 Release 包有 Bug,Debug 包正常,或者是本地构建没问题,上传到 App Store 或者 TestFlight 版本就有 Bug。 能有个方式调试一下就好了。

这个可以有,解决的方式也很简单,我主要的灵感来自 MonkeyDev。如果你用过 MonkeyDev,会感觉使用这个工具逆向非常方便,我们可以直接在 Xcode 上像普通的 App 一样构建调试。

这个思路也可以用在我们调试一些“不能调试”的 ipa。

所以我们先来看一看,MonkeyDev 是如何将已经构建好的 ipa 直接放到 Xcode 运行起来的。

# 借助 Xcode 重签名运行 App

事实上这一过程很简单,基本上我们直接将原本构建的流程改成直接将从 ipa 解压的 app 拷贝过去即可。

为了了解 MonkeyDev 在这里做了什么,我创建了一个新的工程,这个工程只有三个文件 Info.plist*.xcodeprojcopy_target_app.sh

这个工程没有需要编译的文件,也没有需要处理的资源。只有一个 Copy Target App 的脚本。

脚本执行关键步骤如下:

  1. 拷贝要调试的 .app 中的内容到 Xcode 构建产物路径
  2. 设置产物的 Bundle ID 和工程配置的 PRODUCT_BUNDLE_IDENTIFIER 一致
  3. 使用 Xcode 提供的环境变量 EXPANDED_CODE_SIGN_IDENTITY 给动态库签名

对应脚本内容也非常少:

# Xcode 生成 .app 产物的路径
BUILD_APP_PATH="${BUILT_PRODUCTS_DIR}/${TARGET_NAME}.app"
# 待拷贝的 .app 路径
TARGET_APP_PUT_PATH="${SRCROOT}/${TARGET_NAME}/TargetApp"
# 当前工程的 Info.plist 路径
TARGET_INFO_PLIST="${SRCROOT}/${INFOPLIST_FILE}"
# 拷贝 .app 中所有文件
COPY_APP_PATH=$(find "${TARGET_APP_PUT_PATH}" -type d | grep "\.app$" | head -n 1)
cp -rf "${COPY_APP_PATH}/" "${BUILD_APP_PATH}/"
# 移除不考虑支持的 PlugIns 和 Watch
rm -rf "${BUILD_APP_PATH}/PlugIns" "${BUILD_APP_PATH}/Watch" || true
# 工程和产物的 Bundle ID 应当保持一致
cp -rf "${COPY_APP_PATH}/Info.plist" "${TARGET_INFO_PLIST}"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${PRODUCT_BUNDLE_IDENTIFIER}" "${TARGET_INFO_PLIST}"
cp -rf "${TARGET_INFO_PLIST}" "${BUILD_APP_PATH}/Info.plist"
# 给所有的动态库签名
for library in "${BUILD_APP_PATH}/Frameworks"/*; do
	/usr/bin/codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY}" "${library}"
done

Xcode 会帮我们完成剩下的事情,比如创建 .app 目录、给 .app 进行签名:

以上的这些工作,足以使用 Xcode 将一个已经构建好的 app 运行到手机中,并使用 lldb 进行调试。

那 ipa/app 从哪里获得?我们自己开发的 app,手里当然有未加密的 ipa(这可以从 .xcarchive 获得),完全不需要砸壳的参与,自然也用不到越狱的设备。

一般传到 App Store 的 app 我们都会使用 CI 进行构建,相关的产物也可以保留下来。只有找到需要调试那次的 ipa,直接使用上述流程处理即可。

# 完成源码调试

这一步的关键是完成符号表恢复和源码路径映射。

为 App Store 构建的 app 没有调试信息,不过既然是我们自己的 app,每次构建应当留下 dSYM 文件供跟踪线上崩溃,这当然也可以用来进行本地调试。

lldb 提供了一个 add-dsym 参数,我们可以在 app 启动时(可以使用 LLDB Init File)添加 dSYM。

这里我随便挑选了一个 Demo app 进行尝试,这个工程来自 Adopting Menus and UIActions in your User Interface (opens new window)

在 CI 上打包上传后,可以得到 ControlMenus.xcarchive

既有 app 又有 dSYM。

将 app 按照上一小节进行构建后,使用 (lldb) add-dsym {dsym-path} 即可添加符号信息。

如下图所示,我们可以看到调用栈和一些符号信息,同时还可以使用符号断点:

符号信息恢复完毕,为了查看源码,我们先来使用 source info 查看看断点所在的源码位置:

(lldb) source info
Lines found in module `ControlMenus
[0x00000001025f7cdc-0x00000001025f7cf4): /Users/yahaha/Downloads/AdoptingMenusAndUIActionsInYourUserInterface/ControlMenus/ViewController.swift:13:15

(Downloads 只是举个例子)这个路径是 CI 上打包的路径,我们可能很难在本地相同路径放一份代码。

不过我们可以使用 lldb 的源码路径映射解决。这个文件对应到我设备上的本地路径为:

/Users/yahaha/Desktop/AdoptingMenusAndUIActionsInYourUserInterface/ControlMenus/ViewController.swift

只是举例说明,CI 环境放到了 Downloads 目录下,本地放在了 Desktop 目录下。

此时我们使用一个 lldb settings append target.source-map {old-path} {new path} 命令,即可完成目录映射,具体到这个场景命令如下:

(lldb) settings append target.source-map /Users/yahaha/Downloads /Users/yahaha/Desktop

此时在调试到指令属于某一行代码时,Xcode 将显示具体的代码信息,同时还可以查看到各个变量信息:

此外还可以从上图中看到 source info 中路径从 Downloads 变成了 Desktop

以上实践,我在 https://github.com/DianQK/debug-ipa (opens new window) 中提供了所有的内容,这包括一个我生成的 ControlMenus.xcarchive 以及相关源码。 甚至我们可以借助这个工程进行改造,完成一个专属的 App Store 调试套装。

需要额外补充的一点是,线上包一般会有一些反调试的手段,我们可以参考 MonkeyDev 增加动态库的注入,去掉这些反调试,也可以直接使用 MonkeyDev,本文主要是理解实现的过程。