ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

iOS无签名IPA打包:原理详解与xcodebuild命令行实战

iOS无签名IPA打包:原理详解与xcodebuild命令行实战

做iOS开发的朋友,应该都遇到过类似的尴尬:证书过期、描述文件失效、或者临时要装一台没有加入开发团队的设备,而手头只有一个.xcarchive或者干脆只有一堆编译好的.app。这时候如果能把工程打成一个没有签名的 ipa 包,很多问题反而好办了。

“ios打没有签名的ipa包”这个需求,在正规开发流程里其实非常常见。CI 流水线里先生成无签名包、然后再由后续步骤统一重签名;或者逆向分析、学习研究时需要一个干净的安装包;再或者团队内部做自动化测试,不想每次都在打包机上签一堆证书。这篇文章我不讲花活,直接把“无签名 ipa 到底怎么打、原理是什么、踩过哪些坑”一次说清楚。全程用命令行操作,不依赖任何第三方工具,你在自己电脑上就能复现。

1. 先把无签名 ipa 这件事拆清楚

1.1 无签名 ipa 的本质是什么

正常情况下,一个 ipa 包就是 zip 压缩包,里面放一个Payload/xxx.app目录。App 内包含可执行文件、资源文件、Info.plist、embedded.mobileprovision(描述文件),以及通过codesign生成的签名信息。

无签名 ipa,说得直白一点,就是去掉最后签名的环节。App 可执行文件仍然会被编译成 Mach-O 格式,但不再附加_CodeSignature目录,也没有embedded.mobileprovision。整个包的结构和正常 ipa 几乎一模一样,只是少了跟“身份”相关的部分。

这里有一个容易混淆的概念:无签名 ipa 不等于“破解版”或“不加密版本”。签名是防篡改和身份校验机制,不签名意味着这个包无法直接被普通 iPhone 安装运行(除非设备越狱或者后续通过其他方式补签名),但它可以作为中间产物流转到后续环节。

1.2 为什么需要无签名包

我实际遇到的需求基本可以归成四类:

第一类是 CI/CD 流水线解耦。很多团队有专门的签名机,开发者本机只负责编译产物,等到真正分发前再统一用企业证书、开发证书或者 App Store 证书重签。这时候如果本机直接打带签名的包,证书还要分发到每个开发机上,麻烦不说,还容易暴露私钥。

第二类是自动化测试的前置步骤。一些 UI 测试框架、云测平台需要先拿到一个未签名的包,然后在自己的环境里进行重签注入,或者在模拟器上直接跑。无签名包正好满足这种需求。

第三类是学习研究和逆向分析。想看看 ipa 内部结构、了解签名机制、分析 Swift 二进制、提取资源文件,拿到一个没有签名的包反而省事,不用处理签名验证。

第四类是解决打包环境证书缺失的问题。新入职的开发机没有配置证书,老板又急着要一个包出来做演示,这时候无签名包虽然不能直接装到真机上,但可以在模拟器上运行,也可以在已经信任的测试设备上临时处理。

2. 打包前必须搞懂的几个机制

2.1 签名在 iOS 分发里到底管什么

iOS 的代码签名机制,可以类比成“快递包裹的封条”。封条的作用有两层:第一层是证明这个包裹确实是从发件人手里寄出的,第二层是确保运输过程中没有人打开过包裹。codesign做的是前者,Info.plist里的CFBundleIdentifier和CFBundleVersion确保系统认识这个 App,embedded.mobileprovision则是系统判定“这个设备能不能装”的凭证。

无签名包等于没有封条。系统拿到之后会认定这个包不可信,所以大多数普通设备装不上。但在开发者环境下,我们经常只是为了得到一个完整、可二次操作的产物,所以“有没有封条”反而不重要。

2.2 无签名包和正常包在结构上的区别

拿一个正常打包的 ipa 做对比,目录结构大概是这样的:

MyApp.ipa ├── Payload │ └── MyApp.app │ ├── MyApp (Mach-O 可执行文件) │ ├── Info.plist │ ├── embedded.mobileprovision │ ├── _CodeSignature │ │ └── CodeResources │ ├── Frameworks │ │ └── ... │ └── Resources

无签名 ipa 的结构则是:

MyApp_unsigned.ipa ├── Payload │ └── MyApp.app │ ├── MyApp (Mach-O 可执行文件) │ ├── Info.plist │ ├── Frameworks │ │ └── ... │ └── Resources

少了_CodeSignature和embedded.mobileprovision,其余部分完全一致。这也是为什么无签名包可以被后续工具快速重签——结构没变,只是补充缺失的签名数据。

2.3 模拟器包和真机包是两个物种

这里要先讲一个容易踩的坑:模拟器上编译出来的 App 和真机上编译出来的 App,本质上是两个物种。

模拟器包使用的是 x86_64 或 arm64(Apple Silicon 的模拟器)架构,而且没有签名需求,跑在 Mac 上。真机包使用的是 arm64 架构,需要签名,跑在 iPhone/iPad 上。无签名打包时,如果 destination 选错了,打出来的包在真机上完全无法使用,即使后续补了签名也白搭。

后面我给的命令专门用了generic/platform=iOS,就是为了确保生成的是真机架构的包,如果你只是想在模拟器上跑,那另说。

3. 用 xcodebuild 打无签名 IPA:完整可复制的方案

3.1 前置准备

打包前先确认几件事:

  • 工程里至少有一个配置正确的 Scheme,且能被xcodebuild识别。
  • 本机安装了对应版本的 Xcode 和 Command Line Tools。
  • 不需要在 Xcode 账号里配置任何开发者团队。

我这里用一个通用工程举个例子。假设项目根目录下有MyApp.xcworkspace,使用的是 CocoaPods 管理依赖。如果你没有 workspace,直接用.xcodeproj也行。

查看当前可用的 Scheme:

xcodebuild -workspace MyApp.xcworkspace -list

输出里能看到 Schemes 列表,比如MyApp、MyAppTests之类的。确定名字后就可以开始打包。

3.2 核心命令:archive 时关闭签名

无签名打包最直接的方式是走xcodebuild archive,在参数里显式关闭签名。命令如下:

cd /path/to/MyAppProject xcodebuild \ -workspace MyApp.xcworkspace \ -scheme MyApp \ -configuration Release \ -archivePath ./build/MyApp_unsigned.xcarchive \ -destination 'generic/platform=iOS' \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY="" \ archive

这里有几个关键参数,逐个解释一下:

  • -destination 'generic/platform=iOS':指定目标平台为 iOS 真机通用目标,保证生成 arm64 架构的包,而不是模拟器包。
  • CODE_SIGNING_ALLOWED=NO:告诉构建系统,这一整条打包链路里不需要执行签名动作。
  • CODE_SIGNING_REQUIRED=NO:即使工程配置里某个 Target 把签名设为必须,也强行降级处理。
  • CODE_SIGN_IDENTITY="":显式抹掉签名身份,防止 Xcode 自动去钥匙串里找证书。

如果你的工程是纯 Swift Package Manager 管理、没有.xcworkspace,把-workspace MyApp.xcworkspace换成-project MyApp.xcodeproj即可。

我在实际项目里还遇到过一种情况:某些第三方库在构建脚本里强制调用了codesign,导致CODE_SIGNING_ALLOWED=NO也没用。这时候可以加一个参数:

CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ OTHER_CODE_SIGN_FLAGS="--deep --force"

或者把ENABLE_BITCODE=NO一起加上,因为 Bitcode 在某些情况下会干扰签名步骤的判断。但注意,如果你之后要上架 App Store,Bitcode 现在其实已经被 Apple 弃用了,这个参数对现代工程影响不大。

3.3 从 xcarchive 生成无签名 ipa

archive命令结束之后,你拿到的是一个.xcarchive包。它本身不是 ipa,需要再处理一步。

.xcarchive内部结构大概是:

MyApp_unsigned.xcarchive ├── Info.plist ├── Products │ └── Applications │ └── MyApp.app ├── dSYMs └── ...

我们真正需要的是Products/Applications/MyApp.app。标准做法是把它放进Payload目录,然后压缩成 zip,改名成 ipa。

我习惯用下面的命令一步到位:

cd /path/to/MyAppProject/build mkdir -p Payload cp -R MyApp_unsigned.xcarchive/Products/Applications/MyApp.app Payload/ zip -r MyApp_unsigned.ipa Payload

执行完之后,根目录下就出现了MyApp_unsigned.ipa。如果你想要可读性更好一点的命名,可以自定义输出路径:

zip -r build/MyApp_unsigned.ipa Payload

这里的zip -r在 macOS 上默认会附带一些 macOS 特有的元数据文件,比如__MACOSX目录,我一般在打包时会顺手清理掉:

zip -r MyApp_unsigned.ipa Payload -x "*.DS_Store"

3.4 更省事的方式:直接对 .app 压缩

如果你连archive都不想走,也可以直接在 DerivedData 里找到编译好的.app,然后手动做同样的目录结构。但这种方式有个前提:必须先跑过一次正常的 build,编译产物才会出现在 DerivedData 里。

操作其实更简单:

xcodebuild \ -workspace MyApp.xcworkspace \ -scheme MyApp \ -configuration Release \ -sdk iphoneos \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ build

编译结束后,用find ~/Library/Developer/Xcode/DerivedData -name "MyApp.app" -type d找到产物路径,然后:

cd path/to/containing/dir mkdir -p Payload cp -R MyApp.app Payload/ zip -r MyApp_unsigned.ipa Payload -x "*.DS_Store"

这种方式的好处是快,不用等 archive 流程。坏处是如果工程配置复杂,比如有多个 Target 或者依赖 Subproject,直接 build 出来的产物路径不太稳定,还是推荐走 archive。

3.5 一键脚本:把整个流程固化下来

为了不每次敲一长串命令,我写了个简单的 shell 脚本放在项目scripts/build_unsigned_ipa.sh里,平时打包就执行一行:

#!/bin/bash WORKSPACE="MyApp.xcworkspace" SCHEME="MyApp" CONFIGURATION="Release" OUTPUT_DIR="$(pwd)/build" ARCHIVE_PATH="${OUTPUT_DIR}/MyApp_unsigned.xcarchive" OUTPUT_IPA="${OUTPUT_DIR}/MyApp_unsigned.ipa" echo "[*] Start building unsigned IPA..." xcodebuild \ -workspace "${WORKSPACE}" \ -scheme "${SCHEME}" \ -configuration "${CONFIGURATION}" \ -archivePath "${ARCHIVE_PATH}" \ -destination 'generic/platform=iOS' \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY="" \ archive if [ $? -ne 0 ]; then echo "[!] Archive failed." exit 1 fi rm -rf "${OUTPUT_DIR}/Payload" mkdir -p "${OUTPUT_DIR}/Payload" cp -R "${ARCHIVE_PATH}/Products/Applications/${SCHEME}.app" "${OUTPUT_DIR}/Payload/" cd "${OUTPUT_DIR}" zip -r "${OUTPUT_IPA}" Payload -x "*.DS_Store" cd - if [ -f "${OUTPUT_IPA}" ]; then echo "[*] Done: ${OUTPUT_IPA}" else echo "[!] Failed to generate ipa." exit 1 fi

把这个脚本放到项目根目录,然后:

chmod +x scripts/build_unsigned_ipa.sh ./scripts/build_unsigned_ipa.sh

整个打包过程我对它的稳定性做过比较长期的测试,本质上是绕过了签名环节,所以不受本地证书状态影响,任何一台机器上跑结果基本一致,适合作为 CI 的第一步。

4. 打包过程中常见的坑与排查办法

4.1 archive 报错 “requires a development team”

这个报错出现时,终端会提示类似:

error: "MyApp" requires a development team. Select a development team in the Signing & Capabilities editor.

原因通常是 Target 的CODE_SIGN_STYLE还是自动管理签名(Automatic),需要到工程设置里找到Build Settings,搜索CODE_SIGN_STYLE,把它改成Manual。或者直接在命令行里覆盖:

CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM="" \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY=""

我在自己的项目里遇到了一个混合场景:主 Target 改了手动签名,但某个依赖库的子 Target 没改,导致 archive 到最后一步报错。解决办法是直接在命令行里加CODE_SIGNING_ALLOWED=NO,它会向下传递到所有 Target 的构建配置。如果还有个别 Target 不听话,那就只能在 Build Settings 里手动把Code Signing Identity设置为Don't Code Sign。

4.2 打出来的是模拟器包而不是真机包

很多第一次接触无签名打包的人会直接写:

xcodebuild archive -scheme MyApp -archivePath xxx.xcarchive

不写-destination时,Xcode 会根据当前选中的 scheme 的 Run destination 决定用的 SDK。如果你之前在模拟器上跑过,默认可能就是模拟器构建。

检查方法很简单,用file命令看可执行文件架构:

file MyApp.app/MyApp

如果是:

Mach-O 64-bit executable x86_64

说明是模拟器包,真机用不了。正确的是:

Mach-O 64-bit executable arm64

所以每次打包前务必带上-destination 'generic/platform=iOS',这个参数是防止打错平台的定心丸。

4.3 无签名包直接在真机上安装失败

这一点必须提前说清楚:无签名 ipa 用常规方式安装到普通真机基本不可能成功。iOS 系统在安装时会对 IPA 做完整性校验和签名校验,没有签名的包会被系统直接拒绝,提示 “Unable to Install” 或者 “App is not trusted”。

所以无签名包的正确用法不是直接安装,而是作为中间产物,后续通过以下几种路径继续流转:

  • 重签名:拿到证书和描述文件后,用codesign对.app重签,再重新打包。
  • 模拟器运行:直接放到模拟器里运行,因为模拟器不强制校验签名。
  • 越狱设备安装:设备越狱后可以绕过签名限制安装。
  • 云测平台:很多云测平台接受无签名包,在自己的签名环境里完成签名后再分发到测试机。

如果你只是想自己快速看一眼界面效果,可以在模拟器上装。这一步不需要签名:

xcrun simctl install booted MyApp.app

如果你是 Apple Silicon 的 Mac,模拟器跑的是 arm64 架构,效率上跟真机差别不大。唯一需要注意的是某些依赖硬件能力的功能,比如相机、推送,模拟器上表现不完整。

4.4 安装后打开闪退

如果后续你确实给无签名包补签了,但在真机上打开闪退,最常见的原因有两个。

第一个是补签时没有正确处理 Frameworks。如果你的 App 里面有Frameworks目录,重签时必须按照“主 App -> 每个 Framework”的顺序逐个签,不能只签主 App。我在做重签时用的命令是:

codesign --force --sign "证书名" --deep MyApp.app

--deep会在一定程度上递归处理子目录,但有时候还是会漏掉某些动态库,所以我更推荐逐个签名:

codesign --force --sign "证书名" MyApp.app/Frameworks/Alamofire.framework codesign --force --sign "证书名" MyApp.app

第二个原因是 App 的Info.plist里某些字段与签名后的描述文件不一致。比如CFBundleIdentifier和描述文件里注册的 App ID 不一致,导致签名成功但安装后启动崩溃。

解决办法是在重签前先检查Info.plist:

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

输出结果要和描述文件里的application-identifier的标识前缀对齐。

4.5 打包产物特别大,里面有多余架构

如果你的项目里集成了某些第三方闭源库,而这些库同时支持模拟器和真机,在 archive 时有可能把多个架构一起编进去。正常签名打包时 Xcode 会启用VALID_ARCHS过滤,但无签名打包时可能过滤不干净。

检查一下 ipa 里的二进制架构:

lipo -info MyApp.app/MyApp

如果发现里面有 x86_64 和 arm64 同时存在,而你确认这是真机包,后续可以瘦身:

lipo -thin arm64 MyApp.app/MyApp -output MyApp.app/MyApp

但要提醒一下,直接改二进制有风险,操作前最好备份原始包。大多数情况下,用xcodebuild archive配合generic/platform=iOS打出来的包不会出现多架构问题,这个步骤只是兜底。

5. 无签名 IPA 在 CI 与测试环节的落地经验

5.1 流水线里安排无签名包的位置

我建议把“打无签名包”放在整套构建流程的最前面。好处是这一步骤不依赖任何签名证书,可以放在任意干净节点上执行。

一个典型的流水线模型是这样的:

  1. 代码拉取。
  2. 执行无签名打包脚本,产出MyApp_unsigned.ipa。
  3. 将包提交到制品库,同时触发后续的签名任务。
  4. 签名任务从制品库拉取无签名包,基于内部证书管理服务申请证书和描述文件。
  5. 执行重签名脚本,产出最终可分发 ipa。

这样可以保证几位开发者共用同一台打包机时,不需要把开发者证书和私钥暴露给每个人。签名集中在一步完成,审计起来也清楚。

5.2 重签名时的衔接细节

从无签名包到签名包的衔接,有几个细节特别容易出问题。

描述文件必须与 App ID 匹配。无签名包里的Info.plist的CFBundleIdentifier是什么,描述文件里限制的 App ID 就必须是什么。如果团队里 App ID 有多个环境前缀(比如com.company.app.dev和com.company.app.prod),打包脚本里要显式把CFBundleIdentifier设置成对应的值。

有一个办法是在构建命令里直接覆盖:

PRODUCT_BUNDLE_IDENTIFIER=com.company.app.dev

但注意,这个覆盖只对使用 Build Settings 里PRODUCT_BUNDLE_IDENTIFIER的 Target 有效。如果代码里或者 Info.plist 写死了CFBundleIdentifier,那必须在打包前先改 Info.plist。

另外,重签名后最好做一次备份验证。用codesign -dv看签名信息:

codesign -dv MyApp.app

确认Identifier、TeamIdentifier和描述文件里的信息一致。这一步能省去后面装到设备上才发现问题的麻烦。

5.3 在 CI 上配置无签名打包的注意点

CI 机器通常是没有 Xcode 图形界面的,需要确保 Command Line Tools 路径正确:

sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

不执行这一步,xcodebuild 可能找不到 SDK。另外,CI 机器的 Keychain 里可能没有证书,这正好符合无签名打包的需求,但要注意不要让 Xcode 在 build 阶段自动去请求证书。

CI 环境里另外一个常见问题是缓存策略。我建议每次打包都强制清掉一次 DerivedData,避免旧产物干扰:

rm -rf ~/Library/Developer/Xcode/DerivedData/*

否则可能会出现“Build succeeded but ipa is not updated”的诡异现象,浪费半小时最后发现是缓存捣乱。

6. 写在最后:几点个人心得

无签名 ipa 这个东西,在一部分人眼里是“不太正规”的产物,但在实际的工程体系里,它其实是非常重要的一环。它相当于把“构建”和“签名”这两个本来纠缠在一起的动作拆开了,各自独立演化。我用了大概一年多之后,最大的感受是构建再也不受证书困扰,新开发机入职当天就能打包,不用等 IT 配证书。

还有一点想强调:无签名包的适用范围一定要清楚。正常的 App Store 上架流程,或者面向企业内部正式分发的场景,必须有完整签名。无签名包永远只是中间产物,不要试图跳过签名环节直接把无签名包拿给业务方分发,最后大概率会收到“装不上”的反馈,而且 iOS 系统的安装拦截会让你找不到原因。

如果你只是想在本地快速打包做验证,或者给测试同学出一份不依赖签名环境的构建产物,这个方案可以直接上手。我已经把它固化成了项目里的标准脚本,从 Git 拉新代码到出包,全程大约只要三到五分钟,省下来的都不只是时间,还有处理证书问题时的烦躁感。

RELATED READING

延伸阅读

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