验证代码的正确性是计算机科学中最难的问题之一,因为不存在普遍意义的正确的算法,所以这一验证通常使用数字签名处理。数字签名主要做两部分工作:
- 验证代码的来源是否合法。
- 代码是否被修改过。
代码签名并非苹果独有技术,Java 和 Android 的 Dalvik 都在使用,但苹果公司是最早开始使用的。大家可以通过阅读下文思考代码来源是否合法和代码是否被修改过的验证是如何实现的。
本篇文章主要参考自 Jonathan Levin 的《最强 iOS 和 macOS 安全宝典》代码签名一章。
- 测试环境:macOS 11.2.3。
- 测试项目:
/bin/ls
在 x86_64 架构下的 MachO 文件。iOS 下的文件与之相差不大。
代码签名格式
在了解代码签名机制前,非常有必要了解代码签名的包含的内容。代码签名附着在 MachO 的尾部。加载命令为LC_CODE_SIGNATURE
,它指向一个超级二进制块Code Signature
,该二进制块又包含了多个其他的子二进制块。之前写过一篇文章,有讲解如何手动解析这个签名二进制块:深入理解MachO数据解析规则。
下面是该二进制块的层级结构:
超级二进制块是一个目录性质的结构,用于指定子二进制块的位置,各个子二进制块才是代码签名的主要角色。
子二进制块类型
子二进制块类型通过不同值进行表示:
值 | 二进制块类型 |
---|---|
0x0000 | 代码目录 |
0x0002 | 需求 |
0x0005 | 授权 |
0x10000 | CMS 二进制块 |
0x10001 | 身份证明(未使用) |
本篇主要就是对这几个子二进制块的功能和部分实现进行分析。
二进制块的提取
jtool 是 Jonathan Levin 开发的一款主要用于 MachO 分析的高效工具,可以使用 homebrew 进行安装。
$ brew install jtool
我们可以使用 jtool 单独提取代码签名部分:
$ jtool -arch x86_64 -e signature /bin/ls
Extracting Code Signature (5728 bytes) into ls.signature
$ od -t x1 -A x ls.signature #原始字节内容
0000000 fa de 0c c0 00 00 14 86 00 00 00 03 00 00 00 00
0000010 00 00 00 24 00 00 00 02 00 00 02 61 00 01 00 00
0000020 00 00 02 9d fa de 0c 02 00 00 02 3d 00 02 01 00
0000030 00 00 00 00 00 00 00 7d 00 00 00 30 00 00 00 02
0000040 00 00 00 0e 00 00 d2 30 20 02 0b 0c 00 00 00 00
0000050 00 00 00 00 63 6f 6d 2e 61 70 70 6c 65 2e 6c 73
# ...
也可以使用 MachO 找到 Code Signature 块进行查看。
代码签名的子二进制块
我们可以使用 jtool 查看代码签名内容的分析:
$ jtool -arch x86_64 --sig -v /bin/ls
Blob at offset: 53808 (5728 bytes) is an embedded signature of 5254 bytes, and 3 blobs
Blob 0: Type: 0 @36: Code Directory (573 bytes)
Version: 20100
Flags: none (0x0)
Platform Binary
CodeLimit: 0xd230
Identifier: com.apple.ls (0x30)
CDHash: 46cc1da7c874a5853984a286ffecb48daf2f65f023d10258a31118acfc8a3697 (computed)
# of Hashes: 14 code + 2 special
Hashes @125 size: 32 Type: SHA-256
Requirements blob: a8ccc60c2a5bff15805beb8687c6a899db386d964a5eb3cf3c895753f6879cea (OK)
Bound Info.plist: Not Bound
Slot 0 (File page @0x0000): e4a537939e00f4974e02b03d36e4dab75f7dc095d2214ba66bc53c73c145ceff (OK)
Slot 1 (File page @0x1000): ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 (OK)
Slot 2 (File page @0x2000): ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 (OK)
Slot 3 (File page @0x3000): 4a7cb3e6c1b3a6ac82e3575239ee53d4f0d3bed260fed63438fd21ce0d00392e (OK)
Slot 4 (File page @0x4000): 9ec9e4e02292dfda34ef3caa8317e8bfbcc41a46b18d994dba45febe31b8c660 (OK)
Slot 5 (File page @0x5000): 037285f744f366210cde48821261d4a5f5b739dcf0b82f94144613e92c4b7c07 (OK)
Slot 6 (File page @0x6000): be89c764e52382702918f2db62ff24d9df40410fe894b11d505a4abc1f854340 (OK)
Slot 7 (File page @0x7000): a6b322014743965656e796155c1e0bf22e19a3e8770a43f1111cfbc961037d26 (OK)
Slot 8 (File page @0x8000): a643fc9485d941019cbdeead1d5c47add9382417ebe4d15768221f3763553b84 (OK)
Slot 9 (File page @0x9000): ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 (OK)
Slot 10 (File page @0xa000): ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 (OK)
Slot 11 (File page @0xb000): ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 (OK)
Slot 12 (File page @0xc000): 23304ae11c1ade4411cb63a0955eb644574b8af416e4e3818e382421272ae1b4 (OK)
Slot 13 (File page @0xd000): e0ca7b7000d04057e71c49365b1937711b3557f6b91e0fa144791c66de2a7a4d (OK)
Blob 1: Type: 2 @609: Requirement Set (60 bytes) with 1 requirement:
0: Designated Requirement (@20, 28 bytes): SIZE: 28
Ident: (com.apple.ls) AND Apple Anchor
Blob 2: Type: 10000 @669: Blob Wrapper (4585 bytes) (0x10000 is CMS (RFC3852) signature)
CA: Apple Certification Authority CN: Apple Root CA
CA: Apple Certification Authority CN: Apple Code Signing Certification Authority
CA: Apple Certification Authority CN: Apple Root CA
CA: Apple Certification Authority CN: Apple Root CA
CA: Apple Certification Authority CN: Apple Code Signing Certification Authority
CA: Apple Software CN: Software Signing
Time: 201222002625Zi
它有三个 Blob,即三个子二进制块,Blob 0 是代码签名 Blob 1是需求,Blob 2 是 CMS,下面是对这几个 Blob 的分析。
代码目录(Code Directory)
代码目录是签名块的主体,它提供了签名资源的散列值(哈希值)。代码签名并非对整个文件进行签名,因为有时二进制文件可能很大,计算全部内容占用资源较多;而且二进制的加载是按需加载,不会一开始就都全部映射到内存中。签名时会将整个 MachO 文件划分成多个页,每个页单独签名。
代码目录部分就是对签名信息的描述,其中包含了各个分页的签名值,签名算法和分页大小等内容。代码签名的数据结构如下:
/*
* C form of a CodeDirectory.
*/
typedef struct __CodeDirectory {
uint32_t magic; /* magic number (CSMAGIC_CODEDIRECTORY) */
uint32_t length; /* total length of CodeDirectory blob */
uint32_t version; /* compatibility version */
uint32_t flags; /* setup and mode flags */
uint32_t hashOffset; /* offset of hash slot element at index zero */
uint32_t identOffset; /* offset of identifier string */
uint32_t nSpecialSlots; /* number of special hash slots */
uint32_t nCodeSlots; /* number of ordinary (code) hash slots */
uint32_t codeLimit; /* limit to main image signature range */
uint8_t hashSize; /* size of each hash in bytes */
uint8_t hashType; /* type of hash (cdHashType* constants) */
uint8_t platform; /* platform identifier; zero if not platform binary */
uint8_t pageSize; /* log2(page size in bytes); 0 => infinite */
uint32_t spare2; /* unused (must be zero) */
/* Version 0x20100 */
uint32_t scatterOffset; /* offset of optional scatter vector */
/* Version 0x20200 */
uint32_t teamOffset; /* offset of optional team identifier */
/* followed by dynamic content as located by offset fields above */
} CS_CodeDirectory;
结合 CodeDirectory 的偏移量,可以从 MachOView 里查看到这部分数据的内容:
找到对应数据结构中的含义,我们关注其中三个 uint8_t 类型的值:
参数 | 值 | 含义 |
---|---|---|
hashSize | 0x20 | hash 值大小,为 0x20 字节。 |
hashType | 0x02 | 表示签名算法,0x01 表示 SHA-1,0x02表示SHA-256。从 macOS10.12 和 iOS11开始,苹果转向使用 SHA-256。 |
pageSize | 0x0C | 这里是一个计算公式:log2(PageSize) = 0x0C |
根据公式算出分页大小:PageSize = 2 ^ 0x0C = 4096 = 0x1000 = 4K。这跟系统的内存分页大小是一致的。
由此可知整个 MachO 文件会按照 0x1000 字节的大小进行分页,分页使用 SHA-256 算出散列值。这些计算出的散列值会记录在代码插槽(Code Slots)里。
代码插槽验证
上面Slot 从 0 到 13 的标记对应的都是代码插槽。
有了计算规则我们还可以手动验证代码签名的正确性,我们以前三个代码插槽为例,也即前 0x1000 字节的内容,尝试手动计算其散列值。
$ lipo /bin/ls -thin x86_64 -output /tmp/ls_x86_64
$ dd bs=0x1000 skip=0 count=1 if=/tmp/ls_x86_64 2>/dev/null | openssl sha256
SHA256(stdin)= e4a537939e00f4974e02b03d36e4dab75f7dc095d2214ba66bc53c73c145ceff
$ dd bs=0x1000 skip=1 count=1 if=/Users/zhangferry/ls_x86_64 2>/dev/null | openssl sha256
SHA256(stdin)= ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7
$ dd bs=0x1000 skip=2 count=1 if=/Users/zhangferry/ls_x86_64 2>/dev/null | openssl sha256
SHA256(stdin)= ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7
注意到后面两个插槽计算结果一样,这是因为这两部分数据为补齐位,它们全部为0。
跟前三个代码插槽的值进行对比:
Slot 0 (File page @0x0000): e4a537939e00f4974e02b03d36e4dab75f7dc095d2214ba66bc53c73c145ceff (OK)
Slot 1 (File page @0x1000): ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 (OK)
Slot 2 (File page @0x2000): ad7facb2586fc6e966c004d7d1d16b024f5805ff7cb47c7a85dabd8b48892ca7 (OK)
发现两边散列值一样,输出内容后面的 OK 是 jtool 验证的结果。
这里可以看到代码插槽有14个,File page 里的内容表示相对起始地址。另外注意到输出部分有一句注释:
# of Hashes: 14 code + 2 special
其表示有 14 个代码插槽和 2 个特殊插槽。
特殊插槽
特殊插槽的出现是因为App由多个内容组成,并非只有二进制文件,为了保证这些非二进制文件的完整性,对它们也会进行签名,它们的签名值就是特殊插槽。因为代码插槽的索引是从0开始的,而且其大小不固定,为了把特殊插槽也能排列进去,就选用负数来表示特殊插槽的含义。以下是特殊插槽的定义:
# | 插槽目的 |
---|---|
-1 | 绑定的info.plist |
-2 | 需求(requirement):二进制块嵌入代码签名 |
-3 | 资源目录:CodeSignature/CodeResources文件的散列值 |
-4 | 具体应用:实际上未被使用 |
-5 | 授权(entitlement):嵌入在代码签名中的授权 |
我们可以在上方 jtool 的输出内容里找到特殊插槽的内容:
Requirements blob: a8ccc60c2a5bff15805beb8687c6a899db386d964a5eb3cf3c895753f6879cea (OK)
Bound Info.plist: Not Bound
因为特殊插槽作用是固定的,也就没用序号表示。
代码签名需求(Requirements)
目前代码签名只是分块取散列值,保存起来,但好像还不够强大。苹果公司已经为代码签名增加了另外一个机制:需求(requirements)。它可以自定义规则以施加特定限制,比如允许哪些动态库加载。
需求有一套特殊的语法规则,其表达由操作数和操作码组成,丰富的操作码集使得构建任何数量的逻辑条件成为可能。可以在requirements.h 文件里查看都有哪些操作码。
enum ExprOp {
opFalse, // unconditionally false
opTrue, // unconditionally true
opIdent, // match canonical code [string]
opAppleAnchor, // signed by Apple as Apple's product
opAnchorHash, // match anchor [cert hash]
opInfoKeyValue, // *legacy* match Info.plist field [key; value]
opAnd, // binary prefix expr AND expr
opOr, // binary prefix expr OR expr
opCDHash, // match hash of CodeDirectory directly
opNot, // logical inverse
opInfoKeyField, // Info.plist key field [string; match suffix]
opCertField, // Certificate field [cert index; field name; match suffix]
opTrustedCert, // require trust settings to approve one particular cert [cert index]
opTrustedCerts, // require trust settings to approve the cert chain
opCertGeneric, // Certificate component by OID [cert index; oid; match suffix]
opAppleGenericAnchor, // signed by Apple in any capacity
opEntitlementField, // entitlement dictionary field [string; match suffix]
exprOpCount // (total opcode count in use)
};
对需求的编译是由 csreq
进行的,对需求的验证可以使用 codesign -v
。
我们这里来尝试解读下已有的需求内容。
大部分二进制文件的需求只是验证签名身份,即使用证书是否为苹果所颁发。在 App Store 里的应用则使用更严格的规则集。我们可以查看 Xcode 的代码签名需求:
$ codesign -d -r- /Applications/Xcode.app/Contents/MacOS/Xcode
Executable=/Applications/Xcode.app/Contents/MacOS/Xcode
designated => (anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = APPLECOMPUTER) and identifier "com.apple.dt.Xcode"
注意这里有不少 1.2.840.113635
开头的标识,它代表的是国际通用标准证书中苹果公司的分支(iso.member-body.us.appleOID)。其中100对应了安全相关的一些定义 appleDataSecurity,详细内容可以看这里oidref.com。
对照Xcode的签名需求,我们可以大致推断出这些规则的含义:
- 由苹果签名且证书节点包含 6.1.9 即 Mac App Store App。
- 或由苹果签名且证书节点包含 6.2.6 即 “dev_program”。(推测是开发版本的应用)
- 其证书节点包含 6.1.13 即 Developer ID Applications。
- 证书的团队标识符(OU)为 APPLECOMPUTER 且 BundleId 为
com.apple.dt.Xcode
。
注意其中最后一项的内容,限定了团队标识符和BundleId,这样就能够解决应用被重签名的问题了。
CMS
CMS 是Cryptographic Message Syntax
的缩写,是一种标准的签名格式,由RFC3852定义。书中并没有提这部分内容,但我认为这部分恰恰是代码签名最关键的步骤。
CMS 格式的签名中,除了包含证书之外,还承载了一些其他的信息,比如签名属性 signedAttrs
。
上面说了 CodeDirectory 里保存了 MachO 分页的 Hash 值,只要保证这个 CodeDirectory 不被修改就可以了。所以对代码目录进行 Hash 计算,获得 CDHash,然后对这个 CDHash 进行签名就可以了。
注意这一步才是真正的签名,其开始涉及加密,前面的代码插槽只是提供摘要信息。
注意到 jtool 的签名输出里有这样一句:
CDHash: 46cc1da7c874a5853984a286ffecb48daf2f65f023d10258a31118acfc8a3697 (computed)
这就是外部计算的 CDHash 值,用于跟 signedAttrs
里的内容进行对比。而更关键的是对 signedAttrs 的加密验证,实际验证流程比较复杂,感兴趣的小伙伴可以阅读这篇细说iOS代码签名(三)。
我结合文中签名校验内容和上面的代码插槽,画出了表示签名校验的整个流程:
这里有两处 Hash 对比,一个是对 signedAttrs 的解密,确保其是可信任的。另一处是 CDHash 的对比,确保代码未被修改。
signerInfo
里包含了 signedAttrs
、签名使用的 Hash 算法、加密算法、签名数据等信息。再结合证书里的公钥,我们就可以验证,signedAttrs
的有效性。
授权
除了确保代码的真实性和完整性,代码签名还为苹果公司及其强大的安全机制提供了授权(entitlement)功能。授权文件也被包含在签名里,其散列值放在索引为-5的插槽中。授权文件是一个 XML 格式的文件,我们可以使用 jtool --ent
查看其内容,因为 ls
没有授权文件,我们以 Mac 端微信为例进行查看:
$ jtool -arch x86_64 --ent /Applications/WeChat.app/Contents/MacOS/WeChat
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>5A4RE8SF68.com.tencent.xinWeChat</string>
</array>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
这里我们可以看到其中包含了沙盒、application-groups、声音输入、摄像头等一系列权限。在应用访问特定 API 的时候苹果可以根据这些授权判定该行为是否合法。因为苹果公司是应用的终极签名者,所以签名过程中也可以很容易的修改授权,比如 com.apple.security.sandbox.container-required
这一表示沙盒权限的值就会被强制安置到授权文件中。
强制验证代码签名
为了使代码签名真正有效,非常重要的一步就是要保证验证过程顺利执行,没有遗漏。当前签名验证是发生在内核模式下,而非用户模式下。签名的验证发生在两个阶段:加载可执行文件时、实际访问二进制代码时(Page Fault)。分成两个阶段也是出于性能方面的考虑,因为二进制文件是动态加载的,对于还没加载的部分仅当其加载如内存时,也即发生 Page Fault 时再进行签名验证。
可执行文件的加载
可执行文件的加载出现在execve()
/mac_execve()
或posix_spawn()
系统调用被触发的时候。对于MachO,exec_mach_imgact()
会被调用,在解析文件时它会找到LC_CODE_SIGNATURE
的位置。代码签名二进制块会被加载到内核的统一高速缓存缓冲区中。
Page Fault时的处理
可以查看 osfmk/vm/vm_fault.c 的代码:
/*
* CODE SIGNING:
* When soft faulting a page, we have to validate the page if:
* 1. the page is being mapped in user space
* 2. the page hasn't already been found to be "tainted"
* 3. the page belongs to a code-signed object
* 4. the page has not been validated yet or has been mapped for write.
*/
#define VM_FAULT_NEED_CS_VALIDATION(pmap, page, page_obj) \
((pmap) != kernel_pmap /*1*/ && \
!(page)->cs_tainted /*2*/ && \
(page_obj)->code_signed /*3*/ && \
(!(page)->cs_validated || (page)->wpmapped /*4*/))
当 Page Fault 满足以上条件时将触发签名验证过程:
1、该页面正在用户空间中映射
2、这个页面还没有被发现为 tainted
3、该页属于一个代码签名对象
4、页面还没有被验证,或者还没有被映射为可写状态
代码签名的漏洞
代码签名机制虽然强大,保护着应用的安全,但依然被攻破过,以下讲解几例曾经出现的漏洞。
JIT(即时生成代码)
该情况发生在 Page Fault 过程,如果该页内容是用于 JIT,将会被特殊标记,可以创建和执行任意代码,而无需代码签名。
从 iOS 10 开始,苹果公司开始在64位的设备上加固 JIT。采用专门的 memcpy()
将JIT映射到可执行但不可读的内存上,然后可执行的 JIT 映射为不可写,可写的 JIT 映射为不可执行状态。
Jekyll 应用
Jekyll 应用的含义是应用在提交至 App Store 时表现为无害,但其实它包含恶意功能,只不过这些功能处于休眠状态。过审之后和本地服务器进行合作,自愿公开其地址空间和符号,通过代码注入或者返回导向编程(Return Oriented Programming,ROP),触发预置的恶意程序。
目前还没有可靠的打击 ROP 的方法,但因为沙盒机制的缘故,恶意代码的影响范围是可控的。
苹果公司使用 LLVM BitCode 向 App Store 提交应用的方案,也会使恶意应用难以事先知晓其地址空间。
内存锁定
从上面我们知道发生Page Fault会触发签名验证的流程,那如果没有Page Fault就不会存在签名验证了。按照mmap -> mlock -> memcpy -> mprotect 的调用顺序,应用可以修改可执行内存,以任何看起来合适的方式修补内存。虽然XNU通常会阻止将曾经可写的内存设置为r-x,但当内存锁定时,会绕过该检测。
苹果在iOS 9.3中修复了这个漏洞。
总结
我们再来尝试回答开头上面遗留的问题:
1、如何验证代码的来源是否合法?
主要通过证书来验证来源是否合法,所有的开发者证书都由苹果颁发,且被 Root CA 认证。另外依托于需求(requirements),还可以再扩展一些其他验证方式。
2、如何确认代码是否被修改过。
主要通过代码插槽和 CDHash,再对 CDHash 进行签名,就可确认其是否被修改过。注意实际验证流程有两处关键的 Hash 比对,可以再结合上面的流程图加深理解。