ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iOS IPA重签名实战:证书、描述文件与entitlements处理指南

iOS IPA重签名实战:证书、描述文件与entitlements处理指南

不知道你们有没有遇到过这种尴尬:手里明明有一个现成的 IPA 包,也能正常跑,但是换了一台测试机、或者团队换了个开发者账号之后,怎么装都装不上去,绕了半天最后发现是签名体系的问题——证书过期了、描述文件里没包含这台设备的 UDID、或者当时打包用的签名身份早就失效了。更麻烦的是,源码不一定还在手里,重新出包的成本往往高得离谱。这时候“IPA 包重签名”就是最实用的一条路:不动业务代码、不进 Xcode 重新编译,直接把这个包里的签名身份整批替换成当前可用的证书和描述文件,让 iOS 的校验链重新认可它,然后把包装上设备。

本文是 iOS 逆向重签名系列的第二篇。上一篇主要把签名体系的基础概念捋了一遍,这一篇直接进入实战,围绕手工重签的完整操作链路展开,同时把我在实际重签过程中踩过的坑和总结出来的排查思路一并写出来。前提说明一下:重签对象必须是你自己有授权处理的 App,比如自己开发或团队内部维护的测试包、历史产物,或者安全分析场景下需要装到真机上的样本。未经授权对他人应用做重签再分发,不在本文讨论范围内。

1. 重签需求从哪儿来:为什么说“不重建源码也能换身份”

1.1 重签的本质:把旧身份换成新身份

先回到问题的根源。iOS 设备在安装和启动一个 App 时,会围绕三个维度做校验:代码签名是否有效、描述文件是否覆盖当前设备、entitlements 是否与描述文件匹配。任何一个环节对不上,App 就会被系统拒绝,表现就是装不上,或者装上后启动即被系统强杀。

关键在于,这三个维度全部写在“包”里,而不是写在源码里。所以对一个已经打好的 IPA,我完全可以只改包内的签名相关内容,不改任何二进制代码。简单说,重签做的事就是:把旧的签名证书、旧的描述文件、旧的权限声明,整体换成一套新的、符合当前测试设备条件的组合,然后重新封包。这个操作会让 iOS 认为这是一个全新的、被当前开发账号正式保护的 App。

有人可能会问,那能不能直接把包里的证书字段改掉就完事儿?不行。iOS 的校验链是环环相扣的:签名由私钥生成,验签用公钥,公钥藏在证书里,而证书又被描述文件引用;描述文件里有明确的 App ID、设备 UDID 列表、授权的权限列表。只改其中一个字段,链就断了。所以重签必须整条链路一起换。

1.2 需要重签的典型场景

从实际经验看,重签需求主要集中在下面这几类场景:

  • 证书过期或描述文件过期。这是最常见的一种。iOS 开发证书和描述文件都有有效期,尤其是免费个人开发账号,描述文件通常只有 7 天。对一个老包来说,如果当初的证书已经废弃,重新生成证书后最省事的做法就是直接重签,而不是找回源码重新构建。
  • 设备不在描述文件的设备列表里。开发描述文件里会写明允许安装的 UDID 白名单。来了新测试机,设备 ID 没加进旧描述文件,装不上;重新生成描述文件之后再对旧 IPA 重签一遍就能继续用。
  • 团队内部切换账号。项目交接时,老包用的是上一个开发者账号的证书,新团队只有自己的证书。重签可以让你不需要拿到全部源码,只用新账号的证书和描述文件把老包“接管”过来。
  • 逆向分析场景。安全测试或漏洞分析经常需要一个没有源码的包跑在特定测试机上。为了在真机上动态跟踪、验证行为,需要用它自己的分析账号重新签名,放到这台设备上。

2. 开动之前先理清三件事:证书、描述文件和 entitlements

这个系列上一篇说过签名体系的基础,但既然这一篇是实操向,我还是把重签最依赖的三个元素再浓缩成“重签视角”。

2.1 证书:你用来签名的身份

证书的本质是一个非对称密钥对加上一个受 CA 信任的身份信息。在 iOS 开发里,你平时用到的证书主要分两类:

  • 开发证书(Apple Development):用于开发调试。用它签出来的包,可以在描述文件指定的设备上跑,并被允许附加调试器调试。
  • 分发证书(Apple Distribution / iPhone Distribution):用于 App Store、Ad Hoc 或企业分发。Ad Hoc 分发可以在描述文件里列出一批真机设备,不需要经过审批;企业分发则面向组织内部不限设备数,但对账号的审核门槛更高。

重签时你会用到哪张证书,取决于你希望这个包以什么方式被测试。一般来说,真机调试安装用开发证书最方便,因为它的描述文件里能直接加设备,还能配合调试器使用。如果只是企业内部测试,用 Ad Hoc 分发证书也可以。

在 macOS 上可以先用这条命令确认当前钥匙串里有哪些可用的签名身份:

security find-identity -v -p codesigning

输出里会列出类似"Apple Development: name@example.com (TEAMID)"的字符串,那就是 codesign 填的签名身份名称,等下脚本里要用到。

2.2 描述文件:设备和权限的边界

描述文件(Provisioning Profile)是一个信息包,里面打包了 App ID、Team ID、设备 UDID 列表,以及期望授予 App 的 entitlements 列表。App 内的描述文件固定放在embedded.mobileprovision,安装时系统会读它来确认这个 App 能不能装到当前设备上。

检查一个 IPA 原本用的描述文件信息,可以把描述文件解出来看:

security cms -D -i Payload/某App.app/embedded.mobileprovision -o profile.plist

得到的profile.plist里能看到ProvisionedDevices数组、ApplicationIdentifierPrefix、Entitlements字典等内容。这一招对排查“为什么装不上”非常有用,建议养成习惯。

顺带提一句,免费的个人团队账号和付费账号在重签场景里的差别很大。免费账号生成的证书和描述文件有效期短,能申请的 entitlements 也有限,App ID 数量一周内还有上限。如果想长期稳定调试、又要推送等功能,还是建议用付费开发者账号。

2.3 entitlements:权限清单嵌在签名里

如果说描述文件是“许可证”,entitlements 就是这份许可证里单列出来的“特权清单”。它本质上是一个 plist,会被编码进代码签名的签名块里。常见的有aps-environment推送权限、associated-domains通用链接、keychain-access-groups钥匙串组、app-groups应用组等。

关键点来了:App 要求的 entitlements,必须都被描述文件授权。如果重签时把一个描述文件里没有的 entitlement 塞进去,App 会在启动时直接被拒。反过来,如果 App 原包里有 push entitlement,而你新用的描述文件没有对应的aps-environment权限,那你重签时必须把这个 key 从签名里去掉,否则系统同样不给过。

搞清楚这三者的关系后,重签就不是什么玄学了。你在重签时本质上在做三件事:

元素位置重签时要做什么
证书系统钥匙串选可用的签名身份,替换旧的
描述文件App 包内embedded.mobileprovision删除旧的,放入新的
entitlements签名块内部从新描述文件提取,过滤掉无权项

3. 手工重签完整链路:从解包到装机的每一步

下面用最直白的方式走一遍手工重签流程,拿到的包假设叫TestApp.ipa。全程在 macOS 上进行,用的工具基本都是系统自带的,不需要额外装任何东西。

3.1 解包并确认包体结构

IPA 本质上是一个 zip 包,解压后根目录下有一个Payload目录,里面放着真正的.app包。

mkdir work && cd work unzip -q ../TestApp.ipa cd Payload ls -ld *.app

进入.app目录后,先看结构:

cd TestApp.app ls -la

正常情况下你会看到这样几类东西:

  • TestApp:主程序可执行文件(Mach-O)
  • Info.plist:App 元信息
  • embedded.mobileprovision:旧的描述文件
  • _CodeSignature/:旧的签名记录目录
  • Frameworks/:内嵌的动态库或框架
  • PlugIns/:App 扩展(widget、notification extension 等)
  • Watch/:Apple Watch App(如果有)

建议先做一次旧签名体检,心里有底。用:

codesign -dv --verbose=4 TestApp

这条命令会显示当前签名所对应的证书、描述文件时间、TeamID 等信息。再看一眼Info.plist里的 Bundle Identifier 是什么后缀:

/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" Info.plist

这个 Bundle ID 必须和你要用的新描述文件里的 App ID 一致(或者描述文件的 App ID 有通配符且匹配)。这一步千万不要偷懒跳过,后面很多失败都源自 Bundle ID 对不上。

3.2 清理旧签名区域并植入新描述文件

把旧的签名记录和旧的描述文件删掉或覆盖掉。_CodeSignature是 codesign 生成的签名资源包,删除它是让后续签名从零开始重建:

rm -rf _CodeSignature # 如果包里有旧的描述文件,先删掉 rm -f embedded.mobileprovision # 拷入新的描述文件 cp /path/to/NewProfile.mobileprovision embedded.mobileprovision

这里有一点要注意:有些从老 Xcode 打包出来的包,可能还有CodeResources、CodeSignature之类命名的文件残留。统一清理思路就是,只要能代表旧签名身份的东西,一律删掉或替换,让 codesign 重新生成。

3.3 先处理内嵌的 Frameworks 和 Dylib

很多人一开始只签主程序,结果装完一启动就闪退,原因就是内嵌框架还是旧签名。iOS 8 之后,App 内嵌的每个 framework、动态库都必须单独签名。而且签名的顺序很讲究:先签最内层,再签外层,最后签主程序。

如果Frameworks/目录存在,把它下面所有.framework和.dylib依次签一遍:

IDENTITY="Apple Development: name@example.com (TEAMID)" for fw in Frameworks/*.framework; do codesign -f -s "$IDENTITY" "$fw" done for dylib in Frameworks/*.dylib; do codesign -f -s "$IDENTITY" "$dylib" done

前面说过的-f是 force 覆盖旧签名,-s后面跟签名身份名称。框架签名不需要传 entitlements,普通情况直接签即可。我看到有些项目会用codesign --deep一把梭,但苹果官方其实不建议用--deep,因为它会把所有嵌套代码全部用同一套参数重签,容易把不该签的资源也签坏了,还可能出现签名顺序错乱。手动按内到外签最稳,脚本也不难写。

3.4 提取并处理 entitlements,再签主程序

接下来是从新描述文件里提取 entitlements。强烈建议不要手动去手写,因为很容易漏项或者写错 key:

# 把描述文件解出来 security cms -D -i embedded.mobileprovision > profile.plist # 用 PlistBuddy 提取 Entitlements /usr/libexec/PlistBuddy -x -c "Print :Entitlements" profile.plist > entitlements.plist

生成的entitlements.plist里会有application-identifier、team-identifier,开发描述文件里还会有get-task-allow等字段。如果你要签的包包含推送、App Groups 等能力,这里也会出现对应的 key。

但新描述文件授权的 entitlements 未必等于老包原本要的 entitlements。前面说了,如果老包里原本有推送 entitlement,而新描述文件没有,你必须主动从entitlements.plist里删掉aps-environment,否则设备会拒签。怎么确认老包要哪些?可以在删除旧签名之前先跑:

codesign -d --entitlements :- TestApp

这样做的好处是能对照新旧两份 entitlements,哪些保留、哪些去掉一目了然。处理完后,签主程序:

codesign -f -s "$IDENTITY" --entitlements entitlements.plist TestApp

签完马上验证一下:

codesign --verify --verbose=2 TestApp

这一步如果通过,说明签名结构本身没问题。再用codesign -dvvv TestApp确认签名身份确实换成了新的。

3.5 重新打包并安装到设备

重签完成后,回到work目录,重新压缩成 IPA:

cd ../.. zip -rq NewSigned.ipa Payload

有个细节:压缩时不要附带__MACOSX这类隐藏资源目录,装到设备上偶发报错就是因为它。可以用:

zip -rq NewSigned.ipa Payload -x "*.DS_Store"

然后通过 Xcode 的 Devices 窗口、Apple Configurator,或者命令行工具把新 IPA 装到测试机上。以我常用的命令行方式为例:

xcrun devicectl device install app --device <UDID> NewSigned.ipa

安装完毕点开 App,如果图标正常出现、没有闪退,整个手工重签流程就算跑通了。

4. 重签失败的几个高频坑:报错现象、根因与排查链路

第一次重签大概率不会一次成功。这里我把实际操作中遇到最多的几类问题拆开,按“现象 → 根因 → 排查链路”写清楚。

4.1 “没有找到有效描述文件/设备不允许安装”类报错

现象:安装报A valid provisioning profile for this executable was not found,或者提示设备 UDID 不在描述文件白名单内。

根因:新放进包的描述文件要么与 App ID 不匹配,要么设备 UDID 不在列表里。

排查链路:第一步先确认包内描述文件确实已经替换,不要拿旧包重签:

security cms -D -i Payload/TestApp.app/embedded.mobileprovision | grep -A 2 ProvisionedDevices

第二步对照Info.plist的CFBundleIdentifier与描述文件里的ApplicationIdentifierPrefix和 App ID。这里最容易坑到人的是:老包 Bundle ID 带了特殊后缀、或者 App ID 用了通配符,新描述文件的显式 App ID 又对不上,就会误以为签名失败。排查时把两边的字符串原样拉出来对比,比瞎猜高效得多。

4.2 安装成功但启动秒闪退:内嵌代码签名被漏签

现象:App 能装上,图标也在,点启动立刻退出,日志里能看到code signature invalid或failed to launch之类的字样。

根因:主程序签名通过了,但Frameworks/里的 framework/dylib、或者PlugIns/里的扩展没有重签,它们的签名仍指向旧证书。系统在主程序启动时会校验“主程序 + 它引用到的所有内嵌代码”整体签名一致性,漏任何一个都不行。

排查链路:先用验证命令把所有内嵌代码全部过一遍:

codesign --verify --deep --strict Payload/TestApp.app

这条命令会递归检查包内所有带签名的组件,哪个签名过期、哪个没签,错误都会指出来。如果定位到某个 framework 没签,就按第 3.3 节的方式补签,然后重新封包。这个坑在逆向场景尤其常见,因为拿到手的包往往框架很多,一个不留神就漏几个。

4.3 “Executable contains disallowed entitlements”:权限清单越界

现象:安装时直接报Executable contains disallowed entitlements,部分设备上甚至安装阶段就被拒绝。

根因:这是重签旧生态 App 时最容易踩的坑。老包里签了一个新描述文件没有授权的 entitlement,最常见的就是aps-environment推送、associated-domains,以及各种com.apple.developer.*开头的系统权限。

排查链路:用新描述文件提取的entitlements.plist跟老包原本的 entitlements 做差集。凡是老包里有、新描述文件里没有的 key,每个都要去掉再签。这一步不要嫌麻烦,宁可多删也不要硬塞,否则一旦 system 层面认定越权,重签多少遍都一样失败。解决办法是在签主程序之前,先用 PlistBuddy 删除entitlements.plist里的超权项。

4.4 扩展和 Watch App 被漏掉:主程序之外的独立签名单元

现象:主程序重签成功,也能装进去,但带的小组件、通知扩展、键盘扩展并没有生效,或者整个安装流程报错。

根因:.appex扩展和 Watch App 在系统看来是独立的签名单元。它们拥有独立的可执行文件和自己的签名记录,主程序签名成功不代表它们也被签了。

排查链路:重签重点不要只盯主程序。检查PlugIns/目录下所有.appex,每个都签一遍;如果有Watch/目录下的.app,也要单独签。这里同样先签内层再签外层。复杂项目里,扩展甚至可能各自带embedded.mobileprovision,重签时最好一并替换成新描述文件。

我自己的习惯是重签前后都跑一遍codesign --verify --deep --strict,一旦某一步做了操作,立刻验证,不要到最后封包时才后悔。

5. 重签批次化:一个可以反复用的脚本思路

手工走一遍流程,最重要的是建立对每个操作的理解。但日常处理多个测试包时,一个个手工操作很不现实,所以第三次重签之后,我就把流程整理成了脚本。下面给一个精简但完整的参考实现,核心逻辑和前面手工流程完全一致。

#!/bin/bash # 用法: ./resign.sh <原IPA路径> <证书名> <描述文件路径> <App名称,可省略> set -euo pipefail IPA_PATH=$1 IDENTITY=$2 PROFILE=$3 APP_NAME=${4:-} WORKDIR=$(mktemp -d) trap 'rm -rf "$WORKDIR"' EXIT unzip -q "$IPA_PATH" -d "$WORKDIR" if [ -z "$APP_NAME" ]; then APP_NAME=$(unzip -l "$IPA_PATH" | grep -oE "[^/ ]+\.app/" | head -1 | tr -d '/') fi APP="$WORKDIR/Payload/$APP_NAME" # 1. 清旧签名、换新描述文件 rm -rf "$APP/_CodeSignature" rm -f "$APP/embedded.mobileprovision" cp "$PROFILE" "$APP/embedded.mobileprovision" # 2. 从描述文件生成 entitlements security cms -D -i "$PROFILE" > "$WORKDIR/profile.plist" /usr/libexec/PlistBuddy -x -c "Print :Entitlements" "$WORKDIR/profile.plist" > "$WORKDIR/entitlements.plist" # 3. 签框架和动态库(按内到外顺序) find "$APP/Frameworks" -name "*.framework" -maxdepth 1 | while read -r fw; do codesign -f -s "$IDENTITY" --timestamp=none "$fw" done find "$APP/Frameworks" -name "*.dylib" -maxdepth 1 | while read -r dylib; do codesign -f -s "$IDENTITY" --timestamp=none "$dylib" done # 4. 签扩展和 Watch(有才签) find "$APP/PlugIns" -name "*.appex" -maxdepth 1 2>/dev/null | while read -r ext; do codesign -f -s "$IDENTITY" "$ext" done if [ -d "$APP/Watch" ]; then find "$APP/Watch" -name "*.app" -maxdepth 1 | while read -r wk; do codesign -f -s "$IDENTITY" "$wk" done fi # 5. 签主程序 codesign -f -s "$IDENTITY" --timestamp=none --entitlements "$WORKDIR/entitlements.plist" "$APP" # 6. 验证并打包 codesign --verify --deep --strict "$APP" cd "$WORKDIR" zip -rq "${IPA_PATH%.*}_resigned.ipa" Payload

脚本里有几个设计点值得说明:

  • 所有签名统一用一个IDENTITY,避免混用证书导致签名链混乱。
  • 框架和扩展用独立循环处理,而不是--deep一把梭,原因前面讲过。
  • --timestamp=none是对离线/本地重签场景的常见优化,能跳过不必要的签名时间戳请求,减少某些网络环境下的随机失败。
  • 最后一步强制验证通过后才封包,把错误拦截在打包之前。

脚本本身比较朴素,但“能用”是第一目标。实际操作中我还会把证书名、描述文件路径、App 名称都写进一个项目配置文件里,这样换一个测试包只需要换配置,不用改脚本。


最后分享一个我自己的习惯:重签不是一个“签完就结束”的一次性操作。因为没有源码,你很难一眼看出某个包当初是按什么配置打的。所以我每处理一个包,都会把“原包 Bundle ID、原 entitlements、新描述文件、过滤掉哪些权限”记在一个 Markdown 文件里,下次再遇到类似问题能直接翻记录。重签看起来就是几条命令的事,但真正能让你省时间的,是对这套签名机制的完整理解,以及那套你自己的排查清单。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进