最近学习了一下iOS代码签名机制 ,这里做个笔记记录,整理思路,加深理解。
想了解更多iOS签名相关,强烈推荐去看看原文,写的太棒了。原文出处 深度长文:细说iOS代码签名 。
背景
经过前面几章的学习,现在已经万事具备,只欠东风。接下来就可以开始研究签名的具体过程了。这里我们首先需要用到 codesign
工具。
codesign
在编译iOS App时,Xcode在编译的打包的流程中会自动进行代码签名, 可以在编译日志界面找到一个Sign
的步骤,内部是调用了codesign
这个命令对app进行签名。
codesign有几个关键参数
-
--sign sign_identity
指定签名所用的证书,可以指定证书的名字,比如"iPhone Developer: xxx (xxx)"
也可以直接写证书文件的sha1值,xcode中就是直接指定sha1值的。 -
--entitlements entitlements_file
指定签名所需要的entitlements文件,这里的entitlements文件跟前面看到的并不是同一个文件,而是基于原有entitlements文件,补充上缺省权限后生成的临时文件
签名后,可以发现多了以下几处变化:
- 签名过的app中多了一个
_CodeSignature
文件夹,里面只有一个文件CodeResources
- 还多了一个
embedded.mobileprovision
文件 - 二进制文件的内容存在差异,并且签名后体积变大了
其中embedded.mobileprovision
就是前文提到的Provisioning Profile文件,它直接被拷贝到了app的根目录并重命名,在此不再赘述,重点研究下另外两个不同点。
_CodeSignature/CodeResources
首先是_CodeSingature/CodeResources
,这是一个plist文件,里面保存了app中每个文件(除了App的可执行文件)的明文哈希值
files
Base.lproj/LaunchScreen.storyboardc/01J-lp-oVM-view-Ze5-6b-2t3.nib
tG+0NJBTDJU1pGyTkthn/3SEV7s=
Base.lproj/LaunchScreen.storyboardc/Info.plist
n2t8gsDpfE6XkhG31p7IQJRxTxU=
embedded.mobileprovision
ElsRWAlatTB+6nEnCd8iJwqEHKE=
...
files2
Base.lproj/LaunchScreen.storyboardc/01J-lp-oVM-view-Ze5-6b-2t3.nib
hash2
eGOOA9v7cm8vfK6MRci5Cka6ymlf1C3AyF6Gqr5XqPo=
Base.lproj/LaunchScreen.storyboardc/Info.plist
hash2
HyVdXMU7Ux4/KalAao30mpWOK/lEPT4gvYN09wf31cg=
embedded.mobileprovision
hash2
d4ANhPQ7uP0OezOoliyo94g1gmeDJXu4rCqR3eIVaYI=
...
rules
...
rules2
...
files
和files2
分别是旧版本和新版本的文件列表,而rules
与rules2
分别是与之对应的规则说明,里面描述了计算hash时需要被排除的文件以及每个文件的权重。
files
中保存的是每个文件的sha1值,而files2
中保存sha256,因为sha1在计算机硬件高度发达的今天,已经相对没有那么安全了,因此最新的签名算法中,引入了sha256。注意,这里的hash值都是base64编码的明文,通过几条简单的命令就可以进行验证:
$ cat Base.lproj/LaunchScreen.storyboardc/Info.plist | shasum -a 1
9f6b7c82c0e97c4e979211b7d69ec84094714f15 -
$ echo -n 'n2t8gsDpfE6XkhG31p7IQJRxTxU=' | base64 -D | hexdump
0000000 9f 6b 7c 82 c0 e9 7c 4e 97 92 11 b7 d6 9e c8 40
0000010 94 71 4f 15
# =========== 分割线 ===========
$ cat Base.lproj/LaunchScreen.storyboardc/Info.plist | shasum -a 256
1f255d5cc53b531e3f29a9406a8df49a958e2bf9443d3e20bd8374f707f7d5c8 -
$ echo -n 'HyVdXMU7Ux4/KalAao30mpWOK/lEPT4gvYN09wf31cg=' | base64 -D | hexdump
0000000 1f 25 5d 5c c5 3b 53 1e 3f 29 a9 40 6a 8d f4 9a
0000010 95 8e 2b f9 44 3d 3e 20 bd 83 74 f7 07 f7 d5 c8
_CodeSignature/CodeResources
文件的主要作用是保存签名时每个文件的哈希值,而这些哈希值并不需要都进行加密,因为非对称加密的性能是比较差的,全部都加密只会拖慢签名和校验的速度。只需要确保 _CodeSignature/CodeResources
这个文件和没有被篡改,就可以确保里面包含的每个文件都是签名时的原始状态。所以我们只需要对 _CodeSignature/CodeResources
这个文件进行加密签名即可,这一点在后续的内容中可以得到验证。
二进制文件中的 LC_CODE_SIGNATURE
对比签名前后的二进制文件,可以发现签名后二进制文件多了一个名为LC_CODE_SIGNATURE
的Load Command,MachOView中查看如下:
代码签名是一段纯二进制的数据,可以在 https://opensource.apple.com/source/Security/Security-55471/sec/Security/Tool/codesign.c.auto.html 看到一些结构定义。使用 dd
命令将签名数据提取出来,结合数据定义来分析。
$ dd bs=1 skip=0x12A50 count=0x4DB0 if=CodeSignTest of=CodeSignatureAll
// 红色部分① Offset: 0x12A50 = 76368 LC_CODE_SIGNATURE->dataoff 这里我们提取出一个新的文件了,所以偏移量是0
struct __SuperBlob {
uint32_t magic; /* 0xFADE0CC0 = CSMAGIC_EMBEDDED_SIGNATURE */
uint32_t length; /* 0x1A02 -> 6658 */
uint32_t count; /* 5 */
CS_BlobIndex index[]; /* 蓝色部分 */
}
// 蓝色部分② 5个BlobIndex
struct __BlobIndex {
uint32_t type; /* 0x0 -> Code Directory */
uint32_t offset; /* 0x34 -> 指向绿色③*/
}
struct __BlobIndex {
uint32_t type; /* 0x2 -> Requirements */
uint32_t offset; /* 0x3ED */
}
struct __BlobIndex {
uint32_t type; /* 0x5 -> Entitlements */
uint32_t offset; /* 0x4A5 */
}
struct __BlobIndex {
uint32_t type; /* 0x7 -> 这里暂时不明,没查到资料 */
uint32_t offset; /* 0x68A */
}
struct __BlobIndex {
uint32_t type; /* 0x10000 -> CMS Signature */
uint32_t offset; /* 0x74D */
}
这部分是典型的数据头结构,声明了5个Blob(二进制大对象),以及每个 Blob 的类型和相对签名头部的偏移量。接下来把每个部分分别提取出来进行分析。
CodeDirectory
CodeDirectory是签名数据中最终要的部分,直译过来就是代码目录,其实里面是整个 MachO 文件的哈希值,这里的哈希并不是一次性对整个文件进行哈希,而是将MachO文件按照 pageSize (一般是4k也就是4096字节,0x1000)进行分页,每一页单独计算哈希,并按照顺序保存下来,就像目录一样。旧版本使用的是 sha1 作为哈希算法,现在采用sha256作为哈希算法,除了算法和哈希的长度不同之外,其他内容基本是一样的。取第一个进行分析:
// 绿色部分③ Offset: 0x34
struct __CodeDirectory {
uint32_t magic; /* 0xFADE0C02 -> CSMAGIC_CODEDIRECTORY */
uint32_t length; /* 0x3B9 */
uint32_t version; /* 0x00020400 -> v2.4.0 */
uint32_t flags; /* 0 */
uint32_t hashOffset; /* 0x159 -> 0x34 + 0x159 = 0x18D */
uint32_t identOffset; /* 0x58 -> 0x34 + 0x58 = 0x8C bundleID & teamID */
uint32_t nSpecialSlots; /* 7 */
uint32_t nCodeSlots; /* 0x13 -> 19 */
uint32_t codeLimit; /* 0x12A50 */
uint8_t hashSize; /* 0x20 -> 32bytes -> 256bits (sha256) */
uint8_t hashType; /* 0x02 (sha256) */
uint8_t spare1; /* unused (must be zero) */
uint8_t pageSize; /* 0x0C -> 2 ^ 0x0C = 0x1000 = 4096 */
uint32_t spare2; /* unused (must be zero) */
/* followed by dynamic content as located by offset fields above */
}
hashOffset
就是”目录”第一页的偏移,从这个位置(0x18D)可以提取到一串32字节的sha256值:
A0 82 FA 0E AD E8 D5 9D E0 BE BC 87 FC 07 11 AF 8C 56 9D 63 3B 06 62 74 CA 18 BB 36 E7 19 82 C1
这个值代表的就是该文件第一页的哈希值,通过以下命令计算文件前4096字节的sha1可进行验证
$ dd bs=1 skip=0 count=0x1000 if=CodeSignTest 2>/dev/null | shasum -a 256
a082fa0eade8d59de0bebc87fc0711af8c569d633b066274ca18bb36e71982c1 -
而紧接着的32个字节就是第二页的哈希值,以此类推,直到原始文件的最后一页。
由于文件不一定是 pageSize 的整数倍,最后一页往往不足”一整页”的大小,因此需要额外的字段codeLimit
记录文件的实际大小,也就是需要签名的数据的实际大小,通过这个值计算出最后一页的实际大小,并提取相应数据计算最后一页的签名。例子中codeLimit=0x12A50
,很容易得出最后一页大小为0xA50
$ dd bs=1 skip=0x12000 count=0xA50 if=CodeSignTest 2>/dev/null | shasum -a 256
6daf15945c5e1964469628251b871a5e3c96890f2d4acff867d94d623b27ba8e -
nCodeSlots记录了文件的总页数0x13,可通过0x12A50 / 0x1000 = 0x12 + 1
得出确实是19页。
第19页的hash值偏移量 = 第一页偏移量 + (19-1)* 32 = 0x18D + 0x12 * 0x20 = 0x18D + 0x240 = 0x3CD
可以看到和我们计算出的最后一页的 sha256 值一致。
identOffset & SpecialSlots
identOffset 是 ASCII 码编码的两串C字符串(以 00 编码结尾),分别是 bundleID 和 teamID,如下图马赛克部分:
细心的朋友已经发现了,identifier(马赛克部分)和 hashOffset(红色部分)之间有一段多出的数据(蓝色部分),并且CodeDirectory中还有一个奇怪的值nSpecialSlots=7
,整个文件的哈希值都已经包含在hashOffset之间了,这多出来的数据是怎么回事呢?
原来,在第一页的前面,还有7个特殊的负数页,用来保存这些额外信息的哈希值。
序号 | 对应内容 |
---|---|
-1 | App根目录的Info.plist文件 |
-2 | Requirements(代码签名的第二部分) |
-3 | Resource Directory (_CodeSignature/CodeResources文件) |
-4 | 暂未使用 |
-5 | Entitlements (代码签名的第三部分) |
-6,-7 | 暂时不明 |
# -1 页 info.plist 文件 验证
$ shasum -a 256 Info.plist
2a56343a7ce09d65fb2f8c4db0d2e7990d229b44b44c9121b6617d3a89dc874b Info.plist
# -3 页 info.plist 文件 验证
$ shasum -a 256 _CodeSignature/CodeResources
db7bb8883f279c78b445d91a3f2b6224c63b4de72e712077640abaef7be3fcac _CodeSignature/CodeResources
Requirements
用于指定签名校验时的一些额外的约束,签名时 codesign
命令会自动生成这部分数据,但目前并没有看到什么地方使用了它,就不深入分析了,官方文档有对这部分内容的详细描述。
- Code Signing Tasks
- Code Signing Requirement Language
Entitlements
通过头部的偏移定位到数据的位置,这是一个Blob结构
struct __Blob { /* Address: 0x4A5 */
uint32_t magic; /* 0xFADE7171 -> CSMAGIC_ENTITLEMENT */
uint32_t length; /* 0x1E5 */
}
之前由Xcode生成的Entitlements文件被整个嵌入到签名数据中。
CMS Signature
CMS是 Cryptographic Message Syntax
的缩写,是一种标准的签名格式,由RFC3852定义。还记得Provisioning Profile的签名吗?它们是相同的格式。CMS格式的签名中,除了包含前面我们推导出的加密哈希和证书之外,还承载了一些其他的信息。由于是二进制格式,不方便分析,可以将其内容从MachO文件中剥离出来,再找合适的工具进行解析。根据偏移量定位到CMS Signature的位置0x74D
struct __Blob { /* Address: 0x74D */
uint32_t magic; /* 0xFADE0B01 -> CSMAGIC_BLOBWRAPPER */
uint32_t length; /* 0x12B5 */
}
除去头部的8个字节(skip = 0x74D + 0x8 = 0x755,count = 0x12B5 - 0x8 = 0x12AD),把对应的内容提取出来。
$ dd bs=1 skip=0x755 count=0x12AD if=CodeSignatureAll of=cms_signature
可以将导出的cms_signature文件上传到在线ASN.1解析工具(支持CMS格式解析)进行分析
文件被解析为树状结构,看起来还是不够直观,因为这个工具只是按照数据格式把内容进行了格式化,但是并没有标注所有字段的确切含义。其实我们还可以使用openssl进行查看,但是因为Mac上自带的openssl以及通过HomeBrew安装的openssl都是没有开启cms支持的,所以可以将文件拷贝到linux机器上或者自行编译openssl进行查看,具体方法在此不表。
$ openssl cms -cmsout -print -inform DER -in cms_signature
CMS_ContentInfo:
contentType: pkcs7-signedData (1.2.840.113549.1.7.2)
d.signedData:
version: 1
digestAlgorithms:
algorithm: sha256 (2.16.840.1.101.3.4.2.1)
parameter: NULL
encapContentInfo:
eContentType: pkcs7-data (1.2.840.113549.1.7.1)
eContent:
certificates:
... [stripped] Apple Worldwide Developer Relations Certification Authority
... [stripped] Apple Root CA
... [stripped] iPhone Developer: xxxxxxx
signerInfos:
version: 1
d.issuerAndSerialNumber:
issuer: C=US, O=Apple Inc., OU=Apple Worldwide Developer Relations, CN=Apple Worldwide Developer Relations Certification Authority
serialNumber: 1008862887770590428
digestAlgorithm:
algorithm: sha256 (2.16.840.1.101.3.4.2.1)
parameter: NULL
signedAttrs:
... [stripped]
SEQUENCE:
0:d=0 hl=2 l= 29 cons: SEQUENCE
2:d=1 hl=2 l= 5 prim: OBJECT :sha1
9:d=1 hl=2 l= 20 prim: OCTET STRING [HEX DUMP]:669421362B2F2B5303BCEBB47D793A75A6BBD32F
... [stripped]
signatureAlgorithm:
algorithm: rsaEncryption (1.2.840.113549.1.1.1)
parameter: NULL
signature:
0000 - 77 00 50 9c 5c 6d 50 1e-cb 4b ca b7 91 d3 5b w.P.\mP..K....[
000f - 2e 28 fe f3 5d 20 73 ef-0a 59 ac 2e ed bd 2a .(..] s..Y....*
... [stripped]
unsignedAttrs:
由于输出内容太多,将部分内容做了删减,可以观察到签名中主要包含了这些内容
-
contentType, 表明消息的类型,有6种取值,这里使用的是表示签名的signedData类型
- Data
- SignedData
- EnvelopedData
- DigestedData
- EncryptedData
- AuthenticatedData
-
content,SignedData类型的数据
- version等:略
- certificates: 证书链,包含用于签名的开发者证书及所有上游CA的证书
- signerInfos:真正的签名信息!
- version:版本号
- issuerAndSerialNumber:签名者信息,根据签名者的名称找到证书链中对应的证书,使用证书中的公钥即可验证签名是否有效
- digestAlgorithm:哈希算法
- signedAttrs:需要签名的属性, 是可选项,为空表示被签名的数据是原始文件的内容,如果不为空则至少要包含原始文件的类型以及其哈希值,此时被签名的数据就是signedAttrs的内容
- signatureAlgorithm:签名算法,这里指对哈希值进行加密所使用的算法
- signature:加密后的哈希值
由于在 CodeDirectory 中已经保存了所有代码(hashOffset)及资源(specialSlot)的哈希值,那么我们只需要确保 CodeDirectory 不被篡改,即可确保整个 app 的完整性, 因此 CMS Signature 中只需要对 CodeDirectory 进行签名即可。而 signedAttrs 中支持这样一种特性:可以先计算被签名数据的哈希,然后再对哈希值进行哈希加密签名。
我们把 CodeDirectory 的内容抠出来,计算其哈希值,以第一个CodeDirectory为例,计算其 sha256:
$ dd bs=1 skip=0x34 count=0x3B9 if=CodeSignatureAll 2>/dev/null | shasum -a 256
7ca6423e96435d3e0e18b3c4d0b6bab549003b41fba8e1798a753406848a721e -
这个值叫做 CDHash(Code Directory’s Hash),对比前面从 cms_signature 中解析出的 signedAttrs,会发现这两个值是一样的,也就是说CodeDirectory的哈希值被放在了 signerInfos->signedAttrs 中,作为最终真正被签名(计算哈希并加密)的内容。
根据 RFC5652 – Cryptographic Message Syntax (CMS) 中的规定,整个 signedAttrs 的内容会作为最终被签名的对象,我们可以按照RFC的规则来手动验证签名的计算过程。结合在线ASN.1解析工具的解析结果,定位到 signedAttrs 的偏移量为4028,先将这部分内容通过dd或者openssl命令提取出来。
# 使用 openssl 命令
$ openssl asn1parse -in cms_signature -inform DER -strparse 4028 -noout -out signedAttrs
# 使用 dd 命令 , 这里长度如图 = 468+4 = 472 ,注意这次下面的都是10进制
$ dd bs=1 skip=4028 count=472 if=cms_signature of=signedAttrs
$ hexdump signedAttrs | head -n 1
0000000 a0 82 02 25 30 18 06 09 2a 86 48 86 f7 0d 01 09
这是一段ASN.1编码的数据,使用BER(BasicEncoding Rules)规则编码,在编码时,表示SET OF
的tag(编码为0x31)会被替换为IMPLICIT [0]
(编码为0xA0),因此,在计算时需要将数据还原,即将首字节a0
替换回31
。我们将修改后的文件另存为 signedAttrs_2。
计算其哈希值,由于singerInfos->digestAlgorithm指明了使用sha256,所以我们计算这个文件的sha256值
$ shasum -a 256 signedAttrs_2
7441035129f541bc361bcd5acb780431df5927c96a727a5a837966ef4ff51864 signedAttrs_2
这个hash值最终会使用开发者证书对应的私钥进行加密,得到签名数据,并保存在signerInfos->signature中。如果要验证签名,则需要使用公钥对签名数据进行解密, 再将解密后的数据与上述hash值进行对比。
首先先从文件中分别提取签名的开发者证书和最终的签名数据,然后再从开发者证书中提取公钥对其进行解密
# 提取证书链,cert0即为签名证书,和前文申请到的开发者证书是完全一样的,会在当前目录生成证书文件 cer0、cer1、cer2
$ codesign -d --extract-certificates=cert TestCodeSign
# 从cms_signature文件中偏移4515处提取最终的签名数据,保存为signature
# 这部分内容是使用开发者的私钥对signedAttrs的hash值进行加密而来的
$ openssl asn1parse -in cms_signature -inform DER -strparse 4515 -noout -out signature
# 提取签名证书中的公钥,保存为pub_key.pem
$ openssl x509 -inform DER -in cert0 -pubkey -noout > pub_key.pem
# 使用公钥对签名数据进行解密,并对解密出的数据按照asn.1格式进行解析
$ openssl rsautl -in signature -verify -asn1parse -inkey pub_key.pem -pubin
0:d=0 hl=2 l= 49 cons: SEQUENCE
2:d=1 hl=2 l= 13 cons: SEQUENCE
4:d=2 hl=2 l= 9 prim: OBJECT :sha256
15:d=2 hl=2 l= 0 prim: NULL
17:d=1 hl=2 l= 32 prim: OCTET STRING
0000 - 74 41 03 51 29 f5 41 bc-36 1b cd 5a cb 78 04 31 tA.Q).A.6..Z.x.1
0010 - df 59 27 c9 6a 72 7a 5a-83 79 66 ef 4f f5 18 64 .Y'.jrzZ.yf.O..d
解密后的数据, 可以看出跟我们自己计算的signedAttrs的hash值是相同的,如此一来也就完成了整个代码签名的校验。
总结
至此,我们已经从头到尾剖析了iOS代码签名的生成方式及数据结构,在这个过程中,至少存在4次计算哈希的行为,并且是环环相扣的。
- _CodeSignature/CodeResources中对每个资源文件计算哈希
- Code Directory 中对MachO文件本身的每个分页,以及Info.plist、CodeResources、Entitlements等文件计算哈希
- CMS Signature的signedAttrs中对Code Directory计算哈希
- 对signedAttrs计算哈希并使用开发者的私钥加密
只有最后一步的哈希值是被加密的, 前面几步的哈希值是否加密都不影响签名的效果,只要任意内容有变化,均会因某个环节的哈希不匹配而导致签名校验的失败。