MachO 代码签名剖析

验证代码的正确性是计算机科学中最难的问题之一,因为不存在普遍意义的正确的算法,所以这一验证通常使用数字签名处理。数字签名主要做两部分工作:

  • 验证代码的来源是否合法。
  • 代码是否被修改过。

代码签名并非苹果独有技术,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授权
0x10000CMS 二进制块
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 类型的值:

参数含义
hashSize0x20hash 值大小,为 0x20 字节。
hashType0x02表示签名算法,0x01 表示 SHA-1,0x02表示SHA-256。从 macOS10.12 和 iOS11开始,苹果转向使用 SHA-256。
pageSize0x0C这里是一个计算公式: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 比对,可以再结合上面的流程图加深理解。