安卓安全内部原理-全-
安卓安全内部原理(全)
原文:
zh.annas-archive.org/md5/57064d318a0e0da376294726ee4cdae0译者:飞龙
前言
在相对较短的时间内,安卓已成为全球最受欢迎的移动平台。尽管最初是为智能手机设计的,但现在它已经支持平板电脑、电视、可穿戴设备,甚至很快会出现在汽车中。安卓正在以惊人的速度发展,每年平均发布两次重大版本。每一个新版本都会带来更好的用户界面、性能改进和一系列新的面向用户的功能,这些通常会被安卓爱好者们在博客中详细解读。
在过去几年里,安卓平台在一个方面取得了显著的改进,尽管这个方面鲜少受到公众关注,那就是安全性。多年来,安卓变得更加抵抗常见的攻击技术(如缓冲区溢出),其应用程序隔离(沙箱技术)得到了加强,通过积极减少以 root 权限运行的系统进程数量,攻击面也大大缩小。除了这些漏洞缓解措施,安卓的最新版本还引入了许多重要的安全功能,如限制用户支持、全盘加密、硬件支持的凭证存储以及对集中式设备管理和配置的支持。为了下一版本的安卓(在我写这篇文章时称为Android L),还宣布了更多面向企业的功能和安全改进,如托管配置文件支持、改进的全盘加密和对生物识别认证的支持。
与任何新平台功能一样,讨论前沿的安全改进令人兴奋,但从底层理解安卓的安全架构可能更为重要,因为每一项新的安全功能都建立在平台核心安全模型的基础上,并与之整合。安卓的沙箱模型(每个应用程序以独立的 Linux 用户身份运行,并拥有专用的数据目录)和权限系统(要求每个应用程序明确声明其所需的系统功能)是相对易于理解和文档化的。然而,影响设备安全的其他基本平台功能,如包管理和代码签名的内部机制,往往被视为一个“黑箱”,超出了安全研究社区的了解范围。
安卓受欢迎的原因之一是相对容易将设备“刷入”定制的安卓版本、通过应用第三方更新包进行“root”或以其他方式进行自定义。安卓爱好者的论坛和博客中有很多实用的“如何做”指南,带领用户完成解锁设备并应用各种自定义包的步骤,但它们很少提供有关这些系统更新如何在后台运行以及它们可能带来哪些风险的结构化信息。
本书旨在填补这些空白,通过从底层描述 Android 的安全架构,深入探讨与设备和数据安全相关的主要 Android 子系统和组件的实现,探索 Android 的工作原理。内容包括影响所有应用程序的广泛主题,如包管理和用户管理、权限和设备策略,以及更具体的主题,如加密提供程序、凭证存储和对安全元件的支持。
在不同版本之间,整个 Android 子系统被替换或重写并不罕见,但与安全相关的开发本质上是保守的,尽管描述的行为可能会在版本之间发生变化或增强,Android 的核心安全架构在未来的版本中应保持相对稳定。
本书适用人群
本书应适用于任何有兴趣了解 Android 安全架构的人。无论是安全研究人员想要评估 Android 整体或特定子系统的安全性,还是平台开发人员致力于定制和扩展 Android,他们都会发现每个安全功能的高级描述和提供的实现细节是理解基础平台源代码的有用起点。应用程序开发者可以更深入地理解平台的工作方式,从而能够编写更安全的应用程序,并更好地利用平台提供的与安全相关的 API。尽管本书的部分内容适合非技术性读者,但大部分讨论与 Android 源代码或系统文件密切相关,因此对 Unix 环境中软件开发核心概念的了解将非常有帮助。
前提条件
本书假定读者具有基本的 Unix 风格操作系统的熟悉度,最好是 Linux,并且不解释像进程、用户组、文件权限等常见概念。与 Linux 特定或最近添加的操作系统功能(如能力和挂载命名空间)相关的内容会在讨论使用它们的 Android 子系统之前简要介绍。大部分平台代码来自核心 Android 守护进程(通常是 C 或 C++ 实现)和系统服务(通常是 Java 实现),因此至少熟悉其中一种语言是必需的。某些代码示例涉及 Linux 系统调用的序列,因此熟悉 Linux 系统编程对于理解代码可能有帮助,但并非绝对必要。最后,虽然本书在最初几章简要描述了 Android 应用程序的基本结构和核心组件(如活动和服务),但假定读者对 Android 开发有基本了解。
Android 版本
本书中关于 Android 架构和实现的描述(除了几个专有的 Google 特性)是基于作为 Android 开源项目(AOSP)一部分公开发布的源代码。大部分讨论和代码片段引用了 Android 4.4,这是在本书撰写时最新的公开版本,且其源代码已发布。AOSP 的主分支也在几处被引用,因为主分支的提交通常能很好地反映未来 Android 发布的方向。然而,并非所有对主分支的更改都会按原样纳入公共发布,因此未来的版本很可能会改变甚至移除一些当前展示的功能。
下一版 Android 系统(前文提到的 Android L)的开发者预览版在本书草稿完成后不久发布。然而,截至目前,Android L 的完整源代码尚未发布,具体的公开发布日期也未知。尽管预览版包含了一些新的安全功能,如设备加密、管理型配置文件和设备管理的改进,但这些功能都还不是最终版本,因此可能会发生变化。这就是本书没有讨论这些新功能的原因。虽然我们可以根据已观察到的行为介绍一些 Android L 的安全改进,但由于缺乏底层源代码,任何关于其实现的讨论都会是不完整和推测性的。
本书是如何组织的?
本书由 13 章组成,旨在按顺序阅读。每一章讨论 Android 安全的不同方面或特性,后续章节建立在前面章节引入的概念之上。即使你已经熟悉 Android 的架构和安全模型,并且正在寻找某个特定话题的详细信息,你至少应该浏览 第一章 到 第三章,因为它们涉及的主题为本书的其他内容奠定了基础。
-
第一章 给出了 Android 架构和安全模型的高级概述。
-
第二章 介绍了 Android 权限的声明、使用及系统如何执行这些权限。
-
第三章 讨论了代码签名,并详细描述了 Android 应用程序安装和管理过程的工作原理。
-
第四章 探讨了 Android 的多用户支持,并描述了如何在多用户设备上实现数据隔离。
-
第五章 概述了 Java 加密架构(JCA)框架,并描述了 Android 的 JCA 加密提供者。
-
第六章 介绍了 Java 安全套接字扩展(JSSE)框架的架构,并深入探讨了其在 Android 中的实现。
-
第七章 探讨了 Android 的凭证存储并介绍了它为需要安全存储加密密钥的应用提供的 API。
-
第八章 讨论了 Android 的在线账户管理框架,并展示了 Google 账户如何集成到 Android 中。
-
第九章 介绍了 Android 的设备管理框架,详细说明了 VPN 支持的实现,并深入探讨了 Android 对可扩展认证协议(EAP)的支持。
-
第十章 介绍了验证启动、磁盘加密和 Android 的锁屏实现,并展示了如何实现安全的 USB 调试和加密的设备备份。
-
第十一章 概述了 Android 的 NFC 堆栈,深入探讨了安全元素(SE)的集成与 API,并介绍了基于主机的卡模拟(HCE)。
-
第十二章 首先简要介绍了 SELinux 的架构和策略语言,详细说明了为将 SELinux 集成到 Android 中所做的改动,并概述了 Android 的基础 SELinux 策略。
-
第十三章 讨论了 Android 启动引导程序和恢复操作系统如何用于执行完整的系统更新,并详细介绍了如何在工程版和生产版 Android 构建中获取 root 访问权限。
约定
由于本书的主要主题是 Android 的架构与实现,因此包含了多个代码片段和文件列表,并在每个列表或代码示例之后的章节中广泛引用这些内容。为了将这些引用(通常包括多个操作系统或编程语言构造)与其他文本区分开来,使用了一些格式约定。
命令;函数和变量名;XML 属性;以及 SQL 对象名称使用 等宽字体 显示(例如:“id 命令”、“getCallingUid() 方法”、“name 属性”等)。文件和目录名、Linux 用户和组、进程及其他操作系统对象使用 斜体 显示(例如:“packages.xml 文件”、“system 用户”、“vold 守护进程”等)。字符串文字也使用 斜体 显示(例如:“AndroidOpenSSL 提供者”)。如果你在程序中使用这些字符串文字,通常需要将它们放在双引号或单引号中(例如:Signature.getInstance("SHA1withRSA", "AndroidOpenSSL"))。
Java 类名通常以未带包名的格式出现(例如:“Binder 类”);只有在讨论的 API 或包中存在多个同名类,或者指定包含的包非常重要时,才会使用完全限定名(例如:“javax.net.ssl.SSLSocketFactory 类”)。在文本中引用时,函数和方法名通常带有括号,但为了简洁,通常省略其参数(例如:“getInstance() 工厂方法”)。有关完整的函数或方法签名,请参阅相关的参考文档。
大多数章节都包括图表,用于说明所讨论的安全子系统或组件的架构或结构。所有图表都遵循一种非正式的“框和箭头”风格,并不严格遵循特定格式。也就是说,大多数图表借鉴了 UML 类图和部署图的思路,框通常代表类或对象,而箭头代表依赖关系或通信路径。
第一章. 安卓的安全模型
本章将首先简要介绍安卓的架构、进程间通信(IPC)机制和主要组件。然后,我们描述安卓的安全模型及其如何与底层的 Linux 安全基础设施和代码签名相关联。最后,我们简要回顾安卓安全模型中的一些新特性,具体包括多用户支持、基于 SELinux 的强制访问控制(MAC)和验证启动。安卓的架构和安全模型建立在传统的 Unix 进程、用户和文件范式之上,但此处并未从头描述这一范式。我们假设读者对类 Unix 系统,尤其是 Linux 有基本的了解。
安卓架构
让我们从下往上简要地查看安卓的架构。图 1-1 展示了安卓栈的简化表示。

图 1-1. 安卓架构
Linux 内核
正如你在图 1-1 中看到的,安卓是建立在 Linux 内核之上的。与任何 Unix 系统一样,内核提供硬件驱动、网络、文件系统访问和进程管理。感谢安卓主线项目^([1]),你现在可以通过一些努力在较新的标准内核上运行安卓,但安卓内核与“常规”的 Linux 内核(你可能在桌面计算机或非安卓嵌入式设备上找到的内核)略有不同。这些差异源于一组新特性(有时称为安卓特性^([2])),这些特性最初是为支持安卓而添加的。安卓特性的一些主要内容包括低内存杀手、唤醒锁(作为主线 Linux 内核中唤醒源支持的一部分)、匿名共享内存(ashmem)、报警、偏执的网络和 Binder。
我们讨论中最重要的安卓特性是 Binder 和偏执的网络。Binder 实现了进程间通信(IPC)及相关的安全机制,我们将在 Binder 中详细讨论。偏执的网络限制了只有持有特定权限的应用才能访问网络套接字。我们将在第二章中更深入地探讨这个话题。
本地用户空间
在内核之上是本地用户空间层,包含了init二进制文件(第一个启动的进程,负责启动所有其他进程)、多个本地守护进程以及几百个系统中使用的本地库。尽管init二进制文件和守护进程的存在让人联想到传统的 Linux 系统,但需要注意的是,init和相关的启动脚本是从零开始开发的,与主线 Linux 系统的版本有很大不同。
Dalvik 虚拟机
Android 的大部分实现是基于 Java 的,因此它是由 Java 虚拟机(JVM)执行的。Android 当前的 Java 虚拟机实现被称为Dalvik,它是我们栈中的下一层。Dalvik 是专为移动设备设计的,无法直接运行 Java 字节码(.class文件):它的本地输入格式叫做Dalvik 可执行文件(DEX),并且被打包在.dex文件中。反过来,.dex文件要么被打包在系统 Java 库(JAR 文件)中,要么被打包在 Android 应用程序(APK 文件)中(详见第三章)。
Dalvik 和 Oracle 的 JVM 有不同的架构——Dalvik 是基于寄存器的,而 JVM 是基于栈的——并且它们的指令集也不同。让我们看一个简单的例子来说明这两个虚拟机之间的差异(参见示例 1-1)。
示例 1-1. 静态 Java 方法,两个整数相加
public static int add(int i, int j) {
return i + j;
}
当为每个虚拟机编译时,add()静态方法(该方法只是简单地将两个整数相加并返回结果)会生成如示例 1-2 所示的字节码。
示例 1-2. JVM 和 Dalvik 字节码
JVM 字节码
public static int add(int, int);
Code:
0: iload_0➊
1: iload_1➋
2: iadd➌
3: ireturn➍
Dalvik 字节码
.method public static add(II)I
add-int v0, p0, p1➎
return v0➏
.end method
在这里,JVM 使用两条指令将参数加载到栈中(➊和➋),然后执行加法操作➌,最后返回结果➍。相比之下,Dalvik 使用单条指令将参数(在寄存器p0和p1中)相加,并将结果存储在v0寄存器中➎。最后,它返回v0寄存器中的内容➏。如你所见,Dalvik 使用较少的指令来实现相同的结果。一般来说,基于寄存器的虚拟机使用较少的指令,但生成的代码比基于栈的虚拟机相应代码要大。然而,在大多数架构中,加载代码的开销低于指令调度的开销,因此基于寄存器的虚拟机能够更高效地进行解释。^([3])
在大多数生产设备中,系统库和预装应用程序不会直接包含设备无关的 DEX 代码。作为性能优化,DEX 代码会转换为设备相关格式,并存储在优化后的 DEX(.odex)文件中,该文件通常与其父 JAR 或 APK 文件位于同一目录下。类似的优化过程也会在用户安装应用时进行。
Java 运行时库
Java 语言实现需要一组运行时库,这些库主要定义在 java.* 和 javax.* 包中。Android 的核心 Java 库最初来源于 Apache Harmony 项目^([4]),是我们堆栈中的下一层。随着 Android 的发展,原始的 Harmony 代码发生了显著变化。在这个过程中,一些特性被完全替换(如国际化支持、加密提供者以及一些相关类),而另一些特性则得到了扩展和改进。核心库主要是用 Java 开发的,但它们也有一些原生代码依赖。原生代码通过标准的 Java 本地接口 (JNI) 链接到 Android 的 Java 库中,^([5]) 允许 Java 代码调用原生代码,反之亦然。Java 运行时库层直接被系统服务和应用程序访问。
系统服务
直到目前为止介绍的各个层次构成了实现 Android 核心——系统服务——所需的管道。系统服务(截至 4.4 版本为 79 个)实现了 Android 大多数基本功能,包括显示和触摸屏支持、电话和网络连接性。大多数系统服务是用 Java 实现的;一些基础服务则是用原生代码编写的。
除少数例外,每个系统服务都定义了一个可以从其他服务和应用程序调用的远程接口。结合服务发现、调解和由 Binder 提供的进程间通信(IPC),系统服务有效地在 Linux 上实现了面向对象的操作系统。
让我们详细了解 Binder 如何在 Android 中实现 IPC,因为这是 Android 安全模型的基石之一。
进程间通信
如前所述,Binder 是一种进程间通信(IPC)机制。在详细讲解 Binder 工作原理之前,让我们简要回顾一下 IPC。
与任何类 Unix 系统一样,Android 中的进程有各自独立的地址空间,进程不能直接访问其他进程的内存(这被称为 进程隔离)。这通常是好事,无论是从稳定性还是安全性角度来看:多个进程修改同一内存可能会导致灾难性后果,而且你不希望一个由其他用户启动的潜在恶意进程通过访问邮件客户端的内存来泄露你的邮件。然而,如果一个进程想为其他进程提供某些有用的服务,它需要提供一种机制,允许其他进程发现并与这些服务进行交互。这种机制就叫做 IPC。
对于标准 IPC 机制的需求并不新鲜,因此在 Android 之前就有了几种选择。这些选项包括文件、信号、套接字、管道、信号量、共享内存、消息队列等。虽然 Android 使用了其中一些(例如本地套接字),但它并不支持其他一些(如 System V IPCs 中的信号量、共享内存段和消息队列)。
Binder
由于标准的 IPC 机制不够灵活和可靠,Android 开发了一个新的 IPC 机制,称为 Binder。虽然 Android 的 Binder 是一种新的实现,但它基于 OpenBinder 的架构和理念。^([6])
Binder 实现了一种基于抽象接口的分布式组件架构。它类似于 Windows 的公共对象模型(COM)和类 Unix 系统上的公共对象代理请求架构(CORBA),但与这些框架不同,Binder 运行在单个设备上,并不支持跨网络的远程过程调用(RPC)(尽管可以在 Binder 之上实现 RPC 支持)。对 Binder 框架的详细描述超出了本书的范围,但我们将在接下来的章节中简要介绍其主要组件。
Binder 实现
如前所述,在类 Unix 系统中,一个进程无法访问另一个进程的内存。然而,内核控制着所有进程,因此可以暴露一个接口来实现进程间通信(IPC)。在 Binder 中,这个接口就是 /dev/binder 设备,由 Binder 内核驱动程序实现。Binder 驱动程序是框架的核心对象,所有的 IPC 调用都通过它进行。进程间通信是通过一个单一的 ioctl() 调用实现的,该调用通过 binder_write_read 结构发送和接收数据,binder_write_read 结构包含一个 write_buffer,其中包含发送给驱动程序的命令,以及一个 read_buffer,其中包含用户空间需要执行的命令。
那么数据是如何在进程之间传递的呢?Binder 驱动程序管理着每个进程的一部分地址空间。Binder 驱动程序管理的内存块对进程是只读的,所有写操作都由内核模块执行。当一个进程向另一个进程发送消息时,内核会在目标进程的内存中分配一些空间,并直接从发送进程复制消息数据。然后,它会将一条简短的消息排入接收进程,告诉它接收到的消息所在的位置。接收方随后可以直接访问该消息(因为它在自己的内存空间中)。当进程处理完消息后,它会通知 Binder 驱动程序将内存标记为可释放。图 1-2 展示了 Binder IPC 架构的简化示意图。

图 1-2. Binder IPC
Android 中更高层次的 IPC 抽象,如 Intent(带有关联数据的命令,传递到跨进程的组件)、Messenger(支持跨进程消息传递的对象)和 ContentProvider(暴露跨进程数据管理接口的组件),都是建立在 Binder 之上的。此外,需要对外暴露服务接口的功能可以通过 Android 接口定义语言(AIDL) 来定义,AIDL 使得客户端可以像调用本地 Java 对象一样调用远程服务。相关的 aidl 工具会自动生成 存根(远程对象的客户端表示)和 代理,这些代理将接口方法映射到低层次的 transact() Binder 方法,并负责将参数转换为 Binder 可以传输的格式(这叫做 参数封送/解封)。由于 Binder 本身是无类型的,因此 AIDL 生成的存根和代理还通过在每个 Binder 事务中包含目标接口名称(在代理中)并在存根中进行验证,提供了类型安全性。
Binder 安全性
在更高层次上,任何可以通过 Binder 框架访问的对象都实现了 IBinder 接口,并称为Binder 对象。对 Binder 对象的调用是在 Binder 事务 中进行的,事务包含对目标对象的引用、要执行的方法 ID 和一个数据缓冲区。Binder 驱动程序会自动将调用进程的进程 ID(PID)和有效用户 ID(EUID)添加到事务数据中。被调用的进程(callee)可以检查 PID 和 EUID,并根据其内部逻辑或关于调用应用程序的系统级元数据决定是否执行请求的方法。
由于 PID 和 EUID 是由内核填充的,调用进程不能伪造其身份以获取系统未允许的更多权限(即 Binder 防止了 权限提升)。这是 Android 安全模型的核心部分,所有更高层次的抽象(如权限)都建立在这一基础之上。调用者的 EUID 和 PID 可以通过 android.os.Binder 类的 getCallingPid() 和 getCallingUid() 方法访问,这些方法是 Android 公共 API 的一部分。
注意
如果多个应用程序在相同的 UID 下运行,则调用进程的 EUID 可能无法映射到单一应用程序(详情见第二章)。然而,这不会影响安全决策,因为通常在相同 UID 下运行的进程会被授予相同的权限和特权(除非定义了特定于进程的 SELinux 规则)。
Binder 身份
Binder 对象最重要的特性之一是它们在不同进程之间保持唯一的身份。因此,如果进程 A 创建了一个 Binder 对象并将其传递给进程 B,进程 B 再将其传递给进程 C,所有三个进程的调用都会由同一个 Binder 对象处理。实际上,进程 A 将通过其内存地址直接引用 Binder 对象(因为它在进程 A 的内存空间中),而进程 B 和 C 只会接收到 Binder 对象的句柄。
内核维护着“活跃”Binder 对象与其他进程中的句柄之间的映射。由于 Binder 对象的身份是唯一的,并由内核维护,因此用户空间进程无法创建 Binder 对象的副本或获得对其的引用,除非通过 IPC 将其传递给进程。因此,Binder 对象是一个独特、不可伪造且可传递的对象,可以充当安全的令牌。这使得 Android 中能够使用基于能力的安全性。
基于能力的安全性
在基于能力的安全模型中,程序通过授予一个不可伪造的能力来获得访问特定资源的权限,该能力既引用目标对象,又封装了一组对该对象的访问权限。由于能力是不可伪造的,因此程序仅凭持有能力就足以获得对目标资源的访问权限;无需维护与实际资源相关的访问控制列表(ACL)或类似结构。
Binder 令牌
在 Android 中,Binder 对象可以充当能力,当以这种方式使用时被称为Binder 令牌。Binder 令牌既可以是能力,也可以是目标资源。拥有 Binder 令牌的进程可以完全访问 Binder 对象,从而能够对目标对象执行 Binder 事务。如果 Binder 对象实现了多个操作(通过选择 Binder 事务的code参数来决定执行的操作),则调用方只要持有该 Binder 对象的引用,就可以执行任何操作。如果需要更细粒度的访问控制,则每个操作的实现需要执行必要的权限检查,通常通过利用调用进程的 PID 和 EUID 来实现。
在 Android 中,一个常见的模式是允许作为system(UID 1000)或root(UID 0)身份运行的调用者执行所有操作,但对所有其他进程执行额外的权限检查。因此,对重要 Binder 对象(如系统服务)的访问通过两种方式进行控制:一是限制谁可以获取该 Binder 对象的引用,二是通过在对 Binder 对象执行操作之前检查调用者的身份来控制访问。(此检查是可选的,并且由 Binder 对象自身实现,若需要的话。)
另一种方式是,Binder 对象可以仅作为一种能力使用,而不实现任何其他功能。在这种使用模式下,同一个 Binder 对象由两个(或更多)协作的进程持有,其中作为服务器的进程(处理某种类型的客户端请求)使用 Binder 令牌来验证其客户端,就像 Web 服务器使用会话 cookie 一样。
这种使用模式在 Android 框架内部使用,通常对应用程序是不可见的。一个显著的 Binder 令牌的使用案例是在公共 API 中可见的 窗口令牌。每个 Activity 的顶层窗口与一个 Binder 令牌(称为窗口令牌)相关联,Android 的窗口管理器(负责管理应用窗口的系统服务)会跟踪它。应用程序可以获得自己的窗口令牌,但无法访问其他应用程序的窗口令牌。通常,你不希望其他应用程序在你的窗口上添加或移除窗口;每个此类请求都必须提供与应用程序相关联的窗口令牌,从而保证窗口请求来自你的应用程序或系统。
访问 Binder 对象
尽管 Android 出于安全原因控制对 Binder 对象的访问,并且与 Binder 对象通信的唯一方式是获得其引用,但一些 Binder 对象(最显著的是系统服务)需要是全局可访问的。然而,将所有系统服务的引用分发给每个进程是不现实的,因此我们需要一些机制,允许进程根据需要发现并获得对系统服务的引用。
为了启用服务发现,Binder 框架有一个单一的 上下文管理器,用于维护对 Binder 对象的引用。Android 的上下文管理器实现是 servicemanager 本地守护进程。它在启动过程中很早就启动,以便系统服务可以在启动时向其注册。服务通过传递服务名称和 Binder 引用给服务管理器进行注册。一旦服务注册完成,任何客户端都可以通过使用其名称来获得该服务的 Binder 引用。然而,大多数系统服务实现了额外的权限检查,因此获得引用并不自动保证可以访问其所有功能。因为任何人都可以访问一个已注册到服务管理器的 Binder 引用,所以只有一小部分被列入白名单的系统进程可以注册系统服务。例如,只有执行 UID 1002(AID_BLUETOOTH)的进程才能注册 bluetooth 系统服务。
你可以使用 service list 命令查看已注册服务的列表,该命令会返回每个注册服务的名称和实现的 IBinder 接口。在 Android 4.4 设备上运行此命令的示例输出显示在 示例 1-3 中。
示例 1-3. 使用 service list 命令获取注册的系统服务列表
$ **service list**
service list
Found 79 services:
0 sip: [android.net.sip.ISipService]
1 phone: [com.android.internal.telephony.ITelephony]
2 iphonesubinfo: [com.android.internal.telephony.IPhoneSubInfo]
3 simphonebook: [com.android.internal.telephony.IIccPhoneBook]
4 isms: [com.android.internal.telephony.ISms]
5 nfc: [android.nfc.INfcAdapter]
6 media_router: [android.media.IMediaRouterService]
7 print: [android.print.IPrintManager]
8 assetatlas: [android.view.IAssetAtlas]
9 dreams: [android.service.dreams.IdreamManager]
--*snip*--
其他 Binder 特性
尽管与 Android 的安全模型没有直接关系,另两个值得注意的 Binder 特性是引用计数和死亡通知(也称为链接到死亡)。引用计数 保证当没有任何引用时,Binder 对象会自动释放,并通过内核驱动中的 BC_INCREFS、BC_ACQUIRE、BC_RELEASE 和 BC_DECREFS 命令实现。引用计数在 Android 框架的各个层次集成,但应用程序无法直接看到。
死亡通知 允许使用由其他进程托管的 Binder 对象的应用程序在这些进程被内核终止时收到通知,并进行必要的清理。死亡通知通过内核驱动中的 BC_REQUEST_DEATH_NOTIFICATION 和 BC_CLEAR_DEATH_NOTIFICATION 命令实现,并通过框架中 IBinder 接口的 linkToDeath() 和 unlinkToDeath() 方法实现^([7])。(本地绑定器不会发送死亡通知,因为本地绑定器无法在托管进程未终止的情况下死亡。)
Android 框架库
接下来是 Android 框架库,有时简称为“框架”。框架包含所有不是标准 Java 运行时一部分的 Java 库(如 java.*、javax.* 等),并且大多数都托管在 android 顶级包下。框架包括构建 Android 应用程序的基本模块,例如活动、服务和内容提供者的基类(位于 android.app.* 包中);GUI 小部件(位于 android.view.* 和 android.widget 包中);以及文件和数据库访问的类(主要位于 android.database.* 和 android.content.* 包中)。它还包括一些类,用于与设备硬件进行交互,以及一些可以利用系统提供的高级服务的类。
尽管几乎所有 Android 操作系统的功能都在内核层级之上以系统服务的形式实现,但它并不会直接暴露在框架中,而是通过称为 管理器 的外观类进行访问。通常,每个管理器背后都有一个相应的系统服务;例如,BluetoothManager 是 BluetoothManagerService 的外观类。
应用程序
在堆栈的最高层是应用程序(或应用),即用户直接交互的程序。虽然所有应用都具有相同的结构,并且建立在 Android 框架之上,但我们区分系统应用和用户安装的应用。
系统应用
系统应用包含在操作系统镜像中,该镜像在生产设备上是只读的(通常挂载为/system),用户无法卸载或更改。因此,这些应用被认为是安全的,并且比用户安装的应用拥有更多的权限。系统应用可以是 Android 操作系统的核心部分,也可以是预安装的用户应用程序,例如电子邮件客户端或浏览器。虽然在早期版本的 Android 中,所有安装在/system下的应用都被平等对待(除了操作系统功能检查应用签名证书),但是 Android 4.4 及更高版本将安装在/system/priv-app/下的应用视为特权应用,只会向特权应用授予带有保护级别signatureOrSystem的权限,而不是所有安装在/system下的应用。使用平台签名密钥签名的应用可以获得带有signature保护级别的系统权限,因此即使它们不是预安装在/system下,也可以获得操作系统级别的权限。(有关权限和代码签名的详细信息,请参见第二章)
虽然系统应用无法卸载或更改,但只要更新使用相同的私钥签名,用户仍然可以更新它们,并且一些系统应用可以被用户安装的应用覆盖。例如,用户可以选择用第三方应用替换预安装的应用启动器或输入法。
用户安装的应用
用户安装的应用安装在专用的读写分区中(通常挂载为/data),该分区存储用户数据,并且可以随时卸载。每个应用都生活在一个专用的安全沙箱中,通常不能影响其他应用或访问它们的数据。此外,应用只能访问其明确获得权限使用的资源。权限隔离和最小权限原则是 Android 安全模型的核心,我们将在下一部分探讨它们是如何实现的。
Android 应用组件
Android 应用程序是由松散耦合的组件组合而成,并且与传统应用程序不同,它们可以有多个入口点。每个组件可以提供多个入口点,这些入口点可以基于用户在同一应用或其他应用中的操作访问,或由应用注册的系统事件触发。
组件及其入口点,以及其他元数据,都在应用的清单文件中定义,该文件名为AndroidManifest.xml。像大多数 Android 资源文件一样,该文件在捆绑到应用包(APK)文件之前,会被编译成二进制 XML 格式(类似于 ASN.1),以减少文件大小并加速解析。清单文件中定义的最重要的应用属性是应用程序包名,它唯一地标识系统中的每个应用。包名采用与 Java 包名相同的格式(反向域名表示法;例如,com.google.email)。
AndroidManifest.xml文件在应用安装时被解析,它定义的包及组件会被系统注册。Android 要求每个应用都必须使用开发者控制的密钥进行签名。这保证了已安装的应用无法被声称具有相同包名的其他应用替代(除非是用相同的密钥签名,在这种情况下现有应用将被更新)。我们将在第三章中讨论代码签名和应用包。
Android 应用的主要组件列在下方。
活动
活动是一个具有用户界面的单一屏幕。活动是 Android 图形用户界面应用的主要构建块。一个应用可以有多个活动,虽然它们通常设计成按照特定顺序显示,但每个活动都可以独立启动,可能由不同的应用启动(如果允许的话)。
服务
服务是一个在后台运行且没有用户界面的组件。服务通常用于执行一些长期运行的操作,例如下载文件或播放音乐,而不会阻塞用户界面。服务还可以使用 AIDL 定义远程接口,并为其他应用提供一些功能。然而,与系统服务不同,系统服务是操作系统的一部分并且始终运行,而应用程序服务是根据需求启动和停止的。
内容提供者
内容提供者为应用数据提供接口,这些数据通常存储在数据库或文件中。内容提供者可以通过 IPC 访问,主要用于与其他应用共享应用的数据。内容提供者提供了精细的控制,允许应用仅共享其数据的子集。
广播接收器
广播接收器是一个响应系统范围事件的组件,这些事件被称为广播。广播可以来自系统(例如,宣布网络连接状态的变化),也可以来自用户应用(例如,宣布后台数据更新已完成)。
Android 的安全模型
和系统的其他部分一样,Android 的安全模型也利用了 Linux 内核提供的安全特性。Linux 是一个多用户操作系统,内核可以将用户资源相互隔离,就像它隔离进程一样。在 Linux 系统中,一个用户不能访问另一个用户的文件(除非明确授予权限),每个进程以启动它的用户的身份(用户和组 ID,通常称为UID和GID)运行,除非在相应的可执行文件上设置了设置用户 ID(SUID)或设置组 ID(SGID)位。
Android 利用了这种用户隔离,但与传统的 Linux 系统(桌面或服务器)对用户的处理方式不同。在传统系统中,UID 要么分配给一个可以登录系统并通过 shell 执行命令的物理用户,要么分配给一个在后台执行的系统服务(守护进程)(因为系统守护进程通常可以通过网络访问,所以为每个守护进程分配一个专用 UID 可以限制如果某个进程被攻破时的损害)。Android 最初是为智能手机设计的,因为手机是个人设备,所以不需要在系统中注册不同的物理用户。物理用户是隐式的,UID 则用于区分应用程序。这构成了 Android 应用沙盒的基础。
应用沙盒
Android 在安装时自动为每个应用分配一个唯一的 UID,通常称为应用 ID,并以该 UID 运行一个专用进程。此外,每个应用还会被分配一个专用的数据目录,只有该应用有权限读写。因此,应用在进程级别(通过让每个应用运行在一个专用进程中)和文件级别(通过拥有一个私有数据目录)都被隔离或沙盒化。这创建了一个内核级的应用沙盒,适用于所有应用,无论它们是在本地进程还是虚拟机进程中执行。
系统守护进程和应用程序在定义明确且固定的 UID 下运行,且很少有守护进程以 root 用户(UID 0)身份运行。Android 没有传统的/etc/password文件,其系统 UID 在android_filesystem_config.h头文件中静态定义。系统服务的 UID 从 1000 开始,1000 为system(AID_SYSTEM)用户,拥有特殊(但仍然有限)的权限。为应用程序自动生成的 UID 从 10000 开始(AID_APP),对应的用户名形式为app_XXX 或 uY_aXXX(对于支持多物理用户的 Android 版本),其中XXX是从AID_APP开始的偏移量,Y是 Android 用户 ID(与 UID 不同)。例如,10037 UID 对应于u0_a37用户名,可能分配给 Google 邮件客户端应用程序(com.google.android.email包)。示例 1-4 显示了邮件应用程序进程作为u0_a37用户 ➊ 执行,而其他应用程序进程则以不同用户身份执行。
示例 1-4. 每个应用程序进程在 Android 上作为专用用户执行
$ **ps**
--*snip*--
u0_a37 16973 182 941052 60800 ffffffff 400d073c S com.google.android.email➊
u0_a8 18788 182 925864 50236 ffffffff 400d073c S com.google.android.dialer
u0_a29 23128 182 875972 35120 ffffffff 400d073c S com.google.android.calendar
u0_a34 23264 182 868424 31980 ffffffff 400d073c S com.google.android.deskclock
--*snip*--
邮件应用程序的数据目录以其包名命名,并在单用户设备的/data/data/目录下创建。(多用户设备使用不同的命名规则,详见第四章)。数据目录中的所有文件都归专用的 Linux 用户u0_a37所有,如示例 1-5 所示(省略了时间戳)。应用程序可以选择使用MODE_WORLD_READABLE和MODE_WORLD_WRITEABLE标志创建文件,以允许其他应用程序直接访问文件,这实际上会在文件上设置S_IROTH和S_IWOTH访问位。然而,不建议直接共享文件,这些标志在 Android 4.2 及更高版本中已被弃用。
示例 1-5. 应用程序目录归专用的 Linux 用户所有
# ls -l /data/data/com.google.android.email
drwxrwx--x u0_a37 u0_a37 app_webview
drwxrwx--x u0_a37 u0_a37 cache
drwxrwx--x u0_a37 u0_a37 databases
drwxrwx--x u0_a37 u0_a37 files
--*snip*--
应用程序 UID 与其他包元数据一起管理,在/data/system/packages.xml文件中(作为权威来源),并且也写入到/data/system/packages.list文件中。(我们将在第三章中讨论包管理和packages.xml文件。)示例 1-6 显示了分配给com.google.android.email包的 UID,在packages.list中的显示方式。
示例 1-6. 每个应用程序对应的 UID 存储在/data/system/packages.list
# grep 'com.google.android.email' /data/system/packages.list
com.google.android.email 10037 0 /data/data/com.google.android.email default 3003,1028,1015
在这里,第一个字段是包名,第二个是分配给应用程序的 UID,第三个是调试标志(如果可调试则为 1),第四个是应用程序的数据目录路径,第五个是 seinfo 标签(由 SELinux 使用)。最后一个字段是应用程序启动时附带的附加 GID 列表。每个 GID 通常与一个 Android 权限相关(接下来会讨论),GID 列表是根据授予应用程序的权限生成的。
应用程序可以使用相同的 UID 安装,这称为 共享用户 ID,在这种情况下,它们可以共享文件,甚至在同一进程中运行。共享用户 ID 被系统应用程序广泛使用,系统应用程序通常需要跨不同包使用相同的资源以实现模块化。例如,在 Android 4.4 中,系统 UI 和锁屏(keyguard 实现)共享 UID 10012(见 示例 1-7)。
示例 1-7. 系统包共享相同的 UID
# grep ' 10012 ' /data/system/packages.list
com.android.keyguard 10012 0 /data/data/com.android.keyguard platform 1028,1015,1035,3002,3001
com.android.systemui 10012 0 /data/data/com.android.systemui platform 1028,1015,1035,3002,3001
尽管不推荐非系统应用程序使用共享用户 ID,但它对第三方应用程序也是可用的。为了共享相同的 UID,应用程序需要由相同的代码签名密钥签署。此外,因为向已安装的应用程序的新版添加共享用户 ID 会导致其 UID 更改,系统不允许这样做(见 第二章)。因此,无法事后添加共享用户 ID,应用程序需要从一开始就设计为支持共享 ID。
权限
因为 Android 应用程序是沙盒化的,它们只能访问自己的文件和设备上任何世界可访问的资源。然而,这样一个有限的应用程序并不太有趣,Android 可以授予应用程序额外的、精细化的访问权限,以允许更丰富的功能。这些访问权限被称为 权限,它们可以控制对硬件设备、互联网连接、数据或操作系统服务的访问。
应用程序可以通过在 AndroidManifest.xml 文件中定义权限来请求权限。在应用程序安装时,Android 会检查请求的权限列表,并决定是否授予它们。一旦授予,权限就无法撤销,并且应用程序可以在没有任何额外确认的情况下访问这些权限。此外,对于如私钥或用户账户访问等功能,即使请求的应用程序已获得相应的权限,也需要每个访问对象的显式用户确认(参见第七章和第八章)。有些权限只能授予属于 Android 操作系统的应用程序,无论是因为它们是预安装的,还是与操作系统签署了相同的密钥。第三方应用程序可以定义自定义权限,并定义类似的限制,称为权限保护级别,从而限制对由相同作者创建的应用程序服务和资源的访问。
权限可以在不同级别进行强制执行。对于较低级别的系统资源请求,如设备文件,Linux 内核通过检查调用进程的 UID 或 GID 是否与资源的所有者和访问权限位匹配来强制执行权限。访问更高级别的 Android 组件时,权限的强制执行由 Android 操作系统或每个组件(或两者)来执行。我们在第二章中讨论权限。
IPC
Android 使用内核驱动程序和用户空间库的组合来实现 IPC。正如在“Binder”中讨论的那样,Binder 内核驱动程序保证了调用者的 UID 和 PID 无法伪造,许多系统服务依赖于 Binder 提供的 UID 和 PID 来动态控制通过 IPC 暴露的敏感 API 的访问。例如,系统蓝牙管理服务仅允许系统应用程序在调用者使用系统 UID(1000)运行时静默启用蓝牙,代码示例请参见示例 1-8。类似的代码在其他系统服务中也可以找到。
示例 1-8. 检查调用者是否使用系统 UID 运行
public boolean enable() {
if ((Binder.getCallingUid() != Process.SYSTEM_UID) &&
(!checkIfCallerIsForegroundUser())) {
Log.w(TAG,"enable(): not allowed for non-active and non-system user");
return false;
}
--*snip*--
}
通过在服务声明中指定权限,系统可以自动强制执行更粗粒度的权限,这些权限影响通过 IPC 暴露的服务的所有方法。与请求的权限类似,所需的权限也在AndroidManifest.xml文件中声明。像上面示例中的动态权限检查一样,按组件的权限也通过查询从 Binder 获取的调用方 UID 来实现。系统使用包数据库来确定被调用组件所需的权限,然后将调用方 UID 映射到包名并检索授予调用方的权限集。如果所需的权限在该权限集中,则调用成功。如果不在,则调用失败,系统会抛出SecurityException。
代码签名和平台密钥
所有 Android 应用必须由其开发者签名,包括系统应用。由于 Android APK 文件是 Java JAR 包格式的扩展,^([8]),因此使用的代码签名方法也是基于 JAR 签名的。Android 使用 APK 签名来确保应用的更新来自同一个作者(这称为同源策略),并建立应用之间的信任关系。这两个安全功能都是通过将当前安装的目标应用的签名证书与更新或相关应用的证书进行比较来实现的。
系统应用由多个平台密钥签名。当系统组件使用相同的平台密钥签名时,它们可以共享资源并在同一进程中运行。平台密钥由维护特定设备上安装的 Android 版本的人员生成和控制:设备制造商、运营商、Nexus 设备的 Google,或自建开源 Android 版本的用户。(我们将在第三章中讨论代码签名和 APK 格式。)
多用户支持
因为 Android 最初是为只有一个物理用户的手机(智能手机)设备设计的,所以它为每个已安装的应用分配一个独特的 Linux UID,并且传统上没有物理用户的概念。Android 在 4.2 版本中增加了对多个物理用户的支持,但多用户支持仅在更可能被共享的平板电脑上启用。通过将最大用户数设置为 1,手机设备上禁用了多用户支持。
每个用户都会分配一个唯一的用户 ID,从 0 开始,用户会在/data/system/users/
为了区分为每个用户安装的应用程序,Android 在为特定用户安装应用程序时,会为每个应用程序分配一个新的有效 UID。这个有效 UID 是基于目标物理用户的用户 ID 和在单用户系统中的应用程序 UID(应用程序 ID)组合结构。通过这种复合结构的 UID 保证,即使相同的应用程序由两个不同的用户安装,这两个应用程序实例也会拥有自己的沙箱。此外,Android 为每个物理用户保证了专用的共享存储(对于旧设备托管在 SD 卡上),该存储是世界可读的。第一个初始化设备的用户称为设备所有者,只有他们可以管理其他用户或执行影响整个设备的管理任务(例如恢复出厂设置)。(我们将在第四章中更详细地讨论多用户支持。)
SELinux
传统的 Android 安全模型在很大程度上依赖于授予应用程序的 UID 和 GID。尽管这些 UID 和 GID 由内核保证,并且默认情况下每个应用程序的文件都是私有的,但没有什么可以阻止应用程序授予其文件世界可读权限(无论是故意还是由于编程错误)。
同样,恶意应用程序也无法避免利用系统文件或本地套接字的过度宽松的访问权限。事实上,不当的权限分配给应用程序或系统文件已成为多个 Android 漏洞的根源。这些漏洞在 Linux 所采用的默认访问控制模型中是不可避免的,该模型称为自主访问控制(DAC)。这里的自主意味着一旦用户获得了对特定资源的访问权限,他们可以按自己的意愿将其传递给其他用户,例如通过将某个文件的访问模式设置为世界可读。与此相对,强制访问控制(MAC)确保资源的访问符合一套系统范围的授权规则,即策略。该策略只能由管理员更改,用户不能覆盖或绕过它,例如,授予所有人访问他们自己文件的权限。
安全增强 Linux(SELinux) 是 Linux 内核的一个强制访问控制(MAC)实现,并且已经在主线内核中集成超过 10 年。从 4.3 版本开始,Android 集成了来自 Android 安全增强(SEAndroid)项目的修改版 SELinux,该版本已被增强以支持 Android 特有的功能,如 Binder。在 Android 中,SELinux 用于将核心系统守护进程和用户应用程序隔离到不同的安全域中,并为每个域定义不同的访问策略。从 4.4 版本开始,SELinux 被部署为强制模式(对系统策略的违规行为会生成运行时错误),但策略强制执行仅应用于核心系统守护进程。应用程序仍然在宽容模式下运行,违规行为会被记录,但不会导致运行时错误。(我们在第十二章中提供了更多关于 Android SELinux 实现的细节。)
系统更新
Android 设备可以通过空中下载(OTA)或将设备连接到 PC,并使用标准的 Android 调试桥(ADB)客户端或一些厂商提供的具有类似功能的应用程序推送更新镜像来进行更新。由于 Android 更新除了可能需要修改系统文件外,还可能需要修改基带(调制解调器)固件、引导加载程序以及其他无法直接从 Android 访问的设备部分,因此更新过程通常使用一个特殊用途、最小化的操作系统,该操作系统可以独占访问所有设备硬件。这被称为恢复操作系统,简称恢复。
OTA 更新通过下载 OTA 包文件(通常是一个附加了代码签名的 ZIP 文件)来执行,该文件包含一个小的脚本文件,由恢复模式解释,并通过重新启动设备进入恢复模式。或者,用户可以在启动设备时使用设备特定的按键组合进入恢复模式,并通过使用恢复模式的菜单界面手动应用更新,恢复模式的菜单界面通常通过设备的硬件按钮(音量增/减,电源等)进行导航。
在生产设备上,恢复模式只接受设备制造商签名的更新。更新文件通过扩展 ZIP 文件格式,在注释部分包含对整个文件的签名(参见 第三章),恢复模式会提取并验证签名后再安装更新。在某些设备上(包括所有 Nexus 设备、专用开发者设备和一些厂商设备),设备所有者可以替换恢复操作系统并禁用系统更新签名验证,从而允许安装第三方更新。将设备的引导加载程序切换到允许替换恢复和系统镜像的模式被称为 引导加载程序解锁(不要与 SIM 解锁混淆,后者允许设备在任何移动网络上使用),通常需要擦除所有用户数据(恢复出厂设置),以确保潜在恶意的第三方系统镜像不会访问现有用户数据。在大多数消费类设备上,解锁引导加载程序的副作用是使设备的保修失效。(我们在 第十三章中讨论系统更新和恢复镜像。)
验证启动
从版本 4.4 开始,Android 支持通过 Linux 的 Device-Mapper 的 verity 目标^([10]) 实现验证启动。Verity 使用加密哈希树对块设备进行透明的完整性检查。树中的每个节点都是一个加密哈希,叶节点包含物理数据块的哈希值,中间节点包含其子节点的哈希值。由于根节点中的哈希值是基于所有其他节点的值,因此只需要信任根哈希值即可验证树的其余部分。
验证通过包含在启动分区中的 RSA 公钥进行。设备块在运行时通过计算块的哈希值并将其与哈希树中记录的值进行比较来进行检查。如果值不匹配,则读取操作会导致 I/O 错误,指示文件系统已损坏。由于所有检查都是由内核执行的,启动过程需要验证内核的完整性才能使验证启动生效。此过程是设备特定的,通常通过使用不可更改的硬件特定密钥来实现,该密钥被“烧录”(写入只读存储器)到设备中。该密钥用于验证每个引导加载程序级别,最终验证内核的完整性。(我们在 第十章中讨论验证启动。)
概述
Android 是一个基于 Linux 内核的特权分离操作系统。高级系统功能作为一组协作的系统服务来实现,这些服务通过一种称为 Binder 的 IPC 机制进行通信。Android 通过为每个应用分配独特的系统身份(Linux UID)来将应用隔离开。默认情况下,应用拥有非常少的权限,必须请求细粒度的权限才能与系统服务、硬件设备或其他应用交互。权限在每个应用的清单文件中定义,并在安装时授予。系统使用每个应用的 UID 来了解其已授予的权限,并在运行时强制执行这些权限。在最近的版本中,系统进程隔离利用 SELinux 进一步限制了每个进程的权限。
^([1]) Android 主线项目,elinux.org/Android_Mainlining_Project
^([2]) 有关 Android 特性更详细的讨论,请参阅 Karim Yaghmour 的 Embedded Android,O'Reilly,2013,pp. 29–38。
^([3]) Yunhe Shi 等,虚拟机对决:栈与寄存器,www.usenix.org/legacy/events/vee05/full_papers/p153-yunhe.pdf
^([4]) Apache 软件基金会,Apache Harmony,harmony.apache.org/
^([5]) Oracle,Java™ Native Interface,docs.oracle.com/javase/7/docs/technotes/guides/jni/
^([6]) PalmSource 公司,OpenBinder,www.angryredplanet.com/~hackbod/openbinder/docs/html/
^([7]) Google,Android API 参考,“IBinder”,developer.android.com/reference/android/os/IBinder.html
^([8]) Oracle,JAR 文件规范,docs.oracle.com/javase/7/docs/technotes/guides/jar/jar.html
^([9]) SELinux 项目,Android 的 SE,selinuxproject.org/page/SEAndroid
^([10]) Linux 内核源代码树,dm-verity,git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/tree/Documentation/device-mapper/verity.txt
第二章. 权限
在上一章中,我们概述了 Android 的安全模型,并简要介绍了权限。在本章中,我们将提供有关权限的更多细节,重点讲解它们的实现和执行。接下来,我们将讨论如何定义自定义权限并将其应用于每个 Android 组件。最后,我们将简要介绍待处理的意图,它是一个令牌,允许应用程序以另一个应用程序的身份和权限启动意图。
权限的性质
正如我们在第一章中所学到的,Android 应用程序是沙箱式的,默认情况下只能访问它们自己的文件和一小部分系统服务。为了与系统和其他应用程序进行交互,Android 应用程序可以请求一组附加权限,这些权限在安装时授予,且在安装后不能更改(但有一些例外,我们将在本章后面讨论)。
在 Android 中,权限只是一个字符串,表示执行特定操作的能力。目标操作可以是访问物理资源(如设备的 SD 卡)或共享数据(如已注册联系人列表),也可以是启动或访问第三方应用程序中的组件的能力。Android 提供了一组内建的预定义权限,每个版本都会添加与新功能相关的新权限。
注意
新的内建权限,限制了以前不需要权限的功能,依赖于 targetSdkVersion 在应用程序清单中指定的版本进行条件应用:针对引入新权限之前发布的 Android 版本的应用程序无法知道这些权限,因此这些权限通常会被隐式授予(无需请求)。不过,隐式授予的权限仍会在应用安装器屏幕的权限列表中显示,以便用户了解它们。针对较新版本的应用程序需要明确请求这些新权限。
内建权限在平台 API 参考文档中有记录。^([11]) 另外,还可以定义由系统和用户安装的应用程序使用的自定义权限。
要查看系统当前已知的权限列表,可以使用pm list permissions命令(参见示例 2-1)。要显示关于权限的更多信息,包括定义的包、标签、描述和保护级别,可以在命令中添加-f参数。
示例 2-1. 获取所有权限的列表
$ **pm list permissions**
All Permissions:
permission:android.permission.REBOOT➊
permission:android.permission.BIND_VPN_SERVICE➋
permission:com.google.android.gallery3d.permission.GALLERY_PROVIDER➌
permission:com.android.launcher3.permission.RECEIVE_LAUNCH_BROADCASTS➍
--*snip*--
权限名称通常以其定义的包名和字符串.permission为前缀。由于内建权限在android包中定义,它们的名称以android.permission开头。例如,在示例 2-1 中,REBOOT ➊和BIND_VPN_SERVICE ➋是内建权限,而GALLERY_PROVIDER ➌是由图库应用(包名为com.google.android.gallery3d)定义的,RECEIVE_LAUNCH_BROADCASTS ➍则由默认启动器应用(包名为com.android.launcher3)定义。
请求权限
应用通过向其AndroidManifest.xml文件添加一个或多个<uses-permission>标签来请求权限,并可以通过<permission>标签定义新的权限。示例 2-2 展示了一个请求INTERNET和WRITE_EXTERNAL_STORAGE权限的清单文件示例。(我们将在“自定义权限”中展示如何定义自定义权限。)
示例 2-2. 使用应用清单文件请求权限
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.app"
android:versionCode="1"
android:versionName="1.0" >
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
--*snip*--
<application android:name="SampleApp" ...>
--*snip*--
</application>
</manifest>
权限管理
权限在应用安装时由系统的包管理器服务分配给每个应用(通过唯一的包名进行标识)。包管理器维护一个包含已安装包的中央数据库,包括预安装和用户安装的包,其中记录了每个包的安装路径、版本、签名证书和分配的权限信息,以及设备上定义的所有权限列表。(前一节介绍的pm list permissions命令通过查询包管理器来获取此列表。)这个包数据库存储在 XML 文件/data/system/packages.xml中,每次应用被安装、更新或卸载时,都会更新该文件。示例 2-3 展示了packages.xml中的典型应用条目。
示例 2-3. packages.xml 中的应用条目
<package name="com.google.android.apps.translate"
codePath="/data/app/com.google.android.apps.translate-2.apk"
nativeLibraryPath="/data/app-lib/com.google.android.apps.translate-2"
flags="4767300" ft="1430dfab9e0" it="142cdf04d67" ut="1430dfabd8d"
version="30000028"
userId="10204"➊
installer="com.android.vending">
<sigs count="1">
<cert index="7" />➋
</sigs>
<perms>➌
<item name="android.permission.READ_EXTERNAL_STORAGE" />
<item name="android.permission.USE_CREDENTIALS" />
<item name="android.permission.READ_SMS" />
<item name="android.permission.CAMERA" />
<item name="android.permission.WRITE_EXTERNAL_STORAGE" />
<item name="android.permission.INTERNET" />
<item name="android.permission.MANAGE_ACCOUNTS" />
<item name="android.permission.GET_ACCOUNTS" />
<item name="android.permission.ACCESS_NETWORK_STATE" />
<item name="android.permission.RECORD_AUDIO" />
</perms>
<signing-keyset identifier="17" />
<signing-keyset identifier="6" />
</package>
我们将在第三章中讨论大多数标签和属性的含义,但现在让我们重点关注与权限相关的内容。每个包通过<package>元素表示,该元素包含有关分配的 UID(在userId属性 ➊中)、签名证书(在<cert>标签 ➋中)以及分配的权限(作为<perms>标签的子项列出 ➌)的信息。要以编程方式获取已安装包的信息,可以使用android.content.pm.PackageManager类的getPackageInfo()方法,该方法返回一个PackageInfo实例,封装了<package>标签中包含的信息。
如果所有权限都在安装时分配,并且在不卸载应用的情况下无法更改或撤销,包管理器如何决定是否授予请求的权限?为了理解这一点,我们需要讨论权限的保护级别。
权限保护级别
根据官方文档,^([12]) 权限的保护级别“描述了该权限所包含的潜在风险,并指示系统在决定是否授予权限时应遵循的程序。”实际上,这意味着权限是否被授予取决于其保护级别。接下来的章节讨论了 Android 中定义的四种保护级别,以及系统如何处理每种级别。
普通
这是默认值。它定义了一种对系统或其他应用程序风险较低的权限。具有普通保护级别的权限会在不需要用户确认的情况下自动授予。例如 ACCESS_NETWORK_STATE(允许应用访问网络信息)和 GET_ACCOUNTS(允许访问帐户服务中的帐户列表)。
危险
具有危险保护级别的权限会访问用户数据或对设备进行某种形式的控制。例如 READ_SMS(允许应用读取短信消息)和 CAMERA(允许应用访问相机设备)。在授予危险权限之前,Android 会显示一个确认对话框,显示请求权限的信息。由于 Android 要求在安装时授予所有请求的权限,用户可以同意安装应用,从而授予请求的危险权限,或者取消应用安装。例如,对于示例 2-3(Google Translate)中显示的应用,系统确认对话框将类似于图 2-1 中所示。
Google Play 和其他应用市场客户端会显示自己的对话框,样式通常不同。对于相同的应用,Google Play 商店客户端会显示图 2-2 中显示的对话框。在这里,所有危险权限按权限组进行组织(参见“系统权限”),普通权限则不会显示。

图 2-1. 默认的 Android 应用安装确认对话框

图 2-2. Google Play 商店客户端应用安装确认对话框
签名
签名权限仅授予与声明该权限的应用使用相同密钥签名的应用。这是“最强”的权限级别,因为它要求拥有一个只有应用(或平台)所有者控制的加密密钥。因此,使用签名权限的应用通常由同一个作者控制。内置的签名权限通常由执行设备管理任务的系统应用使用。示例包括NET_ADMIN(配置网络接口、IPSec 等)和ACCESS_ALL_EXTERNAL_STORAGE(访问所有多用户外部存储)。我们将在“签名权限”中更详细地讨论签名权限。
signatureOrSystem
具有此保护级别的权限可以说是一种折衷:它们授予那些属于系统镜像的一部分,或与声明此权限的应用使用相同密钥签名的应用。这允许那些在 Android 设备上预安装应用的厂商在不共享签名密钥的情况下,分享需要权限的特定功能。直到 Android 4.3,安装在system分区上的任何应用都会自动获得signatureOrSystem权限。自 Android 4.4 起,应用需要安装在/system/priv-app/目录下,才能获得具有此保护级别的权限。
权限分配
权限在 Android 中的各个层面进行强制执行。较高层次的组件,例如应用程序和系统服务,会查询包管理器以确定哪些权限已分配给某个应用,并决定是否授予访问权限。较低层次的组件,如本地守护进程,通常无法访问包管理器,而是依赖于分配给进程的 UID、GID 和附加 GID 来决定授予哪些特权。对系统资源的访问,如设备文件、Unix 域套接字(本地套接字)和网络套接字,由内核基于目标资源的所有者、访问模式以及访问进程的 UID 和 GID 来进行管理。
我们将在“权限强制执行”中深入探讨框架级别的权限强制执行。首先,我们将讨论权限如何映射到操作系统级别的构造(如 UID 和 GID),以及这些进程 ID 如何用于权限强制执行。
权限与进程属性
与任何 Linux 系统一样,Android 进程都有一组关联的进程属性,最重要的是实际和有效的 UID 和 GID,以及一组附加的 GID。
如第一章所述,每个 Android 应用在安装时都会被分配一个唯一的 UID,并在专用进程中执行。当应用启动时,进程的 UID 和 GID 会被设置为安装程序(包管理服务)分配的应用 UID。如果应用被分配了额外的权限,这些权限会映射到 GID,并作为补充 GID 分配给进程。内建权限的权限到 GID 映射定义在 /etc/permission/ platform.xml 文件中。示例 2-4 显示了在 Android 4.4 设备上找到的 platform.xml 文件的摘录。
示例 2-4. 在 platform.xml 中的权限到 GID 映射
<?xml version="1.0" encoding="utf-8"?>
<permissions>
--*snip*--
<permission name="android.permission.INTERNET" >➊
<group gid="inet" />
</permission>
<permission name="android.permission.WRITE_EXTERNAL_STORAGE" >➋
<group gid="sdcard_r" />
<group gid="sdcard_rw" />
</permission>
<assign-permission name="android.permission.MODIFY_AUDIO_SETTINGS"
uid="media" />➌
<assign-permission name="android.permission.ACCESS_SURFACE_FLINGER"
uid="media" />➍
--*snip*--
</permissions>
在这里,INTERNET 权限与 inet GID ➊ 相关联,WRITE_EXTERNAL_STORAGE 权限与 sdcard_r 和 sdcard_rw GIDs ➋ 相关联。因此,任何已被授予 INTERNET 权限的应用进程,都与对应的 inet 组的补充 GID 相关联,而授予 WRITE_EXTERNAL_STORAGE 权限的进程,则将 sdcard_r 和 sdcard_rw 的 GID 添加到相关补充 GID 列表中。
<assign-permission> 标签的作用恰恰相反:它用于将更高级别的权限分配给在特定 UID 下运行的系统进程,这些进程没有对应的包。示例 2-4 显示了与 media UID(实际上是 mediaserver 守护进程)一起运行的进程被分配了 MODIFY_AUDIO_SETTINGS ➌ 和 ACCESS_SURFACE_FLINGER ➍ 权限。
Android 没有/etc/group 文件,因此从组名到 GID 的映射是静态的,并在 android_filesystem_config.h 头文件中定义。示例 2-5 显示了一个包含 sdcard_rw ➊、sdcard_r ➋ 和 inet ➌ 组的摘录。
示例 2-5. 在 android_filesystem_config.h 中的静态用户和组名到 UID/GID 映射
--*snip*-
#define AID_ROOT 0 /* traditional unix root user */
#define AID_SYSTEM 1000 /* system server */
--*snip*--
#define AID_SDCARD_RW 1015 /* external storage write access */
#define AID_SDCARD_R 1028 /* external storage read access */
#define AID_SDCARD_ALL 1035 /* access all users external storage */
--*snip*--
#define AID_INET 3003 /* can create AF_INET and AF_INET6 sockets */
--*snip*--
struct android_id_info {
const char *name;
unsigned aid;
};
static const struct android_id_info android_ids[] = {
{ "root", AID_ROOT, },
{ "system", AID_SYSTEM, },
--*snip*--
{ "sdcard_rw", AID_SDCARD_RW, },➊
{ "sdcard_r", AID_SDCARD_R, },➋
{ "sdcard_all", AID_SDCARD_ALL, },
--*snip*--
{ "inet", AID_INET, },➌
};
android_filesystem_config.h 文件还定义了 Android 核心系统目录和文件的所有者、访问模式以及关联的能力(对于可执行文件)。
包管理器在启动时读取 platform.xml 并维护权限及其关联的 GID 列表。当它在安装过程中授予权限时,包管理器会检查每个权限是否有关联的 GID(s)。如果有,GID(s) 会被添加到与应用相关联的附加 GID 列表中。附加 GID 列表作为 packages.list 文件的最后一项字段写入(参见 示例 1-6)。
进程属性分配
在我们了解内核和低级系统服务如何检查并强制执行权限之前,我们需要先了解 Android 应用进程是如何启动和分配进程属性的。
如 第一章中所讨论,Android 应用是用 Java 实现的,并由 Dalvik 虚拟机执行。因此,每个应用进程实际上是一个执行应用字节码的 Dalvik 虚拟机进程。为了减少应用内存占用并提高启动速度,Android 并不会为每个应用启动一个新的 Dalvik 虚拟机进程。相反,它使用一个部分初始化的进程,称为 zygote,当需要启动新应用时,它会分叉该进程(使用 fork() 系统调用^([13]))。然而,它并不会像启动本地进程时那样调用 exec() 函数,而是仅执行指定 Java 类的 main() 函数。这个过程被称为 专门化,因为通用的 zygote 进程被转化为一个特定的应用进程,就像来自合子细胞的细胞会专门化成执行不同功能的细胞一样。因此,分叉出的进程继承了 zygote 进程的内存映像,而 zygote 进程已经预加载了大多数核心和应用框架 Java 类。由于这些类从不改变,而且 Linux 在分叉进程时使用写时复制机制,所有 zygote 的子进程(即所有 Android 应用)共享相同的框架 Java 类副本。
zygote 进程由 init.rc 初始化脚本启动,并通过一个名为 zygote 的 Unix 域套接字接收命令。当 zygote 接收到启动新应用进程的请求时,它会自我分叉,子进程大致执行以下代码(简化自 dalvik_system_Zygote.cpp 中的 forkAndSpecializeCommon()),以便按 示例 2-6 所示进行自我专门化。
示例 2-6. 在 zygote 中的应用进程专门化
pid = fork();
if (pid == 0) {
int err;
/* The child process */
err = setgroupsIntarray(gids);➊
err = setrlimitsFromArray(rlimits);➋
err = setresgid(gid, gid, gid);➌
err = setresuid(uid, uid, uid);➍
err = setCapabilities(permittedCapabilities, effectiveCapabilities);➎
err = set_sched_policy(0, SP_DEFAULT);➏
err = setSELinuxContext(uid, isSystemServer, seInfo, niceName);➐
enableDebugFeatures(debugFlags);➑
}
如图所示,子进程首先使用 setgroups() 设置其附加 GID(对应权限),该函数由 setgroupsIntarray() 在 ➊ 调用。接着,它使用 setrlimit() 设置资源限制,该函数由 setrlimitsFromArray() 在 ➋ 调用,然后使用 setresgid() ➌ 和 setresuid() ➍ 设置实际、有效和保存的用户 ID 和组 ID。
子进程能够改变其资源限制和所有进程属性,因为它最初以 root 身份执行,就像它的父进程zygote一样。在设置完新的进程属性后,子进程将以分配的 UID 和 GID 执行,并且无法再以 root 身份执行,因为保存的用户 ID 不是 0。
设置完 UID 和 GID 后,进程使用 capset() 设置其能力^([14]),该函数由 setCapabilities() 在 ➎ 调用。然后,它通过将自己添加到预定义的控制组之一来设置调度策略 ➏.^([15]) 在 ➐ 处,进程设置其优先级名称(在进程列表中显示,通常是应用程序的包名)和 seinfo 标签(由 SELinux 使用,我们将在第十二章中讨论)。最后,如果请求,进程会启用调试 ➑。
注意
Android 4.4 引入了一个新的实验性运行时,称为 Android 运行时(ART),预计将在未来的版本中替代 Dalvik。虽然 ART 带来了许多对当前执行环境的改变,最重要的是提前编译(AOT),但它使用与 Dalvik 相同的基于 zygote 的应用进程执行模型。
从 ps 命令获取的进程列表中可以明显看到zygote和应用进程之间的关系,如示例 2-7 所示。
示例 2-7. zygote 和应用进程的关系
$ **ps**
USER PID PPID VSIZE RSS WCHAN PC NAME
root 1 0 680 540 ffffffff 00000000 S /init➊
--*snip*--
root 181 1 858808 38280 ffffffff 00000000 S zygote➋
--*snip*--
radio 1139 181 926888 46512 ffffffff 00000000 S com.android.phone
nfc 1154 181 888516 36976 ffffffff 00000000 S com.android.nfc
u0_a7 1219 181 956836 48012 ffffffff 00000000 S com.google.android.gms
在这里,PID 列表示进程 ID,PPID 列表示父进程 ID,NAME 列表示进程名称。如你所见,zygote(PID 181 ➋)是由init进程(PID 1 ➊)启动的,所有应用进程的父进程都是zygote(PPID 181)。每个进程都在一个专用用户下执行,可能是内置的(radio,nfc),或者在安装时自动分配的(u0_a7)。进程名称被设置为每个应用的包名(com.android.phone,com.android.nfc,和 com.google.android.gms)。
权限强制执行
如前一节所述,每个应用进程在从zygote分叉时都会分配一个 UID、GID 和附加的 GID。内核和系统守护进程使用这些进程标识符来决定是否授予访问某个系统资源或功能的权限。
内核级强制执行
对常规文件、设备节点和本地套接字的访问与任何 Linux 系统一样受到管理。Android 特有的一个添加项是要求希望创建网络套接字的进程属于 inet 组。这个 Android 内核的添加项被称为“偏执的网络安全”,并作为 Android 内核中的一个附加检查实现,如 示例 2-8 所示。
示例 2-8. Android 内核中的偏执网络安全实现
#ifdef CONFIG_ANDROID_PARANOID_NETWORK
#include <linux/android_aid.h>
static inline int current_has_network(void)
{ return in_egroup_p(AID_INET) || capable(CAP_NET_RAW);➊}
#else
static inline int current_has_network(void)
{ return 1;➋
}
#endif
--*snip*--
static int inet_create(struct net *net, struct socket *sock, int protocol,
int kern)
{
--*snip*--
if (!current_has_network())
return -EACCES;➌
--*snip*--
}
不属于 AID_INET(GID 3003,名称为 inet)组且没有 CAP_NET_RAW 能力(允许使用 RAW 和 PACKET 套接字)的调用进程会收到访问拒绝错误(➊ 和 ➌)。非 Android 内核未定义 CONFIG_ANDROID_PARANOID_NETWORK,因此创建套接字时不需要特别的组成员身份 ➋。为了将 inet 组分配给应用进程,必须授予其 INTERNET 权限。因此,只有具有 INTERNET 权限的应用程序才能创建网络套接字。除了在创建套接字时检查进程凭据外,Android 内核还会授予具有特定 GID 的进程某些能力:以 AID_NET_RAW(GID 3004)身份执行的进程会获得 CAP_NET_RAW 能力,而以 AID_NET_ADMIN(GID 3005)身份执行的进程会获得 CAP_NET_ADMIN 能力。
偏执的网络安全还用于控制对蓝牙套接字和内核隧道驱动程序(用于 VPN)的访问。内核以特殊方式处理的 Android GID 的完整列表可以在内核源代码树中的 include/linux/android_aid.h 文件中找到。
本地守护进程级别的强制执行
虽然 Binder 是 Android 中首选的进程间通信(IPC)机制,但较低级别的本地守护进程通常使用 Unix 域套接字(本地套接字)进行 IPC。由于 Unix 域套接字在文件系统中以节点的形式表示,因此可以使用标准的文件系统权限来控制访问。
由于大多数套接字是以仅允许其所有者和组访问的访问模式创建的,运行在不同 UID 和 GID 下的客户端无法连接到该套接字。系统守护进程的本地套接字在 init.rc 中定义,并由 init 在启动时以指定的访问模式创建。例如,示例 2-9 显示了如何在 init.rc 中定义卷管理守护进程 (vold):
示例 2-9. init.rc 中的 vold 守护进程条目
service vold /system/bin/vold
class core
socket vold stream 0660 root mount➊
ioprio be 2
vold声明了一个名为vold的套接字,访问模式为 0660,所有者为root,并且组设置为mount ➊。vold守护进程需要以 root 身份运行才能挂载或卸载卷,但mount组的成员(AID_MOUNT,GID 1009)可以通过本地套接字向其发送命令,而无需以超级用户身份运行。Android 守护进程的本地套接字创建在/dev/socket/目录中。示例 2-10 显示了vold套接字➊的所有者和权限设置,这些设置在init.rc中指定。
示例 2-10. /dev/socket/中的核心系统守护进程的本地套接字
$ **ls -l /dev/socket**
srw-rw---- system system 1970-01-18 14:26 adbd
srw------- system system 1970-01-18 14:26 installd
srw-rw---- root system 1970-01-18 14:26 netd
--*snip*--
srw-rw-rw- root root 1970-01-18 14:26 property_service
srw-rw---- root radio 1970-01-18 14:26 rild
srw-rw---- root mount 1970-01-18 14:26 vold➊
srw-rw---- root system 1970-01-18 14:26 zygote
Unix 域套接字允许通过SCM_CREDENTIALS控制消息和SO_PEERCRED套接字选项传递和查询客户端凭证。与 Binder 事务中包含的有效 UID 和有效 GUID 类似,与本地套接字关联的对等凭证由内核检查,并且不能被用户级进程伪造。这允许本地守护进程实现对其允许的特定客户端操作的额外精细化控制,如示例 2-11 所示,使用vold守护进程作为示例。
示例 2-11. 基于套接字客户端凭证的精细化访问控制在 vold 中的应用
int CommandListener::CryptfsCmd::runCommand(SocketClient *cli,
int argc, char **argv) {
if ((cli->getUid() != 0) && (cli->getUid() != AID_SYSTEM)) {➊
cli->sendMsg(ResponseCode::CommandNoPermission,
"No permission to run cryptfs commands", false);
return 0;
}
--*snip*--
}
vold守护进程仅允许以root(UID 0)或system(AID_SYSTEM,UID 1000)用户身份运行的客户端发送加密容器管理命令。在这里,SocketClient->getUid()返回的 UID➊是通过getsockopt(SO_PEERCRED)获取的客户端 UID,如示例 2-12 获取本地套接字客户端凭证")中所示。
示例 2-12. 使用getsockopt()获取本地套接字客户端凭证
void SocketClient::init(int socket, bool owned, bool useCmdNum) {
--*snip*--
struct ucred creds;
socklen_t szCreds = sizeof(creds);
memset(&creds, 0, szCreds);
int err = getsockopt(socket, SOL_SOCKET, SO_PEERCRED, &creds, &szCreds);➊
if (err == 0) {
mPid = creds.pid;
mUid = creds.uid;
mGid = creds.gid;
}
}
本地套接字连接功能封装在android.net.LocalSocket类中,Java 应用程序也可以使用该功能,使得更高层次的系统服务能够与本地守护进程进行通信,而无需使用 JNI 代码。例如,MountService框架类使用LocalSocket向vold守护进程发送命令。
框架级强制执行
正如在 Android 权限介绍中所讨论的那样,访问 Android 组件可以通过声明所需权限在封闭应用程序的清单中来控制。系统会跟踪与每个组件关联的权限,并在允许访问之前检查调用者是否已被授予所需权限。由于组件不能在运行时更改其所需的权限,因此系统的强制执行是静态的。静态权限是声明性安全性的一个例子。使用声明性安全性时,角色和权限等安全属性被放置在组件的元数据中(在 Android 中是AndroidManifest.xml文件),而不是在组件本身,并由容器或运行时环境强制执行。这有一个优点,即将安全决策与业务逻辑隔离开,但相比在组件内部实现安全检查,它可能不够灵活。
Android 组件还可以检查调用进程是否已被授予某个权限,而无需在清单中声明权限。这种动态权限强制执行需要更多的工作,但允许更细粒度的访问控制。动态权限强制执行是命令式安全性的一个例子,因为安全决策由每个组件做出,而不是由运行时环境强制执行。
让我们更详细地了解动态和静态权限强制执行的实现方式。
动态强制执行
正如在第一章中讨论的那样,Android 的核心是由一组合作的系统服务实现的,这些服务可以通过 Binder IPC 机制从其他进程调用。核心服务注册到服务管理器,任何知道它们注册名称的应用程序都可以获取 Binder 引用。由于 Binder 没有内置的访问控制机制,因此当客户端拥有引用时,它们可以通过将适当的参数传递给Binder.transact()来调用底层系统服务的任何方法。因此,访问控制需要由每个系统服务实现。
在第一章中,我们展示了系统服务如何通过直接检查从Binder.getCallingUid()获取的调用者 UID 来调节对导出操作的访问(参见示例 1-8)。然而,这种方法要求服务提前知道允许的 UID 列表,这仅适用于一些已知的固定 UID,如root(UID 0)和system(UID 1000)。此外,大多数服务并不关心调用者的实际 UID;它们只关心是否已授予某个权限。
因为每个 Android 应用的 UID 都与一个唯一的包关联(除非它是共享用户 ID 的一部分),并且包管理器跟踪每个包授予的权限,所以可以通过查询包管理服务来实现这一点。检查调用者是否具有某个权限是一个非常常见的操作,Android 在android.content.Context类中提供了多个辅助方法来执行此检查。
我们首先来看看int Context.checkPermission(String permission, int pid, int uid)方法的工作原理。此方法如果传入的 UID 具有该权限,则返回PERMISSION_GRANTED,否则返回PERMISSION_DENIED。如果调用者是root或system,则权限会自动授予。作为性能优化,如果请求的权限已经被调用的应用声明,则直接授予权限,而无需检查实际权限。如果不是这种情况,方法会检查目标组件是公开(导出)还是私有,并拒绝访问所有私有组件。(我们将在“公共与私有组件”中讨论组件的导出)。最后,代码会查询包管理服务,以查看调用者是否已获得请求的权限。来自PackageManagerService类的相关代码可参见示例 2-13。
示例 2-13. PackageManagerService中的基于 UID 的权限检查
public int checkUidPermission(String permName, int uid) {
synchronized (mPackages) {
Object obj = mSettings.getUserIdLPr(➊UserHandle.getAppId(uid));
if (obj != null) {
GrantedPermissions gp = (GrantedPermissions)obj;➋
if (gp.grantedPermissions.contains(permName)) {
return PackageManager.PERMISSION_GRANTED;
}
} else {
HashSet<String> perms = mSystemPermissions.get(uid);➌
if (perms != null && perms.contains(permName)) {
return PackageManager.PERMISSION_GRANTED;
}
}
}
return PackageManager.PERMISSION_DENIED;
}
在这里,PackageManagerService首先根据传入的 UID ➊确定应用程序的应用 ID(同一个应用程序在为不同用户安装时可以分配多个 UID,详细讨论请参见第四章),然后获取授予的权限集合。如果GrantedPermission类(其中包含实际的java.util.Set<String>类型的权限名称)包含目标权限,则该方法返回PERMISSION_GRANTED ➋。否则,它将检查目标权限是否应自动分配给传入的 UID ➌(依据platform.xml中的<assign-permission>标签,如示例 2-4 所示)。如果该检查也失败,最终返回PERMISSION_DENIED。
Context 类中的其他权限检查辅助方法遵循相同的流程。int checkCallingOrSelfPermission(String permission) 方法会调用 Binder.getCallingUid() 和 Binder.getCallingPid(),然后使用获取到的值调用 checkPermission(String permission, int pid, int uid)。enforcePermission(String permission, int pid, int uid, String message) 方法不会返回结果,而是在没有权限时抛出带有指定消息的 SecurityException。例如,BatteryStatsService 类通过在执行其他代码之前调用 enforceCallingPermission() 来确保只有拥有 BATTERY_STATS 权限的应用程序能够获取电池统计信息,如 示例 2-14 所示。未被授予权限的调用者会收到 SecurityException。
示例 2-14. BatteryStatsService 中的动态权限检查
public byte[] getStatistics() {
mContext.enforceCallingPermission(
android.Manifest.permission.BATTERY_STATS, null);
Parcel out = Parcel.obtain();
mStats.writeToParcel(out, 0);
byte[] data = out.marshall();
out.recycle();
return data;
}
静态强制执行
静态权限强制执行在应用程序尝试与另一个应用程序声明的组件进行交互时起作用。强制执行过程会考虑每个目标组件声明的权限(如果有),并在调用者进程被授予所需权限时允许交互。
Android 使用意图来描述需要执行的操作,完全指定目标组件(通过包名和类名)的意图称为 显式 意图。另一方面,隐式 意图包含一些数据(通常只是一个抽象动作,例如 ACTION_SEND),允许系统找到匹配的组件,但并未完全指定目标组件。
当系统接收到一个隐式意图时,它首先通过搜索匹配的组件来解析该意图。如果找到多个匹配的组件,用户将看到一个选择对话框。当选择了目标组件后,Android 会检查该组件是否有相关的权限,如果有,再检查这些权限是否已经授予给调用者。
一般流程类似于动态权限强制:通过 Binder.getCallingUid() 和 Binder.getCallingPid() 获取调用者的 UID 和 PID,调用者的 UID 会被映射到一个包名,并且检索相关的权限。如果调用者权限集包含目标组件所需的权限,组件将被启动;否则,将抛出 SecurityException。
权限检查由 ActivityManagerService 执行,该服务解析指定的意图并检查目标组件是否有相关的权限属性。如果有,它将权限检查委托给包管理器。具体的权限检查时机和顺序会根据目标组件有所不同。(接下来,我们将检查每个组件如何进行检查。)
活动和服务权限强制执行
如果传递给Context.startActivity()或startActivityForResult()的意图指向一个声明了权限的活动,则会进行活动的权限检查。如果调用者没有所需的权限,将抛出SecurityException。因为 Android 服务可以启动、停止和绑定,因此调用Context.startService()、stopService()和bindService()时,如果目标服务声明了权限,也会受到权限检查。
内容提供者权限强制执行
内容提供者权限可以保护整个组件或特定的导出 URI,并且可以为读取和写入指定不同的权限。(你将在《内容提供者权限》一章中了解更多关于权限声明的内容。)如果为读取和写入指定了不同的权限,则读取权限控制谁可以在目标提供者或 URI 上调用ContentResolver.query(),而写入权限控制谁可以在提供者或其导出的 URI 上调用ContentResolver.insert()、ContentResolver.update()和ContentResolver.delete()。当调用这些方法时,权限检查会同步执行。
广播权限强制执行
在发送广播时,应用程序可以要求接收者持有特定的权限,方法是使用Context.sendBroadcast(Intent intent, String receiverPermission)方法。由于广播是异步的,调用此方法时不会执行权限检查。权限检查是在将意图传递给已注册的接收者时进行的。如果目标接收者没有持有所需的权限,它将被跳过,并且不会接收到广播,但不会抛出异常。反过来,广播接收者也可以要求广播发送者持有特定的权限,才能将广播发送给它们。
所需的权限可以在清单文件中指定,也可以在动态注册广播时指定。这个权限检查也会在传递广播时执行,并且不会导致SecurityException。因此,发送广播可能需要进行两次权限检查:一次是广播发送者的权限检查(如果接收者指定了权限),另一次是广播接收者的权限检查(如果发送者指定了权限)。
受保护和粘性广播
一些系统广播被声明为 protected(例如,BOOT_COMPLETED 和 PACKAGE_INSTALLED),只能由以 SYSTEM_UID、PHONE_UID、SHELL_UID、BLUETOOTH_UID 或 root 身份运行的系统进程发送。如果一个以其他 UID 运行的进程试图发送受保护的广播,在调用 sendBroadcast() 方法时会收到 SecurityException。发送“粘性”广播(如果标记为粘性,系统会在广播完成后保留发送的 Intent 对象)要求发送者持有 BROADCAST_STICKY 权限;否则,会抛出 SecurityException 并且广播不会发送。
系统权限
Android 的内置权限在 android 包中定义,有时也称为“框架”或“平台”。正如我们在第一章中所学到的,核心 Android 框架是由系统服务共享的一组类,其中一些通过公共 SDK 进行公开。框架类打包在 JAR 文件中,这些文件位于 /system/framework/(最新版本中大约 40 个)。
除了 JAR 库,框架还包含一个 APK 文件,framework-res.apk。顾名思义,它打包了框架资源(动画、图形、布局等),但没有实际代码。最重要的是,它定义了 android 包和系统权限。由于 framework-res.apk 是一个 APK 文件,它包含一个 AndroidManifest.xml 文件,在该文件中声明了权限组和权限(参见示例 2-15)。
示例 2-15. framework-res.apk 中清单的系统权限定义
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="android" coreApp="true" android:sharedUserId="android.uid.system"
android:sharedUserLabel="@string/android_system_label">
--*snip*-
<protected-broadcast android:name="android.intent.action.BOOT_COMPLETED" />➊
<protected-broadcast android:name="android.intent.action.PACKAGE_INSTALL" />
--*snip*--
<permission-group android:name="android.permission-group.MESSAGES"
android:label="@string/permgrouplab_messages"
android:icon="@drawable/perm_group_messages"
android:description="@string/permgroupdesc_messages"
android:permissionGroupFlags="personalInfo"
android:priority="360"/>➋
<permission android:name="android.permission.SEND_SMS"
android:permissionGroup="android.permission-group.MESSAGES"➌
android:protectionLevel="dangerous"
android:permissionFlags="costsMoney"
android:label="@string/permlab_sendSms"
android:description="@string/permdesc_sendSms" />
--*snip*--
<permission android:name="android.permission.NET_ADMIN"
android:permissionGroup="android.permission-group.SYSTEM_TOOLS"
android:protectionLevel="signature" />➍
--*snip*--
<permission android:name="android.permission.MANAGE_USB"
android:permissionGroup="android.permission-group.HARDWARE_CONTROLS"
android:protectionLevel="signature|system"➎
android:label="@string/permlab_manageUsb"
android:description="@string/permdesc_manageUsb" />
--*snip*--
<permission android:name="android.permission.WRITE_SECURE_SETTINGS"
android:permissionGroup="android.permission-group.DEVELOPMENT_TOOLS"
android:protectionLevel="signature|system|development"➏
android:label="@string/permlab_writeSecureSettings"
android:description="@string/permdesc_writeSecureSettings" />
--*snip*--
</manifest>
如本列表所示,AndroidManifest.xml 文件还声明了系统的受保护广播 ➊。一个 权限组 ➋ 为一组相关权限指定了一个名称。个别权限可以通过在它们的 permissionGroup 属性中指定组名来加入该组 ➌。
权限组用于在系统 UI 中显示相关的权限,但每个权限仍然需要单独请求。也就是说,应用程序不能请求授予它们整个权限组中的所有权限。
回想一下,每个权限都有一个相关的保护级别,通过 protectionLevel 属性声明,如 ➍ 所示。
保护级别可以与 保护标志 结合使用,进一步限制权限的授予方式。目前定义的标志有 system(0x10)和 development(0x20)。system 标志要求应用程序必须是系统镜像的一部分(即安装在只读的 system 分区上),才能获得权限。例如,MANAGE_USB 权限,允许应用程序管理 USB 设备的偏好设置和权限,只会授予那些同时使用平台签名密钥签名并安装在 system 分区上的应用程序 ➎。development 标志标记了开发权限 ➏,我们将在介绍完签名权限后进行讨论。
签名权限
正如在第一章中讨论的那样,所有 Android 应用程序都需要使用开发者控制的签名密钥进行代码签名。这同样适用于系统应用程序和框架资源包。我们在第三章中会详细讨论包签名的问题,但现在我们先简单说几句关于系统应用签名的情况。
系统应用程序是由 平台密钥 签名的。默认情况下,当前 Android 源代码树中有四种不同的密钥:平台、共享、媒体 和 testkey(发布版本使用 releasekey)。所有被视为核心平台的一部分的包(如系统 UI、设置、电话、蓝牙等)都由 平台 密钥签名;与搜索和联系人相关的包由 共享 密钥签名;图库应用和与媒体相关的提供者由 媒体 密钥签名;其他所有包(包括那些在其 makefile 中未明确指定签名密钥的包)都由 testkey(或 releasekey)签名。定义系统权限的 framework-res.apk APK 文件是由 平台 密钥签名的。因此,任何试图请求具有 签名 保护级别的系统权限的应用程序,都需要使用与框架资源包相同的密钥签名。
例如,示例 2-15 中显示的 NET_ADMIN 权限(允许被授权的应用程序控制网络接口)是使用 签名 保护级别 ➍ 声明的,并且只能授予使用 平台 密钥签名的应用程序。
注意
Android 开源仓库(AOSP)包括预生成的测试密钥,这些密钥在默认情况下用于签署已编译的包。它们不应在生产版本中使用,因为它们是公开的,任何下载 Android 源代码的人都可以获取。发布版应该使用仅属于构建所有者的新生成的私钥进行签名。密钥可以通过 make_key 脚本生成,该脚本包含在 development/tools/ AOSP 目录中。有关平台密钥生成的详细信息,请参阅 build/target/product/security/README 文件。
开发权限
传统上,Android 的权限模型不允许动态授予和撤销权限,应用的已授予权限集在安装时就已固定。然而,自 Android 4.2 起,通过添加一系列开发权限(如 READ_LOGS 和 WRITE_SECURE_SETTINGS),此规则有所放宽。开发权限可以通过 Android shell 上的pm grant 和 pm revoke 命令按需授予或撤销。
注意
当然,这一操作并非对所有人开放,且受GRANT_REVOKE_PERMISSIONS签名权限的保护。该权限授予 android.uid.shell 共享用户 ID(UID 2000),并授予所有从 Android shell 启动的进程(它们也以 UID 2000 运行)。
共享用户 ID
使用相同密钥签名的 Android 应用可以请求以相同的 UID 运行,并可选择在同一进程中运行。此功能称为共享用户 ID,被核心框架服务和系统应用广泛使用。由于它可能对进程计数和应用管理产生微妙的影响,Android 团队不推荐第三方应用使用此功能,但它同样对用户安装的应用可用。此外,切换一个未使用共享用户 ID 的现有应用到共享用户 ID 是不支持的,因此需要使用共享用户 ID 的合作应用应该从一开始就设计并发布为此方式。
共享用户 ID 通过在 AndroidManifest.xml 的根元素中添加 sharedUserId 属性来启用。清单中指定的用户 ID 需要采用 Java 包格式(至少包含一个点 [.]),并作为标识符使用,类似于应用程序的包名。如果指定的共享 UID 不存在,则会创建它。如果已经安装了另一个具有相同共享 UID 的包,则会将签名证书与现有包的证书进行比较,如果不匹配,则返回 INSTALL_FAILED_SHARED_USER_INCOMPATIBLE 错误,安装失败。
将sharedUserId属性添加到已安装应用程序的新版本时,会导致它更改其 UID,这将导致无法访问自己的文件(这是某些早期 Android 版本中的情况)。因此,系统不允许这样做,会拒绝更新并返回INSTALL_FAILED_UID_CHANGED错误。简而言之,如果你打算为应用使用共享 UID,必须从一开始就为此进行设计,并且必须从第一次发布开始就使用它。
共享 UID 本身是系统包数据库中的一个一类对象,类似于应用程序,它具有关联的签名证书和权限。Android 有五个内置的共享 UID,这些 UID 在系统引导时会自动添加:
-
android.uid.system(SYSTEM_UID,1000)
-
android.uid.phone(PHONE_UID,1001)
-
android.uid.bluetooth(BLUETOOH_UID,1002)
-
android.uid.log(LOG_UID,1007)
-
android.uid.nfc(NFC_UID,1027)
示例 2-16 展示了如何定义android.uid.system共享用户:
示例 2-16. android.uid.system 共享用户的定义
<shared-user name="android.uid.system" userId="1000">
<sigs count="1">
<cert index="4" />
</sigs>
<perms>
<item name="android.permission.MASTER_CLEAR" />
<item name="android.permission.CLEAR_APP_USER_DATA" />
<item name="android.permission.MODIFY_NETWORK_ACCOUNTING" />
--*snip*--
<shared-user/>
如你所见,除了拥有一堆令人担忧的权限(在 4.4 设备上约 66 个),其定义与前面展示的包声明非常相似。相反,作为共享用户一部分的包没有关联的已授予权限列表。它们继承了共享用户的权限,这些权限是所有当前安装的具有相同共享用户 ID 的包请求的权限的并集。一个副作用是,如果一个包是共享用户的一部分,它可以访问那些它没有显式请求权限的 API,只要某个具有相同共享用户 ID 的包已经请求了这些权限。然而,权限会在安装或卸载包时动态地从<shared-user>定义中移除,因此可用的权限集合既不保证也不是固定的。
示例 2-17 展示了如何声明在共享用户 ID 下运行的KeyChain系统应用程序。如你所见,它通过sharedUserId属性引用了共享用户,并且没有显式的权限声明:
示例 2-17. 在共享用户 ID 下运行的应用程序的包声明
<package name="com.android.keychain"
codePath="/system/app/KeyChain.apk"
nativeLibraryPath="/data/app-lib/KeyChain"
flags="540229" ft="13cd65721a0"
it="13c2d4721f0" ut="13cd65721a0"
version="19"
sharedUserId="1000">
<sigs count="1">
<cert index="4" />
</sigs>
<signing-keyset identifier="1" />
</package>
共享 UID 不仅仅是一个包管理构造;它实际上在运行时也映射到一个共享的 Linux UID。示例 2-18 展示了两个作为system用户(UID 1000)运行的系统应用程序的示例:
示例 2-18. 运行在共享 UID 下的应用程序(系统)
system 5901 9852 845708 40972 ffffffff 00000000 S com.android.settings
system 6201 9852 824756 22256 ffffffff 00000000 S com.android.keychain
属于共享用户的一部分的应用可以在同一进程中运行,由于它们已经具有相同的 Linux UID 并能够访问相同的系统资源,通常不需要任何额外的修改。可以通过在所有需要在同一进程中运行的应用的<application>标签的process属性中指定相同的进程名称来请求一个公共进程。显而易见,这样做的结果是这些应用可以共享内存并直接通信,而不是使用 IPC,某些系统服务允许对同一进程中运行的组件进行特殊访问(例如,直接访问缓存的密码或在不显示 UI 提示的情况下获取认证令牌)。Google 应用(如 Play 服务和 Google 位置服务)通过请求与 Google 登录服务在同一进程中运行来利用这一点,以便能够在后台同步数据而无需用户交互。自然,它们使用相同的证书签名,并且是com.google.uid.shared共享用户的一部分。
自定义权限
自定义权限只是第三方应用声明的权限。当声明后,它们可以被添加到应用组件中,以便由系统进行静态强制执行,或者应用可以动态检查调用者是否已通过checkPermission()或enforcePermission()方法被授予该权限,这些方法属于Context类。与内建权限一样,应用可以定义权限组,并将自定义权限添加到这些组中。例如,示例 2-19 展示了一个权限组的声明➋及属于该组的权限➌。
示例 2-19. 自定义权限树、权限组和权限声明
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.app"
android:versionCode="1"
android:versionName="1.0" >
--*snip*--
<permission-tree
android:name="com.example.app.permission"
android:label="@string/example_permission_tree_label" />➊
<permission-group
android:name="com.example.app.permission-group.TEST_GROUP"
android:label="@string/test_permission_group_label"
android:description="@string/test_permission_group_desc"/>➋
<permission
android:name="jcom.example.app.permission.PERMISSION1"
android:label="@string/permission1_label"
android:description="@string/permission1_desc"
android:permissionGroup="com.example.app.permission-group.TEST_GROUP"
android:protectionLevel="signature" />➌
--*snip*--
</manifest>
与系统权限类似,如果保护级别为正常或危险,当用户确认对话框时,自定义权限将自动授予。为了能够控制哪些应用被授予自定义权限,你需要声明其签名保护级别,以保证该权限只会授予使用相同密钥签名的应用。
注意
系统只能授予它已知的权限,这意味着定义自定义权限的应用必须在使用这些权限的应用之前安装。如果应用请求系统未知的权限,该权限将被忽略并不会被授予。
应用程序还可以通过android.content.pm.PackageManager.addPermission()API 动态添加新权限,并使用匹配的removePermission()API 将其删除。这些动态添加的权限必须属于应用定义的权限树。应用只能从自己的包或与其共享用户 ID 的另一个包中添加或删除权限。
权限树名称采用反向域名表示法,如果一个权限的名称以权限树名称加上点号(.)作为前缀,则该权限被视为属于该权限树。例如,com.example.app.permission.PERMISSION2权限是com.example.app.permission树的成员,定义在示例 2-19 中的➊位置。示例 2-20 展示了如何通过编程方式添加动态权限。
示例 2-20. 通过编程方式添加动态权限
PackageManager pm = getPackageManager();
PermissionInfo permission = new PermissionInfo();
permission.name = "com.example.app.permission.PERMISSION2";
permission.labelRes = R.string.permission_label;
permission.protectionLevel = PermissionInfo.PROTECTION_SIGNATURE;
boolean added = pm.addPermission(permission);
Log.d(TAG, "permission added: " + added);
动态添加的权限会被添加到包数据库(/data/system/packages.xml)中。它们会在重启后持续存在,就像在清单中定义的权限一样,但它们有一个额外的type属性,值为dynamic。
公共和私有组件
在AndroidManifest.xml文件中定义的组件可以是公开的或私有的。私有组件只能被声明的应用调用,而公开组件则对其他应用也可用。
除内容提供者外,所有组件默认都是私有的。因为内容提供者的目的是与其他应用共享数据,内容提供者最初默认是公开的,但在 Android 4.2(API 级别 17)中,这一行为发生了变化。现在,目标 API 级别为 17 或更高的应用默认获得私有内容提供者,但为了向后兼容,目标较低 API 级别的应用仍然保持公开。
组件可以通过显式设置exported属性为true,或者通过声明意图过滤器隐式设置为公开。具有意图过滤器但不需要公开的组件,可以通过设置exported属性为false来使其私有。如果一个组件未被导出,来自外部应用的调用将被活动管理器阻止,无论调用进程是否拥有相应权限(除非它以root或system身份运行)。示例 2-21 展示了如何通过将exported属性设置为false来保持组件私有。
示例 2-21. 通过设置exported="false"保持组件私有
<service android:name=".MyService" android:exported="false" >
<intent-filter>
<action android:name="com.example.FETCH_DATA" />
</intent-filter>
</service>
除非明确用于公开,否则所有公共组件都应该通过自定义权限进行保护。
活动和服务权限
活动和服务可以通过目标组件的permission属性来分别由单一权限保护。当其他应用程序使用解析到该活动的意图调用Context.startActivity()或Context.startActivityForResult()时,将检查活动权限。对于服务,当其他应用程序使用解析到该服务的意图调用Context.startService()、stopService()或bindService()时,将检查服务权限。
例如,示例 2-22 展示了两个自定义权限,START_MY_ACTIVITY和USE_MY_SERVICE,分别与活动 ➊ 和服务 ➋ 关联。希望使用这些组件的应用程序需要在其清单中使用<uses-permission>标签请求相应的权限。
示例 2-22. 使用自定义权限保护活动和服务
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.myapp"
... >
<permission android:name="com.example.permission.START_MY_ACTIVITY"
android:protectionLevel="signature"
android:label="@string/start_my_activity_perm_label"
android:description="@string/start_my_activity_perm_desc" />
<permission android:name="com.example.permission.USE_MY_SERVICE"
android:protectionLevel="signature"
android:label="@string/use_my_service_perm_label"
android:description="@string/use_my_service_perm_desc" />
--*snip*--
<activity android:name=".MyActivity"
android:label="@string/my_activity"
android:permission="com.example.permission.START_MY_ACTIVITY">➊
<intent-filter>
--*snip*--
</intent-filter>
</activity>
<service android:name=".MyService"
android:permission="com.example.permission.USE_MY_SERVICE">➋
<intent-filter>
--*snip*--
</intent-filter>
</service>
--*snip*--
</manifest>
广播权限
与活动和服务不同,广播接收器的权限既可以由接收器本身指定,也可以由发送广播的应用程序指定。在发送广播时,应用程序可以使用Context.sendBroadcast(Intent intent)方法发送广播,该广播将被传递给所有注册的接收器,或者通过使用Context.sendBroadcast(Intent intent, String receiverPermission)限制接收广播的组件范围。receiverPermission参数指定了感兴趣的接收器需要持有的权限才能接收广播。或者,从 Android 4.0 开始,发送方可以使用Intent.setPackage(String packageName)来限制接收广播的接收器范围,仅限于指定包中定义的接收器。在多用户设备上,持有INTERACT_ACROSS_USERS权限的系统应用程序可以通过使用sendBroadcastAsUser(Intent intent, UserHandle user)和sendBroadcastAsUser(Intent intent, UserHandle user, String receiverPermission)方法,将广播仅发送给特定用户。
接收器可以通过在清单中的<receiver>标签的permission属性中指定权限来限制谁可以向它们发送广播,对于静态注册的接收器,或者通过将所需权限传递给Context.registerReceiver(BroadcastReceiver receiver, IntentFilter filter, String broadcastPermission, Handler scheduler)方法来限制动态注册的接收器。
只有被授予所需权限的广播发送者才能向该接收器发送广播。例如,执行系统安全策略的设备管理应用程序(我们在第九章讨论设备管理)需要BIND_DEVICE_ADMIN权限才能接收DEVICE_ADMIN_ENABLED广播。因为这是一个具有签名保护级别的系统权限,要求此权限可以确保只有系统才能激活设备管理应用程序。例如,示例 2-23 展示了默认的 Android 邮件应用程序如何为其PolicyAdmin接收器指定BIND_DEVICE_ADMIN ➊权限。
示例 2-23. 为静态注册的广播接收器指定权限
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.android.email"
android:versionCode="500060" >
--*snip*--
<receiver
android:name=".SecurityPolicy$PolicyAdmin"
android:label="@string/device_admin_label"
android:description="@string/device_admin_description"
android:permission="android.permission.BIND_DEVICE_ADMIN" >➊
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin" />
<intent-filter>
<action
android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
--*snip*--
</manifest>
与其他组件一样,私有广播接收器只能接收来自同一应用程序的广播。
内容提供者权限
如在“权限的本质”一节中提到的,内容提供者具有比其他组件更复杂的权限模型,我们将在本节中详细描述。
静态提供者权限
虽然可以使用permission属性指定控制整个提供者访问的单一权限,但大多数提供者会为读取和写入操作使用不同的权限,并且还可以为每个 URI 指定权限。一个使用不同权限来进行读取和写入的提供者的例子是内置的ContactsProvider。示例 2-24 展示了其ContactsProvider2类的声明。
示例 2-24. ContactsProvider 权限声明
<manifest
package="com.android.providers.contacts"
android:sharedUserId="android.uid.shared"
android:sharedUserLabel="@string/sharedUserLabel">
--*snip*--
<provider android:name="ContactsProvider2"
android:authorities="contacts;com.android.contacts"
android:label="@string/provider_label"
android:multiprocess="false"
android:exported="true"
android:readPermission="android.permission.READ_CONTACTS"➊
android:writePermission="android.permission.WRITE_CONTACTS">➋
--*snip*--
<path-permission
android:pathPattern="/contacts/.*/photo"
android:readPermission="android.permission.GLOBAL_SEARCH" />➌
<grant-uri-permission android:pathPattern=".*" />
</provider>
--*snip*--
</manifest>
该提供者使用readPermission属性指定用于读取数据的权限(READ_CONTACTS ➊),并使用writePermission属性指定用于写入数据的单独权限(WRITE_CONTACTS ➋)。因此,只有持有READ_CONTACTS权限的应用程序才能调用提供者的query()方法,而对insert()、update()或delete()方法的调用则要求调用者持有WRITE_CONTACTS权限。需要同时读取和写入联系人提供者的应用程序必须同时持有这两种权限。
当全局的读写权限不够灵活时,提供者可以指定每个 URI 的权限,以保护其数据的某个子集。每个 URI 权限的优先级高于组件级权限(或者,如果指定了的话,也高于读写权限)。因此,如果应用程序想访问一个具有相关权限的内容提供者 URI,它只需要持有目标 URI 的权限,而不需要持有组件级权限。在示例 2-24 中,ContactsProvider2使用<path-permission>标签要求试图读取联系人照片的应用持有GLOBAL_SEARCH权限➌。由于每个 URI 的权限会覆盖全局读权限,因此感兴趣的应用不需要持有READ_CONTACTS权限。在实际应用中,GLOBAL_SEARCH权限用于授权 Android 的搜索系统只读访问某些系统提供者的数据,而无需期望它持有所有提供者的读权限。
动态提供者权限
虽然静态定义的每个 URI 权限功能强大,但有时应用程序需要向其他应用授予对特定数据(通过其 URI 引用)的临时访问权限,而不要求它们持有特定的权限。例如,电子邮件或消息应用可能需要与图像查看器应用配合,以显示附件。由于该应用无法提前知道附件的 URI,如果它使用静态的每个 URI 权限,它将需要为图像查看器应用授予对所有附件的读取权限,这是不理想的。
为了避免这种情况和潜在的安全问题,应用程序可以使用Context.grantUriPermission(String toPackage, Uri uri, int modeFlags)方法动态授予临时的每个 URI 访问权限,并通过匹配的revokeUriPermission(Uri uri, int modeFlags)方法撤销访问权限。通过将全局grantUriPermissions属性设置为true,或通过添加<grant-uri-permission>标签来为特定的 URI 启用临时每个 URI 访问权限。例如,示例 2-25 展示了邮件应用如何使用grantUriPermissions属性➊来允许临时访问附件,而不需要READ_ATTACHMENT权限。
示例 2-25. 邮件应用中的AttachmentProvider声明
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.android.email"
android:versionCode="500060" >
<provider
android:name=".provider.AttachmentProvider"
android:authorities="com.android.email.attachmentprovider"
android:grantUriPermissions="true"➊
android:exported="true"
android:readPermission="com.android.email.permission.READ_ATTACHMENT"/>
--*snip*--
</manifest>
实际上,应用程序很少直接使用 Context.grantPermission() 和 revokePermission() 方法来允许按 URI 访问权限。相反,它们将 FLAG_GRANT_READ_URI_PERMISSION 或 FLAG_GRANT_WRITE_URI_PERMISSION 标志设置到用于启动协作应用程序(在我们的示例中是图像查看器)的意图中。当这些标志被设置时,接收意图的应用程序将被授予对意图数据中 URI 进行读取或写入操作的权限。
从 Android 4.4(API 级别 19)开始,按 URI 的访问权限可以通过 ContentResolver.takePersistableUriPermission() 方法在设备重启后保持,如果接收到的意图设置了 FLAG_GRANT_PERSISTABLE_URI_PERMISSION 标志。权限被持久化到 /data/system/urigrants.xml 文件中,可以通过调用 releasePersistableUriPermission() 方法撤销。系统的 ActivityManagerService 会管理所有的短期和持久性按 URI 访问权限的授予,相关的 API 内部调用处理这些权限。
从 Android 4.1(API 级别 16)开始,应用程序可以使用意图的 ClipData 功能^([16]),将多个内容 URI 临时授予访问权限。
每个 URI 的访问权限是通过 FLAG_GRANT_* 意图标志之一授予的,并且在被调用的应用程序的任务完成时自动撤销,因此无需调用 revokePermission()。 示例 2-26 展示了电子邮件应用程序如何创建一个启动附件查看器应用程序的意图。
示例 2-26. 使用 FLAG_GRANT_READ_URI_PERMISSION 标志启动查看器应用程序
public Intent getAttachmentIntent(Context context, long accountId) {
Uri contentUri = getUriForIntent(context, accountId);
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(contentUri, mContentType);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION |
Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
return intent;
}
待处理意图
待处理意图既不是 Android 组件也不是权限,但因为它们允许一个应用程序向另一个应用程序授予自己的权限,所以我们在这里讨论它们。
待处理意图封装了一个意图和一个要执行的目标操作(启动活动、发送广播等)。与“常规”意图的主要区别是,待处理意图还包括创建它们的应用程序的身份。这使得待处理意图可以被传递给其他应用程序,后者可以使用原始应用程序的身份和权限执行指定的操作。待处理意图中存储的身份由系统的 ActivityManagerService 保证,后者跟踪当前活动的待处理意图。
待处理意图用于在 Android 中实现警报和通知。警报和通知允许任何应用程序指定一个需要代表其执行的操作,无论是对警报的指定时间,还是用户与系统通知交互时。即使创建它们的应用程序不再运行,警报和通知仍可以被触发,系统利用待处理意图中的信息来启动该应用程序并代表其执行意图操作。示例 2-27 展示了电子邮件应用程序如何使用通过PendingIntent.getBroadcast()➊创建的待处理意图来调度触发电子邮件同步的广播。
示例 2-27. 使用待处理意图调度警报
private void setAlarm(long id, long millis) {
--*snip*--
Intent i = new Intent(this, MailboxAlarmReceiver.class);
i.putExtra("mailbox", id);
i.setData(Uri.parse("Box" + id));
pi = PendingIntent.getBroadcast(this, 0, i, 0);➊
mPendingIntents.put(id, pi);
AlarmManager am =
(AlarmManager)getSystemService(Context.ALARM_SERVICE);
m.set(AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + millis, pi);
--*snip*--
}
待处理的意图也可以交给非系统应用程序处理。相同的规则适用:接收PendingIntent实例的应用程序可以使用与创建应用程序相同的权限和身份来执行指定的操作。因此,在构建基础意图时应小心,基础意图通常应该尽可能具体(明确指定组件名称),以确保意图被预期的组件接收。
待处理意图的实现相当复杂,但它基于与其他 Android 组件相同的 IPC 和沙箱原则。当一个应用程序创建待处理意图时,系统通过Binder.getCallingUid()和Binder.getCallingPid()获取其 UID 和 PID。根据这些信息,系统检索创建者的包名和用户 ID(在多用户设备上),并将它们与基础意图和任何附加元数据一起存储在PendingIntentRecord中。活动管理器通过存储相应的PendingIntentRecord来保持活跃的待处理意图列表,并在触发时检索必要的记录。然后,它使用记录中的信息来假设待处理意图创建者的身份并执行指定的操作。从那里开始,过程与启动任何 Android 组件相同,并执行相同的权限检查。
总结
Android 在受限的沙箱中运行每个应用程序,并要求应用程序请求特定权限才能与其他应用或系统交互。权限是表示执行特定操作能力的字符串。它们在应用程序安装时授予,并且(开发权限除外)在应用程序的生命周期内保持不变。权限可以映射到 Linux 补充组 ID,内核在授予访问系统资源之前会检查这些 ID。
高级系统服务通过使用 Binder 获取调用应用程序的 UID,并在包管理器数据库中查找其持有的权限,从而强制执行权限。与应用程序清单文件中声明的组件相关的权限由系统自动强制执行,但应用程序也可以选择动态执行额外的权限检查。除了使用内置权限外,应用程序还可以定义自定义权限,并将其与组件关联,以控制访问。
每个 Android 组件都可以要求权限,内容提供者还可以在每个 URI 基础上指定读写权限。挂起的 Intent 封装了创建它们的应用程序的身份,以及要执行的 Intent 和 Action,这使得系统或第三方应用程序能够代表原始应用程序以相同的身份和权限执行操作。
^([11]) Google,Android API 参考,“Manifest.permission 类”,developer.android.com/reference/android/Manifest.permission.html
^([12]) Google,Android API 指南,“应用清单:developer.android.com/guide/topics/manifest/permission-element.html#plevel
^([13]) 关于进程管理函数,如fork()、setuid()等的详细信息,请参阅相关的手册页或 Unix 编程书籍,例如 W. Richard Stevens 和 Stephen A. Rago 的《UNIX 环境高级编程(第 3 版)》,Addison-Wesley Professional,2013 年。
^([14]) 关于 Linux 能力的讨论,请参阅 Michael Kerrisk 的《Linux 编程接口:Linux 和 UNIX 系统编程手册》第三十九章,No Starch Press,2010 年。
^([15]) Linux 内核档案,CGROUPS,www.kernel.org/doc/Documentation/cgroups/cgroups.txt
^([16]) Google,Android API 参考,“ClipData”,developer.android.com/reference/android/content/ClipData.html
第三章:包管理
本章将深入探讨 Android 包管理。我们从 Android 的包格式和代码签名实现的描述开始,然后详细讲解 APK 安装过程。接下来,我们将探讨 Android 对加密 APK 和安全应用容器的支持,这些技术用于实现付费应用的数字版权管理(DRM)。最后,我们将介绍 Android 的包验证机制及其最广泛使用的实现:Google Play 应用验证服务。
Android 应用程序包格式
Android 应用程序以应用包(APK)文件的形式分发和安装,通常称为APK 文件。APK 文件是容器文件,包含应用程序代码和资源,以及应用程序的清单文件。它们还可以包含代码签名。APK 格式是 Java JAR 格式的扩展,^([17]),而 JAR 格式又是流行的 ZIP 文件格式的扩展。APK 文件通常具有 .apk 扩展名,并与 application/vnd.android.package-archive MIME 类型关联。
由于 APK 文件本质上是 ZIP 文件,你可以通过任何支持 ZIP 格式的压缩工具轻松查看其内容。 示例 3-1 展示了典型 APK 文件解压后的内容。
示例 3-1:典型 APK 文件的内容
apk/
|-- AndroidManifest.xml➊
|-- classes.dex➋
|-- resources.arsc➌
|-- assets/➍
|-- lib/➎
| |-- armeabi/
| | `-- libapp.so
| `-- armeabi-v7a/
| `-- libapp.so
|-- META-INF/➏
| |-- CERT.RSA
| |-- CERT.SF
| `-- MANIFEST.MF
`-- res/➐
|-- anim/
|-- color/
|-- drawable/
|-- layout/
|-- menu/
|-- raw/
`-- xml/
每个 APK 文件都包含一个 AndroidManifest.xml 文件 ➊,该文件声明了应用程序的包名、版本、组件和其他元数据。classes.dex 文件 ➋ 包含应用程序的可执行代码,并采用 Dalvik VM 的本地 DEX 格式。resources.arsc ➌ 打包了所有应用程序的已编译资源,如字符串和样式。assets 目录 ➍ 用于将原始资产文件与应用程序一起捆绑,例如字体或音乐文件。
通过 JNI 利用本地库的应用程序包含一个 lib 目录 ➎,其中有每种支持的平台架构的子目录。从 Android 代码直接引用的资源,通常使用 android.content.res.Resources 类或通过更高级的 API 间接引用,都存储在 res 目录 ➐ 中,每种资源类型(动画、图片、菜单定义等)都有独立的目录。像 JAR 文件一样,APK 文件还包含一个 META-INF 目录 ➏,该目录包含包清单文件和代码签名。我们将在下一节中描述该目录的内容。
代码签名
正如我们在第二章中了解到的,Android 使用 APK 代码签名,特别是 APK 签名证书,来控制哪些应用程序可以获得签名保护级别的权限。APK 签名证书还用于在应用程序安装过程中进行各种检查,因此,在详细了解 APK 安装之前,我们应该更加熟悉 Android 中的代码签名。本节提供了有关 Java 代码签名的一些细节,并突出显示了与 Android 实现的区别。
让我们从一些关于代码签名的一般性话题开始。为什么有人会想要签名代码?出于通常的原因:完整性和真实性。在执行任何第三方程序之前,你希望确保它没有被篡改(完整性),并且它确实是由声称创建它的实体生成的(真实性)。这些特性通常通过数字签名方案来实现,该方案保证只有拥有签名密钥的实体才能生成有效的代码签名。
签名验证过程既验证了代码是否未被篡改,也验证了签名是否是使用预期的密钥生成的。但代码签名无法直接解决的一个问题是代码签署者(软件发布者)是否值得信任。建立信任的通常方法是要求代码签署者持有数字证书,并将其附加到签名代码中。验证者根据信任模型(如 PKI 或信任网络)或逐案判断是否信任该证书。
另一个代码签名甚至没有尝试解决的问题是,签名代码是否安全运行。正如 Flame^([18])和其他签名恶意软件所示,甚至看似由可信第三方签名的代码也可能不安全。
Java 代码签名
Java 代码签名在 JAR 文件级别进行。它重用并扩展 JAR 清单文件,以便在 JAR 归档文件中添加代码签名。主要的 JAR 清单文件(MANIFEST.MF)包含归档中每个文件的文件名和摘要值。例如,示例 3-2 展示了一个典型 APK 文件的 JAR 清单文件的开头。(在本节的所有示例中,我们将使用 APK 而不是常规 JAR。)
示例 3-2. JAR 清单文件摘录
Manifest-Version: 1.0
Created-By: 1.0 (Android)
Name: res/drawable-xhdpi/ic_launcher.png
SHA1-Digest: K/0Rd/lt0qSlgDD/9DY7aCNlBvU=
Name: res/menu/main.xml
SHA1-Digest: kG8WDil9ur0f+F2AxgcSSKDhjn0=
Name: ...
实现
Java 代码签名是通过添加另一个清单文件称为 签名文件(扩展名为 .SF)来实现的,该文件包含待签名数据,并对其进行数字签名。数字签名称为 签名块文件,并作为二进制文件存储在存档中,文件扩展名为 .RSA、.DSA 或 .EC,具体取决于使用的签名算法。如 示例 3-3 所示,签名文件与清单文件非常相似。
示例 3-3. JAR 签名文件摘录
Signature-Version: 1.0
SHA1-Digest-Manifest-Main-Attributes: ZKXxNW/3Rg7JA1r0+RlbJIP6IMA=
Created-By: 1.7.0_51 (Sun Microsystems Inc.)
SHA1-Digest-Manifest: zb0XjEhVBxE0z2ZC+B4OW25WBxo=➊
Name: res/drawable-xhdpi/ic_launcher.png
SHA1-Digest: jTeE2Y5L3uBdQ2g40PB2n72L3dE=➋
Name: res/menu/main.xml
SHA1-Digest: kSQDLtTE07cLhTH/cY54UjbbNBo=➌
Name: ...
签名文件包含整个清单文件的摘要(SHA1-Digest-Manifest ➊),以及 MANIFEST.MF 中每个条目的摘要(➋ 和 ➌)。SHA-1 是默认的摘要算法,直到 Java 6 为止,但 Java 7 及更高版本可以使用 SHA-256 和 SHA-512 哈希算法生成文件和清单摘要,在这种情况下,摘要属性分别变为 SHA-256-Digest 和 SHA-512-Digest。自版本 4.3 起,Android 支持 SHA-256 和 SHA-512 摘要。
签名文件中的摘要可以通过使用以下 OpenSSL 命令轻松验证,如 示例 3-4 所示。
示例 3-4. 使用 OpenSSL 验证 JAR 签名文件摘要
$ **openssl sha1 -binary MANIFEST.MF |openssl base64**➊
zb0XjEhVBxE0z2ZC+B4OW25WBxo=
$ **echo -en "Name: res/drawable-xhdpi/ic_launcher.png\r\nSHA1-Digest: \**
**K/0Rd/lt0qSlgDD/9DY7aCNlBvU=\r\n\r\n"|openssl sha1 -binary |openssl base64**➋
jTeE2Y5L3uBdQ2g40PB2n72L3dE=
第一个命令 ➊ 获取整个清单文件的 SHA-1 摘要,并将其编码为 Base64,从而生成 SHA1-Digest-Manifest 值。第二个命令 ➋ 模拟了计算单个清单条目的摘要的方式。它还展示了 JAR 规范要求的属性标准化格式。
实际的数字签名采用二进制 PKCS#7([19])(或更一般来说,CMS([20])) 格式,并包含签名值和签名证书。使用 RSA 算法生成的签名块文件保存为 .RSA 扩展名,而使用 DSA 或 EC 密钥生成的签名块文件则保存为 .DSA 或 .EC 扩展名。还可以执行多个签名操作,从而在 JAR 文件的 META-INF 目录中生成多个 .SF 和 .RSA/DSA/EC 文件。
CMS 格式相当复杂,允许进行签名 和 加密,两者使用不同的算法和参数。它还可以通过自定义的签名或非签名属性进行扩展。深入讨论超出了本章的范围(有关 CMS 的详细信息,请参见 RFC 5652),但在 JAR 签名中使用的 CMS 结构基本上包含摘要算法、签名证书和签名值。CMS 规范允许在 SignedData CMS 结构中包含签名数据(这种格式变体称为 附加签名),但 JAR 签名并不包括它。当签名数据不包含在 CMS 结构中时,签名称为 分离签名,验证者需要拥有原始签名数据的副本才能进行验证。示例 3-5 显示了一个解析为 ASN.1 的 RSA 签名块文件,证书细节被裁剪:
示例 3-5. JAR 文件签名块内容
$ **openssl asn1parse -i -inform DER -in CERT.RSA**
0:d=0 hl=4 l= 888 cons: SEQUENCE
4:d=1 hl=2 l= 9 prim: OBJECT :pkcs7-signedData➊
15:d=1 hl=4 l= 873 cons: cont [ 0 ]
19:d=2 hl=4 l= 869 cons: SEQUENCE
23:d=3 hl=2 l= 1 prim: INTEGER :01➋
26:d=3 hl=2 l= 11 cons: SET
28:d=4 hl=2 l= 9 cons: SEQUENCE
30:d=5 hl=2 l= 5 prim: OBJECT :sha1➌
37:d=5 hl=2 l= 0 prim: NULL
39:d=3 hl=2 l= 11 cons: SEQUENCE
41:d=4 hl=2 l= 9 prim: OBJECT :pkcs7-data➍
52:d=3 hl=4 l= 607 cons: cont [ 0 ]➎
56:d=4 hl=4 l= 603 cons: SEQUENCE
60:d=5 hl=4 l= 452 cons: SEQUENCE
64:d=6 hl=2 l= 3 cons: cont [ 0 ]
66:d=7 hl=2 l= 1 prim: INTEGER :02
69:d=6 hl=2 l= 1 prim: INTEGER :04
72:d=6 hl=2 l= 13 cons: SEQUENCE
74:d=7 hl=2 l= 9 prim: OBJECT :sha1WithRSAEncryption
85:d=7 hl=2 l= 0 prim: NULL
87:d=6 hl=2 l= 56 cons: SEQUENCE
89:d=7 hl=2 l= 11 cons: SET
91:d=8 hl=2 l= 9 cons: SEQUENCE
93:d=9 hl=2 l= 3 prim: OBJECT :countryName
98:d=9 hl=2 l= 2 prim: PRINTABLESTRING :JP
--*snip*--
735:d=5 hl=2 l= 9 cons: SEQUENCE
737:d=6 hl=2 l= 5 prim: OBJECT :sha1➏
744:d=6 hl=2 l= 0 prim: NULL
746:d=5 hl=2 l= 13 cons: SEQUENCE
748:d=6 hl=2 l= 9 prim: OBJECT :rsaEncryption➐
759:d=6 hl=2 l= 0 prim: NULL
761:d=5 hl=3 l= 128 prim: OCTET STRING [HEX DUMP]:892744D30DCEDF74933007...➑
签名块包含一个对象标识符 ➊,描述了后续数据的类型(ASN.1 对象):SignedData,以及数据本身。包含的 SignedData 对象包含一个版本 ➋(1);使用的哈希算法标识符集 ➌(单个签名者时仅一个,这个例子中是 SHA-1);被签署的数据类型 ➍(pkcs7-data,即“任意二进制数据”);签名证书集 ➎;以及一个或多个(每个签名者一个)SignerInfo 结构,封装签名值(在示例 3-5 中未完全显示)。SignerInfo 包含一个版本;一个 SignerIdentifier 对象,通常包含证书颁发者的 DN 和证书序列号(未显示);使用的摘要算法 ➏(SHA-1,包含于 ➌);用于生成签名值的摘要加密算法 ➐;以及加密的摘要(签名值)本身 ➑。
与 JAR 和 APK 签名相关的 SignedData 结构中最重要的元素是签名证书集 ➎ 和签名值 ➑(当由多个签名者签署时为多个值)。
如果我们提取 JAR 文件的内容,可以使用 OpenSSL 的 smime 命令通过指定签名文件作为内容或签名数据来验证其签名。smime 命令将打印签名数据和验证结果,如示例 3-6 所示:
示例 3-6. 验证 JAR 文件签名块
$ **openssl smime -verify -in CERT.RSA -inform DER -content CERT.SF signing-cert.pem**
Signature-Version: 1.0
SHA1-Digest-Manifest-Main-Attributes: ZKXxNW/3Rg7JA1r0+RlbJIP6IMA=
Created-By: 1.7.0_51 (Sun Microsystems Inc.)
SHA1-Digest-Manifest: zb0XjEhVBxE0z2ZC+B4OW25WBxo=
Name: res/drawable-xhdpi/ic_launcher.png
SHA1-Digest: jTeE2Y5L3uBdQ2g40PB2n72L3dE=
--*snip*--
Verification successful
JAR 文件签名
JDK 官方提供的 JAR 签名和验证工具是 jarsigner 和 keytool 命令。自 Java 5.0 起,jarsigner 还支持通过时间戳授权机构 (TSA) 对签名进行时间戳处理,这在需要确认签名是在签名证书过期之前还是之后生成时非常有用。然而,这一功能并未被广泛使用,且在 Android 上不支持。
使用 jarsigner 命令通过指定密钥库文件(参见 第五章)和用于签名的密钥别名(别名的前八个字符将成为签名块文件的基础名称,除非指定了 -sigfile 选项)以及可选的签名算法来对 JAR 文件进行签名。参见 示例 3-7 ➊ 中的 jarsigner 命令调用示例。
注意
自 Java 7 起,默认算法已更改为 SHA256withRSA,因此如果你希望使用 SHA-1 以保持向后兼容性,需显式指定。自 Android 4.3 起,已支持基于 SHA-256 和 SHA-512 的签名。
示例 3-7. 使用 jarsigner 命令对 APK 文件进行签名并验证签名
$ **jarsigner -keystore debug.keystore -sigalg SHA1withRSA test.apk androiddebugkey**➊
$ **jarsigner -keystore debug.keystore -verify -verbose -certs test.apk**➋
--*snip*--
smk 965 Sat Mar 08 23:55:34 JST 2014 res/drawable-xxhdpi/ic_launcher.png
X.509, CN=Android Debug, O=Android, C=US (androiddebugkey)➌
[certificate is valid from 6/18/11 7:31 PM to 6/10/41 7:31 PM]
smk 458072 Sun Mar 09 01:16:18 JST 2013 classes.dex
X.509, CN=Android Debug, O=Android, C=US (androiddebugkey)➍
[certificate is valid from 6/18/11 7:31 PM to 6/10/41 7:31 PM]
903 Sun Mar 09 01:16:18 JST 2014 META-INF/MANIFEST.MF
956 Sun Mar 09 01:16:18 JST 2014 META-INF/CERT.SF
776 Sun Mar 09 01:16:18 JST 2014 META-INF/CERT.RSA
s = signature was verified
m = entry is listed in manifest
k = at least one certificate was found in keystore
i = at least one certificate was found in identity scope
jar verified.
jarsigner 工具可以使用平台支持的所有密钥库类型,以及那些不原生支持且需要专用 JCA 提供程序的密钥库,如由智能卡、HSM 或其他硬件设备支持的密钥库。用于签名的密钥库类型通过 -storetype 选项指定,提供程序名称和类通过 -providerName 和 -providerClass 选项指定。更新版本的 Android 专用 signapk 工具(详见 “Android 代码签名工具”)也支持 -providerClass 选项。
JAR 文件验证
JAR 文件验证是通过使用 jarsigner 命令并指定 -verify 选项来执行的。在 示例 3-7 中的第二个 jarsigner 命令 ➋ 首先验证签名块和签名证书,确保签名文件未被篡改。接下来,它验证签名文件 (CERT.SF) 中的每个摘要是否与清单文件 (MANIFEST.MF) 中的相应部分匹配。(签名文件中的条目数量不必与清单文件中的条目数量匹配。文件可以被添加到已签名的 JAR 文件中,而不影响其签名:只要没有任何原始文件被更改,验证就会成功。)
最后,jarsigner 读取每个清单条目并检查文件摘要是否与实际文件内容匹配。如果通过 -keystore 选项指定了密钥库(如我们示例中所示),jarsigner 还会检查签名证书是否存在于指定的密钥库中。从 Java 7 开始,新增了 -strict 选项,用于启用额外的证书验证,包括时间有效性检查和证书链验证。验证错误会被视为警告,并反映在 jarsigner 命令的退出代码中。
查看或提取签名者信息
如 示例 3-7 中所见,默认情况下,jarsigner 会打印每个条目的证书详情(➌ 和 ➍),尽管它们对于所有条目都是相同的。在使用 Java 7 时,查看签名者信息的稍好方法是指定 -verbose:summary 或 -verbose:grouped 选项,或者使用 keytool 命令,如 示例 3-8 所示。
示例 3-8. 使用 keytool 命令查看 APK 签名者信息
$ **keytool -list -printcert -jarfile test.apk**
Signer #1:
Signature:
Owner: CN=Android Debug, O=Android, C=US
Issuer: CN=Android Debug, O=Android, C=US
Serial number: 4dfc7e9a
Valid from: Sat Jun 18 19:31:54 JST 2011 until: Mon Jun 10 19:31:54 JST 2041
Certificate fingerprints:
MD5: E8:93:6E:43:99:61:C8:37:E1:30:36:14:CF:71:C2:32
SHA1: 08:53:74:41:50:26:07:E7:8F:A5:5F:56:4B:11:62:52:06:54:83:BE
Signature algorithm name: SHA1withRSA
Version: 3
一旦你找到签名块文件名(例如通过列出归档内容),你可以使用 OpenSSL 配合 unzip 命令轻松地将签名证书提取到文件中,如 示例 3-9 所示。(如果 SignedData 结构包含多个证书,所有证书将被提取。在这种情况下,你需要解析 SignedInfo 结构来找到实际签名证书的标识符。)
示例 3-9. 使用 unzip 和 OpenSSL pkcs7 命令提取 APK 签名证书
$ **unzip -q -c test.apk META-INF/CERT.RSA|openssl pkcs7 -inform DER -print_certs -out cert.pem**
Android 代码签名
因为 Android 代码签名是基于 Java JAR 签名的,它像许多代码签名方案一样使用公钥加密和 X.509 证书,但这也是相似之处的终结。
在几乎所有其他使用代码签名的平台上(例如 Java ME 和 Windows Phone),代码签名证书必须由平台信任的 CA 颁发。虽然有许多 CA 颁发代码签名证书,但要获得一个被所有目标设备信任的证书可能会相当困难。Android 很简单地解决了这个问题:它不关心签名证书的内容或签署者。因此,你不需要让证书由 CA 颁发,几乎所有用于 Android 的代码签名证书都是自签名的。此外,你也不需要以任何方式声明你的身份:你几乎可以使用任何东西作为主体名称。(Google Play 商店确实会进行一些检查,以排除一些常见的名称,但 Android 操作系统本身并不会。)Android 将签名证书视为二进制大对象,它们是 X.509 格式仅仅是因为使用了 JAR 格式。
Android 并不会按照 PKI 的方式验证证书(参见 第六章)。事实上,如果证书不是自签名的,那么签名证书颁发机构(CA)的证书不需要存在或被信任;Android 甚至会乐意安装带有过期签名证书的应用。如果你来自传统的 PKI 背景,可能会觉得这听起来像是异端邪说,但请记住,Android 并不使用 PKI 进行代码签名,它只是使用相同的证书和签名格式。
Android 与“标准”JAR 签名的另一个区别是,所有 APK 条目必须由同一组证书签名。JAR 文件格式允许每个文件由不同的签署者签名,并允许未签名的条目。这在 Java 沙箱和访问控制机制中是有意义的,因为该模型最初是为小程序设计的,它定义了 代码来源 为签名证书和代码来源 URL 的组合。然而,Android 为每个 APK 分配签署者(通常只有一个,但也支持多个签署者),并不允许为不同的 APK 文件条目使用不同的签署者。
Android 的代码签名模型,加上 java.util.jar.JarFile 类的糟糕接口,这个类并不是一个很好地抽象底层 CMS 签名格式复杂性的工具,使得正确验证 APK 文件的签名变得相当困难。虽然 Android 通过在其包解析例程中增加额外的签名证书检查,成功地验证了 APK 的完整性并确保所有 APK 文件条目都是由同一组证书签名的,但显然 JAR 文件格式并不是 Android 代码签名的最佳选择。
Android 代码签名工具
正如“Java 代码签名”部分中的示例所示,你可以使用常规的 JDK 代码签名工具来签名或验证 APK。除了这些工具之外,AOSP 的build/目录还包含一个名为signapk的 Android 特定工具。这个工具在签名模式下与jarsigner执行几乎相同的任务,但有一些显著的不同之处。首先,jarsigner要求密钥存储在兼容的密钥库文件中,而signapk则接受一个独立的签名密钥(以 DER 编码的PKCS#8格式^([22]))和证书文件(以 DER 编码的 X.509 格式)作为输入。PKCS#8 格式的优势是,它包括一个明确的算法标识符,描述了编码私钥的类型。编码后的私钥可能包含密钥材料,可能是加密的,或者它可能仅包含一个引用,例如密钥 ID,指向存储在硬件设备中的密钥。
从 Android 4.4 开始,signapk只能生成使用SHA1withRSA或SHA256withRSA(在 Android 4.3 中新增)的签名机制。到目前为止,AOSP 主分支中的signapk版本已扩展为支持 ECDSA 签名。
虽然 PKCS#8 格式的原始私钥比较难获得,但你可以通过使用development/tools/目录中的make_key脚本轻松生成一个测试密钥对和一个自签名证书。如果你已经有现成的 OpenSSL 密钥,你需要先将它们转换为 PKCS#8 格式,可以使用像 OpenSSL 的pkcs8命令,如示例 3-10 所示:
示例 3-10. 将 OpenSSL 密钥转换为 PKCS#8 格式
$ **echo "keypwd"|openssl pkcs8 -in mykey.pem -topk8 -outform DER -out mykey.pk8 -passout stdin**
一旦你拥有所需的密钥,就可以使用signapk签名 APK,如示例 3-11 所示。
示例 3-11. 使用signapk工具签名 APK
$ **java -jar signapk.jar cert.cer key.pk8 test.apk test-signed.apk**
OTA 文件代码签名
除了默认的 APK 签名模式外,signapk工具还有一个“签名整个文件”模式,可以通过-w选项启用。在此模式下,除了签名每个单独的 JAR 条目外,该工具还会对整个归档文件生成签名。此模式不被jarsigner支持,且是 Android 特有的。
为什么要对整个归档文件进行签名,而每个文件本身已经签名了呢?这是为了支持空中下载(OTA)更新。OTA 包是类似于 JAR 文件格式的 ZIP 文件,包含更新的文件以及应用这些文件的脚本。包内包括一个META-INF/目录、清单文件、签名块和一些额外的文件,其中包括META-INF/com/android/otacert,该文件包含更新签名证书(PEM 格式)。在启动到恢复模式以应用更新之前,Android 会验证包的签名,然后检查签名证书是否可信来签署更新。OTA 信任的证书与“常规”系统信任存储分开(见第六章),并存储在通常作为/system/etc/security/otacerts.zip的 ZIP 文件中。在生产设备上,这个文件通常包含一个单独的文件,通常命名为releasekey.x509.pem。设备重启后,恢复操作系统会再次验证 OTA 包的签名,然后再应用它,以确保在此期间 OTA 文件没有被篡改。
如果 OTA 文件类似于 JAR 文件,而 JAR 文件不支持整个文件的签名,那么签名会放在哪里呢?Android 的signapk工具稍微滥用 ZIP 格式,通过在 ZIP 注释部分添加一个以 null 结尾的字符串注释,后面跟着二进制签名块和一个 6 字节的最终记录,包含签名偏移量和整个注释部分的大小。将偏移记录添加到文件的末尾,使得通过首先读取并验证文件末尾的签名块来验证包变得容易,只有当签名验证通过时,才会读取文件的其余部分(可能是几百兆字节)。
APK 安装过程
安装 Android 应用程序有几种方式:
-
通过应用商店客户端(如 Google Play 商店)进行安装。这是大多数用户安装应用的方式。
-
直接在设备上通过打开下载的应用文件进行安装(前提是系统设置中已启用“未知来源”选项)。这种方式通常称为侧载应用。
-
从 USB 连接的计算机通过
adb installAndroid SDK 命令进行安装,该命令进而调用pm命令行工具并使用install参数。此方法主要由应用开发者使用。 -
通过使用 Android shell 将 APK 文件直接复制到某个系统应用目录中进行安装。由于生产版本无法访问应用目录,因此此方法只能在运行工程(开发)版本的设备上使用。
当 APK 文件直接复制到其中一个应用程序目录时,包管理器会自动检测到并安装它,因为包管理器会监视这些目录的变化。在所有其他安装方法中,安装应用程序(无论是 Google Play 商店客户端、默认系统包安装活动、pm 命令或其他)都会调用系统包管理器的 installPackage() 方法之一,后者会将 APK 文件复制到其中一个应用程序目录并进行安装。在接下来的章节中,我们将探索 Android 包安装过程的主要步骤,并讨论一些更复杂的安装步骤,例如加密容器创建和包验证。
Android 的包管理功能分布在多个系统组件中,这些组件在安装包时相互作用,如 图 3-1 所示。图中实线箭头表示组件之间的依赖关系以及函数调用。虚线箭头指向由组件监视以检测更改的文件或目录,但这些文件或目录不会被该组件直接修改。

图 3-1. 包管理组件
应用程序包和数据的位置
请回顾 第一章,Android 区分系统安装应用和用户安装应用。系统应用程序位于只读的 system 分区(在 图 3-1 的左下角),在生产设备上不能更改或卸载。因此,系统应用被认为是受信任的,享有更多权限,并且某些签名检查被放宽。大多数系统应用程序位于 /system/app/ 目录中,而 /system/priv-app/ 存放有特权应用,这些应用可以通过 signatureOrSystem 保护级别授予权限(如 第二章 中所讨论)。/system/vendor/app/ 目录则用于存放厂商特定的应用程序。用户安装的应用程序位于可读写的 userdata 分区(在 图 3-1 的右下角),并且可以随时卸载或替换。大多数用户安装的应用程序安装在 /data/app/ 目录中。
系统和用户安装的应用的数据目录都在 userdata 分区的 /data/data/ 目录下创建。userdata 分区还存储了用户安装的应用的优化 DEX 文件(在 /data/dalvik-cache/ 中),系统包数据库(在 /data/system/packages.xml 中)以及其他系统数据库和设置文件。(当我们讨论 APK 安装过程时,会涉及图 3-1 中显示的其余 userdata 分区目录。)
活动组件
在确定了 userdata 和 system 分区的角色后,让我们介绍在包安装过程中起作用的活动组件。
PackageInstaller 系统应用
这是默认的 APK 文件处理程序。它提供了一个基本的包管理图形界面,当传递一个包含 VIEW 或 INSTALL_ACTION 意图动作的 APK 文件 URI 时,它会解析该包并显示安装确认界面,显示应用程序所需的权限(参见图 2-1)。只有当用户在设备的安全设置中启用了“未知来源”选项时,才能使用 PackageInstaller 应用进行安装(参见图 3-2)。如果未启用“未知来源”,PackageInstaller 将显示一个对话框,通知用户已阻止从未知来源安装应用。

图 3-2. 应用安装安全设置
什么被视为“未知来源”?虽然屏幕上的提示定义它为“来自 Play 商店以外来源的应用”,但实际定义要广泛一些。PackageInstaller 启动时,会检索请求 APK 安装的应用的 UID 和包名,并检查它是否为特权应用(安装在 /system/priv-app/ 目录下)。如果请求应用没有特权,它将被视为未知来源。如果选择了“未知来源”选项,并且用户确认安装对话框,PackageInstaller 会调用 PackageManagerService,后者执行实际安装。当升级侧载包或从系统设置的应用屏幕卸载应用时,也会显示 PackageInstaller 的图形界面。
pm 命令
pm 命令(在第二章中介绍)提供了一个命令行接口,用于访问系统包管理器的部分功能。当在 Android shell 中分别以 pm install 或 pm uninstall 调用时,它可以用来安装或卸载包。此外,Android 调试桥(ADB) 客户端提供了 adb install/uninstall 快捷方式。
与 PackageInstaller 不同,pm install 不依赖于未知来源系统选项,也不显示 GUI,它提供了各种有用的选项,用于测试包安装,这些选项无法通过 PackageInstaller GUI 指定。为了开始安装过程,它调用与 GUI 安装程序相同的 PackageManager API。
PackageManagerService
PackageManagerService(在 图 3-1 中的 PackageManager)是 Android 包管理基础设施中的核心对象。它负责解析 APK 文件,启动应用安装、升级和卸载包,维护包数据库,并管理权限。
PackageManagerService 还提供了多个 installPackage() 方法,可以通过不同的选项执行包安装。其中最通用的是 installPackageWithVerificationAndEncryption(),该方法允许安装加密的 APK 文件,并通过验证代理进行包验证。(我们将在 “安装加密 APK” 和 “包验证” 中详细讨论应用加密和验证。)
注意
android.content.pm.PackageManager Android SDK 外观类向第三方应用程序暴露了 PackageManagerService 的一部分功能。
Installer 类
虽然 PackageManagerService 是 Android 系统服务中权限最高的服务之一,但它仍然运行在系统服务器进程中(具有 system UID),并且没有 root 权限。然而,由于创建、删除和更改应用程序目录的所有权需要超级用户权限,因此 PackageManagerService 将这些操作委托给 installd 守护进程(下文将讨论)。Installer 类通过 /dev/socket/installd Unix 域套接字连接到 installd 守护进程,并封装了 installd 的命令协议。
安装守护进程
installd 守护进程是一个具有提升权限的本地守护进程,为系统包管理器提供应用程序和用户目录管理功能(针对多用户设备)。它还用于启动 dexopt 命令,为新安装的包生成优化的 DEX 文件。
installd 守护进程通过 installd 本地套接字进行访问,该套接字仅对以 system UID 运行的进程可访问。installd 守护进程不以 root 身份执行(尽管在早期的 Android 版本中是这样),而是利用 CAP_DAC_OVERRIDE 和 CAP_CHOWN Linux 能力^([23]) 来设置它创建的应用程序目录和文件的所有者和组 UID 为拥有应用程序的 UID。
MountService
MountService 负责挂载可拆卸的外部存储设备,如 SD 卡,以及 不透明二进制大对象(OBB 文件),这些文件用作应用程序的扩展文件。它还用于启动设备加密(参见 第十章"))并更改加密密码。
MountService 还管理 安全容器,这些容器存储不应被非系统应用访问的应用文件。安全容器是加密的,用于实现一种名为 前向锁定 的数字版权管理(DRM)形式(详见 “前向锁定” 和 “Android 4.1 前向锁定实现”)。前向锁定主要在安装付费应用时使用,以确保其 APK 文件不能轻易从设备中复制并重新分发。
vold 守护进程
vold 是 Android 的卷管理守护进程。虽然 MountService 包含了大多数处理卷管理的系统 API,但由于它作为 系统 用户运行,因此缺少实际挂载和卸载磁盘卷所需的权限。这些特权操作由作为 root 用户运行的 vold 守护进程来实现。
vold 具有一个本地套接字接口,通过 /dev/socket/vold Unix 域套接字暴露,该接口仅对 root 用户和 mount 组成员可访问。由于 system_server 进程(托管 MountService)的附加 GID 列表中包含 mount(GID 1009),MountService 被允许访问 vold 的命令套接字。除了挂载和卸载卷外,vold 还可以创建和格式化文件系统以及管理安全容器。
MediaContainerService
MediaContainerService 将 APK 文件复制到最终安装位置或加密容器中,并允许 PackageManagerService 访问可移动存储上的文件。从远程位置(无论是直接获取还是通过应用市场)获取的 APK 文件通过 Android 的 DownloadManager 服务下载,下载的文件通过 DownloadManager 的内容提供者接口进行访问。PackageManager 授予 MediaContainerService 进程对每个下载的 APK 文件的临时访问。如果 APK 文件是加密的,MediaContainerService 会先解密该文件(详见 “安装带有完整性检查的加密 APK 文件”)。如果请求了加密容器,MediaContainerService 会将加密容器的创建委托给 MountService,并将 APK 的受保护部分(包括代码和资源)复制到新创建的容器中。不需要保护的文件会直接复制到文件系统中。
AppDirObserver
AppDirObserver 是一个监视应用目录中 APK 文件变化的组件^([24]),并根据事件类型调用相应的 PackageManagerService 方法。当一个 APK 文件被添加到系统时,AppDirObserver 会启动包扫描,进而安装或更新应用程序。当一个 APK 文件被移除时,AppDirObserver 会启动卸载过程,移除应用目录和系统包数据库中的应用条目。
图 3-1 由于空间限制,只显示了一个 AppDirObserver 实例,但每个被监视的目录都有一个专用实例。在 系统 分区上监视的目录包括 /system/framework/(存放框架资源包 framework-res.apk);/system/app/ 和 /system/priv-app/(系统包);以及供应商包目录 /system/vendor/app/。在 userdata 分区上监视的目录包括 /data/app/ 和 /data/app-private/,其中存放“旧版” (Android 4.1 之前) 的前向锁定 APK 文件和 APK 解密过程中产生的临时文件。
安装本地包
现在我们已经了解了与包安装相关的 Android 组件,接下来我们将介绍安装过程,从最简单的情况开始:安装一个未经加密的本地包,不进行验证和前向锁定。
解析和验证包
打开本地 APK 文件会启动 application/vnd.android.package-archive 处理器,通常是来自 PackageInstaller 系统应用的 PackageInstallerActivity。PackageInstallerActivity 首先检查请求安装的应用是否被信任(即,是否被认为来自“未知来源”)。如果不是,并且 Settings.Global.INSTALL_NON_MARKET_APPS 为 false(当图 3-2 中勾选了“未知来源”复选框时,该值会被设置为 true),PackageInstaller 会显示警告对话框并终止安装过程。
如果安装被允许,PackageInstallerActivity 将解析 APK 文件,并从 AndroidManifest.xml 文件和包签名中收集信息。在提取每个条目的签名证书时,APK 文件的完整性会被自动验证,使用的是 java.util.jar.JarFile 和相关类。这种实现是必要的,因为 JarFile 类的 API 缺乏任何显式的方法来验证整个文件或某个特定条目的签名。(系统应用被隐式信任,只有 AndroidManifest.xml 文件的完整性会在解析其 APK 文件时进行验证。而对于非系统镜像的一部分的 APK 包,比如用户安装的应用程序或系统应用的更新,所有的 APK 条目都会被验证。)在解析 APK 文件时,AndroidManifest.xml 文件的哈希值也会被计算,并传递给后续的安装步骤,后续步骤使用该哈希值来验证在用户点击安装对话框的“确定”按钮和开始复制 APK 文件之间,APK 文件是否被替换过。
注意
另一个值得注意的细节是,在安装时,APK 文件的完整性是通过使用标准的 Java 库类进行验证的,而在运行时,Dalvik 虚拟机使用自己本地实现的 ZIP/JAR 文件解析器加载 APK 文件。它们实现之间的细微差异已经成为多个 Android 错误的源头,最著名的就是错误 #8219321(通常被称为Android 主密钥),它允许已签名的 APK 文件被修改后仍然被视为有效,而不需要重新签名。为了应对这个问题,AOSP 的主分支中添加了一个 StrictJarFile 类,它使用与 Dalvik 相同的 ZIP 文件解析实现。StrictJarFile 在解析 APK 文件时被系统包管理器使用,确保 Dalvik 和包管理器以相同的方式解析 APK 文件。这一新的统一实现将在未来的 Android 版本中被采纳。
接受权限并启动安装过程
一旦 APK 文件被解析,PackageInstallerActivity 会显示关于应用程序及其所需权限的信息,类似于 图 2-1 中显示的对话框。如果用户同意安装,PackageInstallerActivity 会将 APK 文件及其清单摘要,连同安装元数据(如推荐来源 URL、安装包名和原始 UID)转发给 InstallAppProgress 活动,后者开始实际的包安装过程。然后,InstallAppProgress 会将 APK URI 和安装元数据传递给 PackageManagerService 的 installPackageWithVerificationAndEncryption() 方法,启动安装过程。接着,它会等待该过程完成,并处理任何错误。
安装方法首先验证调用者是否具有 INSTALL_PACKAGES 权限,该权限的保护级别为签名,并且仅限系统应用使用。在多用户设备上,方法还会验证调用用户是否被允许安装应用程序。接下来,它确定首选的安装位置,是内部存储还是外部存储。
复制到应用目录
如果 APK 文件没有加密且不需要验证,下一步是将其复制到应用程序目录(/data/app/)。为了复制文件,PackageManagerService 首先在应用程序目录中创建一个临时文件(以vmdl前缀和.tmp扩展名),然后将复制操作委托给 MediaContainerService。文件不会直接复制,因为它可能需要解密,或者如果需要进行前向锁定,则会为其创建一个加密容器。由于 MediaContainerServices 封装了这些任务,因此 PackageManagerService 无需关心底层实现。
当 APK 文件成功复制时,它所包含的任何本地库都会被提取到系统本地库目录下的专用应用程序目录(/data/app-lib/)中。接下来,临时的 APK 文件和库目录会被重命名为最终的名称,这些名称基于包名,例如 APK 文件为com.example.app-1.apk,库目录为/data/app-lib/com.example.app-1。最后,APK 文件的权限被设置为0644,并且其 SELinux 上下文也会被设置(参见第十二章)。
注意
默认情况下,APK 文件是全局可读的,任何其他应用程序都可以访问它们。这便于共享公共应用资源,并允许开发第三方启动器和其他需要显示所有已安装包列表的应用程序。然而,这些默认权限也允许任何人从设备中提取 APK 文件,这对于通过应用市场分发的付费应用来说是一个问题。APK 文件的前向锁定提供了一种方式,允许 APK 资源保持公开,同时限制对代码和资源的访问。
包扫描
安装过程中的下一步是通过调用 PackageManagerService 的 scanPackageLI() 方法来触发包扫描。(如果安装过程在扫描新 APK 文件之前停止,最终会由监视 /data/app/ 目录的 AppDirObserver 实例接管,并触发包扫描。)
在新安装的情况下,包管理器首先创建一个新的 PackageSettings 结构,该结构包含包名、代码路径、如果包是前向锁定的,则还有单独的资源路径,以及本地库路径。然后,它为新包分配一个 UID,并将其存储在设置结构中。一旦新应用有了 UID,它的数据目录就可以创建了。
创建数据目录
因为PackageManagerService没有足够的权限来创建并设置应用程序目录的所有权,它将目录创建任务委托给installd守护进程,通过向其发送install命令,命令参数包括包名、UID、GID 以及seinfo标签(由 SELinux 使用)。installd守护进程会创建包数据目录(例如,在安装com.example.app包时,会创建/data/data/com.example.app/目录)、共享本地库目录(/data/app-lib/com.example.app/)和本地库目录(/data/data/com.example.app/lib/)。然后,它将包目录权限设置为0751并为应用程序的本地库(如果有的话)在本地库目录中创建符号链接。最后,它会设置包目录的 SELinux 上下文,并将其所有者更改为分配给该应用程序的 UID 和 GID。
如果系统有多个用户,下一步是通过向installd发送mkuserdata命令来为每个用户创建数据目录(见第四章)。当所有必要的目录创建完成后,控制返回给PackageManagerService,它将任何本地库提取到应用程序的本地库目录,并在/data/data/com.example.app/lib/目录中创建符号链接。
生成优化后的 DEX
下一步是为应用程序的代码生成优化过的 DEX 文件。此操作也委托给installd,通过发送dexopt命令来实现。installd守护进程会派生一个dexopt进程,在/data/dalivk-cache/目录中创建优化后的 DEX 文件。(优化过程也被称为“锐化”。)
注意
如果设备使用的是在 4.4 版本中引入的实验性 Android Runtime(ART)而不是生成优化后的 DEX,installd会使用dex2oat命令生成本地代码。
文件和目录结构
当上述所有过程完成后,应用程序的文件和目录可能会像示例 3-12 所示。(时间戳和文件大小已被省略。)
示例 3-12. 安装应用程序后创建的文件和目录
-rw-r--r-- system system ... /data/app/com.example.app-1.apk➊
-rwxr-xr-x system system ... /data/app-lib/com.example.app-1/libapp.so➋
-rw-r--r-- system all_a215 ... /data/dalvik-cache/data@app@com.example.app-1.apk@classes.dex➌
drwxr-x--x u0_a215 u0_a215 ... /data/data/com.example.app➍
drwxrwx--x u0_a215 u0_a215 ... /data/data/com.example.app/databases➎
drwxrwx--x u0_a215 u0_a215 ... /data/data/com.example.app/files
lrwxrwxrwx install install ... /data/data/com.example.app/lib -> /data/app-lib/com.example.app-1➏
drwxrwx--x u0_a215 u0_a215 ... /data/data/com.example.app/shared_prefs
在这里,➊ 是 APK 文件,➋ 是提取的本地库文件。这两个文件都归 system 所有,并且是全局可读的。位置为 ➌ 的文件是优化过的 DEX 文件,包含应用程序代码。该文件的所有者设置为 system,其所属组设置为特殊的 all_a215 组,包含所有安装了该应用的设备用户。这允许所有用户共享相同的优化 DEX 文件,从而避免了为每个用户创建副本的需求,这在多用户设备上可能会占用过多的磁盘空间。应用程序的数据目录 ➍ 及其子目录(例如 databases/ ➎)由专门的 Linux 用户拥有,该用户由安装应用的设备用户的 ID(u0,单用户设备上唯一的用户)与应用 ID(a215)组合生成 u0_a215。 (根据 Android 的沙箱安全模型,应用数据目录不可被其他用户读取或写入。lib/ 目录 ➏ 仅是指向应用共享库目录的符号链接,位置为 /data/app-lib/。)
将新包添加到 packages.xml
下一步是将该包添加到系统包数据库中。生成一个新的包条目,如 示例 3-13 所示,并将其添加到 packages.xml 中。
示例 3-13. 新安装应用的包数据库条目
<package name="com.google.android.apps.chrometophone"
codePath="/data/app/com.google.android.apps.chrometophone-2.apk"
nativeLibraryPath="/data/app-lib/com.google.android.apps.chrometophone-2"
flags="572996"
ft="142dfa0e588"
it="142cbeac305"
ut="142dfa0e8d7"
version="16"
userId="10088"
installer="com.android.vending">➊
<sigs count="1">
<cert index="7" key="30820252..." />
</sigs>➋
<perms>
<item name="android.permission.USE_CREDENTIALS" />
<item name="com.google.android.apps.chrometophone.permission.C2D_MESSAGE" />
<item name="android.permission.GET_ACCOUNTS" />
<item name="android.permission.INTERNET" />
<item name="android.permission.WAKE_LOCK" />
<item name="com.google.android.c2dm.permission.RECEIVE" />
</perms>➌
<signing-keyset identifier="2" />➍
</package>
在这里,<sigs> ➋ 元素包含包签名证书的 DER 编码值(通常只有一个),以十六进制字符串格式表示,或者在多个应用使用相同密钥和证书签名的情况下,包含证书第一次出现的引用。<perms> ➌ 元素包含授予应用程序的权限,具体描述见 第二章。
<signing-keyset> ➍ 元素在 Android 4.4 中是新增的,它引用了应用程序的签名密钥集,该密钥集包含所有签署 APK 文件的公钥(但不包括证书)。PackageManagerService 会收集并存储所有应用的签名密钥,并将其保存在全局的 <keyset-settings> 元素中,但从 Android 4.4 开始,签名密钥集不会被检查或使用。
包属性
根元素<package> ➊(见示例 3-13)包含每个包的核心属性,如安装位置和版本。主要的包属性列在表 3-1 中。每个包条目中的信息可以通过android.content.pm.PackageManager SDK 类的getPackageInfo(String packageName, int flags)方法获得,该方法应返回一个PackageInfo实例,封装了每个packages.xml条目中的可用属性,以及应用清单中定义的组件、权限和特性的信息。
表 3-1. 包属性
| 属性名称 | 描述 |
|---|---|
name |
包名称。 |
codePath |
包的完整路径位置。 |
resourcePath |
包中公开部分的完整路径(主要资源包和清单)。仅在前锁定的应用上设置。 |
nativeLibraryPath |
存储本地库的目录的完整路径。 |
flags |
与应用相关的标志。 |
ft |
APK 文件时间戳(Unix 时间戳,单位为毫秒,按System.currentTimeMillis()获取)。 |
it |
应用首次安装的时间(Unix 时间戳,单位为毫秒)。 |
ut |
应用最后一次更新的时间(Unix 时间戳,单位为毫秒)。 |
version |
包的版本号,由应用清单中的versionCode属性指定。 |
userId |
分配给应用的内核 UID。 |
installer |
安装该应用的应用程序包名称。 |
sharedUserId |
包的共享用户 ID 名称,由清单中的sharedUserId属性指定。 |
更新组件和权限
在创建了packages.xml条目后,PackageManagerService会扫描新应用程序清单中定义的所有 Android 组件,并将它们添加到其内部的内存组件注册表中。接下来,应用声明的任何权限组和权限也会被扫描并添加到权限注册表中。
注意
应用定义的自定义权限使用“先到先得”策略进行注册:如果应用 A 和 B 都定义了权限 P,并且 A 先安装,则 A 的权限定义会被注册,B 的权限定义会被忽略(因为 P 已被注册)。这是可能的,因为权限名称与定义它的应用包没有任何绑定,因此任何应用都可以定义任何权限。这个“先到先得”策略可能会导致权限保护级别降级:如果 A 的权限定义具有较低的保护级别(例如,normal),而 B 的定义具有较高的保护级别(例如,signature),并且 A 先安装,那么访问 B 的受 P 保护的组件时,不需要调用者使用与 B 相同的签名密钥。因此,在使用自定义权限保护组件时,请确保检查当前注册的权限是否具有您的应用所期望的保护级别。^([25])
最后,包数据库的更改(包条目和任何新的权限)被保存到磁盘,并且PackageManagerService发送ACTION_PACKAGE_ADDED通知其他组件有关新添加的应用。
更新包
更新包的过程与安装包的过程大致相同,因此我们这里只强调其差异。
签名验证
第一步是检查新包是否由与现有包相同的签名者签名。这个规则被称为同源策略,或首次使用信任(TOFU)。此签名检查保证更新由与原始应用相同的实体生成(假设签名密钥未被泄露),并在更新和现有应用之间建立信任关系。正如我们将在“更新非系统应用”中看到的那样,更新继承了原始应用的数据。
注意
当比较签名证书的相等性时,这些证书并未在公钥基础设施(PKI)意义上进行验证(例如时间有效性、受信任的发行者、撤销等不会被检查)。
证书相等性检查是通过PackageManagerService.compareSignatrues()方法执行的,如示例 3-14 所示。
示例 3-14. 包签名比较方法
static int compareSignatures(Signature[] s1, Signature[] s2) {
if (s1 == null) {
return s2 == null
? PackageManager.SIGNATURE_NEITHER_SIGNED
: PackageManager.SIGNATURE_FIRST_NOT_SIGNED;
}
if (s2 == null) {
return PackageManager.SIGNATURE_SECOND_NOT_SIGNED;
}
HashSet<Signature> set1 = new HashSet<Signature>();
for (Signature sig : s1) {
set1.add(sig);
}
HashSet<Signature> set2 = new HashSet<Signature>();
for (Signature sig : s2) {
set2.add(sig);
}
// Make sure s2 contains all signatures in s1.
if (set1.equals(set2)) {➊
return PackageManager.SIGNATURE_MATCH;
}
return PackageManager.SIGNATURE_NO_MATCH;
}
在这里,Signature类作为“与应用包相关联的签名的封闭、不变表示”。^([26]) 实际上,它是一个包装器,用于表示与 APK 文件相关联的 DER 编码签名证书。示例 3-15 显示了一个摘录,重点介绍了它的equals()和hashCode()方法。
示例 3-15. 包签名表示
public class Signature implements Parcelable {
private final byte[] mSignature;
private int mHashCode;
private boolean mHaveHashCode;
--*snip*--
public Signature(byte[] signature) {
mSignature = signature.clone();
}
public PublicKey getPublicKey() throws CertificateException {
final CertificateFactory certFactory =
CertificateFactory.getInstance("X.509");
final ByteArrayInputStream bais = new ByteArrayInputStream(mSignature);
final Certificate cert = certFactory.generateCertificate(bais);
return cert.getPublicKey();
}
@Override
public boolean equals(Object obj) {
try {
if (obj != null) {
Signature other = (Signature)obj;
return this == other
|| Arrays.equals(mSignature, other.mSignature);➊
}
} catch (ClassCastException e) {
}
return false;
}
@Override
public int hashCode() {
if (mHaveHashCode) {
return mHashCode;
}
mHashCode = Arrays.hashCode(mSignature);➋
mHaveHashCode = true;
return mHashCode;
}
--*snip*--
}
如你在➊处所见,两个签名类被认为是相等的,如果底层 X.509 证书的 DER 编码完全匹配,并且 Signature 类的哈希值仅根据编码后的证书计算➋。如果签名证书不匹配,compareSignatures() 方法将返回 INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES 错误代码。
这种二进制证书比较自然与 CA 或过期日期无关。其一个后果是,在一个应用(通过唯一的包名标识)安装后,更新需要使用相同的签名证书(系统应用更新除外,详见“更新系统应用”)。
虽然 Android 应用上的多个签名是罕见的,但它们确实存在。如果原始应用由多个签名者签名,则任何更新都需要由相同的签名者签名,并且每个签名者都使用其原始签名证书(通过➊在示例 3-14 中强制执行)。这意味着如果开发者的签名证书过期或他失去对签名密钥的访问权限,他将无法更新应用,必须发布一个新的应用。这样不仅会失去现有的用户群或评分,更重要的是失去对旧版应用数据和设置的访问权限。
解决这个问题的方法很直接,虽然不是最理想的:备份你的签名密钥,并且不要让证书过期。目前推荐的有效期至少为 25 年,且 Google Play 商店要求证书有效期至少到 2033 年 10 月。虽然从技术上讲,这只是将问题推迟,但将来可能会在平台上加入适当的证书迁移支持。
当包管理器确定更新使用相同的证书签名时,它会继续更新包。这个过程对于系统应用和用户安装的应用有所不同,接下来会描述。
更新非系统应用
非系统应用通过基本重新安装应用的方式进行更新,同时保留其数据目录。第一步是终止正在更新的包的任何进程。接下来,从内部结构和包数据库中移除该包,这也会删除该应用注册的所有组件。然后,PackageManagerService 通过调用 scanPackageLI() 方法触发包扫描。扫描过程与新安装时相同,只是它会更新包的代码、资源路径、版本和时间戳。包的清单文件会被扫描,任何定义的组件都会在系统中注册。接着,所有包的权限会被重新授予,以确保它们与更新后的包中的定义匹配。最后,更新后的包数据库会被写入磁盘,并发送 PACKAGE_REPLACED 系统广播。
更新系统应用
与用户安装的应用程序一样,预安装的应用程序(通常位于/system/app/)也可以在不进行完整系统更新的情况下更新,通常是通过 Google Play 商店或类似的应用分发服务。尽管由于系统分区是以只读方式挂载的,更新会安装在/data/app/中,而原始应用保持不变。除了<package>条目,更新后的应用还会有一个<updated-package>条目,可能类似于示例 3-16 中的例子。
示例 3-16 更新的系统包的包数据库条目
<package name="com.google.android.keep"
codePath="/data/app/com.google.android.keep-1.apk"➊
nativeLibraryPath="/data/app-lib/com.google.android.keep-1"
flags="4767461"➋
ft="142ee64d980"
it="14206f3e320"
ut="142ee64dfcb"
version="2101"
userId="10053"➌
installer="com.android.vending">
<sigs count="1">
<cert index="2" />
</sigs>
<signing-keyset identifier="3" />
<signing-keyset identifier="34" />
</package>
--*snip*--
<updated-package name="com.google.android.keep"
codePath="/system/app/Keep.apk"
nativeLibraryPath="/data/app-lib/Keep"
ft="ddc8dee8"
it="14206f3e320"
ut="ddc8dee8"
version="2051"
userId="10053">➍
<perms>
<item name="android.permission.READ_EXTERNAL_STORAGE" />
<item name="android.permission.USE_CREDENTIALS" />
<item name="android.permission.WRITE_EXTERNAL_STORAGE" />
--*snip*--
</perms>
</updated-package>
更新的codePath属性设置为新 APK 在/data/app/中的路径➊。它继承了原始应用的权限和 UID(➌和➍),并通过在其flags属性中添加FLAG_UPDATED_SYSTEM_APP(0x80)来标记为系统应用的更新➋。
系统应用可以直接在系统分区中更新,通常是通过 OTA 系统更新进行的,在这种情况下,更新后的系统 APK 允许使用不同的证书签名。这样做的理由是,如果安装程序有足够的权限写入系统分区,那么它就可以信任修改签名证书。UID 以及任何文件和权限都将被保留。例外情况是,如果该包属于共享用户(在第二章中讨论),则无法更新签名,因为这样做会影响其他应用程序。相反的情况是,当新的系统应用程序由与当前已安装的非系统应用程序(具有相同包名)不同的证书签名时,非系统应用程序将首先被删除。
安装加密的 APK
在 Android 4.1 中,增加了对安装加密 APK 的支持,并支持使用 ASEC 容器进行前向锁定。这两个功能都被宣布为应用加密,但我们将分别讨论它们,首先从对加密 APK 文件的支持开始。但首先,让我们看看如何安装加密的 APK。
可以使用 Google Play 商店客户端或 Android shell 中的pm命令来安装加密的 APK,但系统的PackageInstaller不支持加密 APK。由于我们无法控制 Google Play 商店的安装流程,因此为了安装加密的 APK,我们需要使用pm命令或编写我们自己的安装程序应用。我们将采取简单的方法,使用pm命令。
创建和安装加密的 APK
adb install命令将 APK 文件复制到设备上的临时文件,并启动安装过程。该命令为adb push和pm install命令提供了一个方便的包装器。adb install在 Android 4.1 中新增了三个参数,以支持加密 APK(参见示例 3-17)。
示例 3-17. adb install 命令选项
adb install [-l] [-r] [-s] [--algo <algorithm name> --key <hex-encoded key>
--iv <hex-encoded iv>] <file>
--algo、--key 和 --iv 参数分别用于指定加密算法、密钥和初始化向量(IV)。但是为了使用这些新参数,我们首先需要创建一个加密的 APK 文件。
APK 文件可以使用 enc OpenSSL 命令加密,如示例 3-18 所示。这里我们使用 128 位密钥的 AES CBC 模式,并指定一个与密钥相同的 IV,以简化操作。
示例 3-18. 使用 OpenSSL 加密 APK 文件
$ **openssl enc -aes-128-cbc -K 000102030405060708090A0B0C0D0E0F**
**-iv 000102030405060708090A0B0C0D0E0F -in my-app.apk -out my-app-enc.apk**
接下来,我们通过将加密算法密钥(以 javax.crypto.Cipher 转换字符串格式表示,具体内容见第五章)和 IV 字节传递给 adb install 命令,安装我们的加密 APK,如示例 3-19 所示。
示例 3-19. 使用 adb install 安装加密的 APK
$ **adb install --algo 'AES/CBC/PKCS5Padding' \**
**--key 000102030405060708090A0B0C0D0E0F \**
**--iv 000102030405060708090A0B0C0D0E0F my-app-enc.apk**
pkg: /data/local/tmp/my-app-enc.apk
Success
正如 Success 输出所示,APK 安装没有错误。实际的 APK 文件被复制到 /data/app/,并且将其哈希值与加密的 APK 进行比较,结果表明它实际上是一个不同的文件。哈希值与原始(未加密)APK 的哈希值完全相同,因此我们可以得出结论,APK 在安装时使用提供的加密参数(算法、密钥和 IV)被解密。
实现与加密参数
让我们看看这是如何实现的。在将 APK 文件传输到设备后,adb install 调用 pm Android 命令行工具,传递 install 参数和已复制 APK 文件的路径。负责在 Android 上安装应用的组件是 PackageManagerService,而 pm 命令只是其某些功能的便捷前端。当以 install 参数启动时,pm 调用方法 installPackageWithVerificationAndEncryption(),并根据需要将其选项转换为相关参数。示例 3-20 方法签名")展示了该方法的完整签名。
示例 3-20. PackageManagerService.installPackageWithVerificationAndEncryption() 方法签名
public void installPackageWithVerificationAndEncryption(Uri packageURI,
IPackageInstallObserver observer, int flags,
String installerPackageName,
VerificationParams verificationParams,
ContainerEncryptionParams encryptionParams) {
--*snip*--
}
我们在前面“APK 安装过程”中讨论了该方法的大部分参数,但我们尚未涉及 VerificationParams 和 ContainerEncryptionParams 类。顾名思义,VerificationParams 类封装了在包验证过程中使用的参数,稍后我们会在“包验证”中讨论。ContainerEncryptionParams 类包含加密参数,包括通过 adb install 的 --algo、--key 和 --iv 选项传递的值。示例 3-21 展示了它的数据成员。
示例 3-21. ContainerEncryptionParams 数据成员
public class ContainerEncryptionParams implements Parcelable {
private final String mEncryptionAlgorithm;
private final IvParameterSpec mEncryptionSpec;
private final SecretKey mEncryptionKey;
private final String mMacAlgorithm;
private final AlgorithmParameterSpec mMacSpec;
private final SecretKey mMacKey;
private final byte[] mMacTag;
private final long mAuthenticatedDataStart;
private final long mEncryptedDataStart;
private final long mDataEnd;
--*snip*--
}
上述 adb install 参数对应类的前三个字段。虽然 adb install 包装器中不可用,但 pm install 命令同样支持 --macalgo、--mackey 和 --tag 参数,这些参数分别对应 ContainerEncryptionParams 类中的 mMacAlgorithm、mMacKey 和 mMacTag 字段。为了使用这些参数,我们需要先计算加密 APK 的 MAC 值,方法是使用 OpenSSL 的 dgst 命令,如在示例 3-22 中所示。
示例 3-22. 计算加密 APK 的 MAC
$ **openssl dgst -hmac 'hmac_key_1' -sha1 -hex my-app-enc.apk**
HMAC-SHA1(my-app-enc.apk)= 962ecdb4e99551f6c2cf72f641362d657164f55a
注意
dgst 命令不允许你使用十六进制或 Base64 来指定 HMAC 密钥,因此我们只能使用 ASCII 字符。这样做可能不适合生产环境,所以建议使用真正的密钥,并以其他方式计算 MAC(例如,使用 JCE 程序)。
安装带有完整性检查的加密 APK
现在,我们可以通过打开 Android shell 使用 adb shell 并执行示例 3-23 中所示的命令来安装加密 APK 并验证其完整性。
示例 3-23. 使用pm install安装带有完整性验证的加密 APK
$ **pm install -r --algo 'AES/CBC/PKCS5Padding' \**
**--key 000102030405060708090A0B0C0D0E0F \**
**--iv 000102030405060708090A0B0C0D0E0F \**
**--macalgo HmacSHA1 --mackey 686d61635f6b65795f31 \**
**--tag 962ecdb4e99551f6c2cf72f641362d657164f55a /sdcard/my-app-enc.apk**
pkg: /sdcard/kr-enc.apk
Success
通过将指定的 MAC 标签与基于实际文件内容计算的值进行比较来检查应用的完整性,文件内容会被解密,解密后的 APK 会被复制到 /data/app/ 目录。(为了测试是否真的执行了 MAC 验证,可以稍微更改标签值。这样做应该会导致安装错误,错误码为 INSTALL_FAILED_INVALID_APK。)
正如我们在示例 3-19 和示例 3-23 中看到的那样,最终复制到 /data/app/ 的 APK 文件并未加密,因此安装过程与未加密 APK 相同,唯一不同的是文件解密和可选的完整性验证。解密和完整性验证由 MediaContainerService 在将 APK 复制到应用目录时透明地执行。如果将 ContainerEncryptionParams 实例传递给其 copyResource() 方法,它会使用提供的加密参数实例化 JCA 类 Cipher 和 Mac(见第五章),以执行解密和完整性检查。
注意
MAC 标签和加密 APK 可以捆绑在一个文件中,在这种情况下,MediaContainerService 使用 mAuthenticatedDataStart、mEncryptedDataStart 和 mDataEnd 成员从文件中提取 MAC 和 APK 数据。
前向加锁
前向加锁出现在铃声、壁纸和其他数字“商品”开始在功能手机上销售的时候。由于 Android 上安装的 APK 文件是世界可读的,即使是生产设备,也相对容易提取应用程序。为了在不失去操作系统灵活性的情况下锁定付费应用程序(并防止用户将其转发给其他用户),早期版本的 Android 引入了前向加锁(也称为 复制保护)。
前向加锁的理念是将应用包分为两个部分:一个包含资源和清单的世界可读部分(位于 /data/app/),以及一个仅系统用户可读、包含可执行代码的包(位于 /data/app-private/)。代码包通过文件系统权限进行保护,使其对大多数消费者设备上的用户不可访问,但可以从具有 root 权限的设备中提取,这种早期的前向加锁机制很快被弃用,并被一种名为 Google Play Licensing 的在线应用授权服务所取代。
Google Play Licensing 的问题在于,它将应用保护的实现从操作系统转移到了应用开发者身上,结果不尽如人意。前向加锁机制在 Android 4.1 中进行了重新设计,现在提供了将 APK 存储在加密容器中的能力,并且需要设备特定的密钥在运行时进行挂载。我们来详细了解一下。
Android 4.1 前向加锁实现
虽然将加密应用容器作为前向锁定机制是在 Android 4.1 中引入的,但加密容器最早在 Android 2.2 中就已出现。当时(2010 年中期),大多数 Android 设备的内部存储较为有限,而外部存储相对较大(几 GB),通常为 microSD 卡。为了简化文件共享,外部存储使用 FAT 文件系统格式化,但该文件系统不支持文件权限。因此,SD 卡上的文件可以被任何应用读取和写入。
为了防止用户仅通过复制已付费的应用程序到 SD 卡来绕过保护,Android 2.2 创建了一个加密文件系统映像文件,并在用户选择将应用移动到外部存储时将 APK 存储在其中。系统会为该加密映像创建一个挂载点,并使用 Linux 的设备映射器将其挂载。Android 在运行时从挂载点加载每个应用的文件。
Android 4.1 在此基础上进行了扩展,使容器使用 ext4 文件系统,从而支持文件权限。现在,一个典型的前向锁定应用的挂载点如 示例 3-24 所示(省略时间戳)。
示例 3-24. 前向锁定应用挂载点的内容
# ls -l /mnt/asec/com.example.app-1
drwxr-xr-x system system lib
drwx------ root root lost+found
-rw-r----- system u0_a96 1319057 pkg.apk
-rw-r--r-- system system 526091 res.zip
在这里,res.zip 包含应用资源和清单文件,并且是世界可读的,而 pkg.apk 文件包含完整的 APK,仅可由系统和应用的专用用户(u0_a96)读取。实际的应用容器存储在 /data/app-asec/ 目录中,文件扩展名为 .asec。
加密应用容器
加密应用容器被称为 Android 安全外部缓存,或 ASEC 容器。ASEC 容器管理(创建、删除、挂载和卸载)由系统卷守护进程(vold)实现,而 MountService 提供接口,将其功能暴露给框架服务。我们还可以使用 vdc 命令行工具与 vold 交互,以便通过 Android 的 Shell 管理前向锁定的应用(参见 示例 3-25)。
示例 3-25. 使用 vdc 发出 ASEC 管理命令
# vdc asec list➊
vdc asec list
111 0 com.example.app-1
111 0 org.foo.app-1
200 0 asec operation succeeded
# vdc asec path com.example.app-1➋
vdc asec path com.example.app-1
211 0 /mnt/asec/com.example.app-1
# vdc asec unmount org.example.app-1➌
200 0 asec operation succeeded
# vdc asec mount com.example.app-1 000102030405060708090a0b0c0d0e0f 1000➍
com.example.app-1 000102030405060708090a0b0c0d0e0f 1000
200 0 asec operation succeeded
在这里,asec list 命令 ➊ 列出了挂载的 ASEC 容器的命名空间 ID。命名空间 ID 基于包名,并且与非前向锁定应用的 APK 文件名格式相同。所有其他命令都以命名空间 ID 作为参数。
asec path 命令 ➋ 显示指定 ASEC 容器的挂载点,而 asec unmount 命令用于卸载它 ➌。除了命名空间 ID,asec mount ➍ 还要求指定加密密钥和挂载点的所有者 UID(1000 是 系统)。
ASEC 容器的加密算法和密钥长度与原始 Android 2.2 版本的应用至 SD 实现保持不变:使用 128 位密钥的 Twofish 加密算法,密钥存储在 /data/misc/systemkeys/ 中,如 示例 3-26 所示。
示例 3-26. ASEC 容器加密密钥的位置和内容
# ls -l /data/misc/systemkeys
-rw------- system system 16 AppsOnSD.sks
# od -t x1 /data/misc/systemkeys/AppsOnSD.sks
0000000 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
0000020
向前锁定应用程序是通过指定 pm install 的 -l 选项,或者在调用 PackageManager 的 installPackage() 方法时指定 INSTALL_FORWARD_LOCK 标志来触发的。
安装向前锁定的 APK
向前锁定 APK 的安装过程涉及两个额外的步骤:创建和挂载安全容器,以及从 APK 文件中提取公共资源文件。与加密 APK 相同,这些步骤由 MediaContainerService 封装,并在将 APK 复制到应用程序目录时执行。由于 MediaContainerService 没有足够的权限来创建和挂载安全容器,它通过调用适当的 MountService 方法(如 createSecureContainer()、mountSecureContainer() 等)将容器管理委托给 vold 守护进程。
加密应用与 Google Play
由于安装应用程序(无论是否加密)需要系统权限,因此只有系统应用才能安装应用。Google 自家的 Play 商店 Android 客户端利用了加密应用和向前锁定技术。虽然要详细描述 Google Play 客户端的工作原理需要对底层协议有深入了解(该协议并不公开且不断发展),但我们通过简单了解最近版本的 Google Play 商店客户端的实现,仍然可以获得一些有用的信息。
Google Play 服务器会发送很多关于即将下载和安装的应用的元数据,例如下载 URL、APK 文件大小、版本号和退款窗口等。在这些信息中,显示在 示例 3-27 中的 EncryptionParams 与在 示例 3-21 中显示的 ContainerEncryptionParams 非常相似。
示例 3-27. 在 Google Play 商店协议中使用的 EncryptionParams
class AndroidAppDelivery$EncryptionParams {
--*snip*--
private String encryptionKey;
private String hmacKey;
private int version;
}
从 Google Play 下载的付费应用的加密算法和 HMAC 算法分别始终设置为 AES/CBC/PKCS5Padding 和 HMACSHA1。初始化向量(IV)和消息认证码(MAC)标签与加密的 APK 一起打包成一个 blob。读取和验证所有参数后,它们本质上会转换为 ContainerEncryptionParams 实例,并使用 PackageManager.installPackageWithVerification() 方法安装应用。
当安装付费应用时,INSTALL_FORWARD_LOCK 标志会被设置,以启用前向加密。操作系统从这里开始处理,过程如前两节所述:免费应用会被解密,APK 最终会存放在 /data/app/ 目录下,而加密容器会在 /data/app-asec/ 中创建,并挂载到 /mnt/asec/
这种做法在实践中有多安全?Google Play 现在可以声称,付费应用始终以加密形式传输和存储,若你决定使用 Android 提供的应用加密功能,自己的应用分发渠道也可以做到这一点。然而,APK 文件的内容在某个时刻必须提供给操作系统,因此,如果你拥有对运行中的 Android 设备的 root 权限,仍然可以提取前向加密的 APK 或容器加密密钥。
包验证
包验证作为 Android 4.2 版本中的正式功能引入,最初被称为 应用验证,后来被回移植到所有运行 Android 2.3 及更高版本的设备和 Google Play 商店。实现包验证的基础设施已经内置到操作系统中,但 Android 系统本身并未提供内置的验证器。最广泛使用的包验证实现是内置在 Google Play 商店客户端中的,并由 Google 的应用分析基础设施支持。该实现旨在保护 Android 设备免受 Google 所称的“潜在有害应用程序”(如后门程序、钓鱼应用、间谍软件等),通常简称为 恶意软件。

图 3-3. 应用验证警告对话框
当启用包验证时,APK 会在安装之前由验证器扫描,如果验证器认为该 APK 可能有害,系统会显示警告(见 图 3-3),或在安装过程中阻止安装。默认情况下,支持的设备会开启验证功能,但首次使用时需要用户一次性授权,因为这会向 Google 发送应用数据。应用验证可以通过系统设置中的“安全性”屏幕中的“验证应用”选项进行切换(见 图 3-2)。
以下章节讨论了 Android 包验证基础设施,并简要回顾了 Google Play 的实现。
Android 对包验证的支持
与大多数处理应用管理的内容一样,包验证是在PackageManagerService中实现的,并自 Android 4.0(API 级别 14)以来可用。包验证由一个或多个验证代理执行,并具有一个必需验证器和零个或多个足够验证器。当必需验证器和至少一个足够验证器返回正面结果时,验证被视为完成。应用可以通过声明一个具有匹配PACKAGE_NEEDS_VERIFICATION动作和 APK 文件 MIME 类型(application/vnd.android.package-archive)的意图过滤器的广播接收器,注册自己作为必需验证器,如示例 3-28 所示。
示例 3-28. AndroidManifest.xml 中必需验证器的声明
<receiver android:name=".MyPackageVerificationReceiver"
android:permission="android.permission.BIND_PACKAGE_VERIFIER">
<intent-filter>
<action
android:name="android.intent.action.PACKAGE_NEEDS_VERIFICATION" />
<action android:name="android.intent.action.PACKAGE_VERIFIED" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent-filter>
</receiver>
此外,声明的应用需要被授予PACKAGE_VERIFICATION_AGENT权限。由于这是一个保留给系统应用的签名权限(signature|system),只有系统应用才能成为所需的验证代理。
应用可以通过在其清单文件中添加<package-verifier>标签,并在标签的属性中列出足够验证器的包名和公钥,来注册足够的验证器,如示例 3-29 所示。
示例 3-29. AndroidManifest.xml 中足够验证器的声明
<manifest
package="com.example.app">
<package-verifier android:name="com.example.verifier"
android:publicKey="MIIB..." />
<application ...>
--*snip*--
</application>
</manifest>
安装包时,当安装了必需验证器且Settings.Global.PACKAGE_VERIFIER_ENABLE系统设置为true时,PackageManagerService会执行验证。通过将 APK 添加到待安装队列并发送ACTION_PACKAGE_NEEDS_VERIFICATION广播给已注册的验证器来启用验证。
广播包含一个唯一的验证 ID,以及有关正在验证包的各种元数据。验证代理通过调用verifyPendingInstall()方法并传递验证 ID 和验证状态来响应。调用此方法需要PACKAGE_VERIFICATION_AGENT权限,这确保了非系统应用无法参与包验证。每次调用verifyPendingInstall()时,PackageManagerService都会检查是否已收到足够的待安装验证。如果是,它将从队列中移除待安装项,发送PACKAGE_VERIFIED广播,并启动包安装过程。如果包被验证代理拒绝,或者在指定时间内未收到足够的验证,安装将失败并返回INSTALL_FAILED_VERIFICATION_FAILURE错误。
Google Play 实现
Google 的应用验证实现集成在 Google Play 商店客户端中。Google Play 商店应用将自己注册为必需的验证代理,如果启用了“验证应用”选项,每次应用即将被安装时,无论是通过 Google Play 商店客户端、PackgeInstaller 应用还是通过 adb install,都会接收到一个广播。
该实现并非开源,且公开的细节较少,但 Google 的“防范有害应用”Android 帮助页面中指出,“当您验证应用时,Google 会接收日志信息、与应用相关的 URL 以及设备的一般信息,例如设备 ID、操作系统版本和 IP 地址。”^([28]) 从目前的信息来看,除了这些信息外,Play 商店客户端还会发送 APK 文件的 SHA-256 哈希值、文件大小、应用包名、资源的名称及其 SHA-256 哈希值、应用的清单和类文件的 SHA-256 哈希值、版本号和签名证书,以及有关安装应用和推荐 URL 的一些元数据(如果有的话)。基于这些信息,Google 的 APK 分析算法会判断 APK 是否可能有害,并返回一个结果给 Play 商店客户端,其中包含一个状态码和错误信息,以便在 APK 被判定为可能有害时显示。然后,Play 商店客户端会调用 PackageManagerService 的 verifyPendingInstall() 方法,带上适当的状态码。应用安装是否被接受或拒绝,取决于上一节描述的算法。
在实际操作中(至少在“Google 体验”设备上),Google Play 商店验证器通常是唯一的验证代理,因此包是否被安装或拒绝,完全取决于 Google 在线验证服务的响应。
总结
Android 应用程序包(APK 文件)是 JAR 文件格式的扩展,包含资源、代码和清单文件。APK 文件使用 JAR 文件代码签名格式进行签名,但要求所有文件都使用同一组证书进行签名。Android 使用代码签名证书来建立应用及其更新的相同来源,并建立应用之间的信任关系。APK 文件通过将其复制到 /data/app/ 目录并在 /data/data/ 下为每个应用创建一个专用数据目录来安装。
Android 支持加密 APK 文件和安全应用容器,用于前锁定的应用。加密应用在被复制到应用目录之前会自动解密。前锁定的应用被拆分成资源和清单部分,公开可访问,以及私有代码和资源部分,这些部分存储在一个专门的加密容器中,仅操作系统可以直接访问。
Android 可以在安装应用之前选择性地通过咨询一个或多个验证代理来进行验证。目前,最广泛使用的验证代理内置于 Google Play Store 客户端应用中,并使用 Google 的在线应用验证服务来检测潜在有害的应用程序。
^([17]) Oracle,JAR 文件规范,docs.oracle.com/javase/7/docs/technotes/guides/jar/jar.html
^([18]) 微软公司,Flame 恶意软件碰撞攻击解释,blogs.technet.com/b/srd/archive/2012/06/06/more-information-about-the-digital-certificates-used-to-sign-the-flame-malware.aspx
^([19]) EMC RSA 实验室,PKCS #7:加密消息语法标准,www.emc.com/emc-plus/rsa-labs/standards-initiatives/pkcs-7-cryptographic-message-syntax-standar.htm
^([20]) Housley,RFC 5652 – 加密消息语法(CMS),tools.ietf.org/html/rfc5652
^([21]) 抽象语法表示法一(ASN.1)是一种标准符号,用于描述在电信和计算机网络中编码数据的规则和结构。它广泛应用于加密标准中,以定义加密对象的结构。
^([22]) EMC RSA 实验室,PKCS #8:私钥信息语法标准,www.emc.com/emc-plus/rsa-labs/standards-initiatives/pkcs-8-private-key-information-syntax-stand.htm
^([23]) 有关 Linux 能力的讨论,请参见 Michael Kerrisk 的 The Linux Programming Interface: A Linux and UNIX System Programming Handbook 第三十九章,No Starch Press,2010 年。
^([24]) 文件监控是通过使用 Linux 的 inotify 功能来实现的。有关 inotify 的更多细节,请参见 Michael Kerrisk 的 The Linux Programming Interface: A Linux and UNIX System Programming Handbook 第十九章,No Starch Press,2010 年。
^([25]) 请参见 CommonsWare,CWAC-Security,github.com/commonsguy/cwac-security,以获取进一步讨论和一个示例项目,展示如何执行该检查。
^([26]) Google, Android API 参考文档,“签名”,developer.android.com/reference/android/content/pm/Signature.html
^([27]) Google,从零开始的安卓安全实务,在 2013 年 VirusBulletin 会议上展示。资料来源:* docs.google.com/presentation/d/1YDYUrD22Xq12nKkhBfwoJBfw2Q-OReMr0BrDfHyfyPw*
^([28]) Google,防范有害应用程序,* support.google.com/accounts/answer/2812853*
第四章 用户管理
Android 最初是面向个人设备(如智能手机)的,假设每个设备只有一个用户。随着平板电脑和其他共享设备的普及,Android 在 4.2 版本中增加了多用户支持,并在后续版本中进行了扩展。
本章将讨论 Android 如何管理共享设备和数据的用户。我们首先介绍 Android 支持的用户类型及其如何存储用户元数据。接着,我们讨论 Android 如何在用户之间共享已安装的应用程序,同时隔离应用程序数据并确保每个用户的数据隐私。最后,我们讲解 Android 如何实现隔离的外部存储。
多用户支持概述
Android 的多用户支持允许多个用户共享同一设备,通过为每个用户提供一个独立的、个人化的环境。每个用户可以拥有自己的主屏幕、小部件、应用程序、在线账户和文件,这些内容对其他用户不可访问。
用户通过一个独特的用户 ID(不要与 Linux 的 UID 混淆)进行标识,只有系统可以在用户之间切换。用户切换通常通过从 Android 锁屏界面选择一个用户来触发,并(可选地)通过图案、PIN、密码等方式进行身份验证(详见第十章)。应用程序可以通过 UserManager API 获取当前用户的信息,但通常不需要修改代码即可支持多用户环境。需要在受限配置文件下修改行为的应用程序是一个例外:这些应用程序需要额外的代码来检查当前用户所施加的任何限制(详见“受限配置文件”了解详情)。
多用户支持内建于 Android 核心平台,因此在所有运行 Android 4.2 或更高版本的设备上均可使用。然而,默认的平台配置只允许单个用户,这实际上禁用了多用户支持。为了启用多用户支持,config_multiuserMaximumUsers 系统资源必须设置为大于 1 的值,通常通过添加设备特定的覆盖配置文件来实现。例如,在 Nexus 7(2013)上,覆盖文件放置在 device/ asus/flo/overlay/frameworks/base/core/res/res/values/config.xml 文件中,config_multiuserMaximumUsers 设置如下所示,在示例 4-1 中定义,允许最多支持 8 个用户。
示例 4-1. 使用资源覆盖文件启用多用户支持
<?xml version="1.0" encoding="utf-8"?>
<resources >
--*snip*--
<!-- Maximum number of supported users -->
<integer name="config_multiuserMaximumUsers">8</integer>
--*snip*--
</resources>
注意
Android 兼容性定义要求支持电话功能的设备(如手机)不得启用多用户支持,因为“目前多用户设备上的电话 API 行为未定义。”^([29]) 因此,在当前的生产版本中,所有手机都被配置为单用户设备。
当启用多用户支持时,系统设置应用会显示一个“用户”条目,允许设备所有者(下一节中讨论的第一个创建的用户)创建和管理其他用户。图 4-1 显示了用户管理屏幕。

图 4-1. 用户管理屏幕
一旦创建了多个用户,锁屏上会显示一个用户小部件,显示当前用户并允许切换到其他用户。图 4-2 展示了一个包含八个用户的设备上的锁屏界面。

图 4-2. 带有用户切换小部件的锁屏
用户类型
尽管 Android 缺乏大多数多用户操作系统的完整用户管理功能,这些操作系统通常允许用户添加多个管理员并定义用户组,但它确实支持配置具有不同权限的用户类型。每种用户类型及其权限将在接下来的章节中详细描述。
主要用户(所有者)
主要用户,也称为设备的所有者,是多用户设备上创建的第一个用户,或者是单用户设备上的唯一用户。所有者默认创建并始终存在。主要用户被分配用户 ID 0。在单用户设备上,主要用户是唯一用户,Android 的行为类似于没有多用户支持的早期版本:已安装应用程序分配的目录和 UID 保持与早期版本相同的格式和权限(详细内容请参见“用户管理”和“应用程序共享”)。
主要用户被分配所有权限,可以创建和删除其他用户,并更改影响所有用户的系统设置,包括与设备安全性、网络连接性和应用程序管理相关的设置。设备和用户管理权限通过在系统设置中显示相关设置界面并将其隐藏于其他用户之外来授予主要用户。此外,底层系统服务在执行可能影响所有用户的操作之前,会检查调用用户的身份,仅当由设备所有者调用时才允许执行。
从 Android 版本 4.4 开始,系统设置中“无线和网络”部分的以下页面仅对主用户显示:
-
蜂窝广播
-
管理移动计划
-
移动网络
-
共享与便携式热点
-
VPN
-
WiMAX(如果设备支持,则显示)
安全部分的以下页面也仅限主用户使用:
-
设备加密
-
SIM 卡锁
-
未知来源(控制应用程序侧载;见第三章)
-
验证应用程序(控制软件包验证;见第三章)
次要用户
除限制配置文件(将在下一节中讨论)外,所有添加的用户都是 次要用户。每个次要用户都有一个专用的用户目录(见“用户管理”),他们自己的已安装应用程序列表,以及每个已安装应用程序的私人数据目录。
次要用户不能添加或管理用户;他们只能通过用户屏幕设置自己的用户名(见图 4-1)。此外,他们不能执行任何主用户专有的特权操作,如前面部分所列。否则,次要用户可以执行主用户可以执行的所有操作,包括安装和使用应用程序,以及更改系统外观和设置。
尽管次要用户受到限制,他们的行为仍然可能影响设备行为和其他用户。例如,他们可以添加并连接到新的 Wi-Fi 网络。由于 Wi-Fi 连接状态是系统共享的,切换到不同用户并不会重置无线连接,该用户将连接到上一个用户选择的无线网络。次要用户还可以切换飞行模式和 NFC,并更改全局声音和显示设置。最重要的是,由于应用程序包在所有用户之间共享(如“应用程序共享”中讨论的),如果次要用户更新了一个添加了新权限的应用程序,权限将直接授予该应用程序,无需其他用户同意,且其他用户不会收到权限更改的通知。
限制配置文件
与次要用户不同,限制配置文件(在 Android 4.3 中新增)是基于主用户的,并共享其应用程序、数据和帐户,但有一些限制。因此,主用户必须设置锁屏密码以保护其数据。如果主用户在创建限制配置文件时未设置锁屏密码,Android 会提示他们设置一个密码。
用户限制
Android 定义了以下默认限制,以控制用户可以做的事情。所有限制默认值为 false。下面的列表显示了受限用户的相应值(括号内为限制用户的值)。
-
DISALLOW_CONFIG_BLUETOOTH。指定是否禁止用户配置蓝牙。(默认值:false) -
DISALLOW_CONFIG_CREDENTIALS。指定是否禁止用户配置用户凭证。当该限制设置为true时,受限配置文件无法向系统凭证存储中添加受信任的 CA 证书或导入私钥;有关详细信息,请参见 第六章 和 第七章。(默认值:false) -
DISALLOW_CONFIG_WIFI。指定是否禁止用户更改 Wi-Fi 接入点。(默认值:false) -
DISALLOW_INSTALL_APPS。指定是否禁止用户安装应用程序。(默认值:false) -
DISALLOW_INSTALL_UNKNOWN_SOURCES。指定是否禁止用户启用未知来源设置(见 第三章)。(默认值:false) -
DISALLOW_MODIFY_ACCOUNTS。指定是否禁止用户添加和删除账户。(默认值:true) -
DISALLOW_REMOVE_USER。指定是否禁止用户删除其他用户。(默认值:false) -
DISALLOW_SHARE_LOCATION。指定是否禁止用户切换位置共享。(默认值:true) -
DISALLOW_UNINSTALL_APPS。指定是否禁止用户卸载应用程序。(默认值:false) -
DISALLOW_USB_FILE_TRANSFER。指定是否禁止用户通过 USB 转移文件。(默认值:false)
应用限制
在运行时,应用程序可以使用 UserManager.getUserRestrictions() 方法获取一个包含用户限制的 Bundle(一个将字符串键映射到各种值类型的通用容器类)。限制被定义为键值对,其中键是限制名称,布尔值指定该限制是否生效。应用程序可以使用该值来禁用在受限配置文件下运行时的某些功能。例如,系统设置应用会在显示位置偏好时检查 DISALLOW_SHARE_LOCATION 限制的值。如果值为 true,则禁用位置模式设置。另一个例子是 PackageManagerService:它会在安装或卸载应用之前检查 DISALLOW_INSTALL_APPS 和 DISALLOW_UNINSTALL_APPS 限制,并在这些限制中的任何一个被设置为 true 时返回 INSTALL_FAILED_USER_RESTRICTED 错误代码。
主要用户可以选择哪些应用程序对受限配置文件可用。当创建受限配置文件时,所有已安装的应用程序默认会被禁用,所有者必须明确启用他们希望让受限配置文件使用的应用程序(见 图 4-3)。

图 4-3. 受限个人资料管理界面
除了操作系统定义的内置限制外,应用程序还可以通过创建一个接收ACTION_GET_RESTRICTION_ENTRIES意图的BroadcastReceiver来定义自定义限制。Android 会调用此意图查询所有应用程序的可用限制,并自动构建一个用户界面,允许设备所有者切换应用程序的自定义限制。
在运行时,应用程序可以使用UserManager.getApplicationRestrictions()方法获取一个包含保存的限制条件的Bundle,这些限制以键值对的形式存储。然后,应用程序可以根据所应用的限制禁用或修改某些功能。设备所有者可以在同一个设置界面上切换系统和自定义限制,这个界面用于管理可供受限个人资料使用的应用程序。例如,在图 4-3 中,设置应用程序支持的单一应用程序限制(是否允许应用程序使用位置信息)显示在主应用程序切换按钮下方。
访问在线帐户
受限个人资料也可以通过AccountManager API 访问主用户的在线帐户(参见第八章),但默认情况下该访问是禁用的。需要访问帐户的应用程序在受限个人资料下运行时,必须明确声明它们所需的帐户类型,使用<application>标签的restrictedAccountType属性,如示例 4-2 所示。
示例 4-2. 允许受限个人资料访问所有者的帐户
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.app" ...>
<application android:restrictedAccountType="com.google" ... >
--*snip*--
</application>
</manifest>
另一方面,不希望向受限个人资料公开帐户信息的应用程序可以通过指定帐户类型(可以使用星号来匹配所有帐户类型)作为<application>标签的requiredAccountType属性的值来声明这一点。如果指定了requiredAccountType属性,Android 将自动禁用这些应用程序的受限个人资料。例如,由于 Android 日历应用程序在其清单文件中声明了android:requiredAccountType="*",因此无法向受限个人资料提供该应用程序,并且在限制设置界面中被禁用(参见图 4-3)。
客户端用户
Android 支持单个访客用户,但该功能默认情况下是禁用的。虽然可以通过调用 UserManager.setGuestEnabled() 方法启用访客用户,但在当前 Android 版本中,访客用户似乎只在 UserManager 和相关类中被引用。代码注释表明访客用户可能是临时的,但截至目前,其确切用途尚不明确。它似乎是一个被拒绝或从未完全实现的拟议功能的遗留物。
用户管理
Android 用户由 UserManagerService 管理,后者负责读取和持久化用户信息,并维护活跃用户的列表。由于用户管理与包管理密切相关,PackageManagerService 在安装或移除包时会调用 UserManagerService 查询或修改用户。android.os.UserManager 类为 UserManagerService 提供了一个外观,并将其部分功能暴露给第三方应用程序。应用程序可以获取系统中的用户数量、用户的序列号、当前用户的名称和限制列表,以及某个包的限制列表,而无需任何特殊权限。所有其他用户操作,包括查询、添加、删除或修改用户,都需要 MANAGE_USERS 系统签名权限。
命令行工具
用户管理操作也可以通过 Android shell 使用 pm 命令来执行。这些命令可以通过 shell 运行,无需 root 权限,因为 shell 用户(UID 2000)被授予了 MANAGE_USERS 权限。你可以使用 pm create-user 命令创建新用户,使用 pm remove-user 删除用户。命令 pm get-max-users 返回操作系统支持的最大用户数,而 pm list users 列出所有用户。pm list users 命令的输出可能像 示例 4-3 中的输出那样,显示设备上有五个用户。大括号中的数字依次表示用户 ID、姓名和标志。
示例 4-3. 使用 pm list 命令列出用户
$ **pm list users**
Users:
UserInfo{0:Owner:13}
UserInfo{10:User1:10}
UserInfo{11:User2:10}
UserInfo{12:User3:10}
UserInfo{13:Profile1:18}
用户状态和相关广播
UserManagerService 会发送多个广播来通知其他组件有关用户的事件。当添加用户时,它会发送 USER_ADDED 广播;当移除用户时,它会发送 USER_REMOVED 广播。如果用户名称或头像被更改,UserManagerService 会发送 USER_INFO_CHANGED 广播。切换用户时,会触发 USER_BACKGROUND、USER_FOREGROUND 和 USER_SWITCHED 广播,所有这些广播都会包含相关的用户 ID 作为附加信息。
虽然 Android 最多支持八个用户,但一次只能有三个用户处于活动状态。当通过锁屏用户切换器首次切换到某个用户时,该用户会被启动。Android 根据最少最近使用(LRU)缓存算法停止不活跃的用户,以确保最多只有三个用户处于活动状态。
当用户被停止时,其进程会被终止,且不再接收任何广播。启动或停止用户时,系统会发送USER_STARTING、USER_STARTED、USER_STOPPING和USER_STOPPED广播。主用户在系统启动时自动启动,并且永远不会停止。
启动、停止、切换用户,以及通过广播定位特定用户,需要INTERACT_ACROSS_USERS权限。这是一个具有签名保护的系统权限,但它也设置了development标志(见第二章),因此可以动态授予声明该权限的非系统应用(使用pm grant命令)。INTERACT_ACROSS_USERS_FULL签名权限允许向所有用户发送广播、更改设备管理员以及执行其他影响所有用户的特权操作。
用户元数据
Android 将用户数据存储在/data/system/users/目录中,该目录包含以 XML 格式存储的用户元数据以及用户目录。在一个有五个用户的设备上,其内容可能类似于示例 4-4(时间戳已省略)。
示例 4-4. /data/system/users/目录内容
# ls -lF /data/system/users
drwx------ system system 0➊
-rw------- system system 230 0.xml➋
drwx------ system system 10
-rw------- system system 245 10.xml
drwx------ system system 11
-rw------- system system 245 11.xml
drwx------ system system 12
-rw------- system system 245 12.xml
drwx------ system system 13
-rw------- system system 299 13.xml
-rw------- system system 212 userlist.xml➌
用户列表文件
如示例 4-4 所示,每个用户都有一个名为用户系统目录的专用目录,目录名与分配的用户 ID 匹配(➊为主用户),并且有一个存储用户元数据的 XML 文件,文件名同样基于用户 ID(➋为主用户)。userlists.xml文件➌保存了关于系统上所有用户的数据,系统上有五个用户时,它可能类似于示例 4-5。
示例 4-5. userlist.xml 文件内容
<users nextSerialNumber="19" version="4">
<user id="0" />
<user id="10" />
<user id="11" />
<user id="12" />
<user id="13" />
</users>
文件格式基本上是一个包含每个用户 ID 的<user>标签列表。根元素<users>具有一个version属性,指定当前文件版本,还有一个nextSerialNumber属性,表示下一个用户将分配的序列号。主用户的 ID 总是分配为 0。
用户分配给应用程序的 UID 是基于拥有用户的用户 ID,这确保在单用户设备上,分配给应用程序的 UID 与引入多用户支持之前相同。(有关应用程序 UID 的更多信息,请参见 “应用程序数据目录”。)次要用户和限制型配置文件的 ID 从数字 10 开始。
用户元数据文件
每个用户的属性都存储在专用的 XML 文件中。示例 4-6 展示了一个限制型配置文件的示例。
示例 4-6. 用户元数据文件内容
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<user id="13"
serialNumber="18"
flags="24"
created="1394551856450"
lastLoggedIn="1394551882324"
icon="/data/system/users/13/photo.png">➊
<name>Profile1</name>➋
<restrictions no_modify_accounts="true" no_share_location="true" />➌
</user>
在这里,<name> 标签 ➋ 保存用户的名称,<restrictions> 标签 ➌ 含有每个启用的限制条件的属性。(有关内置限制的列表,请参见 “限制型配置文件”。)表 4-1 总结了在 示例 4-6 中显示的根 <user> 元素的属性,见 ➊。
表 4-1. <user> 元素属性
| 名称 | 格式 | 描述 |
|---|---|---|
id |
整数 | 用户 ID |
serialNumber |
整数 | 用户序列号 |
flags |
整数 | 表示用户类型的标志 |
created |
自 Unix 纪元以来的毫秒数,参照 System.currentTimeMillis() |
用户创建时间 |
lastLoggedIn |
自 Unix 纪元以来的毫秒数,参照 System.currentTimeMillis() |
上次登录时间 |
icon |
字符串 | 用户图标文件的完整路径 |
partial |
布尔值 | 表示用户为部分初始化。部分用户可能还没有创建所有文件和目录。 |
pinHash |
十六进制字符串 | 用于 PIN 保护限制条件的盐值 SHA1+MD5 PIN 哈希 |
salt |
长整型 | 用于 PIN 保护限制条件的 PIN 盐值 |
failedAttempts |
整数 | PIN 保护的限制条件下失败的 PIN 输入尝试次数 |
lastAttemptMs |
自 Unix 纪元以来的毫秒数,参照 System.currentTimeMillis() |
上次尝试输入 PIN 的时间(单位为自 Unix 纪元以来的毫秒数,参照 System.currentTimeMillis()) |
flags 属性是最重要的属性之一,因为它决定了用户的类型。截止目前,标志值的六个位被用于表示用户类型,其余部分是保留的,目前已定义以下标志:
-
FLAG_PRIMARY(0x00000001) 标记主用户。 -
FLAG_ADMIN(0x00000002) 标记管理员用户。管理员可以创建和删除用户。 -
FLAG_GUEST(0x00000004) 标记访客用户。 -
FLAG_RESTRICTED(0x00000008) 标记为受限用户。 -
FLAG_INITIALIZED(0x00000010) 标记用户为完全初始化。
虽然不同的标志组合是可能的,但大多数组合并不表示有效的用户类型或状态,实际中主拥有者的属性设置为 19(0x13 或FLAG_INITIALIZED|FLAG_ADMIN|FLAG_PRIMARY),辅助用户的标志为 16(0x10 或FLAG_INITIALIZED),限制配置文件的标志为 24(0x18 或FLAG_INITIALIZED|FLAG_RESTRICTED)。
用户系统目录
每个用户系统目录包含特定用户的系统设置和数据,但不包含应用数据。如我们在下一节中所看到的,每个用户安装的应用都会在/data目录下获得一个专用的数据目录,类似于单用户设备上的情况。(有关应用数据目录的更多信息,请参见第三章)。例如,对于用户 ID 为 12 的辅助用户,用户系统目录将命名为/data/system/users/12/,并可能包含示例 4-7 中列出的文件和目录。
示例 4-7. 用户目录的内容
- accounts.db➊
- accounts.db-journal
- appwidgets.xml➋
- device_policies.xml➌
- gesture.key➍
d inputmethod➎
- package-restrictions.xml➏
- password.key➐
- photo.png➑
- settings.db➒
- settings.db-journal
- wallpaper➓
- wallpaper_info.xml
文件accounts.db ➊是一个 SQLite 数据库,存储在线账户的详细信息。(我们在第八章中讨论在线账户管理。)文件appwidgets.xml ➋存储有关用户已添加到主屏幕的小部件的信息。device_policies.xml ➌文件描述了当前设备策略(有关详细信息,请参见第九章),而gesture.key ➍和password.key ➐分别包含当前选定的锁屏图案或 PIN 码/密码的哈希值(有关格式的详细信息,请参见第十章)。
inputmethod目录 ➎包含有关输入法的信息。photo.png文件 ➑存储用户的个人资料图片或照片。settings.db文件 ➒保存特定于该用户的系统设置,而wallpaper ➓是当前选定的壁纸图片。package-restrictions.xml文件 ➏定义了用户安装了哪些应用,并存储它们的状态。(我们将在下一节中讨论应用共享和每用户应用数据。)
每用户应用管理
如在“多用户支持概述”中提到的,除了专用账户和设置外,每个用户都会获得自己的一份应用数据副本,其他用户无法访问。Android 通过为每个应用分配一个新的、按用户划分的有效 UID,并为该 UID 创建一个专用的应用数据目录来实现这一点。我们将在接下来的章节中讨论这一实现的细节。
应用数据目录
正如我们在第三章中讲到的,Android 通过将 APK 包复制到/data/app/目录来安装应用程序,并在/data/data/下为每个应用程序创建一个专用的数据目录。当启用多用户支持时,这一布局不会改变,而是扩展以支持额外的用户。主用户的应用数据仍然存储在/data/data/中,以便向后兼容。
如果在安装新应用程序时系统上存在其他用户,PackageManagerService 会为每个用户创建应用数据目录。与主用户的数据目录一样,这些目录是通过 installd 守护进程(使用mkuserdata命令)创建的,因为 system 用户没有足够的权限来更改目录所有权。
用户数据目录存储在/data/user/中,并以用户的 ID 命名。设备所有者目录 (0/) 是一个符号链接,指向/data/data/,如示例 4-8 所示。
示例 4-8. 多用户设备上 /data/user/ 的内容
# ls -l /data/user/
lrwxrwxrwx root root 0 -> /data/data/
drwxrwx--x system system 10
drwxrwx--x system system 11
drwxrwx--x system system 12
drwxrwx--x system system 13
每个应用数据目录的内容与/data/data/相同,但每个用户实例的相同应用的目录由不同的 Linux 用户拥有,如示例 4-9 所示。
示例 4-9. 主用户和一个次要用户的应用数据目录内容
# ls -l /data/data/➊
drwxr-x--x u0_a12 u0_a12 com.android.apps.tag
drwxr-x--x u0_a0 u0_a0 com.android.backupconfirm
drwxr-x--x bluetooth bluetooth com.android.bluetooth
drwxr-x--x u0_a16 u0_a16 com.android.browser➋
drwxr-x--x u0_a17 u0_a17 com.android.calculator2
drwxr-x--x u0_a18 u0_a18 com.android.calendar
--*snip*--
# ls -l /data/user/13/➌
ls -l /data/user/13
drwxr-x--x u13_system u13_system android
drwxr-x--x u13_a12 u13_a12 com.android.apps.tag
drwxr-x--x u13_a0 u13_a0 com.android.backupconfirm
drwxr-x--x u13_bluetooth u13_bluetooth com.android.bluetooth
drwxr-x--x u13_a16 u13_a16 com.android.browser➍
drwxr-x--x u13_a17 u13_a17 com.android.calculator2
drwxr-x--x u13_a18 u13_a18 com.android.calendar
--*snip*--
此列表显示了主用户➊和用户 ID 为 13 的次要用户➌的应用数据目录的内容。如你所见,尽管两个用户都拥有相同应用的应用数据目录,例如浏览器应用(主用户为➋,次要用户为➍),这些目录分别属于不同的 Linux 用户:主用户为u0_a16,次要用户为u13_a16。如果我们使用su和id命令检查这些用户的 UID,会发现u0_a16的 UID 为 10016,而u13_a16的 UID 为 1310016。
两个 UID 都包含数字 10016 这一事实并非巧合。重复的部分称为 应用程序 ID,它与应用程序在单用户设备上首次安装时分配的 UID 相同。在多用户设备上,应用程序的 UID 是通过以下代码从用户 ID 和应用程序 ID 派生出来的:
uid = userId * 100000 + (appId % 100000)
因为拥有者的用户 ID 总是 0,所以设备拥有者的应用的 UID 总是与其应用 ID 相同。当同一个应用在不同用户的上下文中执行时,它会在分配给每个用户应用实例的相应 UID 下执行。例如,如果浏览器应用同时由设备拥有者和用户 ID 为 13 的次级用户执行,将会创建两个分别作为 u0_a16 和 u13_a16 的 Linux 用户进程(设备拥有者的 UID 为 10016 ➊,次级用户的 UID 为 1310016 ➋),如 示例 4-10 所示。
示例 4-10. 不同设备用户执行浏览器应用时的进程信息
USER PID PPID VSIZE RSS WCHAN PC NAME
--*snip*--
u13_a16 1149 180 1020680 72928 ffffffff 4006a58c R com.android.browser➊
--*snip*--
u0_a16 30500 180 1022796 73384 ffffffff 4006b73c S com.android.browser➋
--*snip*--
应用共享
虽然已安装的应用程序为每个用户提供了专用的数据目录,但 APK 文件在所有用户之间共享。APK 文件被复制到 /data/app/ 并且对所有用户可读;应用程序使用的共享库被复制到 /data/app-lib/
Android 通过在每个用户的系统目录中创建一个 package-restrictions.xml 文件(见 示例 4-7 ➏),使得用户可以拥有不同的应用程序,Android 使用该文件跟踪某个应用是否启用。除了包的安装状态外,该文件还包含有关每个应用禁用的组件的信息,以及在处理可以由多个应用处理的意图时,启动的首选应用列表(例如打开文本文件)。package-restrictions.xml 文件的内容可能如下所示 示例 4-11,针对次级用户的情况。
示例 4-11. package-restrictions.xml 文件的内容
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<package-restrictions>
<pkg name="com.example.app" inst="false" stopped="true" nl="true" />➊
<pkg name="com.example.app2" stopped="true" nl="true" />➋
--*snip*--
<pkg name="com.android.settings">
<disabled-components>
<item name="com.android.settings.CryptKeeper" />
</disabled-components>
</pkg>
<preferred-activities />
</package-restrictions>
在这里,com.example.app 包在系统上可用,但没有为该次用户安装,如通过添加 <pkg> 来表示该应用并将 inst 属性设置为 false ➊。根据此信息,PackageManagerService 将 com.example.app 包标记为该用户未安装,因此该包不会出现在启动器或设置中的应用列表中。
应用程序可以被安装,但仍然标记为停止,如➋所示。在这里,com.example.app2包被安装,但通过将stopped属性设置为true,该应用被标记为停止。Android 有一个特别的状态,针对那些从未启动过的应用;该状态通过<pkg>标签中的nl属性来保存。设备所有者可以阻止某个用户使用某个包,在这种情况下,blocked属性会被设置为true,但这不会在图 4-4 中显示。
当设备用户安装应用时,inst="false"标签会被添加到所有用户的package-restrictions.xml文件中。当其他用户安装相同的应用时,inst属性会被移除,应用被视为已为该用户安装。(根据第二个用户启动安装过程的方式,/data/app/中的 APK 文件可能会被替换,类似于应用更新的情况。)
受限用户无法安装应用,但当设备所有者为受限用户启用应用时,仍然会按照相同的程序进行:应用通过调用PackageManagerService.installExistingPackageAsUser()方法安装,该方法为包设置安装标志,并相应地更新package-restrictions.xml文件。

图 4-4. 设备所有者尝试为所有用户卸载应用时显示的警告
当用户卸载一个应用包时,他们的应用数据会被删除,且每个用户的包安装标志会被设置为false。这个状态会通过在用户的package-restrictions.xml文件中为已移除包的标签设置inst="false"来保存。APK 文件和本地库目录仅在最后一个安装了该应用的用户卸载应用时才会被移除。然而,设备所有者可以在“应用设置”界面的“所有”标签页中查看系统上安装的所有应用,包括他们没有安装的应用,他们可以为所有用户卸载这些应用。为所有用户卸载操作会隐藏在溢出菜单中,以避免被意外选中。该操作会弹出警告,警告内容见图 4-4。如果设备所有者在警告对话框中点击确定,所有用户的应用目录将被删除,APK 文件也将从设备中删除。
在多用户 Android 设备上实现的应用共享方案向后兼容以前的版本,并通过不为所有用户复制 APK 文件来节省设备空间。然而,它也有一个主要的缺点:任何用户都可以更新应用,即使它最初是由另一个用户安装的。
这个方案通常没有问题,因为每个用户的应用实例都有一个单独的数据目录,除非更新添加了新的权限。由于 Android 在安装时授予权限,如果用户更新了一个应用并接受了一个影响用户隐私的新权限(例如,READ_CONTACTS),那么这个权限将适用于所有使用该应用的用户。其他用户不会被通知该应用已经被授予新权限,并且可能永远不会注意到这一变化,除非他们手动检查系统设置中的应用详细信息。Android 确实会在首次启用多用户支持时显示警告,提醒用户这一事实,但不会针对特定应用发送后续通知。
外部存储
Android 从最早的公开版本开始就支持外部存储。因为最初几代 Android 设备通过简单地挂载一个 FAT 格式的可移动 SD 卡来实现外部存储,所以外部存储常常被称为“SD 卡”。然而,外部存储的定义更为广泛,只要求外部存储是一个“区分大小写的文件系统,具有不可变的 POSIX 权限类和模式。”^([30]) 底层实现可以是任何满足这个定义的东西。
外部存储实现
更新的设备倾向于通过仿真来实现外部存储,有些设备甚至根本没有 SD 卡槽。例如,最后一款带有 SD 卡槽的 Google Nexus 设备是 2010 年 1 月发布的 Nexus One,而所有 Nexus 设备(包括在 Nexus S 后发布的设备,这些设备使用专用分区来处理外部存储)都通过仿真来实现外部存储。在没有 SD 卡槽的设备上,外部存储通常是通过直接挂载一个 FAT 格式的分区,该分区与主存储设备位于同一个块设备上,或者使用一个辅助守护进程来进行仿真。
从 Android 4.4 版本开始,应用程序可以管理其特定于包的目录(例如,com.example.app 包的应用程序位于 Android/data/com.example.app/)在外部存储上,而不需要 WRITE_EXTERNAL_STORAGE 权限,该权限允许访问外部存储上的所有数据,包括相机照片、视频和其他媒体。这个功能叫做 合成权限,其 AOSP 实现基于一个 FUSE 守护进程,它将原始设备存储封装起来,并根据指定的权限仿真模式管理文件访问和权限。
注意
用户空间文件系统*,或称 FUSE,^([31]) *是 Linux 的一项功能,允许在用户空间程序中实现一个完全功能的文件系统。这是通过使用一个通用的 FUSE 内核模块来实现的,该模块将所有针对目标文件系统的虚拟文件系统(VFS)系统调用路由到其用户空间实现。内核模块和用户空间实现通过打开 * /dev/fuse 获取的特殊文件描述符进行通信。
从 Android 版本 4.4 开始,应用程序可以访问多个外部存储设备,但仅允许它们在 主要外部存储 上写入任意文件(前提是它们持有 WRITE_EXTERNAL_STORAGE 权限),并且只能有限地访问其他外部存储设备,这些设备被称为 次要外部存储。我们的讨论将重点关注主要外部存储,因为它与多用户支持关系最为密切。
多用户外部存储
为了在多用户环境中维护 Android 的安全模型,Android 兼容性定义文档(CDD)对外部存储提出了诸多要求。其中最重要的一条是:“每个 Android 设备上的用户实例必须拥有独立且隔离的外部存储目录。”^([32])
不幸的是,实现这一要求存在问题,因为外部存储传统上是全局可读的,并且使用 FAT 文件系统实现,而 FAT 文件系统不支持权限。Google 对多用户外部存储的实现利用了三个 Linux 内核特性,以提供向后兼容的、按用户划分的外部存储:挂载命名空间、绑定挂载和共享子树。
高级 Linux 挂载功能
如同其他 Unix 系统一样,Linux 将来自所有存储设备的所有文件作为单一目录树的一部分进行管理。每个文件系统通过在指定目录(称为 挂载点)处挂载来链接到特定的子树。传统上,目录树是所有进程共享的,每个进程看到的是相同的目录层次结构。
Linux 2.4.19 及更高版本添加了对每个进程挂载命名空间的支持,这使得每个进程可以拥有自己的一组挂载点,从而使用与其他进程不同的目录层次结构。^([33]) 当前每个进程的挂载列表可以从 /proc/PID/mounts 虚拟文件中读取,其中 PID 是进程 ID。一个被 fork 的 Linux 进程可以通过在 Linux 特有的 clone()^([34]) 和 unshare()^([35]) 系统调用中指定 CLONE_NEWNS 标志来请求一个单独的挂载命名空间。在这种情况下,父进程的命名空间被称为 父命名空间。
绑定挂载 允许一个目录或文件在目录树中的另一路径上挂载,从而使同一个文件或目录在多个位置可见。绑定挂载通过在 mount() 系统调用中指定 MS_BIND 标志,或者通过向 mount 命令传递 --bind 参数来创建。
最后,共享子树,^([36])首次在 Linux 2.6.15 中引入,提供了一种控制文件系统挂载在挂载命名空间中传播的方式。共享子树使得进程能够拥有自己的命名空间,但仍能访问在启动后挂载的文件系统。共享子树提供了四种不同的挂载类型,其中 Android 使用了共享挂载和从属挂载。共享挂载 在父命名空间中创建后会传播到所有子命名空间,因此对所有已克隆命名空间的进程都是可见的。从属挂载 有一个共享挂载作为主挂载,并且也会传播新的挂载。然而,传播是单向的:主挂载的挂载会传播到从属挂载,但从属挂载的挂载不会传播到主挂载。此方案允许进程将其挂载保持对其他任何进程不可见,同时仍然能够看到共享的系统挂载。通过向 mount() 系统调用传递 MS_SHARED 标志来创建共享挂载,而创建从属挂载则需要传递 MS_SLAVE 标志。
Android 实现
自 Android 4.4 起,直接挂载外部存储不再被支持,而是通过 FUSE sdcard 守护进程进行模拟,即使底层设备是物理 SD 卡。我们将基于一个由内部存储目录支持的配置进行讨论,这对于没有物理 SD 卡的设备是典型配置。(官方文档^([37])包含了有关其他可能配置的更多细节。)
在一个主外部存储由内部存储支持的设备上,sdcard FUSE 守护进程使用 /data/media/ 目录作为源,并在 /mnt/shell/emulated 创建一个模拟文件系统。示例 4-12 显示了在这种情况下,如何在设备特定的 init.rc 文件中声明 sdcard 服务 ➐。
示例 4-12. 为模拟外部存储声明 sdcard 服务
--*snip*--
on init
mkdir /mnt/shell/emulated 0700 shell shell➊
mkdir /storage/emulated 0555 root root➋
export EXTERNAL_STORAGE /storage/emulated/legacy➌
export EMULATED_STORAGE_SOURCE /mnt/shell/emulated➍
export EMULATED_STORAGE_TARGET /storage/emulated➎
# Support legacy paths
symlink /storage/emulated/legacy /sdcard➏
symlink /storage/emulated/legacy /mnt/sdcard
symlink /storage/emulated/legacy /storage/sdcard0
symlink /mnt/shell/emulated/0 /storage/emulated/legacy
# virtual sdcard daemon running as media_rw (1023)
service sdcard /system/bin/sdcard -u 1023 -g 1023 -l /data/media /mnt/shell/emulated➐
class late_start
--*snip*--
在这里,-u 和 -g 选项指定了守护进程应该以哪个用户和组身份运行,-l 指定了用于模拟存储的布局(将在本节稍后讨论)。如 ➊ 所示,/mnt/shell/emulated/ 目录(通过 EMULATED_STORAGE_SOURCE 环境变量 ➍ 可用)由 shell 用户拥有并且仅该用户可访问。其内容在拥有五个用户的设备上可能类似于 示例 4-13。
示例 4-13. /mnt/shell/emulated/ 的内容
# ls -l /mnt/shell/emulated/
drwxrwx--x root sdcard_r 0
drwxrwx--x root sdcard_r 10
drwxrwx--x root sdcard_r 11
drwxrwx--x root sdcard_r 12
drwxrwx--x root sdcard_r 13
drwxrwx--x root sdcard_r legacy
drwxrwx--x root sdcard_r obb
与应用数据目录一样,每个用户都有一个专用的外部存储数据目录,该目录以用户的 ID 命名。Android 使用挂载命名空间和绑定挂载的组合,使每个用户的外部存储数据目录仅对用户启动的应用程序可用,而不会显示其他用户的数据目录。由于所有应用程序都是从 zygote 进程分叉出来的(详见 第二章),外部存储设置分为两步:第一步是所有进程共有的,第二步是每个进程特有的。首先,在唯一的 zygote 进程中设置所有分叉的应用进程共享的挂载点。然后,设置每个应用进程专用的挂载点,这些挂载点仅对该进程可见。
首先让我们看看 zygote 进程中的共享部分。示例 4-14 显示了 initZygote() 函数的摘录(该函数位于 dalvik/vm/Init.cpp 中),重点展示了挂载点设置。
示例 4-14. zygote 中的挂载点设置
static bool initZygote()
{
setpgid(0,0);
if (unshare(CLONE_NEWNS) == -1) {➊
return -1;
}
// Mark rootfs as being a slave so that changes from default
// namespace only flow into our children.
if (mount("rootfs", "/", NULL, (MS_SLAVE | MS_REC), NULL) == -1) {➋
return -1;
}
const char* target_base = getenv("EMULATED_STORAGE_TARGET");
if (target_base != NULL) {
if (mount("tmpfs", target_base, "tmpfs", MS_NOSUID | MS_NODEV,➌
"uid=0,gid=1028,mode=0751") == -1) {
return -1;
}
}
--*snip*--
return true;
}
在这里,zygote 将 CLONE_NEWNS 标志传递给 unshare() 系统调用 ➊,以创建一个新的私有挂载命名空间,并由其所有子进程(应用进程)共享。接着,它通过将 MS_SLAVE 标志传递给 mount() 系统调用 ➋ 将根文件系统(挂载在 / 下)标记为从属。这确保了来自默认挂载命名空间的更改(如挂载加密容器或可移动存储)仅传播到其子进程,同时确保任何子进程创建的挂载不会传播到默认命名空间。最后,zygote 通过创建一个 tmpfs 文件系统 ➌ 创建了一个基于内存的 EMULATED_STORAGE_TARGET(通常是 /storage/emulated/)挂载点,子进程使用该挂载点将外部存储绑定挂载到它们的私有命名空间中。
示例 4-15 显示了在 dalvik/vm/native/dalvik_system_Zygote.cpp 中找到的特定进程挂载点设置,该设置在从 zygote 分叉每个应用进程时执行。(错误处理、日志记录和某些变量声明已被省略。)
示例 4-15. 应用进程的外部存储设置
static int mountEmulatedStorage(uid_t uid, u4 mountMode) {
userid_t userid = multiuser_get_user_id(uid);➊
// Create a second private mount namespace for our process
if (unshare(CLONE_NEWNS) == -1) {➋
return -1;
}
// Create bind mounts to expose external storage
if (mountMode == MOUNT_EXTERNAL_MULTIUSER
|| mountMode == MOUNT_EXTERNAL_MULTIUSER_ALL) {
// These paths must already be created by init.rc
const char* source = getenv("EMULATED_STORAGE_SOURCE");➌
const char* target = getenv("EMULATED_STORAGE_TARGET");➍
const char* legacy = getenv("EXTERNAL_STORAGE");➎
if (source == NULL || target == NULL || legacy == NULL) {
return -1;
}
--*snip*--
// /mnt/shell/emulated/0
snprintf(source_user, PATH_MAX, "%s/%d", source, userid);➏
// /storage/emulated/0
snprintf(target_user, PATH_MAX, "%s/%d", target, userid);➐
--*snip*--
if (mountMode == MOUNT_EXTERNAL_MULTIUSER_ALL) {
// Mount entire external storage tree for all users
if (mount(source, target, NULL, MS_BIND, NULL) == -1) {
return -1;
}
} else {
// Only mount user-specific external storage
if (mount(source_user, target_user, NULL, MS_BIND, NULL) == -1) {➑
return -1;
}
}
--*snip*--
// Finally, mount user-specific path into place for legacy users
if (mount(target_user, legacy, NULL, MS_BIND | MS_REC, NULL) == -1) {➒
return -1;
}
} else {
return -1;
}
return 0;
}
在这里,mountEmulatedStorage() 函数首先通过进程 UID 获取当前用户 ID ➊,然后使用 unshare() 系统调用通过传递 CLONE_NEWNS 标志为进程创建一个新的挂载命名空间 ➋。然后该函数获取环境变量 EMULATED_STORAGE_SOURCE ➌、EMULATED_STORAGE_TARGET ➍ 和 EXTERNAL_STORAGE ➎ 的值,这些环境变量都在设备特定的 init.rc 文件中初始化(请参见 示例 4-12 中的 ➌、➍ 和 ➎)。接下来,它根据 EMULATED_STORAGE_SOURCE、EMULATED_STORAGE_TARGET 和当前用户 ID 的值准备挂载源 ➏ 和目标 ➐ 目录路径。
如果目录不存在,它们会被创建,然后该方法会将源目录(例如 /mnt/shell/emulated/0 为拥有者用户)绑定挂载到目标路径(例如 /storage/emulated/0 为拥有者用户)➑。这样可以确保外部存储能够通过 Android shell(通过 adb shell 命令启动)访问,该功能在应用开发和调试中广泛使用。
最后一步是将目标目录递归地绑定挂载到固定的遗留目录(/storage/emulated/legacy/)➒。遗留目录在设备特定的 init.rc 文件中通过符号链接指向 /sdcard/(➏ 在 示例 4-12),以便与硬编码此路径的应用保持向后兼容(通常通过 android.os.Environment.getExternalStorageDirectory() API 获取此路径)。
执行完所有步骤后,新创建的应用进程只会看到分配给启动它的用户的外部存储。我们可以通过查看由不同用户启动的两个应用进程的挂载列表来验证这一点,具体内容如 示例 4-16 所示。
示例 4-16. 不同用户启动的进程的挂载点列表
# cat /proc/7382/mounts
--*snip*--
/dev/fuse /mnt/shell/emulated fuse rw,nosuid,nodev,relatime,user_id=1023,
group_id=1023,default_permissions,allow_other 0 0➊
/dev/fuse /storage/emulated/0 fuse rw,nosuid,nodev,relatime,user_id=1023,
group_id=1023,default_permissions,allow_other 0 0➋
/dev/fuse /storage/emulated/legacy fuse rw,nosuid,nodev,relatime,user_id=1023,
group_id=1023,default_permissions,allow_other 0 0➌
# cat /proc/7538/mounts
--*snip*--
/dev/fuse /mnt/shell/emulated fuse rw,nosuid,nodev,relatime,user_id=1023,
group_id=1023,default_permissions,allow_other 0 0➍
/dev/fuse /storage/emulated/10 fuse rw,nosuid,nodev,relatime,user_id=1023,
group_id=1023,default_permissions,allow_other 0 0➎
/dev/fuse /storage/emulated/legacy fuse rw,nosuid,nodev,relatime,user_id=1023,
group_id=1023,default_permissions,allow_other 0 0➏
在这里,由拥有者用户启动的进程(PID 为 7382)有一个 /storage/emulated/0 挂载点 ➋,这是 /mnt/shell/emulated/0/ 的绑定挂载,而由次要用户启动的进程(PID 为 7538)有一个 /storage/emulated/10 挂载点 ➎,这是 /mnt/shell/emulated/10/ 的绑定挂载。
由于两个进程没有对方外部存储目录的挂载点,每个进程只能看到并修改自己的文件。两个进程都有一个/storage/emulated/legacy挂载点(➌和➏),但由于它们绑定到不同的目录(分别是/storage/emulated/0/ 和 /mnt/shell/emulated/10/),每个进程看到的内容不同。两个进程都可以看到/mnt/shell/emulated/(➊和➍),但因为该目录仅对shell用户可访问(权限 0700),应用程序进程无法看到其内容。
外部存储权限
为了模拟最初用于外部存储的 FAT 文件系统,sdcard FUSE 守护进程为外部存储上的每个文件或目录分配固定的所有者、组和访问权限。此外,权限不可更改,且不支持符号链接和硬链接。分配的所有者和权限由sdcard守护进程使用的权限推导模式决定。
在遗留模式下(通过-l选项指定),该模式向后兼容先前的 Android 版本,并且仍然是 Android 4.4 中的默认模式,大多数文件和目录的所有者是 root 用户,并且它们的组被设置为sdcard_r。授予 READ_EXTERNAL_STORAGE 权限的应用程序具有sdcard_r作为其附加组,因此即使文件最初由其他应用程序创建,它们也可以读取外部存储上的大多数文件。示例 4-17 显示了外部存储根目录中文件和目录的所有者和权限。
示例 4-17. 外部存储中文件的所有者和权限
# ls -l /sdcard/
drwxrwx--- root sdcard_r Alarms
drwxrwx--x root sdcard_r Android
drwxrwx--- root sdcard_r DCIM
--*snip*--
-rw-rw---- root sdcard_r 5 text.txt
在 Android 的早期版本中,外部存储上的所有文件和目录都分配了相同的所有者和权限,但 Android 4.4 对应用程序特定的外部文件目录(Android/data/Context.getExternalFilesDir()方法返回)进行了不同的处理。应用程序不必持有 WRITE_EXTERNAL_STORAGE 权限,就可以在该目录中读写文件,因为该目录的所有者是创建该文件的应用程序。
尽管如此,即使在 Android 4.4 中,任何持有 READ_EXTERNAL_STORAGE 或 WRITE_EXTERNAL_STORAGE 权限的应用程序仍可以访问应用程序的外部文件目录,因为该目录的组设置为sdcard_r,如示例 4-18 所示。
示例 4-18. 应用程序外部文件目录的所有者和权限
$ **ls -l Android/data/**
drwxrwx--- u10_a16 sdcard_r com.android.browser
Android 4.4 支持一种更灵活的权限派生模式,该模式基于目录结构,并通过传递 -d 选项给 sdcard 守护进程来指定。此派生模式将专用组分配给 Pictures/ 和 Music/ 目录(如示例 4-19 中所示,分别为 sdcard_pics ➊ 和 sdcard_av ➋),从而实现对应用程序可以访问的文件的精细控制。根据本文撰写时的情况,Android 尚不支持这种精细的访问控制,但可以通过定义额外的权限来实现,这些权限映射到 sdcard_pics 和 sdcard_av 组。在基于目录结构的权限模式中,用户目录托管在 Android/user/ ➌ 下。
注意
尽管 Android 4.4 支持这种新的权限派生模式,但在撰写本文时,Nexus 设备仍使用传统的权限模式。
示例 4-19. 新权限派生模式下的目录所有者和权限
rwxrwx--x root:sdcard_rw /
rwxrwx--- root:sdcard_pics /Pictures➊
rwxrwx--- root:sdcard_av /Music➋
rwxrwx--x root:sdcard_rw /Android
rwxrwx--x root:sdcard_rw /Android/data
rwxrwx--- u0_a12:sdcard_rw /Android/data/com.example.app
rwxrwx--x root:sdcard_rw /Android/obb/
rwxrwx--- u0_a12:sdcard_rw /Android/obb/com.example.app
rwxrwx--- root:sdcard_all /Android/user➌
rwxrwx--x root:sdcard_rw /Android/user/10
rwxrwx--- u10_a12:sdcard_rw /Android/user/10/Android/data/com.example.app
其他多用户功能
除了专用的应用目录、外部存储和设置,其他 Android 功能也支持多用户设备配置。例如,从 4.4 版本开始,Android 的凭证存储(用于安全管理加密密钥)允许每个用户拥有自己的密钥存储。(我们在第七章中将详细讨论凭证存储。)
此外,Android 的在线账户数据库可以通过 AccountManager API 访问,现已扩展,允许次要用户拥有自己的账户,并且允许限制性配置文件共享部分主用户的账户(如果需要账户访问的应用支持该功能)。我们在第八章中讨论了在线账户支持和 AccountManager API。
最后,Android 允许为每个用户设置不同的设备管理策略。从 4.4 版本开始,它还支持为每个用户设置 VPN,仅路由单个用户的流量,并且其他用户无法访问这些 VPN。(我们在第九章中讨论了设备管理、VPN 和其他企业功能。)
总结
Android 允许多个用户共享设备,通过为每个用户提供专用的内部和外部存储。多用户支持遵循既定的安全模型,每个用户的应用程序被分配一个唯一的 UID,并在专用进程中运行,不能访问其他用户的数据。用户隔离是通过结合考虑用户 ID 的 UID 分配方案和允许每个用户只查看自己存储的存储挂载规则来实现的。
截至本文写作时,多用户支持仅在不支持电话功能的设备(通常是平板电脑)上可用,因为在多用户环境中,电话功能的行为目前尚未定义。大多数 Android 功能,包括账户数据库管理、凭证存储、设备策略和 VPN 支持,都具有多用户意识,允许每个用户拥有自己的配置。
^([29]) Google, Android 4.4 兼容性定义, “9.5. 多用户支持,” static.googleusercontent.com/media/source.android.com/en//compatibility/4.4/android-4.4-cdd.pdf
^([30]) Google, “外部存储技术信息,” source.android.com/devices/tech/storage/index.html
^([31]) “用户空间中的文件系统,” fuse.sourceforge.net/
^([32]) Google, Android 4.4 兼容性定义, “9.5. 多用户支持,” static.googleusercontent.com/media/source.android.com/en//compatibility/4.4/android-4.4-cdd.pdf
^([33]) Michael Kerrisk, 《Linux 编程接口:Linux 和 UNIX 系统编程手册》, No Starch Press, 2010, 第 261 页
^([34]) 同上,第 598 页
^([35]) 同上,第 603 页
^([36]) Linux 内核, 共享子树, www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt
^([37]) Google, “外部存储:典型配置示例,” source.android.com/devices/tech/storage/config-example.html
第五章. 加密提供者
本章介绍了 Android 的加密提供者架构,并讨论了内置的提供者及其支持的算法。由于 Android 构建于Java 加密架构(JCA)之上,我们简要介绍了其设计,从加密服务提供者(CSP)框架开始。接着,我们讨论了主要的 JCA 类和接口,以及它们实现的加密原语。(我们将简要介绍每个加密原语,但详细讨论超出了本书的范围,假设读者对基本的加密学有一定了解。)然后,我们介绍了 Android 的 JCA 提供者和加密库,以及每个提供者支持的算法。最后,我们展示了如何通过安装自定义的 JCA 提供者来使用额外的加密算法。
JCA 提供者架构
JCA 提供了一个可扩展的加密提供者框架和一组 API,涵盖了当今使用的主要加密原语(块密码、消息摘要、数字签名等)。该架构旨在实现独立且可扩展。仅使用标准 JCA API 的应用程序只需要指定它们想要使用的加密算法,并且(在大多数情况下)不依赖于特定的提供者实现。通过简单地注册一个实现所需算法的附加提供者,可以为新的加密算法添加支持。此外,不同提供者提供的加密服务通常是互操作的(当密钥受到硬件保护或密钥材料不可直接访问时,会有某些限制),并且应用程序可以根据需要自由混合和匹配来自不同提供者的服务。让我们更详细地了解 JCA 的架构。
加密服务提供者
JCA 将加密功能分为多个抽象的加密服务,称为引擎,并为每项服务定义了相应的 API,形式为引擎类。例如,数字签名由 Signature 引擎类表示,加密则通过 Cipher 类建模。(你将在下一节找到完整的引擎类列表。)
在 JCA 的上下文中,加密服务提供者(CSP,简称提供者)是一个包(或一组包),提供某些加密服务的具体实现。每个提供者都会发布它实现的服务和算法,使得 JCA 框架能够维护一个支持的算法及其实现提供者的注册表。这个注册表会保持提供者的优先顺序,因此,如果某个算法被多个提供者提供,系统会返回优先顺序更高的那个提供者给请求的应用程序。这个规则有一个例外,那就是对于支持延迟提供者选择(Cipher、KeyAgreement、Mac 和 Signature)的引擎类。在延迟提供者选择的情况下,提供者不是在引擎类实例化时选择,而是在引擎类为特定加密操作初始化时选择。初始化需要一个Key实例,系统使用该实例来找到能够接受指定Key对象的提供者。延迟提供者选择在使用硬件存储的密钥时特别有用,因为系统仅凭算法名称无法找到硬件支持的提供者。然而,传递给初始化方法的具体Key实例通常包含足够的信息来确定底层的提供者。
注意
当前的 Android 版本不支持延迟提供者选择,但在主分支中正在进行相关工作,未来版本可能会支持延迟提供者选择。
我们来看一个使用提供者配置的例子,如图 5-1 所示。

图 5-1. JCA 算法实现选择(当未指定提供者时)
如果应用程序请求 SHA-256 摘要算法的实现而没有指定提供者(如示例 5-1 所示),则提供者框架返回在ProviderB中找到的实现(图 5-1 中列表中的第 2 项),而不是在ProviderC中找到的实现,后者也支持 SHA-256,但在图 5-1 中的列表中排在第 3 位。
示例 5-1. 请求未指定提供者的 SHA-256 实现
MessageDigest md = MessageDigest.getInstance("SHA-256");
另一方面,如果应用程序明确请求 ProviderC(如示例 5-2)所示,即使 ProviderB 的优先级较高,它的实现仍会被返回。
示例 5-2. 从特定提供者请求 SHA-256 实现
MessageDigest md = MessageDigest.getInstance("SHA-256", "ProviderC");
通常,应用程序不应显式请求提供者,除非它们将所请求的提供者作为应用程序的一部分,或能够在首选提供者不可用时处理回退。
提供者实现
JCA 框架通过要求所有特定加密服务或算法的实现符合一个公共接口,保证了实现的独立性。对于每个表示特定加密服务的引擎类,框架定义了一个对应的抽象 服务提供者接口(SPI) 类。提供特定加密服务的提供者实现并公布相应的 SPI 类。例如,提供实现某个加密算法的提供者,将有一个对应于 Cipher 引擎类的 CipherSpi 类实现。当应用程序调用 Cipher.getInstance() 工厂方法时,JCA 框架通过使用 “加密服务提供者” 中概述的过程,找到合适的提供者,并返回一个 Cipher 实例,该实例将所有方法调用路由到所选提供者中实现的 CipherSpi 子类。
除了 SPI 实现类外,每个提供者还有一个 java.security.Provider 类的子类,该子类定义了提供者的名称和版本,更重要的是,定义了支持的算法列表以及匹配的 SPI 实现类。JCA 提供者框架使用这个 Provider 类来构建提供者注册表,并在搜索算法实现时查询它,以返回给客户端。
静态提供者注册
为了使提供者对 JCA 框架可见,它必须首先注册。注册提供者有两种方式:静态注册和动态注册。静态注册需要编辑系统安全属性文件并为提供者添加条目。(在 Android 上,这个属性文件叫做 security.properties,并仅存在于 core.jar 系统库内。因此,它无法编辑,并且不支持静态提供者注册。我们这里只是为了完整性提到它。)
安全属性文件中的提供者条目格式如示例 5-3 所示。
示例 5-3. JCA 提供者的静态注册
security.provider.n=ProviderClassName
这里,n是提供者的优先顺序,用于在搜索请求的算法时(当未指定提供者名称时)。顺序是基于 1 的;即,1 为最优先,接下来是 2,依此类推。ProviderClassName是java.security.Provider类实现的名称,如“提供者实现”所述。
动态提供者注册
提供者通过java.security.Security类的addProvider()和insertProviderAt()方法动态注册(在运行时)。这些方法返回提供者添加的实际位置,或者如果提供者未被添加(因为它已安装),则返回-1。还可以通过调用removeProvider()方法动态移除提供者。
Security类管理安全Provider列表,并有效地充当前面章节描述的提供者注册表。在 Java SE 中,程序需要特殊权限才能注册提供者并修改提供者注册表,因为通过将新提供者插入到提供者列表的顶部,它们可以有效地替换系统安全实现。在 Android 中,提供者注册表的修改仅限于当前应用进程,不能影响系统或其他应用程序。因此,注册 JCA 提供者无需特殊权限。
对提供者注册表的动态修改通常放置在静态代码块中,以确保它们在任何应用程序代码之前执行。示例 5-4 展示了一个将默认(优先级最高)提供者替换为自定义提供者的示例。
示例 5-4. 动态插入自定义 JCA 提供者
static {
Security.insertProviderAt(new MyProvider(), 1);
}
注意
如果类被加载多次(例如,由不同的类加载器加载),静态代码块可能会多次执行。可以通过检查提供者是否已可用或使用仅加载一次的持有类来解决此问题。
JCA 引擎类
引擎类提供特定类型加密服务的接口。JCA 引擎提供以下服务之一:
-
加密操作(加密/解密、签名/验证、哈希等)
-
生成或转换加密材料(密钥和算法参数)
-
加密对象的管理和存储,如密钥和数字证书
获取引擎类实例
除了提供统一的加密操作接口外,engine 类还将客户端代码与底层实现解耦,这就是为什么它们不能直接实例化的原因;相反,它们提供了一个静态工厂方法getInstance(),使你可以间接请求一个实现。getInstance()方法通常具有示例 5-5 中显示的签名之一。
示例 5-5. JCA engine 类工厂方法签名
static EngineClassName getInstance(String algorithm)➊
throws NoSuchAlgorithmException
static EngineClassName getInstance(String algorithm, String provider)➋
throws NoSuchAlgorithmException, NoSuchProviderException
static EngineClassName getInstance(String algorithm, Provider provider)➌
throws NoSuchAlgorithmException
通常,你会使用➊处的签名并仅指定算法名称。➋和➌处的签名允许你从特定提供者请求实现。如果请求的算法没有可用的实现,所有变体都会抛出NoSuchAlgorithmException,而➋会抛出NoSuchProviderException,如果指定名称的提供者未注册。
算法名称
所有工厂方法所接受的algorithm参数映射到特定的加密算法或变换,或指定用于管理证书或密钥集合的更高层对象的实现策略。通常,映射是直接的。例如,SHA-256映射到 SHA-256 哈希算法的实现,AES请求 AES 加密算法的实现。然而,一些算法名称具有结构,指定请求的实现的多个参数。例如,SHA256withRSA指定一个签名实现,使用 SHA-256 对签名的消息进行哈希处理,使用 RSA 执行签名操作。算法还可以有别名,多个算法名称可以映射到相同的实现。
算法名称不区分大小写。每个 JCA engine 类支持的标准算法名称在JCA 标准算法名称文档中定义(有时简称为标准名称)。^([38]) 除了这些,提供者还可以定义自己的算法名称和别名。(有关详细信息,请参见每个提供者的文档。)你可以使用示例 5-6 中的代码列出所有提供者、每个提供者提供的加密服务的算法名称,以及它们映射到的实现类。
示例 5-6. 列出所有 JCA 提供者及其支持的算法
Provider[] providers = Security.getProviders();
for (Provider p : providers) {
System.out.printf("%s/%s/%f\n", p.getName(), p.getInfo(), p.getVersion());
Set<Service> services = p.getServices();
for (Service s : services) {
System.out.printf("\t%s/%s/%s\n", s.getType(),
s.getAlgorithm(), s.getClassName());
}
}
我们将在接下来的各节中介绍每个主要 engine 类的算法名称格式。
SecureRandom
SecureRandom 类代表一个加密的 随机数生成器(RNG)。虽然你可能不会直接频繁使用它,但它被大多数加密操作内部使用,用于生成密钥和其他加密材料。典型的软件实现通常是一个 加密安全伪随机数生成器(CSPRNG),它根据一个初始值(称为 种子)生成一系列近似真正随机数的数字。由于 CSPRNG 生成的随机数的质量在很大程度上取决于其种子,因此种子的选择非常重要,通常基于一个真实随机数生成器的输出。
在 Android 上,CSPRNG 实现通过从标准 Linux 的 /dev/urandom 设备文件读取种子字节来进行种子设置,该文件是内核 CSPRNG 的接口。由于内核 CSPRNG 本身在启动后可能处于一个相对可预测的状态,Android 会定期将内核 CSPRNG 的状态(在 Android 4.4 中为 4096 字节)保存到 /data/system/entropy.dat 文件中。该文件的内容在启动时会被写回到 /dev/urandom,以便保留之前的 CSPRNG 状态。此操作由 EntropyMixer 系统服务执行。
与大多数引擎类不同,SecureRandom 具有公共构造函数,你可以使用这些构造函数创建实例。在 Android 上,推荐的获取正确种子实例的方式是使用默认(无参数)构造函数(➊ 在 示例 5-7 中)。如果使用 getInstance() 工厂方法,你需要传递 SHA1PRNG 作为算法名称,这是 SecureRandom 唯一被普遍支持的算法名称。由于 SHA1PRNG 并不是一个严格的加密标准,不同提供者的实现可能会有不同的表现。为了让 SecureRandom 生成随机字节,你将一个字节数组传递给它的 nextBytes() 方法(➋ 在 示例 5-7 中)。它会生成与数组长度相等的字节(在 示例 5-7 中为 16),并将这些字节存储到数组中。
示例 5-7. 使用 SecureRandom 生成随机字节
SecureRandom sr = new SecureRandom();➊
byte[] output = new byte[16];
sr.nextBytes(output);➋
不推荐手动设置 SecureRandom 的种子,因为不正确地设置系统 CSPRNG 的种子可能会导致其生成可预测的字节序列,从而可能危及任何需要随机输入的高级操作。然而,如果你出于某些原因需要手动设置 SecureRandom 的种子(例如,如果已知默认的系统种子实现存在缺陷),你可以通过使用 SecureRandom(byte[] seed) 构造函数或调用 setSeed() 方法来进行设置。手动设置种子时,确保使用的种子足够随机;例如,可以从 /dev/urandom 中读取。
此外,根据底层实现的不同,调用 setSeed() 可能不会替换内部的 CSPRNG 状态,而是仅仅将其添加到内部状态中;因此,用相同种子值初始化的两个 SecureRandom 实例可能不会产生相同的数字序列。因此,当需要确定性值时,不应使用 SecureRandom。而应使用一个设计用于根据给定输入生成确定性输出的加密原语,例如哈希算法或密钥派生函数。
MessageDigest
MessageDigest 类表示加密消息摘要的功能,也称为哈希函数。加密消息摘要接受任意长度的字节序列并生成一个固定大小的字节序列,称为 摘要 或 哈希。一个好的哈希函数保证即使输入发生微小变化,也会产生完全不同的输出,并且很难找到两个不同的输入但产生相同的哈希值(碰撞抗性),或者生成具有特定哈希值的输入(原像抗性)。哈希函数的另一个重要属性是第二原像抗性。为了抵御第二原像攻击,哈希函数应使得很难找到第二个输入 m[2],其哈希值与给定输入 m[1] 相同。
示例 5-8 展示了如何使用 MessageDigest 类。
示例 5-8. 使用 MessageDigest 进行数据哈希
MessageDigest md = MessageDigest.getInstance("SHA-256");➊
byte[] data = getMessage();
byte[] digest = md.digest(data);➋
MessageDigest 实例是通过将哈希算法名称传递给 getInstance() 工厂方法 ➊ 来创建的。可以通过使用其中一个 update() 方法分块提供输入数据,然后调用 digest() 方法之一来获取计算出的哈希值。或者,如果输入数据的大小是固定且相对较短的,也可以通过使用 digest(byte[] input) 方法 ➋ 一次性进行哈希,如 示例 5-8 中所示。
签名
Signature 类提供了一个基于非对称加密的数字签名算法的通用接口。数字签名算法接受一个任意消息和一个私钥,并生成一个固定大小的字节串,称为 签名。数字签名通常会对输入消息应用摘要算法,编码计算出的哈希值,然后使用私钥操作生成签名。然后,可以通过应用反向操作、计算签名消息的哈希值,并将其与签名中编码的哈希值进行比较,使用相应的公钥来验证签名。成功的验证保证了签名消息的完整性,并且在签名私钥保持确实私密的前提下,保证了其真实性。
Signature实例通过标准的getInstance()工厂方法创建。所使用的算法名称通常采用MessageDigest使用的哈希算法名称(例如SHA256),Signature将首先使用 SHA-512 哈希算法生成摘要值,然后使用 RSA 私钥加密编码后的摘要值以生成签名。对于使用掩码生成函数(如 RSA-PSS)的签名算法,算法名称采用
示例 5-9 展示了如何使用Signature类生成和验证加密签名。
示例 5-9. 使用Signature类生成和验证签名
PrivateKey privKey = getPrivateKey();
PublicKey pubKey = getPublicKey();
byte[] data = "sign me".getBytes("ASCII");
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privKey);➊
sig.update(data);➋
byte[] signature = sig.sign();➌
sig.initVerify(pubKey);➍
sig.update(data);
boolean valid = sig.verify(signature);➎
获取实例后,Signature对象通过传递私钥给initSign()方法(➊,在示例 5-9 中)来初始化签名,或者通过传递公钥或证书给initVerify()方法 ➍来初始化验证。
签名类似于使用MessageDigest计算哈希值:要签名的数据被分块传递给update()方法 ➋,或批量传递给sign()方法 ➌,后者返回签名值。要验证签名,已签名的数据被传递给update()方法。最后,签名被传递给verify()方法 ➎,如果签名有效,则返回true。
Cipher
Cipher类提供了一个用于加密和解密操作的通用接口。加密是使用某种算法(称为加密算法)和密钥将数据(称为明文或明文消息)转换为看似随机的形式(称为密文)。逆操作,称为解密,将密文转换回原始的明文。
当今广泛使用的两种主要加密类型是symmetric encryption(对称加密)和asymmetric encryption(非对称加密)。对称加密或秘密密钥加密使用相同的密钥进行数据加密和解密。非对称加密使用一对密钥:公钥和私钥。使用其中一个密钥加密的数据只能用另一把密钥解密。Cipher类支持对称加密和非对称加密。
根据处理输入的方式,密码算法可以是分组密码或流密码。分组密码处理固定大小的数据块,这些数据块称为数据块。如果输入数据无法分割成一个整数个数据块,最后一个数据块将通过添加必要的字节来进行填充,以匹配数据块的大小。操作和添加的字节都被称为填充。在解密过程中,填充会被去除,并且不会包含在解密后的明文中。如果指定了填充算法,Cipher类可以自动添加和去除填充。另一方面,流密码一次处理一个字节(甚至一个比特)数据,不需要填充。
分组密码操作模式
分组密码在处理输入数据块时采用不同的策略,以生成最终的密文(或在解密时生成明文)。这些策略称为操作模式、密码模式,或简单地称为模式。最简单的处理策略是将明文分割成数据块(根据需要进行填充),对每个数据块应用密码算法,然后将加密后的数据块连接在一起以生成密文。这种模式称为电子代码本(ECB)模式,虽然它简单易用,但它的主要缺点是相同的明文块会生成相同的密文块。因此,明文的结构会在密文中反映出来,这破坏了消息的机密性,并有助于密码分析。这个问题常常通过维基百科上的“ECB 企鹅”图示来说明。^([39]) 我们在图 5-2 中展示了我们的 Android 版本。^([40]) 这里,➊是原始图像,➋是使用 ECB 模式加密后的图像,➌是使用 CBC 模式加密后的相同图像。正如你所看到的,原始图像的模式在➋中是可辨识的,而➌看起来像是随机噪声。

图 5-2. 不同密码模式产生的密文模式
反馈模式通过在加密之前将前一个加密块与当前明文块结合来增加密文的随机性。为了生成第一个密文块,它们将第一个明文块与一个未出现在原始明文中的、大小与数据块相同的字节串结合,这个字节串称为初始化向量(IV)。在配置为使用反馈模式时,Cipher类可以使用客户端指定的 IV,或者自动生成一个。常用的反馈模式包括密码块链(CBC)、密码反馈(CFB)和输出反馈(OFB)。
另一种向密文添加随机性的方式是 Counter (CTR) 模式,它通过加密连续的计数器序列值,为每个需要加密的明文块生成一个新的密钥。这实际上将底层的块密码转换为流密码,因此不需要填充。
更新的密码模式,如 Galois/Counter Mode (GCM),不仅能扩散原始明文中的模式,还能对密文进行认证,确保其未被篡改。它们提供了 认证加密 (AE) 或 带关联数据的认证加密 (AEAD)。^([41]) Cipher API 在 Java SE 7 中扩展了对认证加密的支持,并且这些扩展自 Android 4.4 以来就已经可用,该版本具有与 Java 7 兼容的运行时库 API。AE 密码通过将加密操作输出的认证标签与该操作生成的密文拼接在一起,从而形成最终输出。在 Java Cipher API 中,标签会在调用 doFinal() 后隐式包含(或在解密时进行验证),因此在确定隐式标签验证通过之前,不应使用 update() 的输出。
获取 Cipher 实例
在回顾了密码的主要参数后,我们终于可以讨论如何创建 Cipher 实例。像其他引擎类一样,Cipher 对象是通过 getInstance() 工厂方法创建的,这不仅要求提供一个简单的算法名称,还需要你完全指定所请求的密码将执行的加密 转换。
示例 5-10 展示了如何通过将转换字符串传递给 getInstance() 来创建一个 Cipher 实例。
示例 5-10. 创建一个 Cipher 实例
Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
转换需要指定加密算法、密码模式和填充方式。传递给 getInstance() 的转换字符串采用 算法/模式/填充 格式。例如,在 示例 5-10 中使用的转换字符串将创建一个使用 AES 作为加密算法、CBC 作为密码模式、PKCS#5 填充的 Cipher 实例。
注意
术语 PKCS 将在我们讨论 JCA 提供者和引擎类时多次出现。这个缩写代表 公钥密码学标准 ,它指的是一组最初由 RSA Security, Inc. 在 1990 年代初期开发和发布的密码学标准。大多数已经发展成为公共互联网标准,并以 RFC(请求评论,描述互联网标准的正式文件)的形式发布和维护,但它们仍然以原始名称被称呼。著名的标准包括 PKCS#1,它定义了 RSA 加密和签名的基本算法;PKCS#5,它定义了基于密码的加密;PKCS#7,它定义了在公钥基础设施下的消息加密和签名,并成为 S/MIME 的基础;以及 PKCS#12,它定义了密钥和证书的容器。完整的标准列表可以在 EMC 的网站上找到。^([42])
可以仅通过传递算法名称来创建一个 Cipher 实例,但在这种情况下,返回的实现会使用提供者特定的默认值来设置加密模式和填充方式。这不仅无法在不同提供者之间移植,而且如果运行时使用了不如预期安全的加密模式(例如 ECB 模式),可能会严重影响系统的安全性。这种“快捷方式”是 JCA 提供者框架的一个重大设计缺陷,绝对不应该使用。
使用 Cipher
一旦获得了 Cipher 实例,它需要在加密或解密数据之前进行初始化。Cipher 通过将表示操作模式的整数常量(ENCRYPT_MODE、DECRYPT_MODE、WRAP_MODE 或 UNWRAP_MODE)、密钥或证书,以及可选的算法参数,传递给相应的 init() 方法来进行初始化。ENCRYPT_MODE 和 DECRYPT_MODE 用于加密和解密任意数据,而 WRAP_MODE 和 UNWRAP_MODE 是专门用于加密(封装)和解密(解封装) Key 对象的密钥材料的模式。
示例 5-11 演示了如何使用 Cipher 类加密和解密数据。
示例 5-11. 使用 Cipher 类加密和解密数据
SecureRandom sr = new SecureRandom();
SecretKey key = getSecretKey();
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");➊
byte[] iv = new byte[cipher.getBlockSize()];
sr.nextBytes(iv);
IvParameterSpec ivParams = new IvParameterSpec(iv);➋
cipher.init(Cipher.ENCRYPT_MODE, key, ivParams);➌
byte[] plaintext = "encrypt me".getBytes("UTF-8");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] output = cipher.update(plaintext);➍
if (output != null) {
baos.write(output);
}
output = cipher.doFinal();➎
baos.write(output);
byte[] ciphertext = baos.toByteArray();
cipher.init(Cipher.DECRYPT_MODE, key, ivParams);➏
baos = new ByteArrayOutputStream();
output = cipher.update(ciphertext);➐
if (output != null) {
baos.write(output);
}
output = cipher.doFinal();➑
baos.write(output);
byte[] decryptedPlaintext = baos.toByteArray();➒
在这个例子中,我们创建了一个使用 AES CBC 模式和 PKCS#5 填充的 Cipher 实例 ➊;生成一个随机的 IV 并将其封装进 IvParameterSpec 对象 ➋;然后通过将 ENCRYPT_MODE、加密密钥和 IV 传递给 init() 方法来初始化 Cipher 进行加密 ➌。接着,我们可以通过将数据块传递给 update() 方法 ➍ 来加密数据,该方法返回中间结果(如果输入数据太短以至于无法形成新块,则返回 null),并通过调用 doFinal() 方法 ➎ 获得最后一个数据块。最终的密文是将中间结果和最后一个数据块连接起来得到的。
要进行解密,我们将cipher初始化为DECRYPT_MODE ➏,并传入与加密时相同的密钥和 IV。然后调用update() ➐,这时使用密文作为输入,最后调用doFinal() ➑以获得最后一块明文。最终的明文是通过将中间结果与最后一块明文连接起来获得的 ➒。
消息认证码(MAC)
Mac类提供了一个通用接口,用于消息认证码(MAC)算法。MAC 用于检查通过不可靠通道传输的消息的完整性。MAC 算法使用一个密钥计算一个值,即MAC(也叫做标签),该值可用于验证消息并检查其完整性。相同的密钥用于进行验证,因此它需要在通信双方之间共享。(MAC 通常与密码学算法结合使用,以提供保密性和完整性。)
示例 5-12. 使用Mac类生成消息认证码
KeyGenerator keygen = KeyGenerator.getInstance("HmacSha256");
SecretKey key = keygen.generateKey();
Mac mac = Mac.getInstance("HmacSha256");➊
mac.init(key);➋
byte[] message = "MAC me".getBytes("UTF-8");
byte[] tag = mac.doFinal(message);➌
一个Mac实例是通过getInstance()工厂方法 ➊(如示例 5-12)获取的,它请求一个实现 HMAC^([43]) MAC 算法,该算法使用 SHA-256 作为哈希函数。然后它被初始化 ➋,使用SecretKey实例,这个实例可以通过KeyGenerator生成(见“KeyGenerator”),也可以通过密码派生或直接从原始密钥字节实例化。对于基于哈希函数的 MAC 实现(例如本例中的 HMAC SHA-256),密钥的类型不重要,但使用对称密码的实现可能需要传入匹配的密钥类型。然后,我们可以通过使用update()方法之一分批传递消息,并调用doFinal()以获得最终的 MAC 值,或者通过将消息字节直接传递给doFinal() ➌来一步完成操作。
密钥
Key接口表示 JCA 框架中的不透明密钥。不透明密钥可以用于加密操作,但通常无法访问底层的密钥材料(原始密钥字节)。这使得我们可以在使用存储密钥材料于内存的软件加密算法实现和硬件支持的加密算法(密钥材料可能存储在硬件令牌中,如智能卡、HSM^([44])等)之间,使用相同的 JCA 类和接口,并且密钥材料是不可直接访问的。
Key接口仅定义了三个方法:
-
String getAlgorithm()。返回此密钥可以使用的加密算法(对称或非对称)的名称。例如,AES或RSA。 -
byte[] getEncoded()。返回密钥的标准编码形式,通常用于将密钥传输到其他系统。这可以针对私钥进行加密。对于不允许导出密钥材料的硬件支持实现,这个方法通常会返回null。 -
String getFormat()。返回编码密钥的格式。对于未以特定格式编码的密钥,这通常是 RAW。JCA 中定义的其他格式有 X.509 和 PKCS#8。
你可以通过以下方式获取 Key 实例:
-
使用
KeyGenerator或KeyPairGenerator生成密钥。 -
使用
KeyFactory从某些编码表示中转换。 -
从
KeyStore中检索已存储的密钥。
我们将在接下来的小节中讨论不同的 Key 类型以及如何创建和访问它们。
SecretKey 和 PBEKey
SecretKey 接口表示对称算法中使用的密钥。它是一个标记接口,并没有为父接口 Key 增加任何方法。它只有一个可以直接实例化的实现类,即 SecretKeySpec。它既是一个密钥实现类,也是一个密钥规格类(如后文的 “KeySpec” 小节所述),并允许你根据原始密钥材料实例化 SecretKey 实例。
PBEKey 子接口表示通过 基于密码的加密 (PBE) 派生的密钥。^([45]) PBE 定义了从密码和口令短语(通常具有低熵,因此不能直接作为密钥使用)中派生强加密密钥的算法。PBE 基于两个主要思想:使用 盐值 保护免受表辅助(预计算)字典攻击(加盐),并使用较大的迭代次数使密钥派生计算上变得昂贵(密钥拉伸)。盐值和迭代次数作为 PBE 算法的参数,因此需要保留,以便从特定的密码生成相同的密钥。因此,PBEKey 实现需要实现 getSalt()、getIterationCount() 和 getPassword() 方法。
公钥、私钥和密钥对
非对称加密算法的公钥和私钥通过 PublicKey 和 PrivateKey 接口进行建模。它们是标记接口,并没有增加任何新方法。JCA 定义了具体非对称算法的专用类,这些类包含相应密钥的参数,例如 RSAPublicKey 和 RSAPrivateCrtKey。KeyPair 接口只是一个容器,包含一个公钥和一个私钥。
KeySpec
如在 Key 小节所述,JCA 的 Key 接口表示不透明的密钥。另一方面,KeySpec 模型化了一个 密钥规格,它是一个 透明的 密钥表示形式,允许你访问各个密钥参数。
在实际应用中,大多数具体算法的Key和KeySpec接口有很大的重叠,因为密钥参数需要可访问,以便实现加密算法。例如,RSAPrivateKey和RSAPrivateKeySpec都定义了getModulus()和getPrivateExponent()方法。只有在算法实现为硬件时,差异才变得重要,此时KeySpec只包含对硬件管理密钥的引用,而不包含实际的密钥参数。相应的Key将持有指向硬件管理密钥的句柄,并可用于执行加密操作,但它不会包含任何密钥材料。例如,存储在硬件中的RSAPrivateKey在调用其getPrivateExponent()方法时将返回null。
KeySpec实现可以持有编码后的密钥表示,在这种情况下,它们是与算法无关的。例如,PKCS8EncodedKeySpec可以持有 RSA 密钥或 DSA 密钥,格式为 DER 编码的 PKCS#8 格式。^([46])另一方面,特定算法的KeySpec将所有密钥参数作为字段保存。例如,RSAPrivateKeySpec包含 RSA 密钥的模数和私有指数,可以分别通过getModulus()和getPrivateExponent()方法获取。无论类型如何,KeySpec都可以通过KeyFactory转换为Key对象。
KeyFactory
KeyFactory封装了一个转换例程,用于将透明的公钥或私钥表示(某个KeySpec子类)转换为不可见的密钥对象(某个Key子类),该对象可以用于执行加密操作,反之亦然。一个转换编码密钥的KeyFactory通常会解析编码的密钥数据,并将每个密钥参数存储在具体Key类的相应字段中。例如,要解析一个 X.509 编码的 RSA 公钥,可以使用以下代码(参见示例 5-13)。
示例 5-13. 使用KeyFactory将 X.509 编码的密钥转换为RSAPublicKey对象
KeyFactory kf = KeyFactory.getInstance("RSA");➊
byte[] encodedKey = readRsaPublicKey();
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encodedKey);➋
RSAPublicKey pubKey = (RSAPublicKey) kf.generatePublic(keySpec);➌
在这里,我们通过将RSA传递给KeyFactory.getInstance() ➊来创建一个 RSA KeyFactory。然后,我们读取编码后的 RSA 密钥,使用编码后的密钥字节实例化一个X509EncodedKeySpec ➋,最后将KeySpec传递给工厂的generatePublic()方法 ➌,以获取一个RSAPublicKey实例。
KeyFactory也可以将特定算法的KeySpec(如RSAPrivateKeySpec)转换为匹配的Key(在这个例子中是RSAPrivateKey)实例,但在这种情况下,它仅仅是将密钥参数(或密钥句柄)从一个类复制到另一个类。调用KeyFactory.getKeySpec()方法将Key对象转换为KeySpec,但这种用法并不常见,因为通过直接调用getEncoded()方法在密钥对象上可以简单地获取编码的密钥表示,而且特定算法的KeySpec通常并不会提供比具体的Key类更多的信息。
KeyFactory的另一个特性是将来自不同提供者的Key实例转换为与当前提供者兼容的相应密钥对象。这个操作称为密钥转换,是通过调用translateKey(Key key)方法来执行的。
SecretKeyFactory
SecretKeyFactory与KeyFactory非常相似,区别在于它只处理对称(秘密)密钥。你可以使用它将对称密钥规格转换为Key对象,反之亦然。然而在实践中,如果你能够访问对称密钥的密钥材料,那么直接实例化一个SecretKeySpec(它也是一个Key)要简单得多,因此这种用法并不常见。
一个更常见的使用场景是使用 PBE 从用户提供的密码生成对称密钥(参见示例 5-14)。
示例 5-14. 使用SecretKeyFactory从密码生成密钥
byte[] salt = generateSalt();
int iterationCount = 1000; int keyLength = 256;
KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt,
iterationCount, keyLength);➊
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");➋
SecretKey key = skf.generateSecret(keySpec);➌
在这种情况下,PBEKeySpec通过密码、随机生成的盐值、迭代次数和所需的密钥长度来初始化 ➊。然后,通过调用getInstance() ➋ 获取实现 PBE 密钥派生算法(在此例中为 PBKDF2)的SecretKey工厂。将PBEKeySpec传递给generateSecret()执行密钥派生算法,并返回一个可以用于加密或解密的SecretKey实例 ➌。
KeyPairGenerator
KeyPairGenerator类生成公钥和私钥对。通过将非对称算法名称传递给getInstance()工厂方法来实例化KeyPairGenerator(➊,参见示例 5-15)。
示例 5-15. 使用特定算法参数初始化KeyPairGenerator
KeyPairGenerator kpg = KeyPairGenerator.getInstance("ECDH");➊
ECGenParameterSpec ecParamSpec = new ECGenParameterSpec("secp256r1");➋
kpg.initialize(ecParamSpec);➌
KeyPair keyPair = kpg.generateKeyPair();➍
有两种方法可以初始化 KeyPairGenerator:一种是指定所需的密钥大小,另一种是指定算法特定的参数。在这两种情况下,你都可以选择性地传入一个 SecureRandom 实例用于密钥生成。如果仅指定了密钥大小,密钥生成将使用默认参数(如果有的话)。要指定其他参数,必须实例化并配置一个适用于你所使用的非对称算法的 AlgorithmParameterSpec 实例,并将其传递给 initialize() 方法,如 示例 5-15 中所示。在此示例中,➋ 初始化的 ECGenParameterSpec 是一个 AlgorithmParameterSpec,它允许你在生成 椭圆曲线(EC) 密码学密钥时指定使用的曲线名称。将其传递给 ➌ 中的 initialize() 方法后,随后的 generateKeyPair() 调用将在 ➍ 中使用指定的曲线(secp256r1)来生成密钥对。
注意
虽然许多标准已经定义了命名曲线,但 Oracle JCA 规范并未明确规定任何椭圆曲线名称。由于没有官方的 JCA 标准,Android 支持的曲线名称可能会根据平台版本有所不同。
KeyGenerator
KeyGenerator 类与 KeyPairGenerator 类非常相似,不同之处在于它生成的是对称密钥。虽然你可以通过从 SecureRandom 请求一系列随机字节来生成大多数对称密钥,但 KeyGenerator 实现会进行额外的弱密钥检查,并在适当的位置设置密钥奇偶校验字节(对于 DES 及衍生算法),还可以利用可用的密码硬件,因此最好使用 KeyGenerator 而不是手动生成密钥。
示例 5-16 展示了如何使用 KeyGenerator 生成 AES 密钥。
示例 5-16. 使用 KeyGenerator 生成 AES 密钥
KeyGenerator keygen = KeyGenerator.getInstance("AES");➊
kg.init(256);➋
SecretKey key = keygen.generateKey();➌
要使用 KeyGenerator 生成密钥,首先创建一个实例 ➊,通过 init() 方法指定所需的密钥大小 ➋,然后调用 generateKey() ➌ 生成密钥。
KeyAgreement
KeyAgreement 类表示一个 密钥协商协议,它允许两个或更多方生成一个共享密钥,而无需交换秘密信息。虽然有多种密钥协商协议,但目前最广泛使用的协议是基于 Diffie-Hellman (DH) 密钥交换——无论是基于离散对数密码学的原始协议^([48])(通常称为 DH),还是基于椭圆曲线密码学的更新版协议(ECDH^([49]))。
协议的两种变体在 JCA 中都通过 KeyAgreement 类建模,并且可以以相同的方式执行,唯一的区别在于密钥。对于这两种变体,每个通信方都需要拥有一对密钥,且这两对密钥是使用相同的密钥参数生成的(DH 的素数模数和基生成器,通常 ECDH 使用相同的预定义命名曲线)。然后,通信方只需要交换公钥并执行密钥协议算法以达成共享密钥。
示例 5-17 展示了如何使用 KeyAgreement 类通过 ECDH 生成共享密钥。
示例 5-17. 使用 KeyAgreement 生成共享密钥
PrivateKey myPrivKey = getPrivateKey();
PublicKey remotePubKey = getRemotePubKey();
KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH");➊
keyAgreement.init(myPrivKey);➋
keyAgreement.doPhase(remotePubKey, true);➌
byte[] secret = keyAgreement.generateSecret();➍
首先通过将算法名称 ECDH 传递给 getInstance() 工厂方法 ➊ 来创建 KeyAgreement 实例。然后,通过将本地私钥传递给 init() 方法 ➋ 来初始化协议。接下来,调用 doPhase() 方法 N - 1 次,其中 N 是通信方的数量,每次传递每个通信方的公钥作为第一个参数,当执行协议的最后阶段时,将第二个参数设置为 true ➌。(对于两个通信方,如本示例所示,doPhase() 方法只需要调用一次。)最后,调用 generateSecret() 方法 ➍ 生成共享密钥。
示例 5-17 展示了其中一方(A)的调用流程,但另一方(B)需要使用自己的私钥执行相同的步骤来初始化协议,并将 A 的公钥传递给 doPhase()。
请注意,虽然 generateSecret() 返回的值(或其部分)可以直接用作对称密钥,但推荐的做法是将其作为 密钥派生函数(KDF) 的输入,并使用 KDF 的输出作为密钥。直接使用生成的共享密钥可能会导致一些熵丧失,并且这样做限制了使用单次 DH 密钥协议操作生成密钥的数量。另一方面,使用 KDF 可以扩散密钥的结构(如填充),并通过混合盐值来生成多个派生密钥。
KeyAgreement 还有另一个 generateSecret() 方法,该方法接收一个算法名称作为参数,并返回一个可以直接用于初始化 Cipher 的 SecretKey 实例。如果 KeyAgreement 实例是使用包含 KDF 规格的算法字符串创建的(例如 ECDHwithSHA1KDF),该方法会在返回 SecretKey 之前将 KDF 应用到共享密钥上。如果没有指定 KDF,大多数实现会简单地截断共享密钥,以获得用于返回 SecretKey 的密钥材料。
密钥库
JCA 使用术语 keystore 来指代包含密钥和证书的数据库。密钥库管理多个加密对象,这些对象称为 entries,每个条目都与一个字符串 alias 相关联。KeyStore 类为密钥库提供了一个明确定义的接口,该接口定义了三种类型的条目:
-
PrivateKeyEntry。一个私钥和一个关联的证书链。对于软件实现,私钥通常是加密的,并由用户提供的密码短语保护。 -
SecretKeyEntry。一个对称(秘密)密钥。并非所有KeyStore实现都支持存储秘密密钥。 -
TrustedCertificateEntry。 另一个方的公钥证书。TrustedCertificateEntry通常包含可用于建立信任关系的 CA 证书。只包含TrustedCertificateEntry的密钥库被称为 信任库。
密钥库类型
KeyStore 实现不需要持久化,但大多数实现是持久化的。不同的实现通过 密钥库类型 来识别,该类型定义了密钥库的存储和数据格式,以及用于保护存储密钥的方法。默认的 KeyStore 类型通过 keystore.type 系统属性设置。
大多数 JCA 提供程序的默认KeyStore实现通常是一种将数据存储在文件中的密钥库类型。文件格式可能是专有的,也可能基于公开标准。专有格式包括原始的 Java SE JKS 格式及其安全增强版本 JCEKS,以及 Bouncy Castle KeyStore (BKS) 格式,这是 Android 中的默认格式。
PKCS#12 文件支持的密钥库
最广泛使用的公共标准之一,是允许将私钥及其关联证书捆绑在一个文件中的 个人信息交换语法标准,通常称为 PKCS#12。它是 个人信息交换语法(PFX) 标准的继承者,因此 PKCS#12 和 PFX 这两个术语在一定程度上可以互换使用,PKCS#12 文件通常被称为 PFX 文件。
PKCS#12 是一种容器格式,可以包含多个嵌入对象,例如私钥、证书,甚至 CRL。像 PKCS#12 所基于的前几个 PKCS 标准一样,容器内容在 ASN.1^([50]) 中定义,本质上是一个嵌套结构的序列。内部容器结构称为 SafeBags,并为证书(CertBag)、私钥(KeyBag)和加密私钥(PKCS8ShroudedKeyBag)定义了不同的袋子。整个文件的完整性由一个使用 完整性密码 派生的密钥保护的 MAC 保护,每个私钥条目都使用一个从 隐私密码 派生的密钥加密。实际上,这两个密码通常是相同的。PKCS#12 还可以使用公钥来保护归档内容的隐私性和完整性,但这种用法并不常见。
一个典型的 PKCS#12 文件包含用户加密的密码密钥和关联证书,其结构可能如图 5-3 所示(注意,为了简洁,某些包装结构已被移除)。

图 5-3. 持有私钥和关联证书的 PKCS#12 文件结构
示例 5-18 展示了如何从 PKCS#12 文件中获取私钥和证书。
示例 5-18. 使用 KeyStore 类从 PKCS#12 文件中提取私钥和证书
KeyStore keyStore = KeyStore.getInstance("PKCS12");➊
InputStream in = new FileInputStream("mykey.pfx");
keyStore.load(in, "password".toCharArray());➋
KeyStore.PrivateKeyEntry keyEntry =
(KeyStore.PrivateKeyEntry)keyStore.getEntry("mykey", null);➌
X509Certificate cert = (X509Certificate) keyEntry.getCertificate();➍
RSAPrivateKey privKey = (RSAPrivateKey) keyEntry.getPrivateKey();➎
KeyStore 类可以通过在创建实例时指定 PKCS12 作为密钥库类型来访问 PKCS#12 文件的内容(➊,见示例 5-18)。为了加载和解析 PKCS#12 文件,我们调用 load() 方法 ➋,传入用于读取文件的 InputStream 和文件完整性密码。文件加载后,我们可以通过调用 getEntry() 方法并传入密钥别名 ➌ 来获取私钥条目,另外,如果请求的条目密码与文件完整性密码不同,还可以传入一个用该密码初始化的 KeyStore.PasswordProtection 实例。如果别名未知,可以使用 aliases() 方法列出所有别名。一旦获得了 PrivateKeyEntry,我们可以访问公钥证书 ➍ 或私钥 ➎。可以使用 setEntry() 方法添加新条目,使用 deleteEntry() 方法删除条目。通过调用 store() 方法,可以将对 KeyStore 内容的更改持久化到磁盘,该方法接受一个 OutputStream(将密钥库字节写入该流)和一个完整性密码(用于推导 MAC 和加密密钥)作为参数。
KeyStore 实现不一定需要使用单个文件来存储密钥和证书对象。它可以使用多个文件、数据库或任何其他存储机制。事实上,密钥可能根本不存储在主机系统上,而是存储在单独的硬件设备上,例如智能卡或硬件安全模块(HSM)。(针对 Android 的 KeyStore 实现提供了对系统的信任存储和凭证存储的接口,相关内容在第六章和第七章中介绍。)
CertificateFactory 和 CertPath
CertificateFactory 充当证书和 CRL 解析器,可以从证书列表中构建证书链。它可以读取包含编码证书或 CRL 的流,并输出一个 java.security.cert.Certificate 和 java.security.cert.CRL 对象的集合(或单个实例)。通常,只有解析 X.509 证书和 CRL 的 X.509 实现是可用的。
示例 5-19 展示了如何使用 CertificateFactory 解析证书文件。
示例 5-19. 使用 CertificateFactory 解析 X.509 证书文件
CertificateFactory cf = CertificateFactory.getInstance("X.509");➊
InputStream in = new FileInputStream("certificate.cer");
X509Certificate cert = (X509Certificate) cf.generateCertificate(in);➋
要创建一个 CertificateFactory,我们将 X.509 作为工厂类型传递给 getInstance() ➊,然后调用 generateCertificate(),传入一个 InputStream 来读取数据 ➋。由于这是一个 X.509 工厂,获取的对象可以安全地转换为 java.security.cert.X509Certificate。如果读取的文件包含多个证书,形成一个证书链,可以通过调用 generateCertPath() 方法获得一个 CertPath 对象。
CertPathValidator 和 CertPathBuilder
CertPathValidator 类封装了一个证书链验证算法,该算法由 公钥基础设施(X.509) 或 PKIX 标准定义。我们在第六章中将更详细地讨论 PKIX 和证书链验证,但示例 5-20 展示了如何使用 CertificateFactory 和 CertPathValidator 来构建和验证证书链。
示例 5-20. 使用 CertPathValidator 构建并验证证书链
CertPathValidator certPathValidator = CertPathValidator.getInstance("PKIX");➊
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate[] chain = getCertChain();
CertPath certPath = cf.generateCertPath(Arrays.asList(chain));➋
Set<TrustAnchor> trustAnchors = getTrustAnchors();
PKIXParameters result = new PKIXParameters(trustAnchors);➌
PKIXCertPathValidatorResult result = (PKIXCertPathValidatorResult)
certPathValidator.validate(certPath, pkixParams);➍
如你所见,我们首先通过将 PKIX 传递给 getInstance() 方法 ➊ 获得一个 CertPathValidator 实例。然后,我们使用 CertificateFactory 的 generateCertPath() 方法 ➋ 构建证书链。请注意,如果传入的证书列表无法形成有效的链条,方法将抛出 CertificateException。如果我们还没有所有构成链条所需的证书,我们可以使用初始化为 CertStore 的 CertPathBuilder 来查找所需的证书并构建一个 CertPath(未显示)。
一旦我们拥有了 CertPath,我们通过一组 信任锚(通常这些是可信的 CA 证书;详细信息请参见第六章)初始化 PKIXParameters 类 ➌,然后调用 CertPathValidator.validate() ➍,传入我们在 ➋ 中构建的 CertPath 和 PKIXParameters 实例。如果验证成功,validate() 返回一个 PKIXCertPathValidatorResult 实例;如果失败,则抛出一个包含失败原因的 CertPathValidatorException。
Android JCA 提供者
Android 的加密提供者基于 JCA,并遵循其架构,只有一些相对较小的例外。虽然低级 Android 组件直接使用本地加密库(如 OpenSSL),JCA 是主要的加密 API,系统组件和第三方应用程序都在使用它。
Android 有三个核心 JCA 提供者,其中包括前述引擎类的实现,还有两个 Java Secure Socket Extension (JSSE) 提供者,它们实现了 SSL 功能。(JSSE 在第六章中有详细讨论。)
让我们来看看 Android 的核心 JCA 提供者。
Harmony 的 Crypto 提供者
Android 的 Java 运行时库实现来源于已退役的 Apache Harmony 项目,^([52]),该项目还包括一个名为 Crypto 的有限 JCA 提供者,提供了基本的加密服务实现,如随机数生成、哈希和数字签名。Crypto 仍然包含在 Android 中,以确保向后兼容,但它在所有 JCA 提供者中优先级最低,因此,除非显式请求,否则不会返回 Crypto 的引擎类实现。表 5-1 显示了 Crypto 支持的引擎类和算法。
表 5-1. 截至 Android 4.4.4 加密提供者支持的算法
| 引擎类名称 | 支持的算法 |
|---|---|
KeyFactory |
DSA |
MessageDigest |
SHA-1 |
SecureRandom |
SHA1PRNG |
Signature |
SHA1withDSA |
注意
虽然 表 5-1 中列出的算法仍然可以在 Android 4.4 中使用,但除 SHA1PRNG 外,所有算法已在 Android 主分支中移除,并可能在未来的版本中不可用。
Android 的 Bouncy Castle 提供者
在 Android 4.0 之前,Android 唯一的全功能 JCA 提供者是 Bouncy Castle 提供者。Bouncy Castle 提供者是 Bouncy Castle 加密 API 的一部分,^([53]),这是一套开源的 Java 加密算法和协议实现。
Android 包含了一个修改版的 Bouncy Castle 提供者,该版本通过应用一系列 Android 特定的补丁从主流版本派生而来。这些补丁在 Android 源代码树中维护,并随着主流 Bouncy Castle 提供者的每个新版本进行更新。与主流版本的主要差异如下所述。
-
不被 Java 参考实现(RI)支持的算法、模式和算法参数已被移除(如 RIPEMD、SHA-224、GOST3411、Twofish、CMAC、El Gamal、RSA-PSS、ECMQV 等)。
-
不安全的算法如 MD2 和 RC2 已被移除。
-
基于 Java 的 MD5 和 SHA 系列摘要算法的实现已被本地实现所替代。
-
已移除一些 PBE 算法(例如,PBEwithHmacSHA256)。
-
已移除对存储在 LDAP 中的证书的访问支持。
-
已添加对证书黑名单的支持(黑名单详见 第六章)。
-
已进行各种性能优化。
-
包名已更改为
com.android.org.bouncycastle,以避免与捆绑 Bouncy Castle 的应用程序发生冲突(自 Android 3.0 起)。
Android Bouncy Castle 提供程序支持的引擎类和算法列表(基于 Bouncy Castle 1.49,版本为 4.4.4)见 表 5-2。
表 5-2. Android 4.4.4 版本的 Bouncy Castle 提供程序支持的算法
| 引擎类名称 | 支持的算法 |
|---|---|
CertPathBuilder |
PKIX |
CertPathValidator |
PKIX |
CertStore |
Collection |
CertificateFactory |
X.509 |
Cipher |
AESAESWRAPARC4BLOWFISHDESDESEDEDESEDEWRAPPBEWITHMD5AND128BITAES-CBC-OPENSSLPBEWITHMD5AND192BITAES-CBC-OPENSSLPBEWITHMD5AND256BITAES-CBC-OPENSSLPBEWITHMD5ANDDESPBEWITHMD5ANDRC2PBEWITHSHA1ANDDESPBEWITHSHA1ANDRC2PBEWITHSHA256AND128BITAES-CBC-BCPBEWITHSHA256AND192BITAES-CBC-BCPBEWITHSHA256AND256BITAES-CBC-BCPBEWITHSHAAND128BITAES-CBC-BCPBEWITHSHAAND128BITRC2-CBCPBEWITHSHAAND128BITRC4PBEWITHSHAAND192BITAES-CBC-BCPBEWITHSHAAND2-KEYTRIPLEDES-CBCPBEWITHSHAAND256BITAES-CBC-BCPBEWITHSHAAND3-KEYTRIPLEDES-CBCPBEWITHSHAAND40BITRC2-CBCPBEWITHSHAAND40BITRC4PBEWITHSHAANDTWOFISH-CBC**RSA |
KeyAgreement |
DH**ECDH |
KeyFactory |
DHDSAEC**RSA |
KeyGenerator |
AESARC4BLOWFISHDESDESEDEHMACMD5HMACSHA1HMACSHA256HMACSHA384**HMACSHA512 |
KeyPairGenerator |
DHDSAEC**RSA |
KeyStore |
BKS(默认)BouncyCastle**PKCS12 |
Mac |
HMACMD5HMACSHA1HMACSHA256HMACSHA384HMACSHA512PBEWITHHMACSHAPBEWITHHMACSHA1 |
MessageDigest |
MD5SHA-1SHA-256SHA-384SHA-512 |
SecretKeyFactory |
DESDESEDEPBEWITHHMACSHA1PBEWITHMD5AND128BITAES-CBC-OPENSSLPBEWITHMD5AND192BITAES-CBC-OPENSSLPBEWITHMD5AND256BITAES-CBC-OPENSSLPBEWITHMD5ANDDESPBEWITHMD5ANDRC2PBEWITHSHA1ANDDESPBEWITHSHA1ANDRC2PBEWITHSHA256AND128BITAES-CBC-BCPBEWITHSHA256AND192BITAES-CBC-BCPBEWITHSHA256AND256BITAES-CBC-BCPBEWITHSHAAND128BITAES-CBC-BCPBEWITHSHAAND128BITRC2-CBCPBEWITHSHAAND128BITRC4PBEWITHSHAAND192BITAES-CBC-BCPBEWITHSHAAND2-KEYTRIPLEDES-CBCPBEWITHSHAAND256BITAES-CBC-BCPBEWITHSHAAND3-KEYTRIPLEDES-CBCPBEWITHSHAAND40BITRC2-CBCPBEWITHSHAAND40BITRC4PBEWITHSHAANDTWOFISH-CBCPBKDF2WithHmacSHA1PBKDF2WithHmacSHA1And8BIT |
Signature |
ECDSAMD5WITHRSANONEWITHDSANONEwithECDSASHA1WITHRSASHA1withDSASHA256WITHECDSASHA256WITHRSASHA384WITHECDSASHA384WITHRSASHA512WITHECDSA**SHA512WITHRSA |
AndroidOpenSSL 提供程序
如在 Android 的 Bouncy Castle 提供程序中提到的,Android 的 Bouncy Castle 提供程序中的哈希算法已经被本地代码替换,以提高性能。为了进一步提高加密性能,自 4.0 版本以来,每次发布时本地 AndroidOpenSSL 提供程序支持的引擎类和算法数量都在稳步增长。
最初,AndroidOpenSSL 仅用于实现 SSL 套接字,但从 Android 4.4 开始,它涵盖了 Bouncy Castle 提供程序提供的大部分功能。由于它是首选提供程序(具有最高优先级,1),因此那些没有明确请求 Bouncy Castle 的引擎类会从 AndroidOpenSSL 提供程序获取实现。顾名思义,它的加密功能由 OpenSSL 库提供。提供程序实现通过 JNI 将 OpenSSL 的本地代码与实现 JCA 提供程序所需的 Java SPI 类连接起来。大部分实现位于 NativeCrypto Java 类中,该类被大多数 SPI 类调用。
AndroidOpenSSL 是 Android libcore 库的一部分,该库实现了 Android Java 运行时库的核心部分。从 Android 4.4 开始,AndroidOpenSSL 已与 libcore 解耦,以便可以作为独立库进行编译,并包含在需要稳定加密实现且不依赖于平台版本的应用程序中。独立提供程序名为 Conscrypt,并位于 org.conscrypt 包中,当作为 Android 平台的一部分构建时,该包重命名为 com.android.org.conscrypt。
AndroidOpenSSL 提供程序在 4.4.4 版本中支持的引擎类和算法列在表 5-3 中。
表 5-3. AndroidOpenSSL 提供程序在 Android 4.4.4 版本中支持的算法
| 引擎类名称 | 支持的算法 |
|---|---|
CertificateFactory |
X509 |
Cipher |
AES/CBC/NoPaddingAES/CBC/PKCS5PaddingAES/CFB/NoPaddingAES/CTR/NoPaddingAES/ECB/NoPaddingAES/ECB/PKCS5PaddingAES/OFB/NoPaddingARC4DESEDE/CBC/NoPaddingDESEDE/CBC/PKCS5PaddingDESEDE/CFB/NoPaddingDESEDE/ECB/NoPaddingDESEDE/ECB/PKCS5PaddingDESEDE/OFB/NoPaddingRSA/ECB/NoPadding**RSA/ECB/PKCS1Padding |
KeyAgreement |
ECDH |
KeyFactory |
DSAECRSA |
KeyPairGenerator |
DSAECRSA |
Mac |
HmacMD5HmacSHA1HmacSHA256HmacSHA384HmacSHA512 |
MessageDigest |
MD5SHA-1SHA-256SHA-384SHA-512 |
SecureRandom |
SHA1PRNG |
Signature |
ECDSAMD5WithRSANONEwithRSASHA1WithRSASHA1withDSASHA256WithRSASHA256withECDSASHA384WithRSASHA384withECDSASHA512WithRSASHA512withECDSA |
OpenSSL
OpenSSL 是一个开源加密工具包,实施了 SSL 和 TLS 协议,并被广泛用作通用加密库。^([54]) 它作为系统库包含在 Android 中,并用于实现 AndroidOpenSSL JCA 提供者,该提供者在 “AndroidOpenSSL Provider” 中引入,也被一些其他系统组件使用。
不同的 Android 版本使用不同的 OpenSSL 版本(通常是最新的稳定版本,例如 Android 4.4 中的 1.0.1e),并应用了一系列的补丁。因此,Android 并未提供稳定的公开 OpenSSL API,因此需要使用 OpenSSL 的应用程序应当包含该库,而不是链接到系统版本。唯一的公开加密 API 是 JCA API,它提供了一个稳定的接口,独立于底层实现。
使用自定义提供者
尽管 Android 的内置提供者覆盖了大多数广泛使用的加密原语,但它们并不支持一些更为特殊的算法,甚至一些较新的标准。如在 JCA 架构讨论中所述,Android 应用程序可以注册自定义提供者供自身使用,但无法影响系统范围内的提供者。
Bouncy Castle 是最广泛使用且功能最全的 JCA 提供者之一,也是 Android 内置提供者的基础。然而,如在 Android’s Bouncy Castle Provider 中讨论的那样,Android 随附的版本已经移除了一些算法。如果你需要使用这些算法,可以尝试将完整的 Bouncy Castle 库直接捆绑到你的应用中——但这可能会导致类加载冲突,特别是在 Android 3.0 之前的版本中,因为它们不会更改系统 Bouncy Castle 的包名称。为避免此问题,你可以使用 jarjar 等工具更改库的根包名,^([55]) 或者使用 Spongy Castle。^([56])
Spongy Castle
Spongy Castle 是 Bouncy Castle 的重新包装版本。它将所有的包名称从 org.bouncycastle.* 移动到 org.spongycastle.*,以避免类加载器冲突,并将提供者名称从 BC 改为 SC。类名没有改变,因此 API 与 Bouncy Castle 相同。要使用 Spongy Castle,你只需通过 Security.addProvider() 或 Security.insertProviderAt() 将其注册到 JCA 框架中。然后,你可以通过将算法名称传递给相应的 getInstance() 方法,简单地请求 Android 内置提供者未实现的算法。
若要明确请求 Spongy Castle 提供程序的实现,可以将 SC 字符串作为提供程序名称。如果你将 Spongy Castle 库与应用打包在一起,你也可以直接使用 Bouncy Castle 的轻量级加密 API(通常更灵活),而无需经过 JCA 引擎类。此外,某些加密操作,例如签署 X.509 证书或创建 S/MIME 消息,并没有与之匹配的 JCA API,只能通过更低层次的 Bouncy Castle API 执行。
示例 5-21 显示了如何注册 Spongy Castle 提供程序并请求一个 RSA-PSS(最初在 PKCS#1^([57])) Signature 实现,该实现并未被 Android 内置的任何 JCA 提供程序所支持。
示例 5-21. 注册并使用 Spongy Castle 提供程序
static {
Security.insertProviderAt(
new org.spongycastle.jce.provider.BouncyCastleProvider(), 1);
}
Signature sig = Signature.getInstance("SHA1withRSA/PSS", "SC");
总结
Android 实现了 Java 加密体系结构(JCA),并自带多个加密提供程序。JCA 定义了以引擎类形式存在的常见加密算法的接口。加密提供程序提供这些引擎类的实现,并允许客户端按名称请求算法的实现,而无需了解实际的底层实现。Android 中的两个主要 JCA 提供程序是 Bouncy Castle 提供程序和 AndroidOpenSSL 提供程序。Bouncy Castle 以纯 Java 实现,而 AndroidOpenSSL 由本地代码支持,提供更好的性能。自 Android 4.4 起,AndroidOpenSSL 被视为首选的 JCA 提供程序。
^([38]) Oracle, Java™ 加密体系结构标准算法名称文档, docs.oracle.com/javase/7/docs/technotes/guides/security/StandardNames.html
^([39]) 维基百科,“分组密码操作模式,” en.wikipedia.org/wiki/Block_cipher_mode_of_operation
^([40]) Android 机器人是由 Google 创作并共享的作品,按照 Creative Commons 3.0 署名许可协议的条款使用。
^([41]) D. McGrew, RFC 5116 – 认证加密接口与算法, www.ietf.org/rfc/rfc5116.txt
^([42]) RSA 实验室, 公钥加密标准(PKCS), www.emc.com/emc-plus/rsa-labs/standards-initiatives/public-key-cryptography-standards.htm
^([43]) H. Krawczyk, M. Bellare, 和 R. Canetti, HMAC: 基于密钥的消息认证哈希, tools.ietf.org/html/rfc2104
^([44]) 硬件安全模块
^([45]) B. Kaliski, PKCS #5: 基于密码的密码学规范,版本 2.0, www.ietf.org/rfc/rfc2898.txt
^([46]) RSA 实验室, PKCS #8: 私钥信息语法标准, www.emc.com/emc-plus/rsa-labs/standards-initiatives/pkcs-8-private-key-information-syntax-stand.htm
^([47]) 一些 Key 子类,诸如 RSAPrivateKey,暴露所有密钥材料,因此在技术上并不完全是封闭的。
^([48]) RSA 实验室, PKCS #3: Diffie-Hellman 密钥协商标准, ftp://ftp.rsasecurity.com/pub/pkcs/ascii/pkcs-3.asc
^([49]) NIST, 使用离散对数密码学的配对密钥建立方案建议, csrc.nist.gov/publications/nistpubs/800-56A/SP800-56A_Revision1_Mar08-2007.pdf
^([50]) 抽象语法表示法(ASN.1): 一种描述数据编码规则和结构的标准符号,广泛应用于电信和计算机网络中。广泛用于密码学标准中,以定义加密对象的结构。
^([51]) D. Cooper 等, 互联网 X.509 公钥基础设施证书和证书吊销列表(CRL)规范, 2008 年 5 月, tools.ietf.org/html/rfc5280
^([52]) Apache 软件基金会, “Apache Harmony,” harmony.apache.org/
^([53]) Bouncy Castle 公司, “Bouncy Castle 加密 API,” www.bouncycastle.org/java.html
^([54]) OpenSSL 项目, “OpenSSL:SSL/TLS 的开源工具包,” www.openssl.org/
^([55]) Chris Nokleberg, “Jar Jar Links,” code.google.com/p/jarjar/
^([56]) Roberto Tyley, “Spongy Castle,” rtyley.github.io/spongycastle/
^([57]) J. Jonsson 和 B. Kaliski, 公钥密码学标准(PKCS)#1:RSA 密码学规范版本 2.1, tools.ietf.org/html/rfc3447
第六章 网络安全与 PKI
如前一章所述,Android 包括各种加密提供程序,这些提供程序实现了大多数现代加密原语:哈希、对称和非对称加密以及消息认证码。这些原语可以结合使用以实现安全通信,但即使是微小的错误也可能导致严重的漏洞,因此实现安全通信的首选方式是使用设计用于保护跨网络传输的数据的隐私和完整性的标准协议。
最广泛使用的安全协议是安全套接字层(SSL)和传输层安全(TLS)。Android 通过提供标准的 Java 安全套接字扩展(JSSE)实现来支持这些协议。在本章中,我们将简要讨论 JSSE 架构,然后提供一些关于 Android 的 JSSE 实现的细节。我们对 Android SSL 堆栈的描述重点是证书验证和信任锚管理,这些是与平台紧密集成的,并且是将其与其他 JSSE 实现区分开的最大特点之一。
注意
虽然 TLS 和 SSL 在技术上是不同的协议,但我们通常使用更常见的术语 SSL 来指代两者,并且仅在讨论协议差异时才区分 SSL 和 TLS。
PKI 和 SSL 概述
TLS^([58]) 和 SSL^([59])(其前身)是安全的点对点通信协议,旨在提供(可选的)认证、消息机密性和消息完整性,供通过 TCP/IP 通信的双方使用。它们结合使用对称加密和非对称加密来实现消息的机密性和完整性,并在很大程度上依赖公钥证书来实现身份验证。
要启动安全的 SSL 通道,客户端首先联系服务器并发送其支持的 SSL 协议版本,以及建议的加密套件列表。加密套件是一组用于身份验证、密钥协商、加密和完整性的算法和密钥大小。为了建立安全通道,服务器和客户端协商一个共同支持的加密套件,然后根据各自的证书验证对方的身份。最后,通信双方商定一个对称加密算法,并计算出一个共享的对称密钥,用于加密所有后续的通信。通常,只验证服务器的身份(服务器认证),而不是客户端的身份。SSL 协议也支持验证客户端身份(客户端认证),但它的使用较为罕见。
注意
虽然像 TLS_DH_anon_WITH_AES_128_CBC_SHA 这样的匿名(未经认证)加密套件在 SSL 规范中有定义,但它们容易受到中间人攻击(MITM),通常仅在 SSL 作为更复杂协议的一部分时使用,该协议有其他方法来确保认证。
公钥证书
如前节所述,SSL 依赖公钥证书来实现身份验证。公钥证书是一种将身份与公钥绑定的构造。对于用于 SSL 通信的 X.509 证书,“身份”是一组通常包括常用名称(CN)、组织和位置的属性,形成实体的区分名称(DN)。X.509 证书的其他主要属性包括颁发者 DN、有效期以及一组扩展字段,这些扩展可能是附加的实体属性,或与证书本身相关(例如,预期的密钥使用)。
绑定是通过对实体的公钥及所有附加属性应用数字签名来生成数字证书的。所使用的签名密钥可以是认证实体自身的私钥,在这种情况下,证书被称为自签名证书,或者它可能属于一个称为证书颁发机构(CA)的受信任第三方。
OpenSSL x509 命令解析的典型 X.509 服务器证书的内容见示例 6-1。这个特定的证书将 C=US, ST=California, L=Mountain View, O=Google Inc, CN=.googlecode.com* DN ➋ 和一组备用的 DNS 名称 ➍ 绑定到服务器的 2048 位 RSA 密钥 ➌,并由 Google Internet Authority G2 CA ➊ 的私钥签名。
示例 6-1. 由 OpenSSL 解析的 X.509 证书内容
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
09:49:24:fd:15:cf:1f:2e
Signature Algorithm: sha1WithRSAEncryption
Issuer: C=US, O=Google Inc, CN=Google Internet Authority G2➊
Validity
Not Before: Oct 9 10:33:36 2013 GMT
Not After : Oct 9 10:33:36 2014 GMT
Subject: C=US, ST=California, L=Mountain View, O=Google Inc, CN=*.googlecode.com➋
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)➌
Modulus:
00:9b:58:02:90:d6:50:03:0a:7c:79:06:99:5b:7a:
--*snip*--
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Extended Key Usage:
TLS Web Server Authentication, TLS Web Client Authentication
X509v3 Subject Alternative Name:
DNS:*.googlecode.com, DNS:*.cloud.google.com, DNS:*.code.google.com,➍
--*snip*--
Authority Information Access:
CA Issuers - URI:http://pki.google.com/GIAG2.crt
OCSP - URI:http://clients1.google.com/ocsp
X509v3 Subject Key Identifier:
65:10:15:1B:C4:26:13:DA:50:3F:84:4E:44:1A:C5:13:B0:98:4F:7B
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Authority Key Identifier:
keyid:4A:DD:06:16:1B:BC:F6:68:B5:76:F5:81:B6:BB:62:1A:BA:5A:81:2F
X509v3 Certificate Policies:
Policy: 1.3.6.1.4.1.11129.2.5.1
X509v3 CRL Distribution Points:
Full Name:
URI:http://pki.google.com/GIAG2.crl
Signature Algorithm: sha1WithRSAEncryption
3f:38:94:1b:f5:0a:49:e7:6f:9b:7b:90:de:b8:05:f8:41:32:
--*snip*--
直接信任和私有 CA
如果一个 SSL 客户端与有限数量的服务器进行通信,它可以预先配置一组它信任的服务器证书(称为信任锚),此时决定是否信任远程方就变得非常简单,只需检查其证书是否在该信任集内即可。这个模型允许对客户端信任的对象进行细粒度控制,但也使得更换或升级服务器密钥变得更加困难,因为这需要颁发一个新的自签名证书。
这个问题可以通过使用私有 CA来解决,并配置客户端和服务器都使用它作为唯一的信任锚。在这种模型中,SSL 双方不会检查特定的实体证书,而是信任任何由私有 CA 颁发的证书。这允许透明地进行密钥和证书的升级,而无需升级 SSL 客户端和服务器,只要 CA 证书仍然有效。缺点是,这种单一 CA 模型会创建一个单点故障;如果 CA 密钥被泄露,任何获得密钥的人都可以颁发所有客户端都会信任的伪造证书(正如我们稍后将看到的,这不仅仅限于私有 CA)。从这种情况恢复需要更新所有客户端并替换 CA 证书。
这个模型的另一个问题是,它无法用于那些无法预先知道需要连接到哪些服务器的客户端——通常是通用的互联网客户端,如网页浏览器、电子邮件应用程序和消息或 VoIP 客户端。此类通用客户端通常配置有一组信任锚点,其中包括知名发行者,我们称之为公共 CA。尽管存在某些指导原则和要求,选择公共 CA 作为默认信任锚点的过程在不同浏览器和操作系统之间差异很大。例如,为了将 CA 证书作为信任锚点包含在其产品中,Mozilla 要求该 CA 必须有公开的证书政策和认证实践声明(CP/CPS)文件,要求操作员账户实施多因素认证,并且该 CA 证书不得直接颁发终端实体证书。^([60])其他供应商可能有较宽松的要求。目前,大多数操作系统和浏览器的最新版本都包含超过 100 个作为信任锚点的 CA 证书。
公钥基础设施
当证书由公共 CA 颁发时,通常会在颁发证书之前进行某种形式的身份验证。验证过程在不同 CA 和所颁发证书的类型之间差异很大,从接受自动电子邮件地址确认(用于廉价的服务器证书)到要求多个政府颁发的身份证明和公司注册文件(用于扩展验证(EV)证书)。
公共证书授权机构(CA)依赖于多个人员、系统、程序和政策来执行实体验证,以及创建、管理和分发证书。这些参与方和系统的集合被称为公钥基础设施(PKI)。PKI 可以是无限复杂的,但在安全通信的上下文中,特别是在 SSL 的应用中,最重要的部分是 CA 证书,它们充当信任锚点,并在验证通信方身份时使用。因此,管理信任锚点将是我们讨论 Android SSL 和 PKI 实现的关键点之一。图 6-1 展示了一个典型 PKI 的简化表示。

图 6-1. PKI 实体
在这里,持有证书的人员或服务器被称为终端实体(EE)。为了获得证书,终端实体向注册机构(RA)发送证书请求。RA 从 EE 获取一些身份验证信息,并根据 CA 的政策要求验证其身份。在 RA 确认 EE 的身份后,RA 检查该身份是否与证书请求的内容匹配,如果匹配,则将请求转发给颁发 CA。颁发 CA 对 EE 证书请求进行签名,从而生成 EE 证书,并维护有关已颁发证书的吊销信息(将在下一节讨论)。另一方面,根 CA 不直接签署 EE 证书,而只签署颁发 CA 的证书以及关于颁发 CA 的吊销信息。根 CA 的使用非常少,通常保持离线状态,以提高其密钥的安全性。
在图 6-1 中描述的 PKI 中,EE 证书与两个 CA 证书相关联:签署该证书的颁发 CA 的证书和签署颁发 CA 证书的根 CA 的证书。这三个证书形成一个证书链(也称为认证路径)。链条以 EE 证书开始,以根 CA 证书结束。为了让 EE 证书被信任,它的认证路径需要指向系统隐式信任的证书(信任锚)。虽然中间证书可以用作信任锚,但通常由根 CA 证书执行此角色。
证书吊销
除了颁发证书外,CA 还可以通过吊销证书来标记证书为无效。吊销涉及将证书序列号和吊销原因添加到 CA 签名并定期发布的证书吊销列表(CRL)中。验证证书的实体可以通过搜索其序列号(在给定的 CA 中是唯一的)来检查证书是否已被吊销,查看它是否出现在颁发 CA 当前的 CRL 中。示例 6-2 展示了由 Google Internet Authority G2 颁发的示例 CRL 文件内容。在这个例子中,序列号为 40BF8571DD53E3BB ➊ 和 0A9F21196A442E45 ➋ 的证书已被吊销。
示例 6-2. CRL 文件内容
Certificate Revocation List (CRL):
Version 2 (0x1)
Signature Algorithm: sha1WithRSAEncryption
Issuer: /C=US/O=Google Inc/CN=Google Internet Authority G2
Last Update: Jan 13 01:00:02 2014 GMT
Next Update: Jan 23 01:00:02 2014 GMT
CRL extensions:
X509v3 Authority Key Identifier:
keyid:4A:DD:06:16:1B:BC:F6:68:B5:76:F5:81:B6:BB:62:1A:BA:5A:81:2F
X509v3 CRL Number:
219
Revoked Certificates:
Serial Number: 40BF8571DD53E3BB➊
Revocation Date: Sep 10 15:19:22 2013 GMT
CRL entry extensions:
X509v3 CRL Reason Code:
Affiliation Changed
--*snip*--
Serial Number: 0A9F21196A442E45➋
Revocation Date: Jun 12 17:42:06 2013 GMT
CRL entry extensions:
X509v3 CRL Reason Code:
Superseded
Signature Algorithm: sha1WithRSAEncryption
40:f6:05:7d:...
通过使用在线证书状态协议(OCSP),可以在不获取所有被吊销证书的完整列表的情况下检查吊销状态。^([61]) CRL 和 OCSP URI 通常作为扩展包含在证书中,以便验证方无需提前知道其位置。所有公共 CA 都维护吊销信息,但实际上,很多 SSL 客户端要么根本不检查吊销,要么即使远程方的证书被吊销,也允许连接(可能带有警告)。这种 SSL 客户端宽松行为的主要原因是获取当前吊销信息所需的开销以及确保连接的可用性。虽然增量 CRL(仅包含与上一个 CRL 版本的差异或 增量)和本地缓存在某种程度上缓解了这个问题,但主要 CA 的 CRL 通常非常庞大,必须在建立 SSL 连接之前下载,这会增加用户可见的延迟。OCSP 改善了这种情况,但仍然需要连接到另一台服务器,这再次增加了延迟。
无论哪种情况,由于 CA 基础设施中的网络或配置问题,吊销信息可能根本不可用。对于一个主要的 CA,吊销数据库故障可能会使大量安全站点瘫痪,这直接转化为其运营者的经济损失。最后,没有人喜欢连接错误,面对吊销错误时,大多数用户只会找到另一个更宽松的 SSL 客户端,它能“正常工作”。
JSSE 介绍
我们将在这里简要介绍 JSSE 的架构和主要组件。(要全面了解,请参见官方的 JSSE 参考指南。^([62]))
JSSE API 位于 javax.net 和 javax.net.ssl 包中,提供表示以下功能的类:
-
SSL 客户端和服务器套接字
-
一个用于生成和消费 SSL 流的引擎(
SSLEngine) -
创建套接字的工厂
-
一个创建安全套接字工厂和引擎的安全套接字上下文类(
SSLContext) -
基于 PKI 的密钥和信任管理器以及用于创建它们的工厂
-
一个用于 HTTPS(TLS 上的 HTTP,指定于 RFC 2818^([63])) URL 连接的类(
HttpsURLConnection)
就像 JCA 加密服务提供者一样,JSSE 提供者为 API 中定义的引擎类提供实现。这些实现类负责创建底层套接字以及建立连接所需的密钥和信任管理器,但 JSSE API 用户从不直接与它们互动,而是仅与相应的引擎类交互。让我们简要回顾 JSSE API 中的关键类和接口,以及它们之间的关系。
安全套接字
JSSE 支持基于流的阻塞 I/O 使用套接字和基于 NIO(新 I/O)通道的非阻塞 I/O。基于流的通信的核心类是 javax.net.ssl.SSLSocket,它可以通过 SSLSocketFactory 创建,或者通过调用 SSLServerSocket 类的 accept() 方法创建。反过来,SSLSocketFactory 和 SSLServerSocketFactory 实例是通过调用 SSLContext 类的适当工厂方法创建的。SSL 套接字工厂封装了创建和配置 SSL 套接字的细节,包括身份验证密钥、对等证书验证策略和启用的密码套件。这些细节通常对于应用程序使用的所有 SSL 套接字都是通用的,并且在初始化应用程序的 SSLContext 时进行配置。然后,它们会传递给由共享 SSLContext 实例创建的所有 SSL 套接字工厂。如果未显式配置 SSLContext,则会为所有 SSL 参数使用系统默认值。
非阻塞 SSL I/O 在 javax.net.ssl.SSLEngine 类中实现。该类封装了一个 SSL 状态机,并对其客户端提供的字节缓冲区进行操作。虽然 SSLSocket 隐藏了 SSL 的复杂性,但为了提供更大的灵活性,SSLEngine 将 I/O 和线程处理交给调用的应用程序。因此,SSLEngine 的客户端需要对 SSL 协议有一定了解。SSLEngine 实例是直接从 SSLContext 创建的,并继承其 SSL 配置,就像 SSL 套接字工厂一样。
对等身份验证
对等身份验证是 SSL 协议的一个重要组成部分,依赖于一组信任锚和身份验证密钥的可用性。在 JSSE 中,对等身份验证配置是通过 KeyStore、KeyManagerFactory 和 TrustManagerFactory 引擎类来提供的。KeyStore 代表一个存储加密密钥和证书的设施,可以用来存储信任锚证书以及终端实体的密钥和相关证书。KeyManagerFactory 和 TrustManagerFactory 分别基于指定的身份验证算法创建 KeyManager 或 TrustManager。虽然基于不同身份验证策略的实现是可能的,但实际上,SSL 仅使用基于 X.509 的 PKI(PKIX)进行身份验证,并且这些工厂类支持的唯一算法是 PKIX(别名为 X.509)。通过调用以下方法,可以使用一组 KeyManager 和 TrustManager 实例初始化 SSLContext。所有参数都是可选的,如果指定为 null,则使用系统默认值(参见 示例 6-3)。
示例 6-3. SSLContext 初始化方法
void init(KeyManager[] km, TrustManager[] tm, SecureRandom random);
TrustManager 决定是否信任呈现的对等身份验证凭据。如果信任,则建立连接;如果不信任,则终止连接。在 PKIX 的上下文中,这意味着基于配置的信任锚来验证呈现的对等证书的证书链。这也体现在 JSSE 使用的 X509TrustManager 接口中(参见 示例 6-4):
示例 6-4. X509TrustManager 接口方法
void checkClientTrusted(X509Certificate[] chain, String authType);
void checkServerTrusted(X509Certificate[] chain, String authType);
X509Certificate[] getAcceptedIssuers();
证书链验证是通过系统的 Java 认证路径 API(或 CertPath API)实现执行的,^([65]) 该实现负责构建和验证证书链。尽管该 API 具有某种与算法无关的接口,但在实践中它与 PKIX 密切相关,并实现了 PKIX 标准中定义的链构建和验证算法。默认的 PKIX TrustManagerFactory 实现可以创建一个 X509TrustManager 实例,该实例使用 KeyStore 对象中存储的信任锚来预配置底层的 CertPath API 类。
KeyStore 对象通常是从一个称为信任库的系统密钥库文件中初始化的。当需要更细粒度的配置时,可以使用包含详细 CertPath API 参数的 CertPathTrustManagerParameters 实例来初始化 TrustManagerFactory。当系统 X509TrustManager 实现无法通过提供的 API 按要求配置时,可以通过直接实现接口来创建自定义实例,可能会将基础案例委托给默认实现。
KeyManager 决定将哪些身份验证凭据发送到远程主机。在 PKIX 的上下文中,这意味着选择发送到 SSL 服务器的客户端身份验证证书。默认的 KeyManagerFactory 可以创建一个 KeyManager 实例,使用 KeyStore 来搜索客户端身份验证密钥和相关证书。与 TrustManager 一样,具体的接口 X509KeyManager(参见 示例 6-5)和 X509ExtendedKeyManager(允许进行连接特定的密钥选择)是 PKIX 特定的,并根据服务器提供的受信任颁发者列表选择客户端证书。如果默认的 KeyStore 支持的实现不够灵活,可以通过扩展抽象的 X509ExtendedKeyManager 类来提供自定义实现。
示例 6-5. X509KeyManager 接口
String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket);
String chooseServerAlias(String keyType, Principal[] issuers, Socket socket);
X509Certificate[] getCertificateChain(String alias);
String[] getClientAliases(String keyType, Principal[] issuers);
PrivateKey getPrivateKey(String alias);
String[] getServerAliases(String keyType, Principal[] issuers);
除了支持“原始”SSL 套接字外,JSSE 还提供了通过 HttpsURLConnection 类支持 HTTPS。HttpsURLConnection 使用默认的 SSLSocketFactory 来创建安全套接字,在与 Web 服务器建立连接时。如果需要额外的 SSL 配置,例如指定应用私有的信任锚或身份验证密钥,则可以通过调用静态方法 setDefaultSSLSocketFactory() 来为所有 HttpsURLConnection 实例替换默认的 SSLSocketFactory。或者,您可以通过调用其 setSSLSocketFactory() 方法为特定实例配置套接字工厂。
主机名验证
虽然 SSL 通过检查服务器证书来验证服务器身份,但协议并未强制要求任何主机名验证,且在使用原始 SSL 套接字时,证书主题不会与服务器主机名匹配。然而,HTTPS 标准确实要求进行这样的检查,HttpsURLConnection 在内部执行此操作。默认的主机名验证算法可以通过将 HostnameVerifier 实例分配给类或单个实例来覆盖。它需要实现的 verify() 回调在示例 6-6 中显示。回调中使用的 SSLSession 类封装了当前 SSL 连接的详细信息,包括选定的协议和密码套件、本地和对等证书链、对等主机名和连接端口号。
示例 6-6. HostnameVerifier 主机名验证回调
boolean verify(String hostname, SSLSession session);
我们已经讨论了构成 JSSE API 的主要类和接口,并介绍了它们之间的关系。它们的关系可以通过图 6-2 来可视化。

图 6-2. JSSE 类及其关系
Android JSSE 实现
Android 配备了两个 JSSE 提供者:基于 Java 的 HarmonyJSSE 和 AndroidOpenSSL 提供者,后者主要通过 JNI 桥接到公共 Java API 实现为本地代码。HarmonyJSSE 基于 Java 套接字和 JCA 类来实现 SSL,而 AndroidOpenSSL 通过使用 OpenSSL 库调用来实现其大部分功能。如第五章中所述,AndroidOpenSSL 是 Android 中首选的 JCA 提供者,它还提供了默认的 SSLSocketFactory 和 SSLServerSocketFactory 实现,分别由 SSLSocketFactory.getDefault() 和 SSLServerSocketFactory.getDefault() 返回。
两个 JSSE 提供者都属于核心 Java 库的一部分(分别位于 core.jar 和 libjavacore.so),而 AndroidOpenSSL 提供者的原生部分被编译到 libjavacrypto.so 中。HarmonyJSSE 仅支持 SSLv3.0 和 TLSv1.0,而 AndroidOpenSSL 则支持 TLSv1.1 和 TLSv1.2。虽然 SSL 套接字实现不同,但两个提供者共享相同的 TrustManager 和 KeyManager 代码。
注意
HarmonyJSSE 提供者仍然可以在 Android 4.4 中使用,但已被视为弃用且不再积极维护。它可能会在未来的 Android 版本中被移除。
除了当前的 TLS 协议版本外,基于 OpenSSL 的提供者还支持 服务器名称指示(SNI) TLS 扩展(在 RFC 3546^([66]) 中定义),它允许 SSL 客户端在连接到托管多个虚拟主机的服务器时指定目标主机名。在 Android 3.0 及之后版本中,建立连接时默认使用 SNI(版本 2.3 对 SNI 的支持有限)。然而,使用 Android 自带的 Apache HTTP 客户端库(位于 org.apache.http 包中)时,SNI 是不支持的。
在 Android 4.2 之前,Android 核心 Java 库中的 HTTP 栈,包括 HttpsURLConnection,是基于 Apache Harmony 代码实现的。在 Android 4.2 及之后的版本中,原始实现被 Square 的 HTTP 和 SPDY 客户端库 OkHttp 替代。^([67])
证书管理与验证
Android 的 JSSE 实现大多符合 JSSE API 规范,但也有一些显著的区别。最大的一点是 Android 如何处理系统信任存储。在 Java SE 的 JSSE 实现中,系统信任存储是一个单独的密钥存储文件(通常称为cacerts),其位置可以通过 javax.net.ssl.trustStore 系统属性设置,但 Android 采用了不同的策略。Android 的最新版本还提供了现代证书验证功能,如黑名单和固定证书,这些在原始的 JSSE 架构文档中并未指定。我们将在接下来的章节中讨论 Android 的信任存储实现和高级证书验证功能。
系统信任存储
如在“对等身份验证”中讨论的那样,JSSE 实现使用信任存储来验证连接对等方。虽然 SSL 确实支持仅加密、不进行身份验证的连接,但在实际操作中,原生的 SSL 客户端通常会执行服务器身份验证,并且 HTTPS 中对此是强制要求的。当没有明确提供每个应用的信任存储时,JSSE 会使用系统信任存储来进行 SSL 对等身份验证。系统信任存储对于像浏览器这样的通用 Internet 客户端尤为重要,因为它们通常不会在移动设备上管理自己的信任存储(Mozilla 客户端的桌面版本确实会维护私有的凭证和证书存储,但在 Android 上没有)。由于系统信任存储对所有使用 JSSE 的应用程序的安全性至关重要,我们将详细研究它们的实现。
在 Android 4.0 之前,操作系统的信任存储是硬编码到系统中的,用户对此完全无法控制。存储中的证书是由设备制造商或运营商单独选择的。唯一能做出更改的方式是对设备进行 root 操作,重新打包信任证书文件,并替换原始文件——这个过程显然不太实际,也是 Android 在企业 PKI 中使用的一大障碍。在多个主要 CA 被攻破之后,第三方工具应运而生,能够更改系统信任证书,但使用这些工具仍然需要一个已 root 的手机。幸运的是,Android 4.0 使得信任存储的管理变得更加灵活,并将控制谁可以信任的权力交给了用户。
Android 4.x 系统信任存储
在 Android 4.0 之前,系统信任存储是一个单独的文件:/system/etc/security/cacerts.bks,这是一个 Bouncy Castle(Android 使用的加密提供者之一;详见第五章)的原生密钥存储文件。它包含了 Android 所信任的所有 CA 证书,并被系统应用程序(如电子邮件客户端和浏览器)以及第三方应用程序使用。因为它位于只读的 system 分区上,所以即使是系统应用程序也无法更改它。
Android 4.0 引入了一个新的、更灵活的 TrustedCertificateStore 类,它允许维护内置的信任锚并添加新的信任锚。它仍然从 /system/etc/security/ 读取系统信任的证书,但增加了两个新的可变位置,用于存储 CA 证书,这些位置位于 /data/misc/keychain/ 下:cacerts-added/ 和 cacerts-removed/ 目录。示例 6-7 显示了它们的内容:
示例 6-7. cacerts-added/ 和 cacerts-removed/ 目录的内容
# ls -l /data/misc/keychain
drwxr-xr-x system system cacerts-added
drwxr-xr-x system system cacerts-removed
-rw-r--r-- system system 81 pubkey_blacklist.txt
-rw-r--r-- system system 7 serial_blacklist.txt
# ls -l /data/misc/keychain/cacerts-added
-rw-r--r-- system system 653 30ef493b.0➊
# ls -l /data/misc/keychain/cacerts-removed
-rw-r--r-- system system 1060 00673b5b.0➋
这些目录中的每个文件包含一个 CA 证书。文件名可能看起来很熟悉:它们是基于 CA 主题名称的 MD5 哈希值(使用 OpenSSL 的X509_NAME_hash_old()函数计算),如同在mod_ssl和其他使用 OpenSSL 实现的加密软件中使用的那样。这使得通过直接将 DN 转换为文件名,可以快速找到证书,而不需要扫描整个存储。
还要注意目录的权限:0775 system system确保只有system用户可以添加或移除证书,但任何人都可以读取它们。如预期所示,添加受信任的 CA 证书是通过将证书存储在适当文件名的cacerts-added/目录下实现的。存储在30ef493b.0文件中的证书(➊在示例 6-7 中)也将在受信任的证书系统应用程序的用户标签中显示(设置▸安全▸受信任的证书)。
但是,操作系统信任的证书是如何被禁用的呢?因为预安装的 CA 证书仍然存储在/system/etc/security/(该目录为只读挂载)中,通过将其证书的副本放置在cacerts-removed/目录下,可以将 CA 标记为不受信任。重新启用通过简单地删除该文件来实现。在这个特殊的例子中,00673b5b.0(➋在示例 6-7 中)是thawte Primary Root CA,在系统标签中显示为禁用(参见图 6-3)。
使用系统信任存储
TrustedCertificateStore 不是 Android SDK 的一部分,但它有一个包装器(TrustedCertificateKeyStoreSpi),可以通过标准的 JCA KeyStore API 访问,供应用程序使用(请参见示例 6-8)。
示例 6-8。 使用 AndroidCAStore 列出受信任的证书
KeyStore ks = KeyStore.getInstance("AndroidCAStore");➊
ks.load(null, null);➋
Enumeration<String> aliases = ks.aliases();➌
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
Log.d(TAG, "Certificate alias: " + alias);
X09Certificate cert = (X509Certificate) ks.getCertificate(alias);➍
Log.d(TAG, "Subject DN: " + cert.getSubjectDN().getName());
Log.d(TAG, "Issuer DN: " + cert.getIssuerDN().getName());
}

图 6-3。标记为不受信任的预安装 CA 证书
要获取当前受信任证书的列表,我们:
-
通过指定AndroidCAStore作为
type参数来创建一个KeyStore实例 ➊。 -
调用其
load()方法并将null传递给两个参数 ➋。 -
使用
aliases()方法获取证书别名的列表 ➌。 -
将每个别名传递给
getCertificate()方法以获取实际的证书对象 ➍。
当你检查这段代码的输出时,你会注意到证书别名以user:(表示用户安装的证书)或system:(表示预安装的证书)为前缀,后面跟着主题的哈希值。
AndroidCAStore KeyStore 实现使我们能够轻松访问操作系统的受信任证书,但在实际应用中,更关注的是是否应该信任某个特定的服务器证书,而不是当前的信任锚是什么。Android 通过将 TrustedCertificateKeyStoreSpi 与其 JSSE 实现集成,使这一过程变得非常简单。默认的 TrustManagerFactory 使用它来获取信任锚列表,从而自动将服务器证书与系统当前受信任的证书进行验证。因此,使用 HttpsURLConnection 或 HttpClient(都建立在 JSSE 之上)的高层代码应当能够正常工作,而无需担心创建和初始化自定义的 SSLSocketFactory。
为了将我们自己的 CA 证书(例如来自私有企业 CA 的证书)安装到系统信任库中,我们需要将其转换为 DER(二进制)格式并复制到设备上。在 Android 4.4.1 之前的版本中,证书文件需要复制到外部存储的根目录,并且扩展名为 .crt 或 .cer。Android 4.4.1 及之后版本使用了 Android 4.4 引入的存储访问框架,允许从设备可以访问的任何存储后端选择证书文件,包括像 Google Drive 这样的集成云服务提供商。然后,我们可以通过选择 设置▸个人▸安全性▸凭据存储▸从存储安装 来导入证书。会显示可用证书文件的列表,点击文件名即可打开导入对话框,如图 6-4 所示。
导入的证书将在受信任的凭据屏幕的用户标签页中显示(见图 6-5)。你可以通过点击列表项查看证书详情,并通过向下滚动到详情页面底部并点击移除按钮来删除它。
注意
如果证书成功导入,在 Android 4.4.1 之前的版本中,外部存储中的证书文件将被删除。

图 6-4. CA 证书导入对话框

图 6-5. 用户导入的 CA 证书
从 Android 4.4 开始,如果用户安装了任何受信任的证书,系统将显示一个通知,警告用户网络活动可能会被监控。SSL 连接监控可以通过使用拦截代理服务器来完成,该服务器为用户尝试访问的站点返回自动生成的证书。只要这些证书是由 Android 信任的 CA 颁发的(例如手动安装到信任存储中的证书),大多数应用无法分辨与原始主机和拦截代理之间的连接差异(除非它们已将目标主机固定;有关详细信息,请参见“证书固定”)。在快捷设置和系统设置中的安全性首选项旁边会显示一个警告图标。点击该图标时,通知会显示图 6-6 中显示的警告信息。

图 6-6。Android 4.4 中的网络监控警告
系统信任存储 API
第三方应用可以通过使用KeyChain API 提示用户将所需证书导入系统信任存储,该 API 也在 Android 4.0 中引入。(我们将在第七章讨论KeyChain API。)从 Android 4.4 开始,设备管理员应用可以在拥有MANAGE_CA_CERTIFICATES系统权限的情况下,悄无声息地将 CA 证书安装到系统信任存储中。(我们将在第九章介绍设备管理和相关 API。)
一旦 CA 证书导入系统信任存储,我们可以使用它来验证证书,使用 JSSE TrustManager API,如示例 6-9 所示。
示例 6-9。使用系统信任锚初始化TrustManager并验证证书
// Certificate chain including the end entity (server) certificate
// and any intermediate issuers.
X509Certificate[] chain = { endEntityCert };
TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");➊
tmf.init((KeyStore) null);➋
TrustManager[] tms = tmf.getTrustManagers();
X509TrustManager xtm = (X509TrustManager) tms[0];➌
Log.d(TAG, "checking chain with " + xtm.getClass().getName());
xtm.checkServerTrusted(chain, "RSA");➍
Log.d(TAG, "chain is valid");
为此,我们首先获取系统的 PKIX(别名为X509)TrustManagerFactory(见 示例 6-9 中的 ➊);通过向其 init(KeyStore ks) 方法传递 null 来使用系统信任库初始化它 ➋;然后获取指定算法的第一个 TrustManager 实现(通常只有一个,但在生产代码中请务必确认)并将其强制转换为与验证算法相关的 X509TrustManager 接口 ➌。最后,我们将证书链和所使用的密钥交换算法(如RSA、DHE_DSS等)传递给 checkServerTrusted() 方法 ➍。如果能够构建出一个指向受信 CA 证书的链,则验证通过,方法返回。如果链中的任何证书已过期或无效,或者链无法指向系统信任锚,则该方法将抛出 java.security.cert.CertificateException(或其子类中的一种)。使用 SSLSocket 和 HttpsURLConnection 建立的连接会自动执行类似的验证。
这段代码效果还不错,但有一个主要问题:它没有检查证书的吊销情况。Android 默认的 TrustManager 在验证证书链时明确关闭了吊销检查。因此,即使证书具有 CRL 分发点(CDP)扩展,指向有效的 CRL,或者在授权信息访问(AIA)扩展中包含了 OCSP 响应者 URI,并且证书实际上已经被吊销,它仍然会在 Android 中验证通过。这里缺少的功能是在线吊销检查:能够动态地获取、缓存并根据证书扩展中的信息更新吊销信息。
证书黑名单
与其使用在线吊销检查,Android 倚赖于 CA 和终端实体证书黑名单机制,接下来我们将讨论这个问题。证书黑名单指的是验证者明确阻止某些证书的使用,而不考虑它们在 PKI 仓库中的状态。黑名单机制并不是原始 PKI 哲学的一部分,也没有在任何相关标准中定义。那么,为什么在实际应用中它是必要的呢?
在一个理想的世界中,一个正常运作的 PKI(公钥基础设施)会负责根据需要发布、分发和撤销证书。系统只需要几个信任锚证书,就可以验证先前未知的机器和用户的身份:任何遇到的端实体证书都会由其中一个受信任的 CA 颁发,或者由它们的下级颁发机构(子 CA)颁发。然而,在实际中,存在许多问题,主要与处理密钥泄露相关。端实体证书的有效期相对较短(通常为一年),这限制了被泄露密钥的利用时间。然而,CA 证书的有效期非常长(通常为 20 年或更长),并且因为 CA 是隐式信任的,密钥泄露可能会长时间未被发现。最近一些顶级 CA 的安全漏洞表明,CA 密钥泄露并非理论问题,CA 泄露的后果可能非常广泛。
处理 CA 密钥泄露
可能最大的 PKI 问题是根证书的撤销并不真正得到支持。大多数操作系统和浏览器都预配置了一套受信任的 CA 证书(数量通常为几十个!),当 CA 证书被泄露时,有两种主要的处理方法:让用户从信任存储中移除它,或发布紧急更新以移除受影响的证书。显然,指望用户自己处理这个问题是不现实的,因此只能选择第二种方法。
Windows 通过 Windows 更新分发补丁来修改操作系统的信任锚,而浏览器厂商则只是发布新的补丁版本。然而,即使更新从系统信任存储中移除了 CA 证书,用户仍然可以重新安装它,尤其是在遇到“做这个,否则你将无法访问该站点”的最后通牒时。
为了确保被移除的信任锚不被恢复,它们的公钥哈希会被添加到黑名单中,操作系统或浏览器即使在用户的信任存储中也会拒绝它们。这种方法有效地撤销了 CA 证书(当然是在操作系统或浏览器的范围内),并解决了 PKI 无法处理泄露的信任锚的问题。然而,这并不完全理想,因为即使是紧急更新也需要一些时间来准备,而发布后,一些用户可能不会立即更新,无论他们被催促多少次。(幸运的是,CA 泄露事件相对较少,并且广泛宣传,因此在实践中似乎有效——至少目前如此。)也有提出过其他方法,但大多数并未得到广泛应用。我们将在“激进解决方案”中讨论一些提议的解决方案。
处理端实体密钥泄露
虽然证书颁发机构(CA)泄露事件相对少见,但最终实体(EE)密钥泄露的情况发生得要频繁得多。无论是由于服务器泄露、笔记本被盗,还是智能卡丢失,这些泄露事件每天都在发生。幸运的是,现代公钥基础设施(PKI)系统在设计时已经考虑到了这一点,CA 可以撤销证书,并以证书撤销列表(CRLs)的形式发布撤销信息,或者通过在线证书状态协议(OCSP)提供在线撤销状态。
不幸的是,这在实际应用中并不太有效。撤销检查通常需要访问与我们尝试连接的机器不同的网络机器,因此失败率相对较高。为了缓解这一问题,大多数浏览器会尝试获取最新的撤销信息,但如果由于某种原因失败,它们会简单地忽略错误(软失败),或者在最好情况下,显示一些视觉提示,表明撤销信息不可用。
注
为了解决这个问题,Google Chrome 完全禁用了在线撤销检查([68]),*并且现在使用其更新机制主动将撤销信息推送到浏览器,而无需应用程序更新或重启。*([69]) 因此,Chrome 可以拥有一个最新的本地撤销信息缓存,这使得证书验证更加快速且可靠。这可以被视为另一个黑名单(Chrome 称之为CRL 集),这次基于每个 CA 发布的信息。浏览器厂商有效地代表用户管理撤销数据是一个相当新颖的做法;虽然并不是每个人都认为这是个好主意,但到目前为止它表现得非常好。
直接将撤销信息作为浏览器更新的一部分推送的替代方案是OCSP stapling,以前称为 TLS 证书状态请求 扩展。^([70]) 与其要求客户端向服务器证书发出 OCSP 请求,不如将相关响应(“钉住”)在 SSL 握手过程中通过证书状态请求扩展响应一起包含。由于响应由 CA 签名,客户端可以像直接从 CA 的 OCSP 服务器获取它一样信任这个响应。如果服务器没有在 SSL 握手中包含 OCSP 响应,客户端则需要自行获取一个。OCSP stapling 已被所有主流 HTTP 服务器支持,但浏览器支持仍然不稳定,尤其是在移动版本中,由于延迟问题。
Android 证书黑名单
正如我们在“Android 4.x 系统信任库”中所学,Android 4.0 添加了管理 UI,以及一个 SDK API,允许将信任锚添加到系统信任库或移除它们。然而,这并没有完全解决 PKI 的首要问题:除了用户手动禁用受损的信任锚外,仍然需要操作系统更新才能移除受损的 CA 证书。此外,由于 Android 在验证证书链时不执行在线吊销检查,因此即使端实体证书已经被吊销,也无法检测到受损的证书。
为了解决这个问题,Android 4.1 引入了证书黑名单,可以在不需要操作系统更新的情况下进行修改。目前有两个系统黑名单:
-
公钥哈希黑名单(用于处理受损的 CA)
-
序列号黑名单(用于处理受损的 EE 证书)
证书链验证器组件在验证网站或用户证书时会考虑这两个黑名单。让我们更详细地看一下如何实现这一点。
Android 使用内容提供者将操作系统设置存储在系统数据库中。一些设置可以由持有必要权限的第三方应用修改,而一些则保留给系统,仅能在系统设置中更改,或通过其他系统应用更改。保留给系统的设置被称为安全设置。Android 4.1 在以下 URI 下新增了两个安全设置:
-
content://settings/secure/pubkey_blacklist
-
content://settings/secure/serial_blacklist
正如名字所示,第一个黑名单存储受损 CA 的公钥哈希,第二个黑名单则存储 EE 证书的序列号列表。此外,系统服务器现在启动了一个CertBlacklister组件,该组件注册为这两个黑名单 URI 的ContentObserver。每当写入任何黑名单安全设置的新值时,CertBlacklister会收到通知并将该值写入磁盘上的文件。文件包含一个由逗号分隔的十六进制编码公钥哈希或证书序列号的列表。文件为:
-
证书黑名单:/data/misc/keychain/pubkey_blacklist.txt
-
序列号黑名单:/data/misc/keychain/serial_blacklist.txt
为什么要将文件写入磁盘,而它们已经可以在设置数据库中找到?因为实际使用黑名单的组件是一个标准的 Java CertPath API 类,它对 Android 及其系统数据库一无所知。证书路径验证器类PKIXCertPathValidatorSpi是 Bouncy Castle JCA 提供程序的一部分,经过修改以处理证书黑名单,这是 Android 特有的功能,标准的 CertPath API 中没有定义。该类实现的 PKIX 证书验证算法相当复杂,但 Android 4.1 所添加的功能相对简单:
-
在验证 EE(叶子)证书时,检查其序列号是否在序列号黑名单中。如果在,则返回与证书已被吊销相同的错误(异常)。
-
在验证 CA 证书时,检查其公钥的哈希值是否在公钥黑名单中。如果在,则返回与证书已被吊销相同的错误。
注意
如果使用不合格的序列号来索引被列入黑名单的 EE 证书,可能会出现问题,如果两个或更多来自不同 CA 的证书恰好具有相同的序列号。在这种情况下,仅列入黑名单其中一张证书将有效地将所有具有相同序列号的证书都列入黑名单。然而在实际中,大多数公共 CA 使用长且随机生成的序列号,因此碰撞的概率非常低。
证书路径验证器组件在整个系统中都被使用,因此黑名单会影响使用 HTTP 客户端类的应用程序,以及原生 Android 浏览器和 WebView。如上所述,修改黑名单需要系统权限,因此只有核心系统应用程序才能进行修改。AOSP 源代码中没有实际调用这些 API 的应用程序,但一个管理黑名单的好候选者是 Google 服务组件,这些组件可以在“Google Experience”设备上找到(即预安装了 Play Store 客户端的设备)。这些组件管理 Google 帐户和 Google 服务的访问,并通过 Google 客户端消息传递(GCM)提供推送式通知。由于 GCM 允许实时服务器发起的推送通知,因此可以放心地认为这些推送将用于触发证书黑名单更新。
重新审视 PKI 信任模型
Android 已采取措施,使其信任库更加灵活,允许按需修改信任锚点和证书黑名单,而无需系统更新。尽管证书黑名单确实使 Android 更加抵抗某些与公钥基础设施(PKI)相关的攻击和漏洞,但它并不能完全解决所有与使用公共 CA 签发的证书相关的问题。接下来我们将介绍一些这些问题以及提出的解决方案。然后,我们将通过描述 Android 实现其中一种解决方案——证书钉扎来结束我们对 PKI 和 SSL 的讨论。
当今 PKI 中的信任问题
在极不可能的情况下,如果你还没听说过,现有公共 CA 模型的可信度近年来遭到严重破坏。它已经存在一些问题,但最近一些高调的 CA 安全漏洞将这个问题推到了风口浪尖。攻击者成功地为许多网站签发了证书,包括 Windows 更新服务器和 Gmail。尽管并非所有这些证书都在实际攻击中被使用(或至少没有被检测到),但这些事件展示了当前互联网技术在多大程度上依赖于证书。
欺诈性证书可以用于从安装恶意软件到监视互联网通信的一切活动,同时欺骗用户认为他们正在使用安全的通道或安装受信的可执行文件。不幸的是,仅仅提高 CA 的安全性并不是解决方案,因为一些主要的 CA 乐意为像localhost、webmail和exchange这样不合格的名称颁发数百个证书^([71])。为不合格主机名颁发的证书可以用来对访问内部服务器时使用不合格名称的客户端发动中间人(MITM)攻击,从而轻松监听内部企业流量。当然,还有被强制颁发证书的问题,政府机构可以强迫 CA 颁发虚假证书,用于拦截安全通信流量。
显然,当前的 PKI 系统主要基于一组预选的受信 CA(其证书作为信任锚预先安装),这系统存在问题,但到底有哪些实际问题呢?对此有不同的看法,但首先,有太多的公共 CA。电子前沿基金会的 SSL 天文台项目^([72]) 显示,主流浏览器信任超过 650 个公共 CA。最近的安卓版本预装了超过 100 个受信 CA 证书,且直到 4.0 版本之前,移除信任证书的唯一方式是通过厂商发布的操作系统更新。
此外,通常没有技术性限制,规定 CA 可以颁发哪些证书。正如 Comodo 和 DigiNotar 攻击事件,以及最近的 ANNSI^([73]) 中间 CA 事件所示,任何人都可以为 .google.com(根 CA 不适用名称限制,并且对公共 CA 也没有太大作用)颁发证书。此外,由于 CA 不会公开他们已颁发的证书,网站运营商(在这种情况下是 Google)无法知道有人为他们的网站颁发了新的、可能是欺诈性的证书,并采取适当的措施(证书透明度标准^([74]) 旨在解决这个问题)。简而言之,在当前的系统下,如果任何内置的信任锚被攻破,攻击者就可以为任何网站颁发证书,而访问该网站的用户和网站所有者都不会察觉。
激进的解决方案
提出的解决方案从激进到温和不等——激进的方案是完全抛弃现有的公钥基础设施(PKI)概念,替换成一种全新的、更好的方法(DNSSEC 通常是一个常见的选择);温和的方案是利用现有的基础设施,但不对证书颁发机构(CA)进行隐性信任;进化性的方案则是保持与现有系统的兼容性,但通过扩展其功能来限制 CA 被攻破的损害。
不幸的是,DNSSEC 仍未普遍部署,尽管关键的顶级域名(TLD)域已经签名。此外,它本质上是层级化的——国家顶级域名由各自的国家控制——实际上比 PKI 更为僵化,因此并不完全适用。改善当前 PKI 状况是一个活跃的研究领域,其他可行的激进解决方案尚未出现。
向中庸方向发展,SSH 模型也被提出(有时称为首次使用时信任,或TOFU)。在这种模型中,最初没有任何网站或证书颁发机构(CA)被信任,用户决定首次访问时信任哪个网站。然而,与 SSH 不同,你直接或间接(通过 CDN、嵌入式内容等)访问的网站数量几乎是无限的,用户管理信任显得非常不现实。
Convergence 和信任灵活性
与此类似,但更为实用的是 Convergence。^([75])Convergence 是一个基于 信任灵活性 理念的系统,信任灵活性定义为“能够轻松选择你信任的人,并随时修订该决定。”它既废除了浏览器(或操作系统)预选的信任锚集,又认识到用户无法独立做出关于他们访问的所有网站的信任决策。信任决策被委托给一组公证人,这些公证人可以通过确认你从网站收到的证书是否是他们之前见过的来为网站背书。如果多个公证人指出同一个证书是正确的,用户可以合理地确信它是真实的,因此是值得信任的。
Convergence 并不是一个正式标准,但已经发布了一个工作实现,包括 Firefox 插件(客户端)和服务器端公证软件。虽然这个系统很有前景,但目前可用的公证人数量有限,谷歌已经公开表示不会将其添加到 Chrome 中。此外,由于 Chrome 不允许第三方扩展覆盖默认的证书验证模块,因此目前无法作为浏览器扩展实现。
证书钉扎
这就引出了当前的演化解决方案,这些方案已经部署给了相当大范围的用户,主要得益于 Chrome 浏览器。其中一个是证书黑名单,我们已经讨论过了,另一个是证书钉扎。
证书固定(更准确地说,是 公钥固定)采取了与黑名单方法相反的做法:它将被信任的用于签发特定站点证书的密钥列入白名单。固定功能最初是在 Google Chrome 版本 13 中引入的,目的是限制可以为 Google 属性签发证书的 CA。它通过维护一份受信任的公钥列表来实现,这些公钥被信任用于签发特定 DNS 名称的证书。验证主机的证书链时,会查阅该列表,如果链中没有至少一个白名单中的密钥,验证就会失败。实际上,浏览器会保留受信任证书的 SubjectPublicKeyInfo(SPKI)字段的 SHA-1 哈希列表。将公钥固定而不是固定实际的证书,允许更新主机证书而不会破坏验证,也不需要更新固定信息。
然而,硬编码的固定列表并不能真正扩展,因此已经提出了几个新的互联网标准来帮助解决这一可扩展性问题:Google 提出的 HTTP 公钥固定扩展(PKPE)^([76]) 和 Moxie Marlinspike 提出的证书密钥信任断言(TACK)^([77])。第一个较为简单,提出了一个新的 HTTP 头部(Public-Key-Pin,或 PKP),用于保存主机证书的固定信息。该头部值可以包括公钥哈希、固定的有效期以及一个标志,用于指定是否将固定应用于当前主机的子域。固定信息(或简单地称为 固定项)由浏览器缓存,并在做出信任决策时使用,直到它过期。固定项需要通过安全(SSL)连接传输,并且第一个包含 PKP 头部的连接被隐式信任(或可以选择验证客户端中内置的固定项)。该协议还支持通过 report-uri 指令报告验证失败的端点,并允许非强制模式(通过 Public-Key-Pins-Report-Only 头部指定),在该模式下,验证失败会被报告,但连接仍然允许。这使得可以通知主机管理员他们站点可能遭遇的 MITM 攻击,以便他们采取适当的措施。
另一方面,TACK 提案则稍微复杂一些,它定义了一个新的 TLS 扩展(也叫 TACK),用于携带用专用TACK 密钥签名的固定信息。连接到固定主机名的 TLS 连接要求服务器提供一个包含固定密钥和相应签名的“tack”,这个签名是对 TLS 服务器公钥的签名。因此,固定信息的交换和验证都是在 TLS 层进行的。相比之下,PKPE 使用 HTTP 层(通过 TLS)向客户端发送固定信息,但也要求在 TLS 层进行验证,如果验证失败,则断开连接。
现在我们已经了解了固定功能的工作原理,接下来我们看看它在 Android 上是如何实现的。
Android 中的证书固定
证书固定是 Android 4.2 中引入的众多安全增强功能之一。操作系统本身并不带有任何内置的固定项,而是从/data/misc/keychain/目录中的文件读取它们(该目录存储了用户添加的证书和黑名单)。该文件名为pins,其格式如下所示(参见示例 6-10):
示例 6-10. 系统固定文件格式
hostname=enforcing|SPKI SHA512 hash, SPKI SHA512 hash,...
在这里,enforcing的值为true或false,后面跟着一个由逗号分隔的 SPKI SHA-512 哈希列表。请注意,固定项没有有效期,因此它们在被删除之前是有效的。该文件不仅由浏览器使用,还被系统广泛使用,因为固定功能已集成在libcore中。实际上,这意味着默认(也是唯一的)系统X509TrustManager实现(TrustManagerImpl)在验证证书链时会查询固定列表。
但有一个变化:标准的checkServerTrusted()方法并不会查询固定列表。因此,任何不支持证书固定的旧版库仍然会按照原来的方式运行,无论固定列表的内容如何。这可能是出于兼容性考虑,因此需要注意:在 Android 4.2 或以上版本运行并不意味着你可以享受到系统级证书固定的好处。固定功能通过新的X509TrustManagerExtensions SDK 类暴露给第三方库和应用程序。该类有一个方法,checkServerTrusted()(完整签名请参见示例 6-11),成功时返回验证后的链,若验证失败则抛出CertificateException。
示例 6-11. X509TrustManagerExtensions证书验证方法
List<X509Certificate> checkServerTrusted(X509Certificate[] chain, String authType, String host)
最后一个参数host是底层实现(TrustManagerImpl)用来在固定列表中查找匹配固定项的依据。如果找到匹配项,将会检查验证中的公钥是否与该主机的固定项哈希匹配。如果没有匹配项,验证将失败,且会抛出CertificateException。
那么系统的哪个部分使用了新的固定功能呢?默认的 SSL 引擎(JSSE 提供者),即客户端握手(ClientHandshakeImpl)和 SSL 套接字(OpenSSLSocketImpl)实现会检查其底层的X509TrustManager,如果它支持固定功能,则会对固定列表进行额外验证。如果验证失败,连接将无法建立,从而在 TLS 层实现证书固定验证,符合前面章节讨论的标准要求。
pins 文件并不是由操作系统直接写入的。它的更新是由一个广播触发的(android.intent.action.UPDATE_PINS),该广播在其附加信息中包含了新的 pins。附加信息包括新 pins 文件的路径、其新版本(存储在 /data/misc/keychain/metadata/version/)、当前 pins 的哈希值以及对上述所有内容的 SHA512withRSA 签名。广播的接收者(CertPinInstallReceiver)随后验证版本、哈希值和签名,如果有效,原子性地将当前 pins 文件替换为新的内容(更新付费短信号码列表时也使用相同的过程)。签署新的 pins 确保它们只能由控制私钥签名的人进行更新。用于验证的公钥存储为系统安全设置,位于 config_update_certificate 键下(通常在 /data/data/com.android.providers.settings/ 数据库/settings.db 的安全表中)。 (截至本文撰写时,Nexus 设备上的 pins 文件包含超过 40 条 pin 记录,涵盖了大多数 Google 服务,包括 Gmail、YouTube 和 Play Store 服务器。)
摘要
Android 基于标准的 Java API,如 JSSE 和 CertPath,来实现 SSL 连接和所需的认证机制。大多数安全套接字功能是通过主要基于 OpenSSL 的本地 JSSE 实现提供的,而证书验证和信任存储管理则是用 Java 实现的。Android 提供了一个共享的系统信任存储,可以通过设置界面或 KeyStore API 来管理。所有使用 SSL 或证书验证 API 的应用程序都会继承系统的信任锚,除非明确指定了特定应用的信任存储。Android 中的证书验证不使用在线吊销检查,而是依赖系统证书黑名单来检测被破坏的 CA 或终端实体证书。最后,Android 的最新版本支持系统级证书钉扎,以便能够限制哪些证书可以为特定主机颁发服务器证书。
^([58]) T. Dierks 和 E. Rescorla,传输层安全协议(TLS)版本 1.2,2008 年 8 月,tools.ietf.org/html/rfc5246
^([59]) A. Freier, P. Karlton 和 P. Kocher,安全套接字层(SSL)协议版本 3.0,2011 年 8 月,tools.ietf.org/html/rfc6101
^([60]) Mozilla,Mozilla CA 证书包含政策(版本 2.2),www.mozilla.org/en-US/about/governance/policies/security-group/certs/policy/inclusion/
^([61]) S. Santesson 等人,X.509 互联网公共密钥基础设施在线证书状态协议 - OCSP,2013 年 6 月,tools.ietf.org/html/rfc6960
^([62]) Oracle,Java™安全套接字扩展(JSSE)参考指南,docs.oracle.com/javase/7/docs/technotes/guides/security/jsse/JSSERefGuide.html
^([63]) E. Rescorla,通过 TLS 的 HTTP,2000 年 5 月,tools.ietf.org/html/rfc2818
^([64]) D. Cooper 等人,互联网 X.509 公共密钥基础设施证书和证书吊销列表(CRL)配置文件,2008 年 5 月,tools.ietf.org/html/rfc5280
^([65]) Oracle,Java™ PKI 程序员指南,docs.oracle.com/javase/7/docs/technotes/guides/security/certpath/CertPathProgGuide.html
^([66]) S. Blake-Wilson 等人,传输层安全性(TLS)扩展,2003 年 6 月,tools.ietf.org/html/rfc3546
^([67]) Square, Inc.,OkHttp:Android 和 Java 应用程序的 HTTP & SPDY 客户端,square.github.io/okhttp/
^([68]) Adam Langley,吊销检查与 Chrome 的 CRL,2012 年 2 月,www.imperialviolet.org/2012/02/05/crlsets.html
^([69]) 通过设置EnableOnlineRevocationChecks选项为true(默认为false),仍然可以启用在线吊销检查。
^([70]) D. Eastlake 3rd, 传输层安全性(TLS)扩展:扩展定义,第八部分,2011 年 1 月,tools.ietf.org/html/rfc6066#section-8
^([71]) 电子前沿基金会,SSL 观测站中的非限定名称,2011 年 4 月,www.eff.org/deeplinks/2011/04/unqualified-names-ssl-observatory
^([72]) 电子前沿基金会,EFF SSL 观测站,www.eff.org/observatory
^([73]) 信息系统安全国家局,法国网络与信息安全局
^([74]) B. Laurie, A. Langley 和 E. Kasper,证书透明性,2013 年 6 月,tools.ietf.org/html/rfc6962
^([75]) Thoughtcrime Labs,Convergence,convergence.io/
^([76]) C. Evans, C. Palmer 和 R. Sleevi,HTTP 的公钥钉扎扩展,2014 年 8 月 7 日,tools.ietf.org/html/draft-ietf-websec-key-pinning-20
^([77]) M. Marlinspike, 证书密钥的信任声明,2013 年 1 月 7 日,tack.io/draft.html
第七章. 凭证存储
上一章介绍了公钥基础设施(PKI)及其在管理信任过程中遇到的挑战。PKI 最常见的用途是认证你连接的实体(服务器认证),但它也用于认证你自己给这些实体(客户端认证)。客户端认证主要出现在企业环境中,用于从桌面登录到远程访问公司服务器等各种场景。基于 PKI 的客户端认证要求客户端通过执行一些加密操作来证明其拥有认证密钥(通常是 RSA 私钥),服务器可以独立验证这些操作。因此,客户端认证的安全性在很大程度上依赖于防止未经授权的使用认证密钥。
大多数操作系统提供系统服务,应用可以利用该服务安全地存储和访问认证密钥,而无需自己实现密钥保护。Android 自 1.6 版本起就提供了这样的服务,并且自 Android 4.0 以来有了显著改进。
Android 的凭证存储可以用来存储内置功能的凭证,如 Wi-Fi 和 VPN 连接凭证,以及第三方应用的凭证。应用可以通过标准 SDK API 访问凭证存储,并利用它安全地管理密钥。最近的 Android 版本引入了硬件支持的密钥存储,提供了更强的密钥保护。 本章讨论了 Android 凭证存储的架构和实现,并介绍了它提供的公共 API。
VPN 和 Wi-Fi EAP 凭证
虚拟专用网络 (VPN) 是提供远程访问私有企业服务的首选方式。我们将在第九章中更详细地讨论 VPN 及相关技术,但简单来说,VPN 通过在远程客户端和公共隧道端点之间创建加密隧道,使远程客户端能够加入私有网络。VPN 的实现方式在隧道技术的使用上有所不同,但在建立安全连接之前,所有 VPN 都需要验证客户端身份。虽然某些 VPN 使用共享密钥或密码进行认证,但企业解决方案通常依赖基于公钥基础设施(PKI)的客户端认证。
可扩展认证协议 (EAP) 是一种在无线网络和点对点(P2P)连接中常用的认证框架。(EAP 在第九章中有更详细的讨论。)像 VPN 一样,EAP 可以使用多种不同的认证方法,但在企业环境中,EAP-传输层安全性 (EAP-TLS) 被首选,特别是在公司 PKI 已经部署的情况下。
认证密钥和证书
在 EAP-TLS 和基于 PKI 的 VPN 的情况下,客户端拥有身份验证密钥,并被颁发一个匹配的证书,通常由公司证书授权中心(CA)颁发。密钥有时存储在便携式、防篡改的设备中,如智能卡或 USB 密钥。这大大提高了安全性,因为密钥不能从设备中导出或提取,因此身份验证既需要物理持有令牌,又需要知道相关的 PIN 或密码。
当安全策略允许使用不受硬件设备保护的身份验证密钥时,密钥及其相关证书通常存储在标准的 PKCS#12 文件格式中。存储在 PKCS#12 文件中的私钥使用从用户提供的密码派生的对称密钥加密,因此提取密钥需要知道密码。有些应用程序使用 PKCS#12 文件作为安全容器,只有在需要时才将密钥和证书提取到内存中,但通常它们会在使用之前被导入到系统或应用程序特定的凭据存储中。这也是 Android 的工作方式。
在 Android 上导入凭据的用户界面实现相当简单:为了导入身份验证密钥和相关证书,用户将其 PKCS#12 文件(如果需要,还包括任何相关的 CA 证书)复制到设备的外部存储(通常是 SD 卡),然后从安全性系统设置屏幕中选择从存储安装。Android 会在外部存储的根目录中搜索匹配的文件(扩展名为 .pfx 或 .p12),并呈现一个导入对话框(参见 图 7-1)。如果提供了正确的密码,密钥将从 PKCS#12 文件中提取并导入到系统凭据存储中。
系统凭据存储
系统凭据存储是一个系统服务,在将导入的凭据存储到磁盘之前,会先对它们进行加密。加密密钥来自用户提供的密码:在 4.0 版本之前为专用的凭据存储保护密码,或者在 4.0 版本之后为设备解锁滑动模式、PIN 或密码。此外,凭据存储系统服务还会调节对存储凭据的访问,确保只有明确被授予访问权限的应用程序才能访问密钥。
原始凭据存储在 Android 1.6 中引入,最初仅限于存储 VPN 和 Wi-Fi EAP 凭据。只有系统—而非第三方应用程序—能够访问存储的密钥和证书。此外,导入凭据的唯一受支持方式是通过前面部分中概述的系统设置界面进行操作,并且没有公开的凭据存储管理 API。
访问系统凭据存储的 API 首次在 Android 4.0 中引入。系统凭据存储后来扩展为支持硬件加速的凭据存储,并提供不仅是共享系统密钥,还有应用私有密钥。表 7-1 显示了每个 Android 版本中添加的主要凭据存储增强功能的摘要。我们将在接下来的章节中介绍这些增强功能及相关 API。

图 7-1. PKCS#12 文件密码对话框
表 7-1. 凭据存储功能进展
| Android 版本 | API 等级 | 凭据存储变化 |
|---|---|---|
| 1.6 | 4 | 增加了 VPN 和 Wi-Fi 的凭据存储支持。 |
| 4.0 | 14 | 添加了凭据存储的公共 API(KeyChain API)。 |
| 4.1 | 16 | 增加了生成和使用密钥而不导出的能力。引入了 keymaster HAL 模块,并对硬件加速 RSA 密钥存储提供了初步支持。 |
| 4.3 | 18 | 增加了通过AndroidKeyStore JCA 提供程序生成和访问应用私有密钥的支持,并增加了检查设备是否支持硬件加速 RSA 密钥存储的 API。 |
| 4.4 | 19 | 向AndroidKeyStore JCA 提供程序添加了对 ECDSA 和 DSA 的支持。 |
凭据存储实现
现在我们知道,Android 能够加密导入的凭据并管理对它们的访问。接下来我们看看这些是如何在底层实现的。
凭据存储服务
Android 中的凭据存储管理最初是由一个名为keystore的本地守护进程实现的。其最初功能仅限于以加密形式存储任意的二进制数据并验证凭据存储密码,但随着 Android 的发展,它被扩展了新功能。它为客户端提供了基于本地套接字的接口,每个客户端负责管理自己的状态和套接字连接。为了更好地与其他框架服务集成并促进扩展,Android 4.3 中将keystore守护进程替换为集中式的 Binder 服务。接下来我们来看一下这个keystore服务是如何工作的。
keystore服务定义在init.rc中,如示例 7-1 所示。
示例 7-1. keystore 服务定义在 init.rc
service keystore /system/bin/keystore /data/misc/keystore
class main
user keystore
group keystore drmrpc
如你所见,keystore服务以专用的keystore用户身份运行,并将文件存储在/data/misc/keystore/中。我们先来看一下/data/misc/keystore/目录。如果你使用的是单用户设备,例如手机,你只会在keystore/目录下找到一个user_0/目录(见示例 7-2,时间戳已删除),但在启用多用户的设备上,你应该会为每个 Android 用户找到一个目录。
示例 7-2. 单用户设备上的密钥存储目录示例内容
# ls -la /data/misc/keystore/user_0
-rw------- keystore keystore 84 .masterkey
-rw------- keystore keystore 980 1000_CACERT_cacert
-rw------- keystore keystore 756 1000_USRCERT_test
-rw------- keystore keystore 884 1000_USRPKEY_test
-rw------- keystore keystore 724 10019_USRCERT_myKey
-rw------- keystore keystore 724 10019_USRCERT_myKey1
在此示例中,每个文件名由创建它的应用程序的 UID(1000 是系统)、条目类型(CA 证书、用户证书或私钥)和密钥名称(别名)组成,并用下划线连接。从 Android 4.3 开始,系统和应用私有密钥也得到了支持,UID 反映了 Android 用户 ID 以及应用程序 ID。在多用户设备中,用户 ID 为UID / 100000,如第四章中所述。
除了系统或应用程序拥有的密钥二进制数据外,还有一个单独的.masterkey文件,稍后我们将讨论这个文件。当一个拥有存储管理密钥的应用程序被用户卸载时,仅会删除该用户创建的密钥。如果应用程序从系统中完全删除,它的密钥会对所有用户删除。由于密钥访问与应用程序 ID 相关联,这个功能防止了另一个恰好获得相同 UID 的应用程序访问已卸载应用程序的密钥。(密钥存储重置会删除密钥文件和主密钥,并且仅影响当前用户。)
在默认的软件实现中,这些文件包含以下内容(硬件支持的实现可能有所不同;它们通常存储的不是加密的密钥数据,而是仅存储指向硬件管理密钥对象的引用):
-
主密钥(存储在.masterkey中)使用从屏幕解锁密码派生的 128 位 AES 密钥进行加密,该密钥通过应用PBKDF2密钥派生函数,进行 8192 次迭代并使用随机生成的 128 位盐值。该盐值存储在.masterkey文件的头部信息中。
-
所有其他文件存储的是密钥二进制数据。密钥二进制数据(binary large object,简称 key blob)包含一个序列化的、可选加密的密钥,以及一些描述该密钥的数据(元数据)。每个密钥存储的密钥二进制数据包含一个元数据头部、用于加密的初始向量(IV),以及一个将数据的 MD5 哈希值与数据本身连接的字符串,并使用 128 位 AES 主密钥进行 CBC 模式加密。或者更简洁地表示为:
metadata || Enc(MD5(data) || data)。
实际上,这种架构意味着,Android 密钥存储对于软件解决方案来说是相当安全的。即使你有访问权限到一个已经获取 root 权限的设备,并设法提取密钥二进制数据,你仍然需要密钥存储密码来推导出主密钥。为了尝试不同的密码以解密主密钥,至少需要进行 8192 次迭代才能推导出一个密钥,这在成本上是不可接受的。此外,由于推导函数使用 128 位随机数作为种子,不能使用预计算的密码表。不过,使用的基于 MD5 的完整性机制并未采用标准的消息认证码(MAC)算法,例如 HMAC,而是原始实现的遗留部分。它保留是为了向后兼容,但在未来的版本中可能会被替换。
密钥二进制数据版本和类型
从 Android 4.1 开始,密钥数据中添加了两个字段:version和type。当前版本(截至 Android 4.4)是2,当应用首次访问密钥时,密钥数据会自动升级到最新版本。根据目前的信息,已定义以下密钥类型:
-
TYPE_ANY -
TYPE_GENERIC -
TYPE_MASTER_KEY -
TYPE_KEY_PAIR
TYPE_ANY是一个元密钥类型,可以匹配任何密钥类型。TYPE_GENERIC用于通过原始的 get/put 接口保存的密钥数据,这个接口存储任意的二进制数据,而TYPE_MASTER_KEY当然仅用于 keystore 的主密钥。TYPE_KEY_PAIR类型用于通过generate_keypair和import_keypair操作创建的密钥数据,这些操作是 Android 4.1 中新增的功能。我们将在“keymaster 模块和 keystore 服务实现”一节中讨论这些内容。
Android 4.3 是第一个使用密钥数据的flags字段的版本。它使用这个字段来区分加密的(默认)和未加密的密钥数据。受硬件实现保护的密钥数据(某些设备上可用)会在没有额外加密的情况下存储。
访问限制
密钥数据由keystore用户拥有,因此在普通(非 root)设备上,你需要通过keystore服务才能访问它们。keystore服务施加以下访问限制:
-
root用户无法锁定或解锁 keystore,但可以访问系统密钥。
-
system用户可以执行大多数 keystore 管理操作(如初始化、重置等),并存储密钥。然而,system用户无法使用或检索其他用户的密钥。
-
非系统用户可以插入、删除和访问密钥,但只能查看自己的密钥。
现在我们知道了keystore服务的作用,接下来让我们看看实际的实现。
keymaster 模块和 keystore 服务实现
虽然原始的守护进程实现将密钥数据管理和加密功能合并在一个二进制文件中,但 Android 4.1 引入了新的keymaster 硬件抽象层(HAL)系统模块,负责生成非对称密钥以及签名/验证数据,而无需先导出密钥。
keymaster模块旨在将keystore服务与底层的非对称密钥操作实现解耦,并允许更容易地集成特定设备的硬件支持实现。一个典型的实现会使用厂商提供的库与加密硬件进行通信,并提供一个“粘合”HAL 库,供keystore守护进程链接使用。
Android 还自带了一个默认的softkeymaster模块,该模块仅通过软件执行所有密钥操作(使用系统的 OpenSSL 库)。该模块在模拟器中使用,并包含在缺少专用加密硬件的设备中。生成的密钥大小最初固定为 2048 位,并且只支持 RSA 密钥。Android 4.4 添加了对指定密钥大小的支持,并支持数字签名算法(DSA)和椭圆曲线 DSA(ECDSA)算法及其相应的密钥。
截至目前,默认的softkeymaster模块支持 RSA 和 DSA 密钥,密钥大小范围为 512 至 8192 位。如果没有明确指定密钥大小,DSA 密钥默认是 1024 位,RSA 密钥默认是 2048 位。对于 EC 密钥,密钥大小将映射到一个标准曲线及其相应的字段大小。例如,当指定密钥大小为 384 时,将使用secp384r1曲线来生成密钥。目前支持以下标准曲线:prime192v1、secp224r1、prime256v1、secp384r1和secp521r1。如果将它们转换为标准的 PKCS#8 格式,支持的算法的密钥也可以导入。
HAL 模块接口定义在hardware/keymaster.h中,并定义了以下操作。
-
generate_keypair -
import_keypair -
sign_data -
verify_data -
get_keypair_public -
delete_keypair -
delete_all
所有由keystore服务暴露的非对称密钥操作都是通过调用系统的keymaster模块来实现的。因此,如果keymaster HAL 模块由硬件加密设备支持,那么所有使用keystore服务接口的上层命令和 API 将自动使用硬件加密。除了非对称密钥操作外,所有其他凭证存储操作都由keystore系统服务实现,并不依赖于 HAL 模块。该服务通过android.security.keystore名称注册到 Android 的ServiceManager,并在启动时启动。与大多数 Android 服务不同,它是用 C++实现的,且实现位于system/security/keystore/目录下。
Nexus 4 硬件支持实现
为了让大家更好地理解“硬件支持”的概念,我们简要讨论一下 Nexus 4 上如何实现这一点。Nexus 4 基于高通的 Snapdragon S4 Pro APQ8064 芯片系统(SoC)。像大多数最新的 ARM SoC 一样,它支持 TrustZone,并且在此基础上实现了高通的安全执行环境(QSEE)。
ARM 的 TrustZone 技术提供了两个由硬件访问控制支持的虚拟处理器,这使得 SoC 系统可以被划分为两个虚拟“世界”:用于安全子系统的安全世界,以及用于其他所有内容的普通世界。运行在安全世界中的应用程序被称为受信应用程序,并且只能通过它们显式公开的有限接口,供普通世界应用程序(Android 操作系统和应用程序运行的地方)访问。图 7-2 显示了一个典型的 TrustZone 启用系统的软件配置。

图 7-2. TrustZone 软件架构
一如既往,实施细节相当稀缺,但在 Nexus 4 中,唯一与受信应用程序交互的方式是通过/dev/qseecom设备提供的受控接口。希望与 QSEE 交互的 Android 应用程序会加载专有的libQSEEComAPI.so库,并使用其函数向 QSEE 发送命令。
与大多数其他 SEE(安全执行环境)一样,QSEECom通信 API 相当低级,基本上只允许交换不透明的二进制数据(通常是命令和响应),其内容完全依赖于你正在与之通信的安全应用程序。在 Nexus 4 的keymaster中,使用的命令有:GENERATE_KEYPAIR、IMPORT_KEYPAIR、SIGN_DATA和VERIFY_DATA。keymaster的实现仅创建命令结构,通过QSEECom API 发送它们,并解析响应。它不包含任何加密代码。
一个有趣的细节是,QSEE 的密钥存储受信应用程序(这可能不是一个专用应用程序,而是一个更通用的受信应用程序的一部分)不会返回简单的受保护密钥引用;它使用专有的加密密钥二进制数据。在这种模型中,唯一由硬件实际保护的是某种形式的主密钥加密密钥(KEK);用户生成的密钥仅通过使用 KEK 加密间接受到保护。
这种方法允许几乎无限数量的受保护密钥,但它的缺点是,如果 KEK 被破解,所有外部存储的密钥二进制数据也将被破解。(当然,实际的实现可能会为每个创建的密钥二进制数据生成专用的 KEK,或者密钥可以被硬件烧录;无论如何,对于内部实现没有可用的细节。)话虽如此,高通keymaster密钥二进制数据在 AOSP 代码中有定义(在示例 7-3 中显示),该定义表明私钥指数使用 AES ➊加密,最有可能采用 CBC 模式,并添加了 HMAC-SHA256 ➋来检查加密数据的完整性。
示例 7-3. QSEE keymaster 二进制数据定义(针对 Nexus 4)
#define KM_MAGIC_NUM (0x4B4D4B42) /* "KMKB" Key Master Key Blob in hex */
#define KM_KEY_SIZE_MAX (512) /* 4096 bits */
#define KM_IV_LENGTH (16) ➊/* AES128 CBC IV */
#define KM_HMAC_LENGTH (32) ➋/* SHA2 will be used for HMAC */
struct qcom_km_key_blob {
uint32_t magic_num;
uint32_t version_num;
uint8_t modulus[KM_KEY_SIZE_MAX];➌
uint32_t modulus_size;
uint8_t public_exponent[KM_KEY_SIZE_MAX];➍
uint32_t public_exponent_size;
uint8_t iv[KM_IV_LENGTH];➎
uint8_t encrypted_private_exponent[KM_KEY_SIZE_MAX];➏
uint32_t encrypted_private_exponent_size;
uint8_t hmac[KM_HMAC_LENGTH];➐
};
如你在示例 7-3 中看到的,QSEE 密钥 blob 包含密钥模数 ➌、公钥指数 ➍、用于私钥加密的 IV ➎、私钥本身 ➏ 和 HMAC 值 ➐。
由于 Nexus 4 中使用的 QSEE 是通过处理器的 TrustZone 功能实现的,因此在这种情况下,硬件支持的凭证存储的“硬件”实际上只是 ARM SoC。是否可能有其他实现?理论上,基于硬件的密钥大师实现不需要依赖于 TrustZone。任何能够安全生成和存储密钥的专用设备都可以使用,常见的候选设备包括嵌入式安全元件(SE)和受信平台模块(TPM)。我们将在第十一章中讨论 SE 和其他防篡改设备,但截至目前,没有主流 Android 设备配备专用的 TPM,最近的旗舰设备也开始出货时不再内置 SE。因此,使用专用硬件的实现不太可能出现在主流设备中。
注意
当然,所有移动设备都有某种形式的通用集成电路卡(UICC),通常被称为 SIM 卡,通常可以生成和存储密钥,但 Android 仍然没有标准 API 来访问 UICC,尽管供应商的固件通常包含该功能。因此,虽然理论上可以实现基于 UICC 的密钥大师模块,但它只适用于定制的 Android 版本,并且需要运营商在其 UICC 中包括支持。
框架集成
虽然安全地管理凭证是 Android 凭证存储的关键特性,但其主要目的是无缝地向系统的其余部分提供此服务。在介绍可供第三方应用程序使用的公共 API 之前,我们简要讨论它如何与 Android 的其余部分集成。
因为密钥库服务是一个标准的 Binder 服务,所以潜在的客户端只需要从ServiceManager获取对它的引用即可使用它。Android 框架提供了一个单例的android.security.KeyStore隐藏类,该类负责获取对密钥库服务的引用,并充当它暴露的IKeystoreService接口的代理。大多数系统应用程序,如 PKCS#12 文件导入器(参见图 7-1)以及一些公共 API 的实现,使用KeyStore代理类与密钥库服务进行通信。
对于一些较低级别的库(如原生库和核心 Java 库中的 JCA 类),它们不是 Android 框架的一部分,系统凭证存储的集成是通过一个名为Android 密钥库引擎的 OpenSSL 引擎间接提供的。
OpenSSL 引擎是一个可插拔的加密模块,作为动态共享库实现。keystore 引擎就是一个这样的模块,它通过调用系统的 keymaster HAL 模块来实现所有操作。它仅支持使用 RSA、DSA 或 EC 私钥加载和签名,但足以实现基于密钥的身份验证(如 SSL 客户端身份验证)。keystore 引擎使得使用 OpenSSL API 的原生代码能够使用保存在系统凭据存储中的私钥,而无需修改代码。它还具有一个 Java 包装器(OpenSSLEngine),用于在 JCA 框架中实现对 keystore 管理的私钥的访问。
公共 API
虽然系统应用可以直接通过 android.security.KeyStore 代理类或通过 keystore 守护进程 AIDL 接口访问,但这些接口与实现过于紧密耦合,不适合作为公共 API 的一部分。Android 提供了更高级的抽象层,供第三方应用使用 KeyChain API 和 AndroidKeyStoreProvider JCA 提供程序。我们将在以下部分展示如何使用这些 API,并提供一些实现细节。
KeyChain API
自 Android 1.6 起,Android 提供了一个系统范围的凭据存储,但当时仅限内建的 VPN 和 Wi-Fi EAP 客户端使用。虽然可以通过设置应用安装私钥/证书对,但已安装的密钥无法被第三方应用访问。
Android 4.0 引入了用于受信任证书管理和安全凭据存储的 SDK API,提供了 KeyChain 类。此功能在 Android 4.3 中得到扩展,支持新引入的硬件支持功能。我们将在以下部分讨论如何使用此功能,并回顾其实现。
KeyChain 类
KeyChain 类非常简单:它提供了六个公共静态方法,足以完成大多数与证书和密钥相关的任务。我们将看看如何安装私钥/证书对,并使用该对访问凭据存储管理的私钥。
KeyChain API 允许你安装捆绑在 PKCS#12 文件中的私钥/证书对。KeyChain.createInstallIntent() 工厂方法是访问此功能的入口。它不接受任何参数,并返回一个系统意图,该意图可以解析并安装密钥和证书。(这实际上是系统设置应用程序内部使用的相同意图。)
安装 PKCS#12 文件
要安装 PKCS#12 文件,你必须将其读取到字节数组中,存储在意图的 extras 中的 EXTRA_PKCS12 键下,然后启动相关的活动(参见 示例 7-4):
示例 7-4:使用 KeyChain API 安装 PKCS#12 文件
Intent intent = KeyChain.createInstallIntent();
byte[] p12 = readFile("keystore-test.pfx");
intent.putExtra(KeyChain.EXTRA_PKCS12, p12);
startActivity(intent);

图 7-3:私钥和证书导入对话框
这应该提示你输入 PKCS#12 密码,以便提取和解析密钥和证书。如果密码正确,系统将提示你输入证书名称,如图 7-3 所示。如果 PKCS#12 包含友好名称属性,它将作为默认值显示;如果没有,你将看到一个长的十六进制哈希字符串。你在此处输入的字符串是你以后可以使用的密钥或证书别名,可以通过 KeyChain API 查找并访问这些密钥。如果你还没有设置锁屏 PIN 或密码,系统将提示你设置,以保护凭证存储。
使用私钥
要使用存储在系统凭证存储中的私钥,你需要使用其别名获取对该密钥的引用,并请求用户授权访问该密钥。如果你之前从未访问过密钥,也不知道其别名,你需要首先调用 KeyChain.choosePrivateKeyAlias() 并提供一个回调实现,该回调接收选中的别名,如示例 7-5 所示。
示例 7-5. 使用存储在系统凭证存储中的私钥
public class KeystoreTest extends Activity implements OnClickListener,
KeyChainAliasCallback {
@Override
public void onClick(View v) {
KeyChain.choosePrivateKeyAlias(➊this, ➋(KeyChainAliasCallback)this,
➌new String[] { "RSA" }, ➍null, ➎null, ➏-1, ➐null);
}
@Override
public void alias(final String alias) {➑
Log.d(TAG, "Thread: " + Thread.currentThread().getName());
Log.d(TAG, "selected alias: " + alias);
}
}
第一个参数 ➊ 是当前上下文;第二个 ➋ 是要调用的回调;第三个和第四个分别指定了可接受的密钥 ➌(RSA、DSA 或 null 表示任何密钥)和与私钥匹配的证书颁发机构 ➍。接下来的两个参数是请求证书的服务器的主机 ➎ 和端口号 ➏,最后一个参数 ➐ 是要在密钥选择对话框中预选的别名。我们将除密钥类型之外的所有参数留为未指定(null 或 -1),以便能够从所有可用证书中进行选择。请注意,alias() ➑ 回调不会在主线程上调用,因此不要尝试直接在回调中操作 UI。(它是在一个绑定线程上调用的。)
使用密钥需要用户授权,因此 Android 应该显示一个密钥选择对话框(见图 7-4),该对话框也用于授权访问选中的密钥。一旦用户授予应用程序访问密钥的权限,它可以直接查找该密钥,而无需通过密钥选择对话框。

图 7-4. 密钥选择对话框
示例 7-6 展示了如何使用 KeyChain API 获取系统密钥库中管理的私钥的引用。
示例 7-6. 获取密钥实例及其证书链
PrivateKey pk = KeyChain.getPrivateKey(context, alias);➊
X509Certificate[] chain = KeyChain.getCertificateChain(context, alias);➋
要获取私钥的引用,您需要调用KeyChain.getPrivateKey() ➊方法,传入在前一步中收到的密钥别名。如果你尝试在主线程中调用此方法,会抛出异常,因此确保从后台线程中调用它,像AsyncTask工具类创建的线程那样。getCertificateChain() ➋方法返回与私钥相关的证书链(见示例 7-6)。如果指定别名的密钥或证书不存在,getPrivateKey()和getCertificateChain()方法将返回null。
安装 CA 证书
安装 CA 证书与安装 PKCS#12 文件没有太大区别。为此,将证书加载到字节数组中,并作为额外参数传递给安装意图下的EXTRA_CERTIFICATE键,如示例 7-7 所示。
示例 7-7. 使用KeyChain API 安装 CA 证书
Intent intent = KeyChain.createInstallIntent();
intent.putExtra(KeyChain.EXTRA_CERTIFICATE, cert);
startActivity(intent);
Android 解析证书,如果其基本约束扩展设置为CA:TRUE,则认为它是一个 CA 证书并将其导入用户信任存储。你需要进行身份验证才能导入证书。
不幸的是,导入对话框(见图 7-5)既没有显示证书的 DN,也没有显示其哈希值。用户在完成操作之前无法知道自己导入的是什么。很少有人会检查证书的有效性,因此这可能是一个潜在的安全威胁,因为恶意应用程序可能会欺骗用户安装恶意证书。
证书导入后,应显示在受信任凭证屏幕的用户选项卡中(设置 ▸ 安全性 ▸ 受信任凭证)。点击证书条目以显示详细信息对话框,你可以查看主题、颁发者、有效期、序列号和 SHA-1/SHA-256 指纹。要移除证书,请按移除按钮(见图 7-6)。

图 7-5. CA 证书导入对话框

图 7-6. 证书详细信息对话框
删除密钥和用户证书
尽管你可以删除单个 CA 证书,但无法删除单个密钥和用户证书,尽管在安全设置中的凭证存储部分有清除凭证选项,它会删除所有的密钥和用户证书。
注意
只要凭证存储中有密钥,就无法移除屏幕锁,因为它用于保护密钥库的访问。
获取支持的算法信息
Android 4.3 向 KeyChain 类添加了与新引入的硬件支持相关的两个方法。根据 API 文档,isBoundKeyAlgorithm(String algorithm) 方法“如果当前设备的 KeyChain 实现将给定算法的任何 PrivateKey 绑定到设备上(无论是导入还是生成),则返回 true。”换句话说,如果你将字符串 RSA 传递给此方法,如果生成或导入的 RSA 密钥具有硬件保护,不能简单地从设备上复制,它应返回 true。isKeyAlgorithmSupported(String algorithm) 方法应返回 true,如果当前的 KeyChain 实现支持指定类型的密钥(如 RSA、DSA、EC 等)。
我们已经介绍了 KeyChain API 的主要功能。现在让我们来看一下底层的 Android 实现。
KeyChain API 实现
公共的 KeyChain 类和支持的接口位于 android.security Java 包中。该包还包含两个隐藏的 AIDL 文件:IKeyChainService.aidl 和 IKeyChainAliasCallback。这暗示了实际的 keystore 功能,像大多数 Android 操作系统服务一样,是作为一个远程服务实现的,公共 API 会绑定到该服务。接口 IKeyChainAliasCallback 在你通过 KeyStore.choosePrivateKeyAlias() 选择密钥时被调用,因此它的兴趣不大。IKeyChainService.aidl 定义了服务使用的实际系统接口,我们将在这里详细描述它。
IKeyChainService 接口有一个实现,即 KeyChain 系统应用中的 KeyChainService 类。除了 KeyChainService,该应用还包含一个活动 KeyChain 和一个广播接收器 KeyChainBroadcastReceiver。KeyChain 应用的 sharedUserId 被设置为 android.uid.system,因此继承了 system 用户的所有权限。这允许其组件向本地 keystore 服务发送管理命令。我们首先来检查这个服务。
KeyChainService 是 android.security.KeyStore 代理类的封装器,直接与本地 keystore 服务通信。它提供了四个主要服务:
-
Keystore 管理:用于获取私钥和证书的方法。
-
信任存储管理:用于在用户信任存储中安装和删除 CA 证书的方法。
-
密钥和信任存储初始化:一个
reset()方法,删除所有密钥存储条目,包括主密钥,从而将 keystore 恢复到未初始化状态;它还会移除所有用户安装的受信任证书。 -
查询和添加条目到密钥访问授权数据库的方法。
控制对 Keystore 的访问
由于KeyChain应用以系统用户身份运行,任何绑定到其远程接口的进程在技术上都可以执行所有密钥和信任存储操作。为了防止这种情况,KeyChainService通过基于调用者的 UID 控制对凭据存储操作的访问,并使用密钥访问授权数据库来调节对各个密钥的访问。只有系统用户可以删除 CA 证书并重置密钥和信任存储(这些操作通常通过设置应用的 UI 进行调用,而设置应用以系统身份运行)。同样,只有系统用户或证书安装应用(com.android.certinstaller包)可以安装受信任的 CA 证书。
控制对凭据存储中单个密钥的访问比操作限制更有意思。KeyChainService维护一个授权数据库(位于/data/data/com.android.keychain/databases/grants.db),该数据库将 UID 与其被允许使用的密钥别名映射。我们可以在示例 7-8 中查看其内部结构。
示例 7-8. 授权数据库的模式和内容
# sqlite3 grants.db
sqlite> .schema
.schema
CREATE TABLE android_metadata (locale TEXT);
CREATE TABLE grants (alias STRING NOT NULL, uid INTEGER NOT NULL, UNIQUE (alias,uid));
sqlite> select * from grants;
select * from grants;
➊test|10044➋
➌key1|10044
在这个示例中,UID 为10044 ➋的应用被授权访问别名为test ➊和key1 ➌的密钥。
每次调用getPrivateKey()或getCertificate()时,都会对照授权数据库进行检查,如果未找到所需别名的授权,则会抛出异常。如前所述,KeyChainService具有用于添加和查询授权的 API,并且只有系统用户可以调用这些 API。但谁负责实际授权和撤销访问权限呢?
记得私钥选择对话框吗?(图 7-4)?当你调用KeyChain.choosePrivateKeyAlias()时,它会启动KeyChainActivity(如上所述),该活动检查密钥库是否已解锁;如果已解锁,KeyChainActivity会显示私钥选择对话框。点击允许按钮后,返回KeyChainActivity,它会调用KeyChainService.setGrant()并使用选定的别名,将其添加到授权数据库中。因此,即使请求访问私钥的活动具有所需的权限,用户仍然需要解锁密钥库并明确授权访问每个单独的密钥。
除了控制私钥存储外,KeyChainService还通过使用新添加的TrustedCertificateStore类(属于libcore)提供信任存储管理功能。该类既可以添加用户安装的受信任 CA 证书,也可以移除(标记为不受信任)系统(预安装)CA 证书。第六章详细讨论了其实现细节。
KeyChainBroadcastReceiver
KeyChain 应用的最后一个组件是 KeyChainBroadcastReceiver。它监听 android.intent.action.PACKAGE_REMOVED 系统广播,并将控制转发给 KeyChainService。在接收到 PACKAGE_REMOVED 操作时,服务会进行一些授权数据库维护:它会遍历所有条目并删除任何不再可用的包(即已卸载的包)。
凭据和信任存储总结
Android 4.0 引入了一项新服务,它授予对系统密钥库(由密钥库系统服务管理)和信任存储(由 TrustedCertificateStore 类管理)的访问权限,这两个存储支持公共 SDK 中暴露的 KeyChain API。此功能使得可以根据调用进程的 UID 和密钥访问授权数据库来控制密钥的访问,从而实现细粒度、由用户驱动的控制,决定每个应用可以访问哪些密钥。Android 凭据和信任存储的组件及其关系展示在图 7-7 中。

图 7-7. 系统凭据存储组件
Android Keystore 提供者
虽然在 Android 4.0 中引入的 KeyChain API 允许应用将密钥导入系统凭据存储区,但这些密钥归系统用户所有,任何应用都可以请求访问这些密钥。Android 4.3 增加了对应用私有密钥的支持,这使得任何应用都可以生成并保存只能由其自身访问和使用的私密密钥,且这些密钥对其他应用不可见。
与其引入另一个 Android 特有的 API,不如通过标准 JCA API 来暴露密钥库访问,即 java.security.KeyPairGenerator 和 java.security.KeyStore。这两个 API 都由新的 Android JCA 提供者 AndroidKeyStoreProvider 支持,并通过将 AndroidKeyStore 作为相应工厂方法的 type 参数来访问。示例 7-9 展示了如何使用 AndroidKeyStoreProvider 生成和访问 RSA 密钥。
示例 7-9. 使用 AndroidKeyStoreProvider 生成和访问 RSA 密钥
// generate a key pair
Calendar notBefore = Calendar.getInstance()
Calendar notAfter = Calendar.getInstance(); notAfter.add(1, Calendar.YEAR);
KeyPairGeneratorSpec spec = new KeyPairGeneratorSpec.Builder(ctx) .setAlias("key1")
.setKeyType("RSA")
.setKeySize(2048)
.setSubject(new X500Principal("CN=test"))
.setSerialNumber(BigInteger.ONE).setStartDate(notBefore.getTime())
.setEndDate(notAfter.getTime()).build();➊
KeyPairGenerator kpGenerator = KeyPairGenerator.getInstance("RSA",
"AndroidKeyStore");
kpGenerator.initialize(spec);➋
KeyPair kp = kpGenerator.generateKeyPair();➌
// in another part of the app, access the keys
KeyStore ks = KeyStore.getInstance("AndroidKeyStore");
ks.load(null);
KeyStore.PrivateKeyEntry keyEntry = (KeyStore.PrivateKeyEntry)keyStore.getEntry("key1", null);➍
RSAPublic pubKey = (RSAPublicKey)keyEntry.getCertificate().getPublicKey();
RSAPrivateKey privKey = (RSAPrivateKey) keyEntry.getPrivateKey();
首先 ➊ 你创建一个 KeyPairGeneratorSpec,描述你想要生成的密钥以及每个密钥所关联的自动创建的自签名证书。你可以使用 setKeyType() 方法指定密钥类型(RSA、DSA 或 EC),并使用 setKeySize() 方法指定密钥大小。
注意
每个由 KeyStore 对象管理的 PrivateKeyEntry 都需要与一个证书链相关联。Android 在生成密钥时会自动创建一个自签名证书,但你可以稍后将默认证书替换为由 CA 签名的证书。
接下来,你使用KeyPairGenerator ➋ 和 KeyPairGeneratorSpec 实例进行初始化,然后通过调用 generateKeyPair() ➌ 来生成密钥。
最重要的参数是别名。你将别名传递给KeyStore.getEntry() ➍,以便稍后获取生成的密钥的引用。返回的密钥对象不包含实际的密钥材料;它只是指向硬件管理的密钥对象的指针。因此,它不能与依赖密钥材料直接访问的加密提供者一起使用。
如果设备有硬件支持的密钥存储实现,密钥将会在 Android 操作系统之外生成,并且即使是系统(或root)用户也无法直接访问这些密钥。如果实现仅为软件,则密钥将使用从解锁 PIN 或密码派生的每用户密钥加密主密钥进行加密。
总结
正如你在本章中学到的,Android 有一个系统凭据存储,可以用于存储 Wi-Fi 和 VPN 连接等内置功能的凭据,以及供第三方应用使用。Android 4.3 及更高版本提供了标准的 JCA API,用于生成和访问应用私有密钥,这使得非系统应用可以更加容易地安全地存储它们的密钥,而无需自己实现密钥保护。硬件支持的密钥存储在支持的设备上提供,保证即使是拥有系统或root权限的应用也无法提取密钥。大多数当前基于硬件的凭据存储实现基于 ARM 的 TrustZone 技术,并且不使用专用的防篡改硬件。
第八章 在线账户管理
尽管企业服务通常使用 PKI 进行用户认证,但大多数公开的在线服务依赖密码来验证用户身份。然而,在触摸屏移动设备上为不同网站多次输入复杂密码并不是一项愉快的操作。
为了改善用户在访问在线服务时的体验,Android 提供了一个集中的用户账户注册表,可以缓存和重用凭据。第三方应用可以访问这个账户注册表,从而代表设备用户访问网络服务,而不需要应用直接处理密码。在本章中,我们将讨论 Android 如何管理用户的在线账户凭据,以及应用程序可以使用的 API,以便利用缓存凭据并注册自定义账户。接着,我们将展示 Google 体验设备(即预装 Google Play 商店的设备)如何存储 Google 账户信息,并通过存储的凭据访问 Google API 和其他在线服务。
Android 账户管理概述
尽管早期的 Android 设备内置支持 Google 账户以及与 Gmail 等 Google 服务的自动后台数据同步,但最初并未提供此功能的 API。Android 2.0(API 级别 5)引入了集中式账户管理的概念,并提供了公共 API。该 API 的核心部分是AccountManager类,它“提供对用户在线账户的集中式注册表的访问。用户每个账户仅需输入一次凭据(用户名和密码),就能通过‘一键’授权,允许应用程序访问在线资源。”^([78]) 该类的另一个主要功能是,它可以为受支持的账户获取认证令牌,从而允许第三方应用在无需处理用户密码的情况下认证在线服务。在某些旧版本的 Android 中,AccountManager还会监控 SIM 卡,并在更换卡片时清除缓存凭据,但这个功能在 Android 2.3.4 及以后版本中已被移除。
账户管理实现
与大多数 Android 系统 API 一样,AccountManager只是AccountManagerService的外壳,实际的工作由它完成。然而,该服务并未提供任何特定形式的认证实现。它仅仅协调多个可插拔的认证模块,以支持不同的账户类型(如 Google、Twitter、Microsoft Exchange 等)。任何应用程序都可以通过实现账户认证器和相关类来注册一个认证模块(如果需要的话)。我们将在“添加认证模块”中展示如何编写和注册一个自定义的认证模块。
将新账户类型注册到系统中可以让你利用多个 Android 基础设施服务,包括以下能力:
-
在系统数据库中使用集中式凭证存储
-
向第三方应用发放令牌
-
利用 Android 的自动后台同步(通过同步适配器)
图 8-1 显示了 Android 账户管理子系统的主要组件及其关系。每个组件及其角色将在接下来的章节中描述。

图 8-1. 账户管理组件
AccountManagerService 和 AccountManager
这里的核心是 AccountManagerService,它协调所有其他组件并将账户数据持久化到账户数据库中。AccountManager 类是一个外观模式,它向第三方应用暴露其部分功能。它为异步方法启动工作线程,并将结果(或错误详情)返回给调用者。此外,当请求的令牌或功能可以由多个账户提供时,AccountManager 会显示账户选择器。然而,它不强制执行任何权限;所有调用者的权限由 AccountManagerService 检查,我们稍后会讨论具体的权限。
身份验证模块
如上所述,每个注册账户的功能是由可插拔的身份验证模块提供的,那么什么是身份验证模块呢?身份验证模块是由应用程序定义并托管的,每个模块只是一个实现了 android.accounts.IAccountAuthenticator AIDL 接口的绑定服务。该接口具有添加账户、提示用户输入凭证、获取身份验证令牌以及更新账户元数据的方法。实际上,应用程序并不直接实现此接口,而是扩展 android.accounts.AbstractAccountAuthenticator 类,该类将实现方法与内部 AIDL 存根链接。
AbstractAccountAuthenticator 还确保所有调用 AIDL 存根的客户端都持有 ACCOUNT_MANAGER 权限;这是一个系统签名权限,仅允许系统组件直接调用身份验证模块。所有其他客户端需要通过 AccountManagerService。
每个认证模块实现一个由字符串唯一标识的账户,称为账户类型。账户类型通常采用反向域名表示法(类似于 Java 包),通常使用定义应用程序的基础包名与账户类型、账户 或 auth 字符串连接而成(不过 Android 并不强制执行此规则,且没有明确的指导方针)。例如,在图 8-1 中,com.example.app 应用定义了一个类型为 com.example.account 的账户,而 org.foo.app 应用定义了一个类型为 org.foo.account 的账户。
认证模块通过添加一个服务来实现,该服务可以通过使用 android.accounts.AccountAuthenticator 意图操作与主机应用程序绑定。账户类型以及其他元数据通过向服务声明中添加 <meta-data> 标签与该服务关联。标签的 resource 属性指向一个包含账户元数据的 XML 文件(示例请参见 示例 8-8)。
注意
认证模块缓存
“可插拔性”由 AccountAuthenticatorCache 类提供,它扫描定义认证模块的包,并将它们提供给 AccountManagerService。AccountAuthenticatorCache 是 Android 提供的更通用的注册服务缓存机制的一种实现。该缓存按需(延迟)构建,通过查询 PackageManagerService 来获取安装的包,这些包注册了特定的意图动作和元数据文件。广播接收器会在安装包被添加、更新或删除时触发更新,以保持缓存的最新状态。缓存是持久化的,每次检测到变化时都会写入磁盘,缓存文件被写入到 /data/system/registered_services/ 目录,并以它们扫描的意图动作命名。认证模块缓存被保存到 android.accounts.AccountAuthenticator.xml 文件中,内容可能类似于 示例 8-1。
示例 8-1. AccountAuthenticator.xml 注册服务缓存文件的内容
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<services>
<service uid="10023" type="com.android.exchange" />➊
<service uid="10023" type="com.android.email" />➋
<service uid="10069" type="com.example.account" />➌
<service uid="10074" type="org.foo.account" />➍
--*snip*--
<service uid="1010023" type="com.android.email" />➎
<service uid="1010023" type="com.android.exchange" />➏
<service uid="1010069" type="com.example.account" />➐
--*snip*--
</services>
这里,com.android.exchange 和 com.android.email 账户类型(➊ 和 ➋)由系统自带的邮件应用注册,而 com.example.account 和 org.foo.account(➌ 和 ➍)由第三方应用注册。在多用户设备上,缓存文件会包含每个用户可用账户的条目。
在这个例子中,第一个次级用户(用户 ID 10)可以使用 com.android.exchange、com.android.email 和 com.example.account(➎、➏ 和 ➐),但不能使用 org.foo.account 账户(因为文件中没有它的条目)。当 AccountManagerService 需要对特定账户执行操作时,它通过传递账户类型查询 AccountAuthenticatorCache 以获取实现服务。如果当前用户已注册该账户类型的实现,AccountAuthenticatorCache 会返回包含实现服务信息的详细信息,其中包括实现组件的名称和主机包的 UID。AccountManagerService 使用这些信息绑定到服务,从而能够调用该服务实现的 IAccountAuthenticator 接口的方法。
AccountManagerService 操作与权限
如 图 8-1 所示,AccountManagerService 通过调用认证模块或使用账户数据库中的缓存数据来实现其功能。第三方组件只能使用 AccountManagerService 暴露的 API;它们不能访问认证模块或账户数据库。这个集中的接口保证了操作流程,并对每个操作执行访问控制。
AccountManagerService通过权限、调用者 UID 和签名检查的组合实现访问控制。让我们来看一下它提供的操作以及相应的权限检查。
列出和验证账户
客户端可以通过调用getAccounts()方法之一获取匹配特定条件(包括类型、声明包和其他特性)的账户列表,他们还可以通过调用hasFeatures()方法检查特定账户是否具有所需特性。这些操作需要GET_ACCOUNTS权限,且具有普通保护级别。可以通过调用addAccount()方法(启动一个特定实现的认证器活动,从用户收集凭据)或通过调用addAccountExplicitly()方法静默添加新的账户,该方法以账户、密码和任何相关用户数据作为参数。第一个方法要求调用者拥有MANAGE_ACCOUNTS权限,而第二个方法则要求调用者同时拥有AUTHENTICATE_ACCOUNTS权限,并且与账户的认证器具有相同的 UID。这两个权限的保护级别为dangerous,因此在应用程序安装时需要用户确认。要求addAccountExplicitly()的调用者与认证器具有相同 UID,确保只有相同的应用程序或属于相同共享用户 ID(有关详细信息,请参见第二章)的应用程序,才能在无需用户交互的情况下添加账户。
其他需要调用者同时拥有AUTHENTICATE_ACCOUNTS权限,并与账户认证器具有相同 UID 的操作列举如下。(为了清晰起见,我们在此及以下部分省略了AccountManager方法的参数。有关完整的方法签名和更多信息,请参见AccountManager类的参考文档^([79])。)
-
getPassword()。返回原始缓存密码。 -
getUserData()。返回与指定键匹配的认证器特定账户元数据。 -
peekAuthToken()。返回指定类型的缓存令牌(如果可用)。 -
setAuthToken()。为账户添加或替换认证令牌。 -
setPassword()。为账户设置或清除缓存密码。 -
setUserData()。设置或清除指定键的元数据条目。
管理账户
正如添加新账户时一样,删除现有账户也需要MANAGE_ACCOUNTS权限。然而,如果调用设备用户设置了DISALLOW_MODIFY_ACCOUNTS限制(有关用户限制的更多信息,请参见第四章),则即使调用应用程序拥有MANAGE_ACCOUNTS权限,他们也无法添加或删除账户。其他需要此权限的方法包括修改账户属性或凭据的方法,具体如下所示。
-
clearPassword(). 清除缓存的密码。 -
confirmCredentials(). 明确确认用户知道密码(即使密码已经缓存),通过显示密码输入界面来实现。 -
editProperties(). 显示一个用户界面,允许用户更改全局身份验证器设置。 -
invalidateAuthToken(). 从缓存中移除身份验证令牌。(如果调用者持有USE_CREDENTIALS权限,也可以调用此方法。) -
removeAccount(). 删除现有账户。 -
updateCredentials(). 请求用户输入当前密码,并相应更新保存的凭据。
使用账户凭据
AccountManagerService可能要求其客户端持有的最终权限是USE_CREDENTIALS。此权限保护返回或修改身份验证令牌的方法,身份验证令牌是一个服务相关的凭据字符串,客户端可以使用该令牌在不发送密码的情况下进行服务器请求的身份验证。
通常,在客户端使用用户名和密码(或其他永久凭据)成功进行身份验证后,服务器会返回一个身份验证令牌。该令牌通过一个字符串来标识,称为令牌类型,它描述了令牌所授予的访问类型(例如,只读或读写)。该令牌是可重用的,可以用于发送多个请求,但可能有有效期限制。此外,如果某个用户账户被认为已被泄露,或者用户更改了密码,通常会使该用户的所有现有身份验证令牌在服务器上失效。在这种情况下,使用缓存的身份验证令牌的请求将因身份验证错误而失败。由于AccountManagerService与协议和应用程序无关,它不会自动使缓存的令牌失效,即使它们已经过期或在服务器上被作废。应用程序需要通过调用invalidateAuthToken()方法来负责清除这些无效的缓存令牌。
以下是需要USE_CREDENTIALS权限的方法:
-
getAuthToken(). 获取指定类型的身份验证令牌,适用于特定账户。 -
invalidateAuthToken(). 从缓存中移除身份验证令牌。(如果调用者持有MANAGE_ACCOUNTS权限,也可以调用此方法。)

图 8-2. 账户访问请求对话框
请求身份验证令牌访问
除了持有USE_CREDENTIALS权限外,为了获得特定类型的身份验证令牌,调用getAuthToken()(或由AccountManager外观类提供的任何包装方法)的调用者必须明确获得访问请求的令牌类型。这是通过显示确认对话框来完成的,类似于图 8-2 中所示的对话框。该对话框显示了请求应用程序的名称(在第一个项目符号中,“账户请求者”,在此示例中),账户类型和名称(在第二个项目符号中,分别为“Example”和“example_user”),以及简短描述(在项目符号下方,“完全访问示例数据”)如果访问请求被批准,将允许的数据访问类型。如果用户授予访问权限,该决定将被缓存,并且如果再次请求相同类型的令牌,系统将不再显示对话框。在与身份验证模块运行在相同 UID 下的应用程序可以无需显示确认对话框直接访问其令牌。此外,特权系统应用程序隐式地允许访问所有令牌类型,而无需用户确认,因此如果令牌请求来自特权应用程序,则不会显示对话框。
账户数据库
我们已经介绍了身份验证模块、身份验证缓存和AccountManagerService的主要功能。现在让我们来看一下该服务如何使用账户数据库,这是一个存储在每个用户系统目录中的 SQLite 数据库,文件名为accounts.db,用于注册账户和缓存凭证。
在单用户设备上,账户数据库位于/data/system/users/0/accounts.db。在多用户设备上,该文件存储主用户的账户信息,而每个次要用户都有自己的实例,路径为/data/system/users/accounts、extras、authtokens、grants、shared_users和meta。截至本文写作时,meta表似乎未被使用;所有其他表及其关系如图 8-1 中所示。
表格架构
accounts表存储注册账户的名称、类型和密码,所有其他表都直接或间接地与之相关联。它可能包含类似于示例 8-2 的数据。
示例 8-2. 账户表内容
**sqlite> select * from accounts;**
_id|name |type |password
1 |user1@gmail.com |com.google |1/......➊
2 |user1@example.com|com.google.android.pop3|password➋
3 |example_user |com.example.account |pass1234➌
这里,➊ 是一个 Google 账户(类型 com.google),允许访问 Gmail、Google Play 商店和其他 Google 服务。Google 账户依赖于专有的系统组件,只能在 Google 体验设备上使用。(你可以在 “Google 登录服务” 中找到关于 Google 账户的更多详细信息。)➋ 处的账户是一个 POP3 邮件账户(类型 com.google.android.pop3),由默认邮件应用程序注册,而 ➌ 是由第三方应用程序注册的自定义账户(类型 com.example.account)。每个账户可以与零个或多个元数据键值对关联,这些键值对存储在 extras 表中,并通过使用其主键(在 _id 列中)与账户关联。例如,如果我们的自定义应用程序(见 示例 8-2,_id=3)进行后台数据同步,它可能会有类似 示例 8-3 中的条目。
示例 8-3. extras 表的内容
**sqlite> select * from extras where accounts_id=3;**
_id|accounts_id|key |value
11 |3 |device_id|0123456789
12 |3 |last_sync|1395297374
13 |3 |user_id |abcdefghij
14 |3 |option1 |1
authtokens 表格存储已为某个账户颁发的令牌。对于我们的自定义应用,它可能类似于 示例 8-4。
示例 8-4. authtokens 表的内容
**sqlite> select * from authtokens where accounts_id=3;**
_id|accounts_id|type |authtoken
16 |3 |com.example.auth|abcdefghij0123456789
grants 表将应用程序 UID 与它们被允许使用的令牌类型关联起来。当用户确认特定账户类型和令牌的访问对话框时,将添加授权记录(见 图 8-2)。例如,如果具有 UID 10291 的应用程序已请求并获得访问 com.example.auth 类型的令牌,如我们的示例应用程序所示(见 示例 8-4),则该授权将通过 grants 表中的以下行表示(见 示例 8-5)。每个账户 ID、令牌类型和授予的应用 UID 的组合都会添加一行。
示例 8-5. grants 表的内容
sqlite> **select * from grants;**
accounts_id|auth_token_type |uid
3 |com.example.auth|10291
shared_accounts 表用于将设备所有者的账户与设备上的某个受限用户共享。(你可以在 “多用户支持” 中找到关于其内容和使用的更多详细信息。)
表格访问
现在我们将研究账户数据库中表格与数据之间的关系,以及AccountManagerService的关键方法。从高层次来看,这种关系相当简单(如果忽略缓存和同步):用于检索或操作账户详情的方法访问accounts表,而处理与账户关联的用户数据的方法则访问extras表。处理身份验证令牌的 API 访问authtokens表,并将每个应用程序的令牌访问授权保存在grants表中。接下来,我们将描述每个方法及其访问的数据。
当你通过调用addAccount()方法添加某种类型的账户时,AccountManagerService会在accounts表中插入一行,包含该账户的类型、用户名和密码。调用getPassword()、setPassword()或clearPassword()方法时,AccountManagerService会访问或更新accounts表中的password列。如果你使用getUserdata()或setUserdata()方法获取或设置账户的用户数据,AccountManagerService会从extras表中获取匹配的条目或将数据保存到该表中。
当你请求某个账户的令牌时,情况会变得稍微复杂一些。如果之前从未发放过指定类型的令牌,AccountManagerService会显示一个确认对话框(参见图 8-2),要求用户批准请求应用程序的访问。如果用户接受,请求应用程序的 UID 和令牌类型将被保存到grants表中。(身份验证器可以通过将customTokens账户元数据属性设置为true来声明它们使用自定义令牌。在这种情况下,它们负责管理令牌,Android 既不会显示令牌访问对话框,也不会自动将令牌保存到authtokens表中。)如果已经存在授权,AccountManagerService会检查authtokens表中是否有与请求匹配的令牌。如果找到有效令牌,则返回该令牌。如果没有找到匹配的令牌,AccountManagerService会在缓存中找到指定账户类型的身份验证器,并调用其getAuthToken()方法请求令牌。这通常涉及身份验证器从accounts表中获取用户名和密码(通过getPassword()方法),并调用相应的在线服务获取新的令牌。当令牌返回时,它会被缓存到authtokens表中,并返回给请求的应用程序(通常通过回调异步返回)。使令牌失效会导致删除存储令牌的行,从authtokens表中删除。最后,当通过调用removeAccount()方法删除账户时,其行将从accounts表中删除,数据库触发器将清理所有与之关联的行,包括authtokens、extras和grants表中的行。
密码安全
需要注意的一点是,虽然凭证(通常是用户名和密码)存储在/data/system/下的一个中央数据库中,且只有系统应用可以访问,但凭证并未加密;加密或以其他方式保护凭证的工作留给认证器模块根据需要来实现。事实上,如果你的设备已经获取 root 权限,你可能会发现查看账户表的内容时会显示某些明文密码,尤其是针对系统自带的电子邮件应用(com.android.email或com.google.android.email包)。例如,在示例 8-2 中,password ➋和pass1234 ➌分别是系统自带应用使用的 POP 账户和自定义com.example.account账户的明文密码。
注意
电子邮件应用程序可能需要存储密码,而不是密码哈希或认证令牌,以支持多种以密码为输入的挑战-响应认证方法,例如 DIGEST-MD5 和 CRAM-MD5。
因为AccountManger.getPassword()方法只能由与账户认证器具有相同 UID 的应用程序调用,所以在运行时,其他应用程序无法访问明文密码,但它们可能会包含在备份或设备转储中。为了避免这一潜在的安全风险,应用程序可以使用设备特定的密钥对密码进行加密,或者在初始认证成功后选择用可撤销的主令牌替代密码。例如,官方 Twitter 客户端不会将用户密码存储在accounts表中,而只是保存获得的认证令牌(存储在authtokens表中)。Google 账户是另一个例子(账户类型com.google):如“Google 登录服务”所示,Google 账户并不存储用户密码,而是存储一个主令牌,用于交换服务特定的认证令牌。
多用户支持
回顾第四章中提到的,在多用户设备上,Android 允许每个用户拥有自己的一组应用、应用数据和系统设置。这种用户隔离也扩展到了在线账户,用户可以在系统的账户管理服务中注册自己的账户。Android 4.3 增加了对受限配置文件的支持,这些配置文件不是完全独立的用户,而是与主用户共享已安装的应用程序。此外,受限配置文件还可以应用一系列限制。使用AccountManager API 的应用程序可以明确支持受限配置文件,从而使受限配置文件能够在支持的应用程序中查看和使用主用户账户的子集。我们将在下面的“共享账户”部分详细解释这一功能。
以下部分将讨论 Android 如何在多用户设备上实现账户隔离和共享。
每用户账户数据库
如在“账户数据库”中提到的,AccountManagerServices用于存储账户信息和缓存认证令牌的账户数据库存储在每个用户的系统目录中,即/data/system/users/
共享账户
主用户账户通过简单地将账户数据克隆到受限配置文件的账户数据库中来与受限配置文件共享。因此,受限配置文件不会直接访问主用户的账户数据,而是拥有自己的副本。当添加新的受限配置文件时,主用户所有当前账户的名称和类型会被复制到受限配置文件的账户数据库中的shared_accounts表中。然而,由于新用户尚未启动,此时accounts表为空,且共享账户尚不可用。
shared_accounts表的结构与accounts表相同,但没有password列。对于受限配置文件,它可能类似于示例 8-6。
示例 8-6. shared_accounts表的内容
**sqlite> select * from shared_accounts;**
_id|name |type
1 |user1@gmail.com |com.google
2 |user1@example.com|com.google.android.pop3
3 |example_user |com.example.account
共享账户并不是通过直接从所有者的accounts表中复制数据来克隆的;而是通过声明账户的认证器进行克隆。默认情况下,所有认证器类都继承自AbstractAccountAuthenticator,而该认证器并不支持账户克隆。
要支持受限配置文件的共享账户,实施方案需要显式地进行支持,通过重写 Android 4.3 引入的几个方法来实现这些支持,此外还有受限配置文件支持:getAccountCredentialsForCloning(),该方法返回一个Bundle,包含克隆账户所需的所有数据;addAccountFromCredentials(),该方法接收这个Bundle作为参数,并负责根据Bundle中的凭证创建账户。AccountManagerService会延迟共享账户的克隆,直到受限用户实际启动。如果所有者用户添加了任何新账户,这些账户会被添加到shared_accounts表中,并同样进行克隆。
即使账户成功克隆,它们也可能对由受限配置文件启动的应用程序不可用。回想一下第四章,如果一个应用程序想要支持共享账户,必须在<application>清单标签的restrictedAccountType属性中显式声明所需的账户类型。AccountManagerServices使用restrictedAccountType属性的值来过滤账户,然后将它们传递给运行在受限配置文件中的应用程序。截至目前,一个应用程序只能通过该属性声明一种账户类型。
注意
次要用户与所有者不会共享账户,因此他们的 shared_accounts 表始终为空,且所有者账户永远不会被克隆。
添加认证器模块
在“认证器模块”中,我们展示了一个认证器模块是一个绑定服务,实现在android.accounts.IAccountAuthenticator AIDL 接口,可以通过使用android.accounts.AccountAuthenticator意图动作进行绑定。在本节中,我们将展示一个应用程序如何实现和声明一个认证器模块。
大多数认证器逻辑,包括添加账户、检查用户提供的凭证和获取认证令牌,都在从 Android 提供的基类——即AbstractAccountAuthenticator派生的认证器类中实现。^([80]) 认证器类需要实现所有抽象方法,但如果不需要所有功能,已实现的方法可以返回null或抛出UnsupportedOperationException。为了存储账户密码,至少需要实现addAccount()方法,并显示一个 UI 来收集用户密码。然后,可以通过调用AccountManager的addAccountExplicitly()方法将密码添加到账户数据库中。实现凭证收集和登录的活动可以继承自AccountAuthenticatorActivity,^([81]) 它提供了一个便利的方法,将收集的凭证传递回AccountManager。
注意
请记住, addAccountExplicitly() 方法默认不会加密或以其他方式保护存储的密码(明文存储)。如果需要加密,应该单独实现加密,并将加密后的密码或令牌传递给 addAccountExplicitly() ,而不是明文版本。
一旦你有了账户认证器的实现,你只需要创建一个服务,当通过android.accounts.AccountAuthenticator意图动作调用时,返回其 Binder 接口,如示例 8-7 所示(AbstractAccountAuthenticator方法实现已省略)。
示例 8-7. 账户验证服务实现
public class ExampleAuthenticatorService extends Service {
public static class ExampleAuthenticator extends
AbstractAccountAuthenticator{
// ...
}
private ExampleAuthenticator authenticator;
@Override
public void onCreate() {
super.onCreate();
authenticator = new ExampleAuthenticator(this);
}
@Override
public IBinder onBind(Intent intent) {
if (AccountManager.ACTION_AUTHENTICATOR_INTENT.equals(intent.
getAction())) {
return authenticator.getIBinder();
}
return null;
}
}
为了能够被AccountAuthenticatorCache接收并通过AccountManagerService提供服务,该服务需要声明android.accounts.AccountAuthenticator意图动作和匹配的元数据,如示例 8-8 所示。访问账户和令牌所需的权限也需要添加到清单中。在本示例中,我们仅添加AUTHENTICATE_ACCOUNTS权限,这是能够通过addAccountExplicitly()方法添加账户所需的最低权限。
示例 8-8. 在 AndroidManifest.xml 中声明账户验证服务
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.app"
android:versionCode="1" android:versionName="1.0" >
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<application ...>
*--snip--*
<service android:name=".ExampleAuthenticatorService" >
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
</application>
</manifest>
最后,账户类型、标签和图标必须在引用的 XML 资源文件中声明,如示例 8-9 所示。这里,账户类型是com.example.account,我们仅使用应用图标作为账户图标。
示例 8-9. 在 XML 资源文件中声明账户元数据
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator
android:accountType="com.example.account"
android:label="@string/account_label"
android:icon="@drawable/ic_launcher"
android:smallIcon="@drawable/ic_launcher"/>
在声明我们新账户的应用程序安装后,可以通过AccountManager API 或系统设置界面通过选择添加账户来添加com.example.account账户。新账户应出现在支持的账户列表中,如图 8-3 所示。
自定义账户仅供声明的应用程序使用,或者在创建同步适配器时使用,后者需要一个专用账户。为了允许第三方应用程序使用您的自定义账户进行身份验证,您必须实现身份验证令牌,因为正如我们在“列出和验证账户”中看到的那样,第三方应用程序无法通过AccountManager.getPassword() API 访问账户密码,除非它们与托管目标账户验证模块的应用程序使用相同的密钥和证书签名。

图 8-3. 通过系统设置界面添加自定义账户
谷歌账户支持
Android 账户管理功能的主要目标是简化将在线服务集成到操作系统中,并通过后台同步实现对用户数据的无缝访问。系统账户管理服务的早期版本旨在支持 Android 与 Google 在线服务的集成,后来该服务被解耦并成为操作系统的一部分。在 Android 2.0 及以后版本中,Google 账户和在线服务支持作为一组组件捆绑提供,这些组件为 com.google 账户类型提供账户验证器,并为 Gmail、日历、联系人等提供同步适配器,使用标准的操作系统 API。然而,与其他第三方验证器模块和同步适配器相比,存在一些显著的不同之处:
-
Google 账户组件与系统捆绑在一起,因此被授予了额外的权限。
-
许多实际功能是在服务器端实现的。
-
账户验证器不会以明文形式在设备上存储密码。
Google 登录服务
实现 Google 账户和服务支持的两个主要组件是 Google 服务框架(GSF)和 Google 登录服务(GLS,最近版本中显示为 Google 账户管理器)。前者为所有 Google 应用提供公共服务,例如集中设置和功能开关管理,而后者实现了 Google 账户的身份验证提供者,将是本节的主要话题。
Google 提供了众多在线服务,并支持多种不同的方式进行身份验证,包括通过面向用户的网页 UI 和几个专用的身份验证 API。然而,Android 的 Google 登录服务并不直接调用这些公共身份验证 API,而是通过一个专用的在线服务来实现,地址是 android.clients.google.com。该服务具有多个端点,用于身份验证、授权令牌发放以及不同的数据源(如邮件、日历等),这些数据源用于数据同步。
尽管许多身份验证和授权逻辑是在服务器端实现的,但仍然需要某种本地存储的凭据,尤其是在后台同步时。设备上的凭据管理是 GLS 提供的服务之一,尽管截至本文撰写时,尚未公开源代码或参考文档,但我们可以观察 GLS 在设备上存储的数据,并推测身份验证的实现方式。
如前所述,GLS 插入系统账户框架,因此缓存的凭证、令牌及相关的额外数据会存储在当前用户的系统账户数据库中,就像其他账户类型一样。然而,与大多数其他应用不同,GLS 并不直接存储 Google 账户密码。相反,GLS 在accounts表的password列中存储一个不透明的主令牌(可能是一种 OAuth 刷新令牌),并通过调用相关的 Web 服务端点将其交换为不同 Google 服务的认证令牌。该令牌在首次将 Google 账户添加到设备时通过输入登录活动中显示的用户名和密码获得,如图 8-4 所示。
如果目标 Google 账户使用默认的仅密码验证方法,并且输入了正确的密码,GLS 在线服务将返回主令牌,账户会被添加到用户的账户数据库中。所有后续的认证请求将使用主令牌获取服务或特定作用域的令牌,用于同步或自动网页登录。如果 Google 账户设置为启用两步验证(2FA),用户会被提示在嵌入式网页视图中输入一次性密码(OTP,在网页界面中称为验证码),就像图 8-5 中显示的那样。

图 8-4. Google 账户登录活动

图 8-5. 添加 Google 账户时输入一次性密码
如果 OTP 验证成功,主令牌会被添加到账户数据库,并显示支持后台同步的服务列表(见图 8-6)。

图 8-6. 支持后台同步的 Google 服务列表
请注意,对于启用了 2FA 的 Google 账户,唯一不同的是初次登录过程:所有后续的认证请求都使用缓存的主令牌,并不需要输入 OTP。因此,一旦缓存,主令牌便可完全访问 Google 账户,不仅可以用于数据同步,还可以用于其他类型的账户访问,包括网页登录。
虽然缓存一个全能的认证令牌非常方便,但这种以便捷为优先的权衡使得多个针对谷歌账户的攻击成为可能,因此许多谷歌服务现在在显示敏感数据或更改账户设置时要求额外的认证。通过更改谷歌账户密码、启用双因素认证,或从关联谷歌账户的账户权限页面中移除安卓设备,都可以使主令牌失效(参见图 8-7)。这些操作中的任何一种都会要求用户在下次尝试通过AccountManager API 获取谷歌认证令牌时,使用新的凭据在设备上重新认证。

图 8-7. 谷歌账户账户权限页面中的安卓设备条目
谷歌服务认证与授权
除了面向用户的在线服务(如 Gmail、Google Calendar 和当然还有搜索)之外,谷歌还通过不同的 Web API 提供程序化访问许多服务。这些大多数都需要认证,或者是为了能够访问特定用户数据的子集,或者是为了配额和计费目的。多年来,谷歌使用了几种标准或专有的认证和授权方法,目前的趋势是将一切迁移到 OAuth 2.0^([82])和 OpenID Connect^([83])。然而,许多服务仍然使用较旧的专有协议,因此我们也会简要介绍这些协议。
大多数认证协议有两种变体:一种适用于 Web 应用程序,另一种适用于所谓的安装应用程序。Web 应用程序在浏览器中运行,预期能够利用所有标准浏览器功能,包括丰富的用户界面、自由格式的用户交互、cookie 存储以及跟随重定向的能力。另一方面,安装应用程序没有原生方式来保存会话信息,可能也没有浏览器的完整 Web 功能。安卓原生应用程序(大多数)属于“安装应用程序”类别,因此我们来看看有哪些协议可供它们使用。
ClientLogin
目前为止,最古老的并且仍被广泛使用的安装应用程序授权协议是ClientLogin。^([84]) 该协议假设应用程序可以访问用户的账户名和密码,并允许你获取特定服务的授权令牌,该令牌可以保存并用于代表用户访问该服务。服务通过专有的服务名称进行标识,例如cl代表 Google 日历,ah代表 Google App Engine。你可以在 Google 数据 API 参考中找到许多支持的服务名称,^([85]) 但这里有一些 Android 特定的服务名称没有列出:ac2dm,android,androidsecure,androiddeveloper 和 androidmarket。
这些服务的授权令牌可以存活相当长的时间(最多两周),但不能刷新,应用程序必须在当前令牌过期时获取新的令牌。不幸的是,除了访问关联服务外,无法验证令牌:如果返回OK HTTP 状态(200),则令牌有效;但如果返回 403,则需要查阅额外的错误代码并重试或获取新令牌。
ClientLogin 授权令牌的另一个限制是,它们不能提供对服务资源的细粒度访问:要么全部访问,要么完全无法访问,无法指定只读访问或仅访问特定资源。
对于移动应用程序来说,最大的缺点是 ClientLogin 需要访问实际的用户密码。因此,除非你希望每次需要新令牌时都强制用户输入密码,否则密码必须保存在设备上,这带来了各种问题和潜在的安全隐患。Android 通过在设备上存储主令牌来避免存储原始密码,并使用 GLS 和相关的在线服务将主令牌交换为 ClientLogin 令牌。获取令牌就像调用适当的 AccountManager 方法一样简单,它要么返回缓存的令牌,要么发出 API 请求以获取一个新的令牌。
尽管有许多限制,ClientLogin 协议易于理解且实现简单,因此得到了广泛使用。然而,它在 2012 年 4 月被正式弃用,使用该协议的应用程序被鼓励迁移到 OAuth 2.0。
OAuth 2.0
OAuth 2.0 授权框架在 2012 年底成为官方互联网标准。它定义了不同的授权流程,以适应不同的使用场景,但我们在这里不会尝试介绍所有的流程。我们只讨论 OAuth 2.0 如何与本地移动应用程序相关。(有关实际协议的更多细节,请参见 RFC 6749。)
OAuth 2.0 规范定义了四种基本流程来获取资源的授权令牌。它还定义了两种不需要客户端(在我们的场景中是 Android 应用)直接处理用户凭证(如 Google 账号的用户名和密码)的流程,即授权码授权流程和隐式授权流程。这两种流程都要求授权服务器(Google 的)对资源所有者(Android 应用用户)进行身份验证,以确定是否授予或拒绝访问请求(比如,只读访问个人信息)。在典型的基于浏览器的 Web 应用中,这个过程非常直接:用户会被重定向到身份验证页面,然后到访问授权页面,页面上基本上会显示“您是否允许应用 X 访问数据 Y 和 Z?”如果用户同意,另一个重定向(包含授权令牌)将把用户带回到原始应用程序。浏览器只需要在下一个请求中传递令牌,即可访问目标资源。
使用原生应用时情况并不那么简单。原生应用可以选择使用系统浏览器处理授权许可步骤,或者在应用的 UI 中嵌入WebView或类似的控件。使用系统浏览器需要启动一个第三方应用程序(浏览器),检测成功或失败,并最终找到一种方法将令牌返回到调用应用程序。嵌入WebView则稍微更具用户友好性,因为它不需要在应用程序之间来回切换,但仍会展示一个非原生的网页 UI,并且需要复杂的代码来检测成功与否并提取访问令牌。两者都不是理想选择,且都会让用户感到困惑。
OAuth 2.0 支持通过原生 Android API 解决的就是这种集成复杂性和 UI 阻抗不匹配的问题。Android 提供了两种可以用来获取 OAuth 2.0 令牌的 API:平台 AccountManager 通过特殊的 oauth2:scope 令牌类型语法,以及 Google Play 服务(在下一节中讨论)。当使用这两种 API 中的任何一种来获取令牌时,用户身份验证通过将保存的主令牌传递给 GLS 的服务器端组件来透明地实现,从而显示原生的 AccountManager 访问授权对话框(见 图 8-8),而不是带有权限授予页面的 WebView。如果你授予请求应用程序令牌访问权限,则会发送第二个请求将此信息传递给服务器,服务器返回请求的令牌。然后,访问令牌直接传递给应用程序,而无需通过中介组件如 WebView。这本质上与 Web 应用程序的流程相同,只不过不需要在原生应用与浏览器之间切换,且更加用户友好。当然,这种原生授权流程仅适用于 Google 账户,编写支持其他使用 OAuth 2.0 的在线服务的客户端仍然需要将其 Web 界面集成到应用中。例如,Twitter 客户端通常使用 WebView 来处理 Twitter API 返回的权限授予回调 URL。
Google Play 服务
Google Play 服务 (GPS)^([86]) 于 2012 年 Google I/O 大会上宣布,作为一个易于使用的平台,提供第三方 Android 应用与 Google 产品集成的方式。从那时起,它已经发展成一个庞大的全能包(拥有超过 14,000 个 Java 方法!),为开发者提供了访问 Google API 和专有操作系统扩展的功能。

图 8-8. OAuth 令牌访问请求对话框
如前一节所述,通过标准的AccountManager接口获取 OAuth 2.0 令牌已从 Android 2.2 及更高版本开始得到支持,但由于不同的 Android 版本捆绑了不同的 GLS 版本,这导致设备之间的行为有所不同,因而无法可靠地跨设备工作。此外,在请求令牌时显示的权限授予对话框并不是特别用户友好,因为在某些情况下它会显示原始的 OAuth 2.0 范围,这对大多数用户来说意义不大(见图 8-8)。尽管某些范围的可读别名部分得到了支持(例如,某些版本中显示管理您的任务字符串,而不是原始的 OAuth 范围oauth2:www.googleapis.com/auth/tasks),但这一解决方案既不理想,也不是普遍可用的,因为它依赖于预安装的 GLS 版本。
一般来说,尽管 Android 的账户管理框架与操作系统紧密集成,并且可以通过第三方身份验证模块进行扩展,但其 API 并不是特别灵活,添加对多步骤身份验证或授权流程(如 OAuth 2.0 中使用的流程)的支持并不简单。GPS 通过在线服务实现这一目标,该服务尽最大努力隐藏 OAuth 2.0 的复杂性,并提供与 Android 账户管理框架兼容的 Web API。我们接下来将讨论这一集成的详细信息。
GPS 通过将令牌发放过程分为两个步骤,增加了对显示用户友好 OAuth 范围描述的通用支持:
-
与之前类似,第一次请求包括账户名、主令牌和请求的服务,采用oauth2:scope格式。GPS 在请求中添加了两个新参数:应用包名和签名证书的 SHA-1 哈希值。响应中包括一些可读的关于请求范围和请求应用的详细信息,GPS 会在权限授予对话框中显示这些信息,如图 8-9 所示。
-
如果用户授权,决定将以专有格式记录在
extras表中,该格式包括请求应用的包名、签名证书哈希值和授予的 OAuth 2.0 范围。(注意,grants表未被使用。)GPS 随后重新发送授权请求,并将has_permission参数设置为 1。成功后,响应中将返回 OAuth 2.0 令牌及其过期日期。过期日期保存在extras表中,令牌以类似格式缓存于authtokens表中。

图 8-9. Google Play 服务账户访问权限对话框
GPS 应用与 GSF 和 GLS 包具有相同的共享用户 ID(com.google.uid.shared),因此它可以直接与这些服务进行交互。这使得它能够,除其他外,直接获取和写入 Google 账户凭据及令牌到账户数据库。正如预期的那样,GPS 运行在一个远程服务中,通过客户端库访问,该库被链接到使用 GPS 的应用中。相较于传统的 AccountManager API,其主要卖点在于,虽然其底层的身份验证模块(GLS 和 GSF)是系统的一部分(因此无法在没有 OTA 的情况下更新),但 GPS 是一个用户可安装的应用,可以通过 Google Play 轻松更新。事实上,它是自动更新的,因此应用开发者显然不必依赖用户更新它,如果他们希望使用更新的功能(除非 GPS 被完全禁用)。这种更新机制旨在提供“推出新平台功能的敏捷性”,但由于 GPS 已集成了非常多样的 API 和功能,需要进行广泛测试,更新并不频繁。话虽如此,如果你的应用使用 OAuth 2.0 令牌来认证 Google API(截至本篇写作时的推荐方法),你绝对应该考虑使用 GPS 而不是“原始”的 AccountManager 访问。
注意
为了能够实际使用 Google API,你必须在 Google 的 API 控制台中注册你的应用包名和签名密钥。此注册允许服务验证令牌并查询 Google 了解该令牌是为哪个应用颁发的,从而识别调用的应用。这个验证过程有一个微妙但重要的副作用:你不需要将 API 密钥嵌入到应用中并随每次请求发送。当然,对于第三方发布的应用,你可以轻松发现包名和签名证书,因此获得一个以其他应用名义颁发的令牌并不特别困难(当然不是通过官方 API)。
概述
Android 提供了一个通过 AccountManager 类的集中式用户在线账户注册表,它允许你为现有账户获取令牌,而无需处理原始用户凭据并注册你自己的自定义账户类型。注册自定义账户类型使你能够访问强大的系统功能,如认证令牌缓存和自动后台同步。Google 体验设备包括对 Google 账户的内置支持,这使得第三方应用可以访问 Google 在线服务,而无需直接向用户请求认证信息。Google Play 服务应用及其客户端库通过便捷地使用来自第三方应用的 OAuth 2.0 令牌,进一步改善了对 Google 账户的支持。
^([78]) Google, Android API Reference, “AccountManager,” developer.android.com/reference/android/accounts/AccountManager.html
^([79]) Google,Android API 参考,“AccountManager”,developer.android.com/reference/android/accounts/AccountManager.html。
^([80]) Google,Android API 参考,“AbstractAccountAuthenticator”,developer.android.com/reference/android/accounts/AbstractAccountAuthenticator.html
^([81]) Google,Android API 参考,“AccountAuthenticatorActivity”,developer.android.com/reference/android/accounts/AccountAuthenticatorActivity.html
^([82]) D. Hardt,OAuth 2.0 授权框架,tools.ietf.org/html/rfc6749
^([83]) N. Sakimura 等人,OpenID Connect Core 1.0,openid.net/specs/openid-connect-core-1_0.html
^([84]) Google,Google 账户身份验证与授权,“已安装应用程序的 ClientLogin”,developers.google.com/accounts/docs/AuthForInstalledApps
^([85]) Google,Google 数据 API,“常见问题解答”,developers.google.com/gdata/faq#clientlogin
^([86]) Google,“Google Play 服务”,developer.android.com/google/play-services/index.html
第九章. 企业安全
最初的 Android 版本主要面向消费者,企业功能有限。然而,随着该平台的普及,Android 设备已进入职场,并越来越多地用于访问公司电子邮件、客户信息及其他公司数据。由于这一趋势,平台安全性需求以及允许有效管理员工设备的工具也稳步增长。虽然 Android 的主要焦点仍是通用消费类设备,但最近的版本已引入了许多企业功能,随着发展,Android 很可能会变得更加适合企业使用。
本章将讨论 Android 的主要企业导向功能,并展示如何使用它们来提高设备安全性并提供集中式设备策略管理。我们将从设备管理开始,展示如何将其集成到第三方应用程序中。然后,我们将探讨 Android 对 VPN 的支持,并描述允许开发新的 VPN 解决方案作为第三方用户安装应用程序的 API。接着,我们展示 Android 如何实现 EAP 认证框架支持的不同认证方法,并描述它是如何管理凭证的。最后,我们将演示如何使用 Android 4.3 中新增的扩展 Wi-Fi 管理 API 编程添加 EAP 配置文件。
设备管理
Android 2.2 引入了设备管理 API,允许开发可以执行系统范围安全策略并根据设备当前的安全级别动态调整其功能的应用程序。这类应用程序称为设备管理员。设备管理员必须在设备的安全设置中显式启用,并且如果它们处于活动状态,无法卸载。启用后,它们将获得特殊权限,允许它们锁定设备、修改锁屏密码,甚至清除设备(删除所有用户数据)。设备管理员通常与特定类型的企业账户(如 Microsoft Exchange 或 Google Apps 账户)配合使用,允许企业管理员通过仅允许符合所需安全策略的设备访问企业数据,从而控制对公司数据的访问。安全策略可以是静态的,内置在设备管理员应用程序中,或者可以在服务器端配置,并作为配置或同步协议的一部分发送到设备。
从版本 4.4 开始,Android 支持在表 9-1 中列出的策略类型。策略常量在DeviceAdminInfo类中定义。^([87])
表 9-1. 支持的设备管理策略
| 策略常量/XML 标签 | 值(设置位) | 描述 | API 级别 |
|---|---|---|---|
USES_POLICY_LIMIT_PASSWORD <limit-password> |
0 | 通过设置最小长度或复杂度来限制用户可选择的密码。 | 8 |
USES_POLICY_WATCH_LOGIN <watch-login> |
1 | 监视用户的登录尝试。 | 8 |
USES_POLICY_RESET_PASSWORD <reset-password> |
2 | 重置用户的密码。 | 8 |
USES_POLICY_FORCE_LOCK <force-lock> |
3 | 强制设备锁定,或限制最大锁定超时。 | 8 |
USES_POLICY_WIPE_DATA <wipe-data> |
4 | 恢复出厂设置,擦除所有用户数据。 | 8 |
USES_POLICY_SETS_GLOBAL_PROXY <set-global-proxy> |
5 | 指定设备的全局代理。(此项对 SDK 应用隐藏。) | 9 |
USES_POLICY_EXPIRE_PASSWORD <expire-password> |
6 | 强制用户在管理员定义的时间限制后更改密码。 | 11 |
USES_ENCRYPTED_STORAGE <encrypted-storage> |
7 | 要求存储的数据进行加密。 | 11 |
USES_POLICY_DISABLE_CAMERA <disable-camera> |
8 | 禁用所有设备相机的使用。 | 14 |
USES_POLICY_DISABLE_KEYGUARD_FEATURES <disable-keyguard-features> |
9 | 禁用锁屏功能,例如锁屏小部件或相机支持。 | 17 |
每个设备管理应用必须在元数据文件中列出其打算使用的策略(有关详细信息,请参见“权限管理”)。当用户激活管理员应用时,支持的策略列表将显示给用户,如图 9-1 所示。
实现
现在我们知道哪些策略可以通过设备管理 API 强制执行,接下来让我们看看内部实现。像大多数公共 Android API 一样,一个名为DevicePolicyManager^([88])的管理类暴露了底层系统服务DevicePolicyManagerService的一部分功能。然而,由于DevicePolicyManager外观类定义了常量并将服务异常转换为返回码,但除此之外增加的功能不多,因此我们将重点关注DevicePolicyManagerService类。

图 9-1. 设备管理员激活屏幕
和大多数系统服务一样,DevicePolicyManagerService由system_server进程启动并运行,且以system用户身份运行,因此可以执行几乎所有 Android 特权操作。与大多数系统服务不同,它可以将某些特权操作(例如更改锁屏密码)的访问权限授予第三方应用程序,而这些应用程序无需持有任何特殊的系统权限。这使得用户能够按需启用和禁用设备管理员,并保证设备管理员只能执行他们明确声明的策略。然而,这种灵活性无法通过标准的 Android 权限轻松实现,因为这些权限仅在安装时授予,且无法撤销(有些例外,详见第二章)。因此,DevicePolicyManagerService采用了一种不同的特权管理方法。
Android 设备管理实现的另一个有趣方面与策略的管理和执行有关。接下来,我们将详细描述设备管理员特权管理和策略执行。
特权管理
在运行时,DevicePolicyManagerService会为每个设备用户保持一个内部的、内存中的策略结构列表。(策略也会保存在磁盘上的 XML 文件中,具体细节见下一节。)
每个策略结构包含当前某个用户的有效策略和关于每个活动设备管理员的元数据列表。由于每个用户可以启用多个具有设备管理员功能的应用程序,因此当前的活动策略是通过在所有管理员中选择定义最严格的策略来计算的。关于每个活动设备管理员的元数据包含有关声明应用程序的信息,以及声明的策略列表(由位掩码表示)。
DevicePolicyManagerService根据其内部活动策略列表来决定是否授予调用应用程序特权操作的访问权限:只有当调用应用程序当前是一个活动设备管理员,且请求的策略与当前请求(API 调用)相对应时,请求才会被授予,操作才会执行。为了确认一个活动管理员组件确实属于调用应用程序,DevicePolicyManagerService将调用进程的 UID(由Binder.getCallingUid()返回)与目标管理员组件的 UID 进行比较。例如,调用resetPassword()的应用程序需要是一个活动设备管理员,且其 UID 与注册的管理员组件相同,并已请求USES_POLICY_RESET_PASSWORD策略,才能使调用成功。
通过添加一个列出设备管理员应用希望使用的所有策略的 XML 资源文件,并将其作为<uses-policies>标签的子项,来请求策略。在激活设备管理员之前,系统会解析 XML 文件并显示一个类似于图 9-1 的对话框,允许用户在启用管理员之前审查请求的策略。与 Android 权限类似,管理员策略是全选或全不选的,无法选择性地启用某些策略。请求所有策略的资源文件可能类似于示例 9-1(每个标签对应的策略,请参见表 9-1 的第一列)。你可以在“添加设备管理员”中找到更多关于将此文件添加到设备管理员应用的详细信息。
示例 9-1. 在设备管理员应用中声明策略
<?xml version="1.0" encoding="utf-8"?>
<device-admin >
<uses-policies>
<limit-password />
<watch-login />
<reset-password />
<force-lock />
<wipe-data />
<expire-password />
<encrypted-storage />
<disable-camera />
<disable-keyguard-features />
<set-global-proxy />
</uses-policies>
</device-admin>
为了接收与策略相关的系统事件通知,并允许访问设备管理 API,设备管理员必须首先被激活。这是通过调用DevicePolicyManagerService的setActiveAdmin()方法实现的。由于此方法需要MANAGE_DEVICE_ADMINS权限,而该权限是系统签名权限,因此只有系统应用可以在没有用户交互的情况下添加设备管理员。
用户安装的设备管理员应用只能通过启动ACTION_ADD_DEVICE_ADMIN隐式意图来请求激活,代码类似于示例 9-2。唯一处理此意图的应用是系统设置应用,它拥有MANAGE_DEVICE_ADMINS权限。在接收到意图后,设置应用检查请求的应用是否是有效的设备管理员,提取请求的策略,并构建图 9-1 中显示的确认对话框。用户点击激活按钮后,调用setActiveAdmin()方法,将该应用添加到当前设备用户的活动管理员列表中。
示例 9-2. 请求设备管理员激活
Intent intent = new Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN);
ComponentName admin = new ComponentName(this, MyDeviceAdminReceiver.class);
intent.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, admin);
intent.putExtra(DevicePolicyManager.EXTRA_ADD_EXPLANATION,
"Required for corporate email access.");
startActivityForResult(intent, REQUEST_CODE_ENABLE_ADMIN);
策略持久化
当设备管理员被激活、停用或其策略更新时,变更会被写入目标用户的 device_policies.xml 文件。对于所有者用户,该文件存储在 /data/system/ 下,对于其他用户,则写入到该用户的系统目录(/data/users/
device_policies.xml 文件包含有关每个活动管理员及其策略的信息,以及有关当前锁屏密码的一些全局信息。该文件可能类似于 示例 9-3。
示例 9-3. devices_policies.xml 文件内容
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<policies>
<admin name="com.google.android.gms/com.google.android.gms.mdm.receivers.MdmDeviceAdminReceiver">➊
<policies flags="28" />
</admin>
<admin name="com.example.android.apis/com.example.android.apis.app.DeviceAdminSampleReceiver">➋
<policies flags="1023" />➌
<password-quality value="327680" />➍
<min-password-length value="6" />
<min-password-letters value="2" />
<min-password-numeric value="2" />
<max-time-to-unlock value="300000" />
<max-failed-password-wipe value="100" />
<encryption-requested value="true" />
<disable-camera value="true" />
<disable-keyguard-features value="1" />
</admin>
<admin name="com.android.email/com.android.email.SecurityPolicy$PolicyAdmin">➎
<policies flags="475" />
</admin>
<password-owner value="10076" />➏
<active-password quality="327680" length="6"
uppercase="0" lowercase="3"
letters="3" numeric="3" symbols="0" nonletter="3" />➐
</policies>
这个示例有三个活动设备管理员,每个管理员都由一个 <admin> 元素表示(➊、➋ 和 ➎)。每个管理员应用程序的策略存储在 <policies> 标签的 flags 属性中 ➌。
如果相应的位被设置,则认为该策略已启用(参见 表 9-1 的值列)。例如,由于 DeviceAdminSample 应用程序请求了所有当前可用的策略,其 flags 属性的值为 1023 (0x3FF,或 1111111111 的二进制表示)。
如果管理员定义了密码质量限制(例如,字母数字或复杂),它们会作为 <password-quality> 标签的 value 属性被保存 ➍。在此示例中,值 327680 (0x50000) 对应于 PASSWORD_QUALITY_ALPHANUMERIC。(密码质量常量在 DevicePolicyManager 类中定义。)
其他策略要求的值,如密码长度和设备加密,也作为每个 <admin> 元素的子元素进行存储。如果密码是通过使用 resetPassword() 方法编程设置的,device_policies.xml 会包含一个 <password-owner> 标签,其 value 属性存储设置密码的应用程序的 UID ➏。最后,<active-password> 标签包含有关当前密码复杂性的详细信息 ➐。
策略强制执行
设备管理员策略具有不同的粒度,可以对当前用户或设备上的所有用户强制执行。有些策略根本没有被系统强制执行——系统只会通知声明的管理应用程序,由其负责采取适当的行动。在本节中,我们将描述每种类型的策略是如何实现和强制执行的。
USES_POLICY_LIMIT_PASSWORD
设置一个或多个密码限制后,用户不能输入不符合当前策略的密码。然而,系统并不要求立即更改密码,因此当前密码将在更改之前继续有效。管理员应用程序可以通过启动一个隐式意图,并使用DevicePolicyManager.ACTION_SET_NEW_PASSWORD操作来提示用户设置新密码。
因为每个设备用户都有单独的解锁密码,密码质量策略是按用户应用的。当设置密码质量时,不允许设置所需密码质量的解锁方法将被禁用。例如,将密码质量设置为PASSWORD_QUALITY_ALPHANUMERIC会禁用图案和 PIN 解锁方法,如图 9-2 所示。

图 9-2. 设置密码质量策略禁用不兼容的解锁方法
USES_POLICY_WATCH_LOGIN
此策略允许设备管理员接收关于登录尝试结果的通知。通知通过ACTION_PASSWORD_FAILED和ACTION_PASSWORD_SUCCEEDED广播发送。继承自DeviceAdminReceiver的广播接收器会通过onPasswordFailed()和onPasswordSucceeded()方法自动接收通知。
USES_POLICY_RESET_PASSWORD
此策略允许管理员应用程序通过resetPassword() API 设置当前用户的密码。指定的密码必须满足当前密码质量要求,并立即生效。请注意,如果设备已加密,为拥有者用户设置锁屏密码时也会更改设备加密密码。(第十章提供了关于设备加密的更多详细信息。)
USES_POLICY_FORCE_LOCK
此策略允许管理员通过调用lockNow()方法立即锁定设备,或通过setMaximumTimeToLock()指定用户不活动的最大时间,直到设备自动锁定。设置最大锁定时间立即生效,并限制用户可以通过系统显示设置调整的不活动睡眠时间。
USES_POLICY_WIPE_DATA
该政策允许设备管理员通过调用wipeData() API 来擦除用户数据。申请了USES_POLICY_WATCH_LOGIN政策的应用程序可以通过setMaximumFailedPasswordsForWipe() API 设置设备在自动擦除数据前允许的最大登录失败次数。当设置的登录失败次数大于零时,锁屏实现会在每次登录失败后通知DevicePolicyManagerService并显示警告对话框,一旦达到阈值就触发数据擦除。如果擦除是由于所有者用户的登录失败触发的,则会执行完全擦除。如果擦除是由次要用户的登录失败触发的,则只会删除该用户及其相关数据,设备会切换回所有者用户。
注
完全擦除设备数据并非即时进行,而是通过在 缓存 分区写入wipe_data命令并重启进入恢复模式来实现的。恢复操作系统负责执行实际的设备数据擦除。因此,如果设备有一个自定义恢复镜像忽略了擦除命令,或者用户设法进入自定义恢复模式并删除或修改了命令文件,设备擦除可能不会执行。(第十章和第十三章更详细地讨论了恢复镜像。)
USES_POLICY_SETS_GLOBAL_PROXY
从 Android 4.4 开始,这项政策不适用于第三方应用程序。它允许设备管理员通过写入全局系统设置提供者来设置全局代理服务器主机(Settings.Global.GLOBAL_HTTP_PROXY_HOST)、端口(GLOBAL_HTTP_PROXY_PORT)和排除的主机列表(GLOBAL_HTTP_PROXY_EXCLUSION_LIST)。只有设备所有者才能设置全局代理设置。
USES_POLICY_EXPIRE_PASSWORD
该政策允许管理员通过setPasswordExpirationTimeout() API 设置密码过期超时。如果设置了过期超时,系统会注册一个每日闹钟以检查密码是否过期。如果密码已经过期,DevicePolicyManagerService会每天发布密码更改通知,直到密码被更改。设备管理员会通过DeviceAdminReceiver.onPasswordExpiring()方法收到密码过期状态的通知。
USES_ENCRYPTED_STORAGE
此政策允许管理员请求通过setStorageEncryption() API 加密设备存储。只有所有者用户才能请求存储加密。如果设备尚未加密,请求存储加密不会自动启动设备加密过程;设备管理员必须使用getStorageEncryptionStatus() API(该 API 检查ro.crypto.state只读系统属性)检查当前存储状态,并启动加密过程。可以通过启动相关的系统活动并带上ACTION_START_ENCRYPTION隐式意图来启动设备加密。
USES_POLICY_DISABLE_CAMERA
此政策允许设备管理员通过setCameraDisabled() API 禁用设备上的所有摄像头。通过将sys.secpolicy.camera.disabled系统属性设置为 1 来禁用摄像头。原生系统CameraService会检查此属性,并在其值为 1 时禁止所有连接,从而有效地禁用设备上所有用户的摄像头。
USES_POLICY_DISABLE_KEYGUARD_FEATURES
此政策允许管理员通过调用setKeyguardDisabledFeatures()方法禁用锁屏小部件等键盘保护定制功能。系统键盘保护实现会检查此政策是否生效,并为目标用户禁用相应功能。
添加设备管理员
与其他应用一样,设备管理员可以包含在系统镜像中,也可以由用户安装。如果管理员是系统镜像的一部分,它可以在 Android 4.4 及更高版本中设置为设备所有者应用,这是一种特殊的设备管理员,用户无法禁用且无法卸载。在本节中,我们将展示如何实现一个设备管理员应用,并演示如何将系统应用设置为设备所有者。
实现设备管理员
设备管理员应用需要声明一个广播接收器,并要求BIND_DEVICE_ADMIN权限(➊ 在示例 9-4 中),声明一个列出其使用的政策的 XML 资源文件 ➋,并响应ACTION_DEVICE_ADMIN_ENABLED意图 ➌。示例 9-1 展示了一个示例政策声明。
示例 9-4. 设备管理员广播接收器声明
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.deviceadmin">
--*snip*--
<receiver android:name=".MyDeviceAdminReceiver"
android:label="@string/device_admin"
android:description="@string/device_admin_description"
android:permission="android.permission.BIND_DEVICE_ADMIN">➊
<meta-data android:name="android.app.device_admin"
android:resource="@xml/device_admin_policy" />➋
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />➌
</intent-filter>
</receiver>
--*snip*--
</manifest>
Android SDK 提供了一个基类,你可以从中派生你的接收器,即 android.app.admin.DeviceAdminReceiver。这个类定义了多个回调方法,你可以重写这些方法来处理系统发送的与设备策略相关的广播。默认实现为空,但至少你应该重写 onEnabled() 和 onDisabled() 方法,以便在管理员启用或禁用时收到通知。在调用 onEnabled() 之前或调用 onDisabled() 之后,设备管理员不能使用任何特权 API。
你可以随时使用 isAdminActive() API 来查看应用程序当前是否是一个活跃的设备管理员。如在 “权限管理” 中提到,管理员不能自动激活自己,而必须启动系统活动以提示用户确认,代码类似于 示例 9-2。然而,当管理员已经激活时,可以通过调用 removeActiveAdmin() 方法来禁用自己。
注意
请参阅官方的设备管理 API 指南^([89]) 获取更多细节和完整的示例应用。
设置设备所有者
系统镜像中的设备管理员应用程序(即其 APK 文件安装在 系统 分区中)可以通过调用 setDeviceOwner(String packageName, String ownerName) 方法来设置为设备所有者(在公共 SDK API 中不可见)。该方法的第一个参数指定目标应用程序的包名,第二个参数指定要在 UI 中显示的所有者名称。虽然此方法不需要特殊权限,但只能在设备配置之前调用(即,如果全局设置 Settings.Global.DEVICE_PROVISIONED 被设置为 0),这意味着它只能由作为设备初始化一部分执行的系统应用程序调用。
成功调用此方法会将一个 device_owner.xml 文件(如 示例 9-5 中的文件)写入到 /data/system/ 目录中。关于当前设备所有者的信息可以通过 getDeviceOwner()、isDeviceOwner()(在 Android SDK API 中暴露为 isDeviceOwnerApp())和 getDeviceOwnerName() 方法获取。
示例 9-5. device_owner.xml 文件的内容
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<device-owner package="com.example.deviceadmin" name="Device Owner" />
当设备所有者被激活时,无论是作为配置过程的一部分还是由用户手动激活,都无法禁用或卸载,如 图 9-3 所示。

图 9-3. 设备所有者管理员无法被禁用。
管理设备
安装了所有者管理员的设备称为受管设备,与未受管设备相比,它对影响设备安全性的配置更改反应不同。如在第六章和第七章中所述,Android 允许用户通过系统设置应用或使用调用 KeyChain API 的第三方应用将证书安装到系统信任存储中。如果系统信任存储中有用户安装的证书,从 Android 4.4 版本开始,Android 会显示警告(见图 6-6),提醒用户他们的通信可能被监控。
企业网络通常要求安装受信证书(例如,企业 PKI 的根证书),才能访问企业服务。设备管理员可以通过 DevicePolicyManager 类的 installCaCert() 和 uninstallCaCert() 方法(这些方法保留给系统应用,且在公开 SDK API 中不可见)静默安装或删除此类证书。若在受管设备上安装了额外的受信证书,则网络监控警告将变为较不令人担忧的信息消息,如图 9-4 所示。
企业账户集成
如在“设备管理”中所述,设备管理员应用程序通常与企业账户配合使用,以便对访问公司数据的设备进行一定的控制。在本节中,我们将讨论两种这样的实现:一种是在内置的电子邮件应用中,它与 Microsoft Exchange ActiveSync 账户配合使用,另一种是在专用的 Google Apps 设备策略应用中,它与公司 Google 账户配合使用。

图 9-4. 受管设备上显示的网络监控信息消息
Microsoft Exchange ActiveSync
Microsoft Exchange ActiveSync(通常缩写为 EAS)是一种协议,支持从组件服务器到移动设备的电子邮件、联系人、日历和任务同步。它不仅支持 Microsoft 自家的 Exchange Server,还支持大多数竞争产品,包括 Google Apps。
Android 中包含的电子邮件应用程序支持 ActiveSync 账户和通过专用账户认证器(见第八章)和同步适配器的数据同步。为了允许企业管理员对访问电子邮件和其他公司数据的设备执行安全策略,电子邮件应用程序在用户启用内置设备管理员之前,不允许进行同步。管理员可以设置锁屏密码规则、擦除所有数据、要求存储加密以及禁用设备摄像头,如图 9-5 所示。然而,这些策略并非内置于应用程序中,而是通过 EAS Provision 协议从服务中获取。

图 9-5. 使用 EAS 账户所需的设备管理员策略
Google Apps
Google 的企业版 Gmail 服务——Google Apps,也支持设置移动设备的安全策略。如果域管理员启用了此功能,Google Apps 账户持有者还可以远程定位、响铃、锁定或擦除其 Android 设备。域管理员还可以选择性地删除 Google Apps 账户及其所有关联内容,而无需执行完全擦除。安全策略执行和远程设备管理都通过专用的 Google Apps 设备策略应用实现(见示例 9-3 中的➎)。
应用首次启动时,会请求用户启用内置的设备管理员,并显示当前的域策略设置,如图 9-6 所示。
域管理员在 Google Apps 管理员控制台中定义策略(见图 9-7),并通过 Google 的专有同步协议将策略设置推送到设备。
虽然免费的 Google 账户不支持设置设备策略,但 Google 体验设备可以使用内置在 Google Play 服务中的基本设备管理员(见示例 9-3 中的➊)。该管理员允许 Google 账户持有者通过 Android 设备管理器网站或关联的 Android 应用程序远程定位或擦除设备。

图 9-6. Google Apps 设备策略应用中的策略执行确认

图 9-7. Google 应用设备策略管理界面
VPN 支持
虚拟专用网络(VPN)允许将私有网络扩展到公共网络,而无需专用的物理连接,从而使所有连接的设备可以像位于同一私有网络并物理连接一样发送和接收数据。当 VPN 用于允许单个设备连接到目标私有网络时,称为远程访问 VPN,而用于连接两个远程网络时,称为站点对站点 VPN。
远程访问 VPN 可以连接具有静态 IP 地址的固定设备,例如远程办公室中的计算机,但移动客户端使用可变网络连接和动态地址的配置更为常见。这种配置通常被称为路上战士配置,也是 Android VPN 客户端最常使用的配置。
为了确保通过 VPN 传输的数据保持私密,VPN 通常会对远程客户端进行身份验证,并通过使用安全隧道协议提供数据的机密性和完整性。VPN 协议非常复杂,因为它们同时在多个网络层上工作,并且通常涉及多个级别的封装,以便与各种网络配置兼容。对它们的详细讨论超出了本书的范围,但在接下来的章节中,你将看到主要 VPN 协议类型的简要概述,重点介绍适用于 Android 的协议。
PPTP
点对点隧道协议(PPTP)使用 TCP 控制通道来建立连接,并使用通用路由封装(GRE)隧道协议来封装点对点协议(PPP)数据包。支持多种认证方法,如密码认证协议(PAP)、挑战握手认证协议(CHAP)、其 Microsoft 扩展 MS-CHAP v1/v2,以及 EAP-TLS,但目前只有 EAP-TLS 被认为是安全的。
PPP 有效负载可以使用 Microsoft 点对点加密(MPPE)协议进行加密,该协议使用 RC4 流密码。由于 MPPE 不使用任何形式的密文认证,它容易受到位翻转攻击。此外,近年来已经揭示出多个 RC4 密码的漏洞,这进一步降低了 MMPE 和 PPTP 的安全性。
L2TP/IPSec
第二层隧道协议(L2TP)类似于 PPTP,并位于数据链路层(OSI 模型中的第二层)。由于 L2TP 本身不提供加密或机密性(它依赖于隧道协议来实现这些功能),因此 L2TP VPN 通常使用 L2TP 和互联网协议安全(IPSec)协议套件的组合来实现,这样可以添加认证、机密性和完整性。
在 L2TP/IPSec 配置中,首先通过 IPSec 建立一个安全通道,然后在该安全通道上建立 L2TP 隧道。L2TP 数据包始终被封装在 IPSec 数据包中,因此是安全的。IPSec 连接需要建立一个 安全关联(SA),它是加密算法和模式、加密密钥以及建立安全通道所需的其他参数的组合。
安全关联(SA)是通过互联网安全关联与密钥管理协议(ISAKMP)建立的。ISAKMP 并不定义特定的密钥交换方法,通常是通过手动配置预共享密钥,或者通过使用互联网密钥交换(IKE 和 IKEv2)协议来实现。IKE 使用 X.509 证书进行对等身份验证(类似于 SSL),并使用 Diffie-Hellman 密钥交换来建立共享密钥,该共享密钥用于派生实际的会话加密密钥。
IPSec Xauth
IPSec 扩展认证(Xauth) 扩展了 IKE,增加了额外的用户认证交换。这使得现有的用户数据库或 RADIUS 基础设施可以用于认证远程访问客户端,并且能够集成双因素认证。
模式配置(Modecfg) 是另一个 IPSec 扩展,通常在远程访问场景中使用。Modecfg 允许 VPN 服务器将网络配置信息(如私有 IP 地址和 DNS 服务器地址)推送给客户端。当 Xauth 和 Modecfg 结合使用时,可以创建一个纯粹的 IPSec VPN 解决方案,该方案不依赖于额外的协议进行认证和隧道化。
基于 SSL 的 VPN
基于 SSL 的 VPN 使用 SSL 或 TLS(见第六章)来建立安全连接并隧道网络流量。没有单一的标准定义基于 SSL 的 VPN,不同的实现使用不同的策略来建立安全通道并封装数据包。
OpenVPN 是一个流行的开源应用程序,使用 SSL 进行身份验证和密钥交换(也支持预配置的共享静态密钥),并使用自定义加密协议^([90])来加密和认证数据包。OpenVPN 复用用于身份验证和密钥交换的 SSL 会话,且加密数据包通过单一的 UDP(或 TCP)端口流动。复用协议为 SSL 提供了基于 UDP 的可靠传输层,但它通过 UDP 隧道加密的 IP 数据包时不增加可靠性。可靠性由隧道协议本身提供,通常是 TCP。
OpenVPN 相对于 IPSec 的主要优点在于它更简单,且可以完全在用户空间实现。另一方面,IPSec 需要内核级支持并实现多个相互操作的协议。此外,OpenVPN 更容易通过防火墙、NAT 和代理,因为它使用常见的网络协议 TCP 和 UDP,并且可以通过单个端口复用隧道流量。
以下章节将探讨 Android 的内建 VPN 支持及其为希望实现额外 VPN 解决方案的应用程序提供的 API。我们还将回顾构成 Android VPN 基础设施的组件,并展示它是如何保护 VPN 凭证的。
传统 VPN
在 Android 4.0 之前,VPN 支持完全内建在平台中,无法扩展。新增的 VPN 类型只能作为平台更新的一部分添加。为了与基于应用的实现区分,内建的 VPN 支持被称为 传统 VPN。
早期的 Android 版本支持基于 PPTP 和 L2TP/IPsec 的不同 VPN 配置,并且在 4.0 版本中增加了对“纯-IPSec” VPN 的支持,通过 IPSec Xauth 实现。此外,Android 4.0 还通过提供基础平台类 VpnService,引入了基于应用的 VPN,使应用程序能够扩展这一类,以实现新的 VPN 解决方案。
传统 VPN 通过系统设置应用程序进行控制,仅对多用户设备中的所有者(也称为主用户)可用。图 9-8 显示了添加新 IPSec 传统 VPN 配置文件的对话框。
实现

图 9-8. 传统 VPN 配置文件定义对话框
传统 VPN 是通过组合内核驱动程序以及原生守护进程、命令和系统服务实现的。PPTP 和 L2TP 隧道的底层实现使用了一种 Android 特有的 PPP 守护进程,名为 mtpd,以及 PPPoPNS 和 PPPoLAC(仅在 Android 内核中可用)内核驱动程序。
因为传统 VPN 每个设备只支持一个 VPN 连接,mtpd 只能创建一个会话。IPSec VPN 利用内建的内核支持和修改过的 racoon IKE 密钥管理守护进程(这是 IPSec-Tools^([91]) 工具包的一部分,补充了 Linux 内核的 IPSec 实现;racoon 仅支持 IKEv1)。示例 9-6 显示了这两个守护进程在 init.rc 中的定义方式。
示例 9-6. racoon 和 mtpd 在 init.rc 中的定义
service racoon /system/bin/racoon➊
class main
socket racoon stream 600 system system➋
# IKE uses UDP port 500\. Racoon will setuid to vpn after binding the port.
group vpn net_admin inet➌
disabled
oneshot
service mtpd /system/bin/mtpd➍
class main
socket mtpd stream 600 system system➎
user vpn
group vpn net_admin inet net_raw➏
disabled
oneshot
racoon ➊ 和 mtpd ➍ 都会创建控制套接字(➋ 和 ➎),这些套接字仅限 system 用户访问,并且默认情况下不会启动。两个守护进程都将 vpn、net_admin(由内核映射到 CAP_NET_ADMIN Linux 能力)和 inet 添加到它们的附加组(➌ 和 ➏),这允许它们创建套接字并控制网络接口设备。mtpd 守护进程还接收 net_raw 组(映射到 CAP_NET_RAW Linux 能力),这使它能够创建 GRE 套接字(PPTP 使用的套接字)。
当通过系统设置应用启动 VPN 时,Android 启动 racoon 和 mtpd 守护进程,并通过它们的本地套接字发送控制命令,以建立配置的连接。守护进程创建请求的 VPN 隧道,然后使用接收到的 IP 地址和网络掩码创建并配置隧道网络接口。虽然 mtpd 在内部执行接口配置,但 racoon 使用辅助命令 ip-up-vpn 来启动隧道接口,通常是 tun0。
为了将连接参数传回框架,VPN 守护进程在 /data/misc/vpn/ 路径下写入 state 文件,如 示例 9-7 所示。
示例 9-7. VPN 状态文件内容
# cat /data/misc/vpn/state
tun0➊
10.8.0.1/24➋
192.168.1.0/24➌
192.168.1.1➍
example.com➎
文件包含隧道接口名称 ➊、其 IP 地址和掩码 ➋、配置的路由 ➌、DNS 服务器 ➍ 和搜索域 ➎,每个项都占一行。
在 VPN 守护进程启动后,框架解析 state 文件,并调用系统 ConnectivityService 来配置新建立的 VPN 连接的路由、DNS 服务器和搜索域。随后,ConnectivityService 通过 netd 守护进程的本地控制套接字发送控制命令,因其以 root 身份运行,可以修改内核的包过滤和路由表。来自所有由所有者用户和受限配置文件启动的应用程序的流量,通过添加匹配应用程序 UID 和相应路由规则的防火墙规则,路由到 VPN 接口。(我们在 “Multi-User Support” 中详细讨论了每个应用程序流量路由和多用户支持。)
配置文件和凭证存储
通过设置应用创建的每个 VPN 配置称为 VPN 配置文件,并以加密形式保存在磁盘上。加密由 Android 凭证存储守护进程 keystore 执行,并使用设备特定的密钥。(有关凭证存储实现的更多信息,请参见 第七章)。
VPN 配置文件是通过将所有配置属性串联起来序列化的,这些属性由 NUL 字符(\0)分隔,并保存在系统密钥库中作为二进制大对象。VPN 配置文件的文件名是通过将当前时间的毫秒数(以十六进制格式)附加到 VPN_ 前缀来生成的。例如,示例 9-8 展示了一个用户的 keystore 目录,其中有三个已配置的 VPN 配置文件(文件时间戳已省略):
示例 9-8. 配置 VPN 配置文件时的 keystore 目录内容
# ls -l /data/misc/keystore/user_0
-rw------- keystore keystore 980 1000_CACERT_cacert➊
-rw------- keystore keystore 52 1000_LOCKDOWN_VPN➋
-rw------- keystore keystore 932 1000_USRCERT_vpnclient➌
-rw------- keystore keystore 1652 1000_USRPKEY_vpnclient➍
-rw------- keystore keystore 116 1000_VPN_144965b85a6➎
-rw------- keystore keystore 84 1000_VPN_145635c88c8➏
-rw------- keystore keystore 116 1000_VPN_14569512c80➐
这三个 VPN 配置文件分别存储在 1000_VPN_144965b85a6 ➎、1000_VPN_145635c88c8 ➏ 和 1000_VPN_14569512c80 ➐ 文件中。1000_ 前缀表示拥有者用户为 system(UID 1000)。由于 VPN 配置文件归 system 用户所有,只有系统应用程序可以检索和解密配置文件内容。
示例 9-9 展示了三个 VPN 配置文件的解密内容。(NUL 字符已被竖线 [|] 替换以提高可读性。)
示例 9-9. VPN 配置文件的内容
psk-vpn|1|vpn1.example.com|test1|pass1234||||true|l2tpsecret|l2tpid|PSK|||➊
pptpvpn|0|vpn2.example.com|user1|password||||true||||||➋
certvpn|4|vpn3.example.com|user3|password||||true||||vpnclient|cacert|➌
配置文件包含在 VPN 配置文件编辑对话框中显示的所有字段(见图 9-8),缺失的属性用空字符串表示。前五个字段分别表示 VPN 的名称、VPN 类型、VPN 网关主机、用户名和密码。在示例 9-9 中,第一个 VPN 配置文件 ➊ 是用于 L2TP/IPsec VPN 的预共享密钥(类型 1);第二个配置文件 ➋ 是用于 PPTP VPN 的(类型 0),最后一个配置文件 ➌ 是用于使用证书和 Xauth 身份验证的 IPSec VPN(类型 4)。
除了用户名和密码外,VPN 配置文件还包含连接到 VPN 所需的所有其他凭据。在示例 9-9 中的第一个 VPN 配置文件 ➊ 中,额外的凭据是用于建立 IPSec 安全连接的预共享密钥(在本示例中由 PSK 字符串表示)。在第三个配置文件 ➌ 中,额外的凭据是用户的私钥和证书。然而,正如你在列表中看到的,完整的密钥和证书并未包含在内;相反,配置文件仅包含密钥和证书的别名(vpnclient)(二者共享相同的别名)。私钥和证书存储在系统凭据库中,VPN 配置文件中包含的别名仅作为标识符,用于访问或检索密钥和证书。
访问凭据
racoon 守护进程最初使用存储在 PEM 文件中的密钥和证书,后来修改为使用 Android 的 keystore OpenSSL 引擎。如第七章所讨论,keystore 引擎是系统凭证存储的网关,当有硬件支持的凭证存储实现时,它可以利用这些硬件支持的实现。当传递一个密钥别名时,它使用相应的私钥来签名认证数据包,而无需从密钥库中提取密钥。
在示例 9-9 中的 VPN 配置文件 ➌ 还包含了 CA 证书的别名 (cacert),该别名用于在验证服务器证书时作为信任锚点。在运行时,框架从系统密钥库中检索客户端证书(➌ 在 示例 9-8 中)和 CA 证书(➊ 在 示例 9-8 中),并将它们与其他连接参数一起通过控制套接字传递给 racoon。私钥 Blob(➍ 在 示例 9-8 中)从未直接传递给 racoon 守护进程,只有其别名 (vpnclient) 被传递。
注意
虽然私钥在具有硬件支持的密钥库的设备上受到硬件保护,但存储在 VPN 配置文件中的预共享密钥或密码并不受到保护。原因在于,直到目前为止,Android 并不支持在硬件支持的密钥库中导入对称密钥;它仅支持非对称密钥(RSA、DSA 和 EC)。因此,使用预共享密钥的 VPN 凭证以明文形式存储在 VPN 配置文件中,并且可以在配置文件解密后,从允许 root 权限的设备中提取。
始终开启 VPN
Android 4.2 及更高版本支持 始终开启 VPN 配置,这会阻止应用程序的所有网络连接,直到与指定的 VPN 配置文件建立连接为止。这可以防止应用程序通过不安全的通道(例如公共 Wi-Fi 网络)发送数据。
设置始终开启的 VPN 需要设置一个 VPN 配置文件,指定 VPN 网关的 IP 地址,并指定一个明确的 DNS 服务器 IP 地址。此明确配置是为了确保 DNS 流量不会发送到本地配置的 DNS 服务器,因为在始终开启的 VPN 生效时,DNS 请求会被阻止。VPN 配置文件选择对话框显示在图 9-9 中。
配置文件选择与其他 VPN 配置文件一起保存在加密文件 LOCKDOWN_VPN 中(示例 9-8 中的 ➋),该文件仅包含所选配置文件的名称;在我们的示例中是 144965b85a6。如果 LOCKDOWN_VPN 文件存在,系统在设备启动时会自动连接到指定的 VPN。如果底层网络连接重新连接或发生更改(例如切换 Wi-Fi 热点),VPN 会自动重新启动。

图 9-9. 始终开启 VPN 配置文件选择对话框
始终开启的 VPN 通过安装防火墙规则,确保所有流量都通过 VPN。这些规则会阻止所有数据包,除了那些通过 VPN 接口的。规则由 LockdownVpnTracker 类安装(在 Android 源代码中,始终开启的 VPN 称为 lockdown VPN),该类监控 VPN 状态,并通过向 netd 守护进程发送命令来调整当前的防火墙状态,后者执行 iptables 工具,以修改内核的包过滤表。例如,当一个始终开启的 L2TP/IPSec VPN 连接到 IP 地址为 11.22.33.44 的 VPN 服务器,并且创建了一个 IP 地址为 10.1.1.1 的隧道接口 tun0 时,安装的防火墙规则(由 iptables 报告;为了简洁,某些列已省略)可能如下所示:示例 9-10。
示例 9-10. 始终开启 VPN 防火墙规则
# iptables -v -L n
--*snip*--
Chain fw_INPUT (1 references)
target prot opt in out source destination
RETURN all -- * * 0.0.0.0/0 10.1.1.0/24➊
RETURN all -- tun0 * 0.0.0.0/0 0.0.0.0/0➋
RETURN udp -- * * 11.22.33.44 0.0.0.0/0 udp spt:1701➌
RETURN tcp -- * * 11.22.33.44 0.0.0.0/0 tcp spt:1701
RETURN udp -- * * 11.22.33.44 0.0.0.0/0 udp spt:4500
RETURN tcp -- * * 11.22.33.44 0.0.0.0/0 tcp spt:4500
RETURN udp -- * * 11.22.33.44 0.0.0.0/0 udp spt:500
RETURN tcp -- * * 11.22.33.44 0.0.0.0/0 tcp spt:500
RETURN all -- lo * 0.0.0.0/0 0.0.0.0/0
DROP all -- * * 0.0.0.0/0 0.0.0.0/0➍
Chain fw_OUTPUT (1 references)
target prot opt in out source destination
RETURN all -- * * 10.1.1.0/24 0.0.0.0/0➎
RETURN all -- * tun0 0.0.0.0/0 0.0.0.0/0➏
RETURN udp -- * * 0.0.0.0/0 11.22.33.44 udp dpt:1701➐
RETURN tcp -- * * 0.0.0.0/0 11.22.33.44 tcp dpt:1701
RETURN udp -- * * 0.0.0.0/0 11.22.33.44 udp dpt:4500
RETURN tcp -- * * 0.0.0.0/0 11.22.33.44 tcp dpt:4500
RETURN udp -- * * 0.0.0.0/0 11.22.33.44 udp dpt:500
RETURN tcp -- * * 0.0.0.0/0 11.22.33.44 tcp dpt:500
RETURN all -- * lo 0.0.0.0/0 0.0.0.0/0
REJECT all -- * * 0.0.0.0/0 0.0.0.0/0 reject-with icmp-port-unreachable➑
--*snip*--
如您在列表中看到的,所有来自 VPN 网络的流量(➊ 和 ➎)和隧道接口上的所有流量(➋ 和 ➏)都是允许的。仅允许通过 IPSec(500 和 4500)和 L2TP(1701)端口的 VPN 服务器流量(➌ 和 ➐)。所有其他传入流量会被丢弃 ➍,所有其他传出流量会被拒绝 ➑。
基于应用程序的 VPN
Android 4.0 添加了 VpnService 公共 API^([92]),第三方应用程序可以利用该 API 构建既不是操作系统内建的,也不需要系统级权限的 VPN 解决方案。VpnService 及其关联的 Builder 类允许应用程序指定网络参数,如接口 IP 地址和路由,系统使用这些参数来创建和配置虚拟网络接口。应用程序会接收到与该网络接口关联的文件描述符,并且可以通过读取或写入接口的文件描述符来进行网络流量隧道传输。
每次读取时会检索一个外发的 IP 数据包,每次写入时会注入一个入站的 IP 数据包。由于对网络数据包的原始访问实际上允许应用拦截和修改网络流量,因此基于应用的 VPN 不能自动启动,并始终需要用户交互。此外,在 VPN 连接时,会显示一个持续的通知。基于应用的 VPN 连接警告对话框可能如图 9-10 所示。

图 9-10. 基于应用的 VPN 连接警告对话框
声明一个 VPN
基于应用的 VPN 通过创建一个扩展VpnService基类的服务组件并在应用清单中注册来实现,如示例 9-11 所示。
示例 9-11. 在应用清单中注册 VPN 服务
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.vpn">
--*snip*--
<application android:label="@string/app">
--*snip*--
<service android:name=".MyVpnService"
android:permission="android.permission.BIND_VPN_SERVICE">➊
<intent-filter>
<action android:name="android.net.VpnService"/>➋
</intent-filter>
</service>
</application>
</manifest>:
该服务必须具有一个意图过滤器,能够匹配android.net.VpnService意图操作➋,以便系统能够绑定到该服务并控制它。此外,该服务必须要求BIND_VPN_SERVICE系统签名权限➊,这确保只有系统应用能够绑定到该服务。
准备 VPN
要将新的 VPN 连接注册到系统中,应用首先调用VpnService.prepare()以获得运行权限,然后调用establish()方法来创建一个网络隧道(在下一节中讨论)。prepare()方法返回一个意图,用于启动图 9-10 中显示的警告对话框。该对话框用于获得用户的许可,并确保每个用户始终只能运行一个 VPN 连接。如果在另一个应用创建的 VPN 连接正在运行时调用prepare(),该连接将被终止。prepare()方法保存调用应用的包名,并且只有该应用被允许启动 VPN 连接,直到该方法再次被调用或系统关闭 VPN 连接(例如,如果 VPN 应用的进程崩溃)。当 VPN 连接因任何原因被停用时,系统会调用当前 VPN 应用的VpnService实现中的onRevoke()方法。
建立 VPN 连接
在准备好 VPN 应用并授予其运行权限后,应用程序可以启动其 VpnService 组件,通常会创建一个隧道连接到 VPN 网关,并协商 VPN 连接的网络参数。接下来,应用程序使用这些参数设置 VpnService.Builder 类,并调用 VpnService.establish() 来接收一个文件描述符,以便读写数据包。establish() 方法首先确保它是由当前获得建立 VPN 连接权限的应用程序调用,通过比较调用者的 UID 和授权应用程序的 UID 来进行验证。然后,establish() 检查当前 Android 用户是否被允许创建 VPN 连接,并验证该服务是否需要 BIND_VPN_SERVICE 权限;如果该服务不需要该权限,则被认为是不安全的,并抛出 SecurityException 异常。接下来,establish() 方法使用本地代码创建并配置隧道接口,并设置路由和 DNS 服务器。
通知用户 VPN 连接
建立 VPN 连接的最后一步是显示一个持续的通知,告知用户网络流量正在通过 VPN 隧道传输,这使得用户能够通过关联的控制对话框监控和控制连接。Android 版 OpenVPN 应用的对话框如图 9-11 所示。
该对话框是专用包 com.android.vpndialogs 的一部分,这是唯一一个被明确允许管理基于应用的 VPN 连接的包,除了 系统 用户。这确保了 VPN 连接只能通过系统强制的用户界面启动和管理。
使用基于应用的 VPN 框架,应用程序可以自由实现网络隧道功能,并根据需要进行身份验证和加密方法的设置。由于设备发送或接收的所有数据包都经过 VPN 应用,因此它不仅可以用于隧道传输,还可以用于流量日志记录、过滤或修改(例如移除广告)。
注意
要实现一个功能完整的基于应用的 VPN,利用 Android 的凭证存储来管理身份验证密钥和证书,请参阅 OpenVPN for Android 的源代码。^([93]) 该应用实现了一个完全兼容 OpenVPN 服务器的 SSL VPN 客户端。
多用户支持
如前所述,在多用户设备上,传统的 VPN 只能由拥有者用户控制。然而,通过引入多用户支持,Android 4.2 及更高版本允许所有次级用户(限制型用户除外,这些用户必须共享主用户的 VPN 连接)启动基于应用的 VPN。虽然这一变化从技术上讲允许每个用户启动自己的 VPN,但由于每次只能激活一个基于应用的 VPN,因此所有设备用户的流量都会通过当前激活的 VPN 路由,而不管是哪个用户启动的。Android 4.4 最终通过引入 每用户 VPN 完全支持多用户 VPN,该功能允许来自任何用户的流量通过其个人的 VPN 路由,从而将其与其他用户的流量隔离开。
Linux 高级路由

图 9-11. 基于应用的 VPN 管理对话框
Android 使用 Linux 内核的几个高级数据包过滤和路由特性来实现每用户 VPN。这些特性(由 netfilter 内核框架实现)包括 Linux iptables 工具的 owner 模块,该模块允许根据创建数据包的进程的 UID、GID 或 PID 来匹配本地生成的数据包。例如,示例 9-12 中显示的命令创建了一个数据包过滤规则,丢弃所有由 UID 为 1234 的用户生成的外发数据包。
示例 9-12. 使用所有者匹配和数据包标记的 iptables
# iptables -A OUTPUT -m owner --uid-owner 1234 -j DROP➊
# iptables -A PREROUTING -t mangle -p tcp --dport 80 -j MARK --set-mark 0x1➋
# ip rule add fwmark 0x1 table web➌
# ip route add default via 1.2.3.4 dev em3 table web➍
另一个重要的 netfilter 特性是能够标记匹配特定选择器的包,并为其指定一个数字(称为 标记)。例如,➋ 处的规则将所有目标端口为 80 的数据包(通常由 Web 服务器使用)标记为 0x1\。然后,可以在后续的过滤或路由规则中匹配此标记,例如,通过添加路由规则,将标记的数据包发送到预定义的路由表,从而将标记的数据包通过特定接口转发,在我们的示例 ➌ 中是 web。最后,可以通过在 ➍ 处显示的命令,添加一个路由规则,将匹配 web 表的数据包发送到 em3 接口。
多用户 VPN 实现
Android 使用这些数据包过滤和路由功能,将来自特定 Android 用户所有应用的包标记,并通过该用户启动的 VPN 应用所创建的隧道接口进行转发。当拥有者用户启动 VPN 时,该 VPN 会与设备上无法启动自己 VPN 的任何限制型用户共享,通过匹配所有来自限制型用户的数据包并将它们路由通过拥有者的 VPN 隧道来实现。
这种拆分路由在框架级别通过NetworkManagementService实现,该服务提供 API 来按 UID 或 UID 范围管理数据包匹配和路由。NetworkManagementService通过向本地的netd守护进程发送命令来实现这些 API,而netd以 root 身份运行,因此可以修改内核的数据包过滤和路由表。netd通过调用iptables和ip用户态工具来操控内核的过滤和路由配置。
让我们通过一个示例来说明 Android 的每个用户 VPN 路由,如示例 9-13 所示。主用户(用户 ID 0)和第一个次级用户(用户 ID 10)分别启动了基于应用的 VPN。主用户的 VPN 分配了tun0隧道接口,次级用户的 VPN 分配了tun1接口。设备上还存在一个带有用户 ID 13 的受限配置文件。示例 9-13 显示了内核的数据包过滤表状态,当两个 VPN 都连接时(一些细节被省略)。
示例 9-13。由两个不同设备用户启动的 VPN 的数据包匹配规则
**# iptables -t mangle -L –n**
--*snip*--
Chain st_mangle_OUTPUT (1 references)
target prot opt source destination
RETURN all -- 0.0.0.0/0 0.0.0.0/0 mark match 0x1➊
RETURN all -- 0.0.0.0/0 0.0.0.0/0 owner UID match 1016➋
--*snip*--
st_mangle_tun0_OUTPUT all -- 0.0.0.0/0 0.0.0.0/0 [goto] owner UID match
0-99999➌
st_mangle_tun0_OUTPUT all -- 0.0.0.0/0 0.0.0.0/0 [goto] owner UID match
1300000-1399999➍
st_mangle_tun1_OUTPUT all -- 0.0.0.0/0 0.0.0.0/0 [goto] owner UID match
1000000-1099999➎
Chain st_mangle_tun0_OUTPUT (3 references)
target prot opt source destination
MARK all -- 0.0.0.0/0 0.0.0.0/0 MARK and 0x0
MARK all -- 0.0.0.0/0 0.0.0.0/0 MARK set 0x3c➏
Chain st_mangle_tun1_OUTPUT (2 references)
target prot opt source destination
MARK all -- 0.0.0.0/0 0.0.0.0/0 MARK and 0x0
MARK all -- 0.0.0.0/0 0.0.0.0/0 MARK set 0x3d➐
外发数据包首先发送到st_mangle_OUTPUT链,该链负责匹配和标记数据包。免于每个用户路由的数据包(那些已经被标记为 0x1 ➊)和来自传统 VPN 的数据包(UID 1016 ➋,分配给内置的vpn用户,该用户同时运行mtd和racoon)无需修改即可通过。
接下来,由 UID 介于 0 到 99999 之间的进程创建的数据包(这些 UID 范围分配给由主用户启动的应用,如第四章所讨论)会被匹配并发送到st_mangle_tun0_OUTPUT链 ➌。来自 UID 1300000–1399999 的数据包,即分配给我们受限配置文件(用户 ID 13)的 UID 范围,会被发送到相同的链 ➍。因此,来自主用户和受限配置文件的流量将以相同的方式处理。然而,来自第一个次级用户(用户 ID 10,UID 范围 1000000-1099999)的数据包将被发送到另一个链——st_mangle_tun1_OUTPUT ➎。目标链本身很简单:st_mangle_tun0_OUTPUT首先清除数据包标记,然后用0x3c标记它们 ➏;st_mangle_tun1_OUTPUT做相同的事情,但使用标记0x3d ➐。数据包被标记后,标记会用于实现并匹配不同的路由规则,如示例 9-14 所示。
示例 9-14。由两个不同设备用户启动的 VPN 的路由规则
# ip rule ls
0: from all lookup local
100: from all fwmark 0x3c lookup 60➊
100: from all fwmark 0x3d lookup 61➋
--*snip*--
# ip route list table 60
default dev tun0 scope link➌
# ip route list table 61
default dev tun1 scope link➍
请注意,已创建了两个匹配每个标记的规则,并且它们与不同的路由表相关联。标记为0x3c的数据包进入路由表 60(16 进制中的 0x3c ➊),而标记为0x3d的数据包进入路由表 61(16 进制中的 0x3d ➋)。表 60 将所有流量通过由拥有者用户创建的tun0隧道接口路由 ➌,而表 61 将所有流量通过由次要用户创建的tun1接口路由 ➍。
注意
尽管 Android 4.4 中引入的 VPN 流量路由方法提供了更大的灵活性,并允许用户的 VPN 流量隔离,但截至目前,实施似乎存在一些问题,尤其是与在不同物理网络之间切换(例如,从移动网络到 Wi-Fi 或反之)相关的问题。这些问题应在未来的版本中解决,可能通过修改数据包过滤链与接口的关联方式,但基本的实施策略可能会保持不变。
Wi-Fi EAP
Android 支持不同的无线网络协议,包括 Wi-Fi 保护访问(WPA)和 Wi-Fi 保护访问 II(WPA2),这两种协议目前在大多数无线设备上都有部署。两种协议都支持一种简单的预共享密钥(PSK)模式,也称为个人模式,在这种模式下,所有访问网络的设备必须配置相同的 256 位认证密钥。
设备可以通过原始密钥字节或使用 ASCII 密码短语来配置,后者用于通过 PBKDF2 密钥派生算法派生认证密钥。虽然 PSK 模式简单,但随着网络用户数量的增加,它无法扩展。如果需要撤销某个用户的访问权限,例如,取消其网络凭证的唯一方法是更改共享密码短语,这将迫使所有其他用户重新配置其设备。此外,由于没有实际的方法来区分用户和设备,因此很难实施灵活的访问规则或记账。
为了解决这个问题,WPA 和 WPA2 都支持 IEEE 802.1X 网络访问控制标准,该标准封装了可扩展认证协议(EAP)。使用 802.1X 并涉及客户端、认证者和认证服务器的无线网络认证如图 9-12 所示。

图 9-12. 802.1X 认证参与者
客户端是希望连接到网络的无线设备,如 Android 手机,认证器是网络的网关,用于验证客户端的身份并提供授权。在典型的 Wi-Fi 配置中,认证器是无线接入点(AP)。认证服务器,通常是 RADIUS 服务器,验证客户端凭据并根据预配置的访问策略决定是否授予访问权限。
通过在三个节点之间交换 EAP 消息来实现身份验证。这些消息被封装成适合连接每两个节点的介质的格式:客户端与认证器之间使用局域网(EAPOL),认证器与认证服务器之间使用 RADIUS。
由于 EAP 是一种支持不同具体身份验证类型的身份验证框架,而不是一种具体的身份验证机制,因此客户端(与认证器的帮助下)需要在执行身份验证之前协商一个共同支持的身份验证方法。存在多种标准和专有的 EAP 身份验证方法,当前的 Android 版本支持大多数用于无线网络中的方法。
以下部分简要概述了 Android 支持的 EAP 身份验证方法,并展示了它如何保护每种方法的凭据。我们还将演示如何使用 Android 的无线网络管理 API 配置使用 EAP 进行身份验证的 Wi-Fi 网络的访问。
EAP 身份验证方法
从 4.4 版本开始,Android 支持 PEAP、EAP-TLS、EAP-TTLS 和 EAP-PWD 身份验证方法。在探索 Android 如何存储每种身份验证方法的凭据之前,让我们简要讨论每种方法的工作原理。
PEAP
保护扩展认证协议(PEAP)通过 SSL 连接传输 EAP 消息,以提供机密性和完整性。它使用公钥基础设施(PKI)和服务器证书来验证服务器并建立 SSL 连接(阶段 1),但不要求指定客户端的身份验证方式。客户端使用第二种内层(阶段 2)身份验证方法进行身份验证,该方法在 SSL 隧道内传输。Android 支持用于阶段 2 身份验证的 MSCHAPv2(在 PEAPv0 中指定^([94]))和通用令牌卡(GTC,在 PEAPv2 中指定^([95]))方法。
EAP-TLS
EAP-传输层安全性(EAP-TLS)方法^([96])使用 TLS 进行相互身份验证,曾是唯一经过认证可以与 WPA 企业版一起使用的 EAP 方法。EAP-TLS 使用服务器证书来验证服务器的身份,并使用客户端证书来验证身份验证服务器以建立请求者身份。授予网络访问权限需要发布和分发 X.509 客户端证书,从而维护公钥基础设施。可以通过吊销请求者证书来阻止现有客户端访问网络。Android 支持 EAP-TLS,并使用系统凭据存储管理客户端密钥和证书。
EAP-TTLS
与 EAP-TLS 类似,EAP 隧道传输层安全性(EAP-TTLS)协议^([97]) 基于 TLS。然而,EAP-TTLS 不需要使用 X.509 证书进行客户端身份验证。客户端可以在握手阶段(阶段 1)使用证书进行身份验证,或者在隧道阶段(阶段 2)使用另一种协议进行身份验证。Android 不支持在阶段 1 进行身份验证,但支持在阶段 2 使用 PAP、MSCHAP、MSCHAPv2 和 GTC 协议进行身份验证。
EAP-PWD
EAP-PWD 身份验证方法^([98])使用共享密码进行身份验证。与依赖简单的挑战-响应机制的传统方案不同,EAP-PWD 旨在防御被动攻击、主动攻击和字典攻击。该协议还提供前向保密性,保证即使密码被泄露,之前的会话也无法被解密。EAP-PWD 基于离散对数密码学,可以使用有限域或椭圆曲线来实现。
Android Wi-Fi 架构
像大多数 Android 硬件支持一样,Android 的 Wi-Fi 架构由内核层(WLAN 适配器驱动模块)、本地守护进程(wpa_supplicant)、硬件抽象层(HAL)、系统服务和系统 UI 组成。Wi-Fi 适配器内核驱动通常是特定于 Android 设备所依赖的系统芯片(SoC)的,通常是闭源的并作为内核模块加载。wpa_supplicant^([99]) 是一个 WPA 请求者守护进程,实现与 WPA 身份验证器的密钥协商,并控制 WLAN 驱动的 802.1X 关联。然而,Android 设备很少包含原始的 wpa_supplicant 代码;包含的实现通常会针对底层 SoC 进行修改,以提高兼容性。
HAL 实现于 libharware_legacy 本地库,并负责通过其控制套接字将框架中的命令传递给 wpa_supplicant。控制 Wi-Fi 连接的系统服务是 WifiService,它通过 WifiManager 外观类提供公共接口。WifiService 将 Wi-Fi 状态管理委托给一个相当复杂的 WifiStateMachine 类,在连接无线网络时,该类可能经历十多个状态。
WLAN 连接通过系统设置应用程序中的 Wi-Fi 屏幕进行控制,连接状态显示在状态栏和快速设置中,二者都是 SystemUI 包的一部分。
Android 将与 Wi-Fi 相关的配置文件存储在 /data/misc/wifi/ 目录下,因为无线连接守护进程会将配置更改直接写入磁盘,因此需要一个可写的目录。该目录属于 wifi 用户(UID 1010),它也是 wpa_supplicant 运行的用户。包括 wpa_supplicant.conf 在内的配置文件的权限设置为 0660,文件的所有者是 system 用户,组设置为 wifi。这确保了系统应用程序和 supplicant 守护进程可以读取和修改配置文件,但其他应用程序无法访问。wpa_supplicant.conf 文件包含格式为键值对的配置参数,包括全局参数和特定于某个网络的参数。特定于网络的参数被包含在网络块中,可能类似于 示例 9-15 中的 PSK 配置。
示例 9-15. wpa_supplicant.conf 中的 PSK 网络配置块
network={
ssid="psk-ap"➊
key_mgmt=WPA-PSK➋
psk="password"➌
priority=805➍
}
如您所见,network 块指定了网络的 SSID ➊、认证密钥管理协议 ➋、预共享密钥本身 ➌ 以及优先级值 ➍。PSK 以明文形式保存,虽然 wpa_supplicant.conf 的访问位禁止非系统应用程序访问它,但它可以从允许 root 访问的设备中轻松提取。
EAP 凭据管理
在本节中,我们将探讨 Android 如何管理每种支持的 EAP 认证方法的 Wi-Fi 凭据,并讨论允许 supplicant 守护进程利用 Android 系统凭据存储的 Android 特定 wpa_supplicant 更改。
示例 9-16 显示了配置为使用 PEAP 的网络在 wpa_supplicant.conf 中的网络块。
示例 9-16. wpa_supplicant.conf 中的 PEAP 网络配置块
network={
ssid="eap-ap"
key_mgmt=WPA-EAP IEEE8021X➊
eap=PEAP➋
identity="android1"➌
anonymous_identity="anon"
password="password"➍
ca_cert="keystore://CACERT_eapclient"➎
phase2="auth=MSCHAPV2"➏
proactive_key_caching=1
}
在这里,密钥管理模式设置为 WPA-EAP IEEE8021X ➊,EAP 方法设置为 PEAP ➋,Phase 2 认证设置为 MSCHAPv2 ➏。凭据,即身份 ➌ 和密码 ➍,以明文存储在配置文件中,就像 PSK 模式一样。
与通用的 wpa_supplicant.conf 的一个显著不同之处是 CA 证书路径 ➎ 的格式。CA 证书路径(ca_cert)在验证服务器证书时使用,在 Android 中,ca_cert 采用类似 URI 的格式,使用 keystore 方案。这一 Android 特有的扩展允许 wpa_supplicant 守护进程从系统凭证存储中检索证书。当守护进程遇到以 keystore:// 开头的证书路径时,它会连接到本地 keystore 服务的 IKeystoreService 远程接口,并使用 URI 路径作为密钥检索证书字节。
EAP-TLS 配置与 PEAP 配置类似,如 示例 9-17 所示。
示例 9-17. wpa_supplicant.conf 中的 EAP-TLS 网络配置块
network={
ssid="eap-ap"
key_mgmt=WPA-EAP IEEE8021X
eap=TLS
identity="android1"
ca_cert="keystore://CACERT_eapclient"
client_cert="keystore://USRCERT_eapclient"➊
engine_id="keystore"➋
key_id="USRPKEY_eapclient"➌
engine=1
priority=803
proactive_key_caching=1
}
新增了客户端证书 URI ➊、引擎 ID ➋ 和密钥 ID ➌。客户端证书从系统凭证存储中获取,类似于 CA 证书。引擎 ID 指的是在连接到 network 块中配置的 SSID 时应使用的 OpenSSL 引擎。wpa_supplicant 原生支持可配置的 OpenSSL 引擎,通常与 PKCS#11 引擎一起使用,以便使用存储在智能卡或其他硬件设备中的密钥。
如 第七章 中所述,Android 的 keystore 引擎使用存储在系统凭证存储中的密钥。如果设备支持硬件支持的凭证存储,keystore 引擎可以通过中间的 keymaster HAL 模块透明地利用它。 示例 9-17 中的密钥 ID 引用用于身份验证的私钥别名。
从版本 4.3 开始,Android 允许在导入私钥和证书时选择其所有者。之前,所有导入的密钥都归 system 用户所有,但如果你在导入对话框中将凭证使用参数设置为 Wi-Fi(参见 图 9-13),密钥所有者将设置为 wifi 用户(UID 1010),且该密钥只能由以 wifi 用户身份运行的系统组件访问,比如 wpa_supplicant。

图 9-13. 在 PKCS#12 导入对话框中设置凭证所有者为 Wi-Fi
由于 Android 不支持在使用 EAP-TTLS 身份验证方法时进行客户端身份验证,因此该配置仅包含 CA 证书引用 ➋,如 示例 9-18 所示。密码 ➊ 以明文形式存储。
示例 9-18. 在 wpa_supplicant.conf 中的 EAP-TTLS 网络配置块
network={
ssid="eap-ap"
key_mgmt=WPA-EAP IEEE8021X
eap=TTLS
identity="android1"
anonymous_identity="anon"
password="pasword"➊
ca_cert="keystore://CACERT_eapclient"➋
phase2="auth=GTC"
proactive_key_caching=1
}
EAP-PWD 方法不依赖于 TLS 来建立安全通道,因此不需要证书配置,如 示例 9-19 所示。凭证以明文存储(➊ 和 ➋),与其他使用密码的配置相同。
示例 9-19. 在 wpa_supplicant.conf 中的 EAP-PWD 网络配置块
network={
ssid="eap-ap"
key_mgmt=WPA-EAP IEEE8021X
eap=PWD
identity="android1"➊
password="password"➋
proactive_key_caching=1
}
总结来说,所有使用密码进行身份验证的 EAP 方法的配置,都将凭证信息以明文形式存储在 wpa_supplicant.conf 文件中。而使用 EAP-TLS 的情况下,由于依赖于客户端身份验证,客户端密钥被存储在系统密钥库中,因此提供了最高级别的凭证保护。
使用 WifiManager 添加 EAP 网络
虽然 Android 支持多种 WPA 企业身份验证方法,但由于需要配置的参数较多,并且需要安装和选择身份验证证书,正确设置这些方法可能会对部分用户构成挑战。由于 Android 在 4.3 版本之前的官方 API WifiManager 不支持 EAP 配置,设置 EAP 网络的唯一方法是通过系统设置应用手动添加并配置它。Android 4.3(API 级别 18)扩展了 WifiManager API,允许程序化配置 EAP,从而支持企业环境中的自动网络配置。在本节中,我们将展示如何使用 WifiManager 添加 EAP-TLS 网络,并讨论其底层实现。
WifiManager 允许持有 CHANGE_WIFI_STATE 权限(保护级别 dangerous)的应用程序通过初始化一个 WifiConfiguration 实例,并设置网络的 SSID、身份验证算法和凭证,然后将其传递给 WifiManager 的 addNetwork() 方法,从而添加 Wi-Fi 网络。Android 4.3 扩展了此 API,通过在 WifiConfiguration 类中新增一个类型为 WifiEnterpriseConfig 的 enterpriseConfig 字段,允许您配置要使用的 EAP 身份验证方法、客户端和 CA 证书、Phase 2 身份验证方法(如果有的话),以及其他凭证,如用户名和密码。示例 9-20 展示了如何使用此 API 添加一个使用 EAP-TLS 进行身份验证的网络。
示例 9-20. 使用 WifiManager 添加 EAP-TLS 网络
X509Certificate caCert = getCaCert();
PrivateKey clientKey = getClientKey();
X509Certificate clientCert = getClientCert();
WifiEnterpriseConfig enterpriseConfig = new WifiEnterpriseConfig();
enterpriseConfig.setCaCertificate(caCert);➊
enterpriseConfig.setClientKeyEntry(clientKey, clientCert);➋
enterpriseConfig.setEapMethod(WifiEnterpriseConfig.Eap.TLS);➌
enterpriseConfig.setPhase2Method(WifiEnterpriseConfig.Phase2.NONE);➍
enterpriseConfig.setIdentity("android1");➎
WifiConfiguration config = new WifiConfiguration();
config.enterpriseConfig = enterpriseConfig;➏
config.SSID = "\"eap-ap\"";
config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.IEEE8021X);➐
config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_EAP);➑
int netId = wm.addNetwork(config);➒
if (netId != -1) {
boolean success = wm.saveConfiguration();➓
}
为了设置 EAP-TLS 认证,我们首先需要获取用于验证服务器身份的 CA 证书,以及客户端的私钥和证书。由于这些通常作为 PKCS#12 文件分发,我们可以使用类型为 PKCS12 的 KeyStore 来提取它们(未展示)。(当你添加使用这些证书的 EAP 配置文件时,Android 会自动将指定的密钥和证书导入系统密钥库,因此无需手动导入 PKCS#12 文件。)获得 CA 证书和客户端凭证后,我们通过 setCaCertificate() ➊ 和 setClientKeyEntry() ➋ 方法将它们设置到 WifiEnterpriseConfig 实例中。然后,我们将 EAP 方法设置为 Eap.TLS ➌,并将第二阶段方法设置为 NONE ➍,因为 EAP-TLS 在建立 SSL 连接(第一阶段)时进行用户认证。虽然认证服务器可能不会使用,但 Android 仍然要求我们设置身份 ➎。配置好 WifiEnterpriseConfig 对象后,我们可以将其添加到主 WifiConfiguration 实例 ➏。密钥管理协议集也需要进行配置(➐ 和 ➑),因为默认情况下它是 WPA PSK。最后,我们可以添加网络 ➒ 并保存配置 ➓,这将更新 wpa_supplicant.conf 文件,包含新配置的网络。
Android 会自动为配置的私钥和证书生成别名,然后将 PKI 凭证导入系统密钥库。别名基于 AP 名称、密钥管理方案和 EAP 认证方法。通过编程配置的网络会自动显示在系统设置应用的 Wi-Fi 屏幕中,可能如下图所示 图 9-14,与 示例 9-20 中的示例相同。

图 9-14. 使用 WifiManager 添加的 EAP-TLS 网络
概述
Android 支持设备管理 API,允许设备管理应用配置安全策略,其中包括锁屏密码复杂度、设备加密和相机使用等要求。设备管理员通常与企业账户一起使用,如 Microsoft Exchange 和 Google Apps,以根据策略和设备设置限制对公司数据的访问。设备管理 API 还提供了启用远程设备锁定和数据擦除的功能。
Android 设备可以连接到各种类型的 VPN,包括 PPTP、L2TP/IPSec 和基于 SSL 的 VPN。PPTP 和 L2TP/IPSec 的支持内置于平台中,且只能通过操作系统更新来扩展。Android 4.0 增加了对基于应用的 VPN 的支持,使得第三方应用可以实现自定义的 VPN 解决方案。
除了广泛使用的预共享密钥 Wi-Fi 认证模式外,Android 还支持各种 WPA 企业配置,即 PEAP、EAP-TLS、EAP-TTLS 和 EAP-PWD。使用 SSL 建立安全通道或认证用户的 EAP 认证方法的证书和私钥存储在系统密钥库中,并且在可用时可以使用硬件保护。使用 EAP 进行认证的 Wi-Fi 网络可以通过最近版本的 Android(从 Android 4.3 开始)中的 WifiManager API 自动配置。
^([87]) Google,Android APIs 参考资料,“DeviceAdminInfo,” developer.android.com/reference/android/app/admin/DeviceAdminInfo.html
^([88]) Google,Android APIs 参考资料,“DevicePolicyManager,” developer.android.com/reference/android/app/admin/DevicePolicyManager.html
^([89]) Google,API 指南,“设备管理,” developer.android.com/guide/topics/admin/device-admin.html
^([90]) OpenVPN Technologies, Inc,“OpenVPN 安全概述,” openvpn.net/index.php/open-source/documentation/security-overview.html
^([91]) IPSec-Tools, ipsec-tools.sourceforge.net/
^([92]) Google,Android APIs 参考资料,“VpnService,” developer.android.com/reference/android/net/VpnService.html
^([93]) Arne Schwabe,“适用于 Android 4.0+ 的 Openvpn,” code.google.com/p/ics-openvpn/
^([94]) Vivek Kamath,Ashwin Palekar 和 Mark Woodrich,微软的 PEAP 版本 0(Windows XP SP1 实现), tools.ietf.org/html/draft-kamath-pppext-peapv0-00/
^([95]) Ashwin Palekar 等人,受保护的 EAP 协议(PEAP)版本 2, tools.ietf.org/html/draft-josefsson-pppext-eap-tls-eap-10/
^([96]) D. Simon,B. Aboba 和 R. Hurst,EAP-TLS 认证协议, tools.ietf.org/html/rfc5216/
^([97]) P. Funk 和 S. Blake-Wilson,扩展认证协议隧道传输层安全认证协议版本 0(EAP-TTLSv0), tools.ietf.org/html/rfc5281/
^([98]) D. Harkins 和 G. Zorn,仅使用密码的可扩展认证协议(EAP)认证,tools.ietf.org/html/rfc5931/
^([99]) Jouni Malinen,Linux WPA/WPA2/IEEE 802.1X 客户端,hostap.epitest.fi/wpa_supplicant/
第十章. 设备安全
到目前为止,我们已经集中讨论了 Android 如何实现沙箱机制和权限隔离,以便将应用程序彼此隔离,并与核心操作系统分开。在本章中,我们将探讨 Android 如何确保操作系统的完整性,并保护设备数据免受具有物理访问权限的攻击者的威胁。我们将从简要介绍 Android 的引导加载程序和恢复操作系统开始,然后讨论 Android 的验证启动功能,它确保系统分区不会被恶意程序修改。接下来,我们将介绍 Android 如何加密userdata分区,该分区存储操作系统配置文件和应用程序数据。这确保了设备在没有解密密码的情况下无法启动,而且即使通过直接访问设备的闪存,也无法提取用户数据。然后,我们将展示 Android 如何实现屏幕锁定功能,以及如何将解锁图案、PIN 码和密码短语进行哈希处理并存储在设备上。
我们还将讨论安全的 USB 调试,它通过 USB 验证连接到Android 调试桥 (ADB) 守护进程的主机,并要求用户显式允许每个主机的访问权限。由于通过 USB 进行 ADB 访问可以执行特权操作,如应用程序安装、完整备份和文件系统访问(包括对外部存储的完全访问),此功能有助于防止对启用了 ADB 调试的设备上的数据和应用程序进行未经授权的访问。最后,我们将描述 Android 完整备份功能的实现以及存档加密格式。
控制操作系统启动和安装
由于设备的物理访问权限,攻击者不仅可以通过更高级别的操作系统结构(如文件和目录)访问或修改用户和系统数据,还可以通过直接访问内存或原始磁盘存储来实现这一点。此类直接访问可以通过物理接口与设备的电子组件进行交互来实现,例如,拆解设备并连接到隐藏的硬件调试接口,或者将闪存拆焊下来并使用专用设备读取内容。
注
此类硬件攻击超出了本书的讨论范围;有关此主题的介绍,请参见《Android 黑客手册》(Wiley,2014)中的第十章。
一种不太具侵入性,但仍然强大的获取数据访问权限的方法是使用设备更新机制修改系统文件并移除访问限制,或引导一个允许直接访问存储设备的替代操作系统。大多数消费级 Android 设备默认是锁定的,因此这些技术要么不可行,要么需要持有代码签名密钥,通常只有设备制造商才能获得该密钥。
在接下来的章节中,我们简要讨论了 Android 的引导加载程序和恢复操作系统如何调控对引导镜像和设备更新机制的访问。(我们将在第十三章中更详细地探讨引导加载程序和恢复功能。)
引导加载程序
引导加载程序是一个专门的、硬件特定的程序,在设备首次开机时执行(对于 ARM 设备,是从复位状态启动)。它的目的是初始化设备硬件,选择性地提供一个最小的设备配置接口,然后找到并启动操作系统。
启动设备通常需要经过不同的阶段,这可能涉及为每个阶段使用一个单独的引导加载程序——但为了简便起见,我们将统一称为一个包含所有引导阶段的整体引导加载程序。Android 引导加载程序通常是专有的,并且针对设备所使用的芯片系统(SoC)特定。设备和 SoC 制造商在其引导加载程序中提供不同的功能和保护级别,但大多数引导加载程序支持fastboot,或者更一般来说,下载模式,它允许将原始分区镜像写入设备的持久存储,以及启动临时系统镜像(而不将其刷写到设备中)。fastboot 模式通过在设备启动时按下特定的硬件键组合来启用,或者通过 ADB 发送reboot bootloader命令来启用。
为了确保设备的完整性,消费类设备出厂时都会有锁定的引导加载程序,这会完全禁止刷写和启动系统镜像,或者仅允许启动经过设备制造商签名的镜像。大多数消费类设备支持解锁引导加载程序,这样可以去除 fastboot 限制和镜像签名检查。解锁引导加载程序通常需要格式化userdata分区,从而确保恶意的操作系统镜像无法访问现有的用户数据。
在一些设备上,解锁引导加载程序是不可逆的过程,但大多数设备提供了一种重新锁定引导加载程序并恢复到原始状态的方法。这通常通过在一个专用的系统分区(通常叫做param或misc)上存储引导加载程序状态标志来实现,该分区托管着各种设备元数据。重新锁定引导加载程序只是简单地重置该标志的值。
恢复
更新设备的一个更灵活的方式是通过其恢复操作系统。恢复操作系统,简称恢复,是一个基于 Linux 的最小操作系统,包括一个内核、带有各种低级工具的 RAM 磁盘和一个最小的用户界面,通常使用设备的硬件按钮操作。恢复操作系统用于应用出厂后更新,通常以无线(OTA)更新包的形式交付。OTA 包包含更新的系统文件的新版本(或二进制补丁)以及一个应用更新的脚本。正如我们在第三章中了解到的那样,OTA 文件也经过设备制造商私钥的代码签名。恢复操作系统包含该密钥的公钥部分,并在应用 OTA 文件之前对其进行验证。这确保只有来自受信任方的 OTA 文件才能修改设备操作系统。
恢复操作系统存储在一个专用分区上,类似于主 Android 操作系统。因此,可以通过将引导加载程序置于下载模式并刷入自定义恢复镜像来替换它,从而替换嵌入的公钥,或者根本不验证 OTA 签名。这样的恢复操作系统允许将主操作系统完全替换为第三方生成的构建。自定义恢复操作系统还可以通过 ADB 允许无限制的 root 访问,以及获取原始分区数据。虽然userdata分区可以被加密(参见“磁盘加密”),使直接数据访问变得不可能,但在恢复模式下安装恶意程序(rootkit)到system分区是很简单的。然后,rootkit 可以在主操作系统启动时启用远程访问,从而允许访问用户数据,这些数据在主操作系统启动时会透明解密。验证启动(在下一节讨论)可以防止这种情况,但前提是设备使用存储在硬件中的不可修改的验证密钥验证boot分区。
解锁的引导加载程序允许启动或刷入自定义系统镜像,并直接访问系统分区。虽然像验证启动和磁盘加密等 Android 安全功能可以限制通过解锁的引导加载程序刷入的恶意系统镜像所造成的损害,但控制对引导加载程序的访问对于保护 Android 设备至关重要。因此,只有在测试或开发设备上才应解锁引导加载程序,或者在修改系统后立即将其重新锁定并恢复到原始状态。
验证启动
Android 的验证启动实现基于 dm-verity 设备映射器块完整性检查目标。^([100]) 设备映射器^([101]) 是 Linux 内核框架,提供了一种实现虚拟块设备的通用方式。它是 Linux 逻辑卷管理器(LVM)的基础,且用于实现全盘加密(使用 dm-crypt 目标)、RAID 阵列,甚至分布式复制存储。
Device-mapper 的工作原理本质上是将一个虚拟块设备映射到一个或多个物理块设备,并在数据传输过程中选择性地修改传输的数据。例如,dm-crypt(它也是 Android 的userdata 分区加密的基础,如 “磁盘加密”中讨论的)在将读取的物理块写入磁盘前会先解密,并对写入的块进行加密。因此,磁盘加密对虚拟 dm-crypt 块设备的用户是透明的。Device-mapper 目标可以叠加使用,使得实现复杂的数据转换成为可能。
dm-verity 概述
由于 dm-verity 是一个块完整性检查工具,它在从磁盘读取每个设备块时会透明地验证该块的完整性。如果块完整性检查通过,则读取成功;如果不通过,则读取会产生 I/O 错误,仿佛该块已被物理损坏。
在底层实现中,dm-verity 使用预先计算的哈希树(也叫Merkle 树),其中包含了所有设备块的哈希值。树的叶子节点包括物理设备块的哈希值,而中间节点则是它们子节点的哈希值(哈希的哈希)。根节点被称为根哈希,它基于所有下层节点的哈希值,如图 10-1 所示。因此,即使是单个设备块的变化,也会导致根哈希的变化。为了验证哈希树的真实性,我们只需要验证其根哈希。
在运行时,dm-verity 会在读取每个块时计算哈希,并通过遍历预先计算的哈希树进行验证。由于从物理设备读取数据本身就是一项耗时操作,因此哈希计算和验证带来的延迟相对较低。此外,一旦验证通过,磁盘块会被缓存,之后对同一块的读取将不再触发完整性验证。

图 10-1. dm-verity 哈希树
由于 dm-verity 依赖于一个覆盖设备所有块的预计算哈希树,因此底层设备必须以只读方式挂载,才能进行验证。大多数文件系统会在其超级块中记录挂载时间和其他元数据,因此即使在运行时没有更改任何文件,如果底层块设备以可读写方式挂载,块完整性检查也会失败。尽管这可以视为一种限制,但它对于存储系统文件的设备或分区而言效果很好,因为系统文件只会在操作系统更新时发生变化。任何其他变化都可能意味着操作系统或磁盘损坏,或是恶意程序试图修改操作系统或伪装成系统文件。
最终,dm-verity 的只读要求非常符合 Android 的安全模型,该模型仅将应用数据保存在可读写分区上,并将操作系统文件保存在只读的system 分区中。
Android 实现
dm-verity 设备映射器目标最初是为实现 Chrome OS 的已验证启动而开发的,并在 Linux 内核 3.4 版本中集成。通过CONFIG_DM_VERITY内核配置项启用。
像 Chrome OS 一样,Android 4.4 也使用 dm-verity 目标,但根哈希的加密验证和已验证分区的挂载实现方式有所不同。用于验证的 RSA 公钥嵌入在启动分区下的verity_key文件名中,并用于验证 dm-verity 映射表,该表保存目标设备的位置和哈希表的偏移量,以及根哈希和盐值。
映射表及其签名是 verity 元数据块的一部分,该元数据块在目标设备的最后一个文件系统块后直接写入磁盘。通过将verify标志添加到设备的fstab文件中的 Android 特定fs_mgr_flags字段,可以将分区标记为可验证。当 Android 的文件系统管理器在fstab中遇到verify标志时,它会从fstab中指定的块设备加载 verity 元数据,并使用包含的 verity 密钥验证其签名。如果签名检查成功,文件系统管理器会解析 dm-verity 映射表,并将其传递给 Linux 设备映射器,后者使用映射表中的信息创建一个虚拟的 dm-verity 块设备。然后,这个虚拟块设备会被挂载到fstab中指定的挂载点,替代相应的物理设备。因此,对底层物理设备的所有读取操作都会透明地与预生成的哈希树进行验证。修改或添加文件,甚至重新挂载分区为读写模式,会导致完整性验证失败并发生 I/O 错误。
注意
由于 dm-verity 是内核功能,因此为了使其完整性保护有效,设备启动时使用的内核需要是可信的。在 Android 上,这要求验证启动分区,其中还包含根文件系统 RAM 磁盘(initrd)和 verity 公钥。内核或启动镜像验证是设备特定的过程,通常由设备的引导加载程序实现,并依赖于存储在硬件中的不可修改的签名验证密钥。
启用已验证的启动
官方 Android 文档描述了启用 Android 已验证启动所需的过程,这是一个多步骤的过程,包括生成哈希树、为哈希树创建 dm-verity 映射表、签署该表,并生成并写入 verity 元数据块到目标设备。^([102]) 在本节中,我们简要描述了这个过程的关键步骤。
dm-verity 哈希树是通过 veritysetup 程序生成的,该程序是 cryptsetup 加密卷管理工具包的一部分。veritysetup 程序可以直接在块设备上操作,或者使用文件系统镜像生成哈希树,并将哈希表写入文件。Android 的 dm-verity 实现要求哈希树数据存储在与目标文件系统相同的设备上,因此在调用 veritysetup 时必须指定一个明确的哈希偏移量,该偏移量指向 verity 元数据块之后的位置。图 10-2 显示了为 dm-verity 验证准备的磁盘分区布局。

图 10-2. 为 dm-verity 验证准备的磁盘分区布局
生成哈希树会产生根哈希,该根哈希用于构建目标设备的 dm-verity 映射表。一个示例映射表显示在 示例 10-1 中。
示例 10-1. Android dm-verity 设备映射表
1➊ /dev/block/mmcblk0p21➋ /dev/block/mmcblk0p21➌ 4096➍ 4096➎
204800➏ 204809➐ sha256➑
1F951588516c7e3eec3ba10796aa17935c0c917475f8992353ef2ba5c3f47bcb➒
5f061f591b51bf541ab9d89652ec543ba253f2ed9c8521ac61f1208267c3bfb1➓
如列表所示,该表格为一行(为了可读性而分为多行),除了根哈希 ➒ 外,还包含了 dm-verity 版本 ➊、底层数据和哈希设备的名称(➋ 和 ➌)、数据和哈希块大小(➍ 和 ➎)、数据和哈希磁盘偏移量(➏ 和 ➐)、哈希算法 ➑,以及盐值 ➓。
映射表使用 2048 位 RSA 密钥进行签名,并与生成的 PKCS#1 v1.5 签名一起,用于形成 32 KB 的 verity 元数据块。表 10-1 显示了元数据块每个字段的内容和大小。
表 10-1. Verity 元数据块内容
| 字段 | 描述 | 大小 | 值 |
|---|---|---|---|
| 魔数 | fs_mgr 用于完整性检查 | 4 字节 | 0xb001b001 |
| 版本 | 元数据块版本 | 4 字节 | 当前为 0 |
| 签名 | 映射表签名(PKCS#1 v1.5) | 256 字节 | |
| 映射表长度 | 映射表的字节长度 | 4 字节 | |
| 映射表 | dm-verity 映射表 | 可变 | |
| 填充 | 填充为 32k 字节长度的零字节 | 可变 |
用于验证的 RSA 公钥需要采用 mincrypt 格式(一个简化的加密库,也在 stock recovery 中用于验证 OTA 文件签名),该格式是 mincrypt 的 RSAPublicKey 结构的序列化版本。这个结构的有趣之处在于,它不仅包含公钥的模数和公有指数值,还包含了 mincrypt RSA 实现(基于 Montgomery 减法)所使用的预计算值。公钥包含在启动镜像的根目录下,文件名为 verity_key。
启用验证启动的最后一步是修改设备的 fstab 文件,以启用 system 分区的块完整性验证。这只是简单地添加 verify 标志,如 示例 10-2(Nexus 4 的示例 fstab 文件)所示。
示例 10-2. 已验证的 dm-verity 格式化分区的 fstab 条目
/dev/block/platform/msm_sdcc.1/by-name/system /system ext4 ro,barrier=1 wait,verify
当设备启动时,Android 会根据 fstab 条目和映射表(包含在元数据块中)的信息自动创建一个虚拟的 dm-verity 设备,并将其挂载到 /system,如 示例 10-3 所示。
示例 10-3. dm-verity 虚拟块设备挂载到 /system
# mount|grep system
/dev/block/dm-0 /system ext4 ro,seclabel,relatime,data=ordered 0 0
现在,任何对系统分区的修改都会导致读取相应文件时出现读取错误。不幸的是,通过基于文件的 OTA 更新进行的系统修改会修改文件块而不更新 verity 元数据,这也会使哈希树无效。正如官方文档中所提到的,为了与基于 dm-verity 的验证启动兼容,OTA 更新应在块级别操作,确保同时更新文件块、哈希树和元数据。这需要更改当前的 OTA 更新基础设施,这可能是验证启动尚未部署到生产设备的原因之一。
磁盘加密
Android 3.0 引入了磁盘加密以及设备管理员策略(详细信息请参见 第九章),可以强制执行设备加密,作为该版本中包含的若干“企业增强功能”之一。磁盘加密在随后的所有版本中都有提供,直到 4.4 版本,才引入了一种新的密钥衍生函数(scrypt)。本节描述了 Android 如何实现磁盘加密,以及如何存储和管理加密密钥和元数据。
注意
Android 兼容性定义要求“如果设备具有锁屏功能,则设备必须支持全盘加密。”^([103])
磁盘加密使用加密算法将写入磁盘的每一位数据转换为密文,确保没有解密密钥就无法从磁盘读取数据。全盘加密(FDE)承诺将磁盘上的所有内容加密,包括操作系统文件、缓存和临时文件。实际上,操作系统的一个小部分或单独的操作系统加载器必须保持未加密,以便它能够获取解密密钥,然后解密并挂载主操作系统使用的磁盘卷。磁盘解密密钥通常是加密存储的,并且需要额外的密钥加密密钥(KEK)才能解密。KEK 可以存储在硬件模块中,例如智能卡或 TPM,或者从用户在每次启动时提供的口令中派生。当存储在硬件模块中时,KEK 还可以通过用户提供的 PIN 或密码进行保护。
Android 的 FDE 实现仅加密userdata分区,该分区存储系统配置文件和应用数据。boot和system分区存储内核和操作系统文件,这些分区不加密,但system可以选择性地使用 dm-verity 设备映射目标进行验证,正如我们在“验证启动”中所描述的那样。Android 的磁盘加密默认未启用,必须通过用户或托管设备上的设备策略触发加密过程。我们将在以下部分中讨论 Android 的磁盘加密实现。
密码模式
Android 的磁盘加密使用 dm-crypt,^([104])目前是 Linux 内核中的标准磁盘加密子系统。与 dm-verity 类似,dm-crypt 是一个设备映射目标,将加密的物理块设备映射到虚拟设备映射设备。所有对虚拟设备的访问都被透明地解密(读取)或加密(写入)。
Android 中使用的加密机制采用随机生成的 128 位密钥,并结合 AES 的 CBC 模式。如我们在第五章中所学,CBC 模式需要一个初始化向量(IV),这个向量必须既随机又不可预测,才能确保加密的安全性。当加密块设备时,这就成了一个问题,因为块设备是非顺序访问的,因此每个扇区(或设备块)都需要一个单独的 IV。
Android 使用加密盐扇区初始化向量(ESSIV)方法,并结合 SHA-256 哈希算法(ESSIV:SHA256)来生成每个扇区的 IV。ESSIV 利用哈希算法从磁盘加密密钥K中派生出一个次级密钥s,称为盐。然后,使用盐作为加密密钥,并加密每个扇区的扇区号SN,生成每个扇区的 IV。换句话说,IV(SN) = AESs,其中s = SHA256(K)。
由于每个扇区的初始化向量(IV)依赖于一个秘密信息(磁盘加密密钥),攻击者无法推断出每个扇区的 IV。然而,ESSIV 并没有改变 CBC 的可变性特性,也没有确保加密块的完整性。事实上,已经证明,如果攻击者知道原始的明文存储在磁盘上,便可以操纵存储的数据,甚至在使用 CBC 进行磁盘加密的卷上注入后门。^([105])
替代密码模式:XTS
针对 ESSIV 模式的这一特定攻击可以通过切换到一种可变加密密码模式来避免,例如 XTS(基于 XEX 的带有密文偷取的变动代码簿模式),该模式使用扇区地址和扇区内加密块的索引的组合来派生每个扇区的唯一“变动值”(变量参数)。
为每个扇区使用不同的变动值与为每个扇区加密一个唯一的密钥效果相同:相同的明文在不同扇区存储时会生成不同的密文,但比为每个扇区派生独立的密钥(或 IV)要具有更好的性能。然而,尽管比 CBC ESSIV 模式更好,XTS 在某些情况下仍然容易受到数据篡改的攻击,并且没有提供密文认证。
截至目前,Android 不支持用于磁盘加密的 XTS 模式。然而,底层的 dm-crypt 设备映射器目标支持 XTS,并且可以通过对 Android 卷守护进程(vold)实现进行一些修改轻松启用。
密钥派生
磁盘加密密钥(在 Android 源代码中称为“主密钥”)使用另一个 128 位 AES 密钥(KEK)加密,该密钥由用户提供的密码派生。在 Android 3.0 至 4.3 版本中,使用的密钥派生函数是 PBKDF2,迭代次数为 2,000 次,且随机盐值为 128 位。加密后的主密钥和盐值与其他元数据(例如失败的解密尝试次数)一起存储在加密分区的最后 16 KB 中的页脚结构中,称为 crypto footer。将加密密钥存储在磁盘上,而不是直接使用从用户提供的密码派生的密钥,可以快速更改解密密码,因为唯一需要使用新密码派生的密钥重新加密的是主密钥(16 字节)。
虽然使用随机盐值使得无法使用预计算表来加速密钥破解,但 PBKDF2 使用的迭代次数(2000 次)在今天的标准下已经不够大。(密钥库密钥派生过程使用了 8192 次迭代,详细内容见第七章。备份加密使用了 10000 次迭代,稍后会在“Android 备份”中讨论。)此外,PBKDF2 是一种迭代算法,基于标准且相对容易实现的哈希函数,这使得 PBKDF2 的密钥派生可以并行化,充分利用多核设备如 GPU 的处理能力。这使得即便是相当复杂的字母数字密码,也能够在几天甚至几小时内被暴力破解。
为了使暴力破解磁盘加密密码变得更加困难,Android 4.4 引入了对一种新的密钥派生函数——scrypt的支持。^([106]) Scrypt 采用一种专门设计的密钥派生算法,旨在需要大量内存以及多次迭代(这种算法被称为memory hard)。这使得针对专用硬件如 ASIC 或 GPU 的暴力攻击变得更加困难,因为这些硬件通常具有有限的内存。
Scrypt 可以通过指定变量参数N、r和p来调整,这些参数分别影响所需的 CPU 资源、内存量和并行化成本。Android 默认使用的值是N = 32768 (2¹⁵),r = 8,p = 2。可以通过使用N_factor:r_factor:p_factor格式设置ro.crypto.scrypt_params系统属性的值来更改这些参数;例如,15:3:1(默认值)。每个参数的值通过将 2 提升到相应因子的幂来计算。Android 4.4 设备会自动更新加密页脚中的密钥派生算法,从 PBKDF2 更新为 scrypt,并使用 scrypt 派生的加密密钥重新加密主密钥。当加密的主密钥被更新时,用于 KEK 派生的N、r和p参数会写入加密页脚中。
注意
在同一台桌面机器上,使用 PBKDF2 暴力破解 4 位 PIN 码(使用一个简单的单线程算法,从 0000 开始生成所有可能的 PIN)时,每个 PIN 约需要 5 毫秒,而使用 scrypt 作为 KEK 派生函数时,每个 PIN 大约需要 230 毫秒。换句话说,暴力破解 PBKDF2 比 scrypt 几乎便宜 50 倍(即更快)。
磁盘加密密码
如前一节所述,用于加密磁盘加密密钥的 KEK 源自用户提供的密码。当你第一次启动设备加密过程时,系统会要求你确认设备解锁 PIN 码或密码,或者如果你还没有设置,或使用图案锁屏,则需要设置一个(参见图 10-3)。输入的密码或 PIN 码随后用于推导主密钥加密密钥,你需要在每次启动设备时输入密码或 PIN 码,然后在设备启动后再次输入密码或 PIN 码以解锁屏幕。
Android 没有专门的设置来管理设备加密密码,在设备加密后,更改屏幕锁密码或 PIN 码也会悄悄更改设备加密密码。这很可能是基于可用性驱动的决策:大多数用户会对需要记住并在不同时间输入两个不同密码感到困惑,并且很可能很快忘记那个不常用的、可能更复杂的磁盘加密密码。虽然这种设计对可用性有好处,但它实际上迫使用户使用简单的磁盘加密密码,因为他们每次解锁设备时都必须输入它,通常每天要输入几十次。没有人想要输入那么多次复杂密码,因此大多数用户选择使用简单的数字 PIN 码(除非设备政策要求不同)。此外,密码限制为 16 个字符(这是框架中硬编码的限制,无法配置),所以使用密码短语并不是一个选项。

图 10-3. 设备加密屏幕
使用相同密码来同时保护磁盘加密和锁屏有什么问题呢?毕竟,要访问手机上的数据,你反正需要猜测锁屏密码,那为什么还要为磁盘加密设置一个单独的密码呢?原因在于,这两个密码保护你的手机免受两种不同类型的攻击。大多数屏幕锁攻击是在线的暴力破解攻击:本质上是有人在短暂接触设备时,尝试不同的密码。经过几次失败后,Android 会将屏幕锁定 30 秒(速率限制),如果解锁尝试失败次数过多(如果设备政策要求的话),还会擦除设备。因此,在大多数情况下,即使是相对简短的屏幕锁 PIN 码也能有效防止在线攻击(有关详细信息,请参见“暴力破解攻击保护”)。
当然,如果有人能够物理访问该设备或设备的磁盘镜像,他们可以提取密码哈希并离线破解,而无需担心速率限制或设备擦除。事实上,这正是全盘加密所设计来防范的场景:当设备被盗或没收时,攻击者可以通过暴力破解设备,或者即使设备被归还或处理后,也可以复制其数据并进行分析。如前所述,在“密钥推导”中,加密的主密钥存储在磁盘上,如果用于推导其加密密钥的密码是基于一个短数字 PIN,它可以在几分钟内被暴力破解^([107])(甚至在使用 PBKDF2 进行密钥推导的 4.4 版本之前的设备上,可能只需几秒钟)。远程擦除解决方案可以通过删除主密钥来防止此类攻击,这只需要片刻时间,并使设备变得无用,但这通常不是一个可行的选项,因为设备可能处于离线状态或关闭状态。
更改磁盘加密密码
磁盘加密的用户级部分是通过 Android 的卷管理守护进程(vold)中的cryptfs模块来实现的。cryptfs有用于创建和挂载加密卷的命令,还可以用来验证和更改主密钥加密密码。Android 系统服务通过本地套接字(也命名为vold)向vold发送命令,与cryptfs进行通信,vold根据接收到的命令设置描述加密或挂载过程当前状态的系统属性。(这导致了一个相当复杂的启动过程,详细描述见下面的“启用加密”以及“启动加密设备”。)
Android 并不提供一个界面来单独更改磁盘加密密码,但可以通过直接与vold守护进程通信,使用vdc命令行工具来做到这一点。然而,vold控制套接字的访问权限仅限于 root 用户和mount组的成员,此外,cryptfs命令仅对root和system用户可用。如果你使用的是工程版本,或者你的设备通过“超级用户”应用提供 root 访问(参见第十三章),你可以发送示例 10-4 中显示的cryptfs命令给vold,以更改磁盘加密密码。
示例 10-4. 使用vdc更改磁盘加密密码
# vdc cryptfs changepw <newpass>
200 0 0
注意
如果你更改了锁屏密码,磁盘加密密码将自动更改。(这不适用于多用户设备上的次要用户。)
启用加密
如前一节所述,Android 的磁盘加密的用户级部分是由 vold 守护进程的专用 cryptfs 模块实现的。cryptfs 提供了 checkpw、restart、cryptocomplete、enablecrypto、changepw、verifypw、getfield 和 setfield 等命令,框架会在加密或加密卷挂载的不同阶段发送这些命令。除了对 vold 本地套接字的权限设置外,cryptfs 还明确检查命令发送者的身份,只允许 root 和 system 用户访问。
使用系统属性控制设备加密
vold 守护进程设置了多个系统属性,以触发设备加密或挂载的各个阶段,并向框架服务传达当前的加密状态。ro.crypto.state 属性保存当前的加密状态,当数据分区成功加密时,它的值为 encrypted,如果尚未加密,则为 unencrypted。如果设备不支持磁盘加密,该属性也可以设置为 unsupported。vold 守护进程还设置了 vold.decrypt 属性的多个预定义值,以便指示设备加密或挂载的当前状态。vold.encrypt_progress 属性保存当前的加密进度(从 0 到 100),如果在设备加密或挂载过程中发生错误,则为错误字符串。
ro.crypto.fs_crypto_blkdev 系统属性包含由设备映射器分配的虚拟设备的名称。在成功解密磁盘加密密钥后,这个虚拟设备将挂载在 /data 上,替代底层的物理卷,如 示例 10-5 所示(输出已拆分以便于阅读)。
示例 10-5. 加密的虚拟块设备挂载在 /data
# mount|grep '/data'
/dev/block/dm-0 /data ext4 rw,seclabel,nosuid,nodev,noatime,
errors=panic,user_xattr,barrier=1,nomblk_io_submit,data=ordered 0 0
卸载 /data
Android 框架期望 /data 可用,但需要先卸载才能进行加密。这造成了一个进退两难的局面,Android 通过卸载物理 userdata 分区并在其位置挂载一个内存文件系统(tempfs)来解决这个问题,同时执行加密。运行时切换分区反过来又需要停止并重启某些系统服务,vold 通过将 vold.decrypt 系统属性的值设置为 trigger_restart_framework、trigger_restart_min_framework 或 trigger_shutdown_framework 来触发这些服务的停止与重启。这些值触发 init.rc 中的不同部分,如 示例 10-6 所示。
示例 10-6. 在 init.rc 中触发 vold.decrypt
--*snip*--
on post-fs-data➊
chown system system /data
chmod 0771 /data
restorecon /data
copy /data/system/entropy.dat /dev/urandom
--*snip*--
on property:vold.decrypt=trigger_reset_main➋
class_reset main
on property:vold.decrypt=trigger_load_persist_props
load_persist_props
on property:vold.decrypt=trigger_post_fs_data➌
trigger post-fs-data
on property:vold.decrypt=trigger_restart_min_framework➍
class_start main
on property:vold.decrypt=trigger_restart_framework➎
class_start main
class_start late_start
on property:vold.decrypt=trigger_shutdown_framework➏
class_reset late_start
class_reset main
--*snip-*
触发加密过程
当用户通过系统设置界面启动加密过程,选择“安全性▸加密手机”时,设置应用会调用MountService,进而向vold发送cryptfs enablecrypto inplace password命令,其中password是锁屏密码。接着,vold 卸载userdata分区并将vold.decrypt设置为trigger_shutdown_framework(➏,见示例 10-6),这将关闭大部分系统服务,除了属于core服务类的服务。然后,vold守护进程卸载/data,在其位置挂载一个 tempfs 文件系统,并将vold.encrypt_progress设置为 0,将vold.decrypt设置为trigger_restart_min_framework(➍,见示例 10-6)。这将启动一些更多系统服务(在main类中),这些服务是显示加密进度 UI 所必需的。
更新加密页脚和加密数据
接下来,vold设置虚拟的 dm-crypt 设备并写入加密页脚。页脚可以写入到userdata分区的末尾,或者写入到专用的分区或文件,其位置在fstab文件中作为encryptable标志的值指定。例如,在 Nexus 5 上,加密页脚被写入到专用分区metadata,如示例 10-7 所示为➊(为了可读性,单行被拆分)。当加密页脚写入到加密分区的末尾时,encryptable标志被设置为字符串footer。
示例 10-7. encryptable fstab 标志指定加密页脚的位置
--*snip*--
/dev/block/platform/msm_sdcc.1/by-name/userdata /data ext4
noatime,nosuid,nodev,barrier=1,data=ordered,nomblk_io_submit,noauto_da_alloc,errors=panic
wait,check,encryptable=/dev/block/platform/msm_sdcc.1/by-name/metadata➊
--*snip*--
加密页脚包含加密的磁盘加密密钥(主密钥)、用于 KEK 推导的盐值以及其他密钥推导参数和元数据。其flags字段设置为CRYPT_ENCRYPTION_IN_PROGRESS(0x2),表示设备加密已启动但尚未完成。
最后,从物理userdata分区读取每个数据块,并将其写入虚拟的 dm-crypt 设备,设备对读取的数据块进行加密并写入磁盘,从而实现对userdata分区的就地加密。如果加密过程没有错误,vold会清除CRYPT_ENCRYPTION_IN_PROGRESS标志并重新启动设备。
启动加密设备
启动加密设备需要向用户询问磁盘加密密码。与其使用专门的引导加载程序用户界面,Android 将vold.decrypt系统属性设置为 1,然后启动一组最小的系统服务以显示标准的 Android 用户界面。与设备加密类似,这同样需要在/data挂载一个 tmpfs 文件系统,以便核心系统服务能够启动。当核心框架启动后,Android 会检测到vold.decrypt被设置为 1,并启动userdata分区的挂载过程。

图 10-4 设备加密密码输入用户界面

图 10-5 设备加密失败时显示的 UI
获取磁盘加密密码
该过程的第一步是通过向vold发送cryptfs cryptocomplete命令,检查分区是否已成功加密,后者会检查加密页脚是否正确格式化,并且CRYPT_ENCRYPTION_IN_PROGRESS标志未被设置。如果发现分区已成功加密,框架将启动由CryptKeeper提供的密码输入用户界面,如图 10-4 所示,CryptKeeper是系统设置应用的一部分。此活动作为主屏幕(启动器)启动,并且由于其优先级高于默认启动器,因此在设备启动后首先启动。
如果设备未加密,CryptKeeper会禁用自身并完成,从而导致系统活动管理器启动默认的主屏幕应用程序。如果设备已加密或正在加密过程中(即vold.crypt属性不为空或未设置为trigger_restart_framework),CryptKeeper活动会启动并隐藏状态栏和系统栏。此外,CryptKeeper会忽略硬件返回按钮的按压,从而禁止用户离开密码输入用户界面。
如果加密设备损坏,或者加密过程被中断,导致userdata分区仅部分加密,设备将无法启动。在这种情况下,CryptKeeper会显示图 10-5 中所示的 UI,允许用户触发恢复出厂设置,这将重新格式化userdata分区。
解密并挂载/data
当用户输入密码时,CryptKeeper通过调用系统MountService的decryptStorage()方法,将cryptfs checkpw命令发送给vold。这指示vold检查输入的密码是否正确,方法是尝试将加密分区挂载到临时挂载点,然后再卸载。如果该过程成功,vold将设备映射器分配的虚拟块设备的名称设置为ro.crypto.fs_crypto_blkdev属性的值,并将控制权返回给MountService,后者进一步发送cryptfs restart命令,指示vold重启main类中的所有系统服务(➋见示例 10-6)。这使得 tempfs 文件系统得以卸载,并且新分配的虚拟 dm-crypt 块设备被挂载到/data。
启动所有系统服务
在加密分区挂载并准备好后,vold将vold.decrypt设置为trigger_post_fs_data(➌见示例 10-6),从而触发init.rc的post-fs-data ➊部分。本部分的命令设置文件和目录权限,恢复 SELinux 上下文,并在必要时在/data下创建所需的目录。
最后,post-fs-data将vold.post_fs_data_done属性设置为 1,vold会定期轮询该属性。当vold检测到值为 1 时,它将vold.decrypt属性设置为trigger_restart_framework(➎见示例 10-6),从而重启main类中的所有服务,并启动所有延迟启动的服务(类late_start)。此时,框架已完全初始化,设备开始使用解密后的userdata分区的视图进行引导,该视图被挂载在/data。从此以后,所有由应用程序或系统写入的数据在提交到磁盘之前都会自动加密。
磁盘加密的限制
磁盘加密仅保护静态数据;也就是说,当设备关闭时。由于磁盘加密是透明的,并且在内核级别实现,在加密卷被挂载后,它对于用户级进程来说与明文卷没有区别。因此,磁盘加密并不能保护数据免受在设备上运行的恶意程序的攻击。处理敏感数据的应用程序不应仅依赖于全盘加密,而应实现自己的基于文件的加密。文件加密密钥应使用从用户提供的密码派生的 KEK 加密,或者如果数据需要与设备绑定,则可以使用不可更改的硬件属性加密。为了确保文件完整性,必须使用经过认证的加密方案(如 GCM)或附加认证功能(如 HMAC)对加密数据进行认证。
屏幕安全性
控制 Android 设备访问的一种方式是要求用户身份验证才能访问系统 UI 和应用程序。用户身份验证通过在设备每次启动或屏幕打开时显示锁屏界面来实现。配置为需要数字 PIN 码解锁的单用户设备上的锁屏界面可能类似于图 10-6。
在早期的 Android 版本中,锁屏仅用于保护对设备 UI 的访问。随着平台的发展,锁屏被扩展了许多功能,包括显示最新设备或应用程序状态的小部件,允许在多用户设备之间切换,并支持解锁系统密钥存储。同样,屏幕解锁的 PIN 码或密码现在用于派生凭证存储加密密钥(用于软件实现),以及磁盘加密密钥 KEK。

图 10-6. PIN 锁屏界面
锁屏实现
Android 的锁屏(或键盘保护)实现方式与普通的 Android 应用程序类似:窗口上布局了小部件。它的特殊之处在于,它的窗口位于一个高层窗口中,其他应用程序无法在其上方绘制或控制。此外,键盘保护拦截了普通的导航按钮,使得无法绕过它,从而实现“锁定”设备。
键盘保护窗口层并不是最高层,然而,源自键盘保护本身的对话框和状态栏会绘制在键盘保护之上。你可以使用 AD 工具包中的层次查看器工具查看当前显示的窗口列表。当屏幕被锁定时,活动窗口是键盘保护窗口,如图 10-7 所示。
注意
在 Android 4.0 之前,第三方应用程序可以在键盘保护层中显示窗口,这允许应用程序拦截 Home 键并实现“自助终端”风格的应用程序。然而,由于某些恶意软件滥用了这一功能,因此自 Android 4.0 以来,向键盘保护层添加窗口需要INTERNAL_SYSTEM_WINDOW签名权限,而该权限仅限系统应用程序使用。

图 10-7. 键盘保护窗口在 Android 窗口堆栈中的位置
长期以来,键盘保护是 Android 窗口系统的一个实现细节,并没有被拆分成独立的组件。随着锁屏小部件、屏幕保护程序(即屏保)和多用户支持的引入,键盘保护获得了大量的新功能,最终在 Android 4.4 中被提取成一个独立的系统应用程序Keyguard。Keyguard应用程序位于com.android.systemui进程中,与核心的 Android UI 实现一起运行。
每种解锁方法的用户界面(稍后讨论)都实现为一个专用的视图组件。这个组件由一个名为KeyguardHostView的专用视图容器类承载,并包含关键 guard 小部件和其他辅助 UI 组件。例如,在图 10-6 中展示的 PIN 码解锁视图是由KeyguardPINView类实现的,而密码解锁是由KeyguardPasswordView类实现的。KeyguardHostView类会自动选择并显示当前配置的解锁方法和设备状态的适当解锁视图。解锁视图将密码检查委托给LockPatternUtils类,该类负责将用户输入与保存的解锁凭据进行比较,并将密码更改持久化到磁盘,并更新与身份验证相关的元数据。
除了关键 guard 解锁视图的实现之外,Keyguard系统应用程序还包括导出的KeyguardService服务,该服务公开了远程 AIDL 接口IKeyguardService。该服务允许客户端检查当前的关键 guard 状态,设置当前用户,启动相机,并隐藏或禁用关键 guard。更改关键 guard 状态的操作受到系统签名权限CONTROL_KEYGUARD的保护。
关键 guard 解锁方法
原生安卓提供了几种关键 guard 解锁方法(在安卓源代码中也称为安全模式)。其中,五种方法可以直接在选择屏幕锁定界面中选择:滑动、面部解锁、图案、PIN 码和密码,如图 10-8 所示。
滑动解锁方法不需要用户身份验证,因此其安全级别相当于选择“无”。这两种状态在内部通过将当前安全模式设置为KeyguardSecurityModel.SecurityMode.None枚举值来表示。截至目前,面部解锁是SecurityMode.Biometric安全模式的唯一实现,并且在内部被称为“弱生物特征”(未来版本可能会使用指纹或虹膜识别实现“强生物特征”)。与当前设备安全策略不兼容的解锁方法(如图 10-8 中列出的前三种方法)会被禁用,无法选择。安全策略可以由设备管理员显式设置,或者通过启用与安全相关的操作系统功能(如凭据存储或全盘加密)隐式设置。
图案解锁方法(SecurityMode.Pattern)是安卓特有的,要求用户在 3×3 网格上绘制预定义的图案以解锁设备,如图 10-9 所示。

图 10-8. 可直接选择的锁屏解锁方法

图 10-9. 配置图案解锁方法
PIN (SecurityMode.PIN) 和密码 (SecurityMode.Password) 解锁方法的实现类似,但在允许的字符范围上有所不同:PIN 只允许数字(0-9),而密码则允许字母数字字符。
SecurityMode 枚举定义了三种不能在选择屏幕锁屏界面直接选择的解锁方法:SecurityMode.Account、SecurityMode.SimPin 和 SecurityMode.SimPuk。SecurityMode.Account 方法仅在支持 Google 帐号(Google 体验设备)的设备上可用,并非独立的解锁方法。它只能作为其他安全模式的后备方法使用。类似地,SecurityMode.SimPin 和 SecurityMode.SimPuk 本身并不是锁屏解锁方法;它们仅在设备的 SIM 卡需要 PIN 码才能使用时可用。由于 SIM 卡会记住 PIN 验证状态,因此 PIN 或 PUK 只需输入一次——在设备启动时(或者如果 SIM 卡状态被重置)。我们将在接下来的章节中深入探讨每种锁屏安全模式的实现。
面部解锁
面部解锁是一种相对较新的解锁方法,首次在 Android 4.0 中引入。它利用设备的前置摄像头注册用户面部的图像(见图 10-10),并依靠图像识别技术来识别解锁时捕捉到的面部图像。尽管自面部解锁推出以来,已经对其准确性进行了改进,但它仍被认为是所有解锁方法中最不安全的,甚至设置屏幕上也警告用户“看起来像你的人可能解锁你的手机。”此外,面部解锁还需要备用解锁方法——图案或 PIN 码,以应对面部识别无法进行的情况(例如光线不足、摄像头故障等)。面部解锁的实现基于 PittPatt(Pittsburgh Pattern Recognition)公司开发的面部识别技术,该公司于 2011 年被 Google 收购。该技术的代码仍为专有代码,关于存储数据格式或所采用的识别算法没有详细信息。截至本文撰写时,面部解锁的实现位于 com.android.facelock 包中。

图 10-10. 面部解锁设置屏幕
图案解锁
如图 10-9 所示,图案解锁的代码是通过在 3×3 矩阵中至少连接四个点来输入的。每个点只能使用一次(交叉的点会被忽略),最大点数为九。内部上,图案以字节序列的形式存储,每个点通过其索引来表示,其中 0 是左上角,8 是右下角。因此,图案类似于一个最小为四位、最大为九位的 PIN 码,且只使用九个不同的数字(0 到 8)。然而,由于点不能重复,解锁图案的变化数远低于九位 PIN 码的变化数。
图案锁的哈希值存储在/data/system/gesture.key(在多用户设备上为/data/system/users/<用户 ID>/gesture.key)中,作为一个无盐的 SHA-1 值。通过简单地导出此文件,我们可以很容易地看到图 10-9 所示的图案(在十六进制中表示为00010204060708)在示例 10-8 中的gesture.key文件内容与图案字节序列的 SHA-1 哈希值相匹配,对于此示例来说,该哈希值为6a062b9b3452e366407181a1bf92ea73e9ed4c48。
示例 10-8. /data/system/gesture.key 文件的内容
# od -t x1 /data/system/gesture.key
0000000 6a 06 2b 9b 34 52 e3 66 40 71 81 a1 bf 92 ea 73
0000020 e9 ed 4c 48
由于在计算哈希时没有使用随机盐值,因此每个图案总是会被哈希为相同的值,这使得生成一个预计算的所有可能图案及其相应哈希值的表格相对容易。(此类表格在网上随处可见。)这使得一旦获取到gesture.key文件,就能立即恢复图案。然而,该文件由system用户拥有,且其权限设置为 0600,因此通常无法在生产设备上恢复。输入的图案会使用LockScreenUtils类的checkPattern()方法与保存的哈希值进行对比,而图案哈希会通过该类的saveLockPattern()方法进行计算并持久化保存。保存图案时,还会将当前密码强度值设置为DevicePolicyManager.PASSWORD_QUALITY_SOMETHING。
图案解锁方法的另一个不幸特点是,由于电容式触摸屏是直接用手指操作(而非使用触控笔或类似工具),多次绘制解锁图案会在触摸屏上留下明显的痕迹,从而容易受到所谓的“污迹攻击”。通过适当的光线和相机,屏幕上的指纹污迹可以被检测到,从而以很高的概率推测出解锁图案。因此,图案解锁方法的安全性被认为非常低。此外,由于组合数量有限,解锁图案是一个糟糕的熵源,在用户的解锁凭证用于推导加密密钥时(例如,用于系统密钥库和设备加密的密钥),该方法是不被允许的。
与面部解锁类似,图案解锁方法支持备份解锁机制,该机制只有在用户输入无效图案超过五次后才会启用。备份认证必须通过按下锁屏底部显示的“忘记图案”按钮手动激活。按下按钮后,设备进入SecurityMode.Account安全模式,并显示图 10-11 所示的屏幕。
用户可以输入设备上任何已注册 Google 账户的凭据来解锁设备,然后重置或更改解锁方法。因此,在设备上注册一个易于猜测(或共享)的密码的 Google 账户,可能成为设备锁屏的潜在后门。
注意
截至本文写作时,已配置为要求两步验证的 Google 账户无法用于解锁设备。

图 10-11. Google 账户解锁模式
PIN 和密码解锁
PIN 和密码方法本质上是等价的:它们将用户输入的哈希值与设备上存储的加盐哈希值进行比较,并在值匹配时解锁设备。PIN 或密码的哈希值是用户输入的 SHA-1 和 MD5 哈希值的组合,并使用 64 位随机值加盐。计算出的哈希值以十六进制字符串的形式存储在/data/misc/password.key(在多用户设备上为/data/system/users/
示例 10-9. /data/misc/password.key 文件的内容
# cat /data/system/password.key && echo
9B93A9A846FE2FC11D49220FC934445DBA277EB0AF4C9E324D84FFC0120D7BAE1041FAAC
用于计算哈希值的盐值被保存在系统 SettingsProvider 内容提供者的 secure 表中,Android 4.2 之前的版本中使用 lockscreen.password_salt 键,但为了支持每个设备的多个用户,它被移动到一个专用的数据库中,数据库还包含其他与锁屏相关的元数据。从 Android 4.4 开始,数据库位于 /data/system/locksettings.db,并通过 LockSettingsService 的 ILockSettings AIDL 接口进行访问。
访问该服务需要 ACCESS_KEYGUARD_SECURE_STORAGE 签名权限,仅允许系统应用程序使用。locksettings.db 数据库有一个名为 locksettings 的表,其中可能包含像 示例 10-10 中为特定用户(user 列包含 Android 用户 ID)提供的数据。
示例 10-10。/data/system/locksettings.db 中的所有者用户内容
sqlite> **select name, user, value from locksettings where user=0;**
name |user|value
--*snip*--
lockscreen.password_salt |0 |6909501022570534487➊
--*snip*--
lockscreen.password_type_alternate|0 |0➋
lockscreen.password_type |0 |131072➌
lockscreen.passwordhistory |0 |5BFE43E89C989972EF0FA0EC00BA30F356EE7B
7C7BF8BC08DEA2E067FF6C18F8CD7134B8,EE29A531FE0903C2144F0618B08D1858473C50341A7
8DEA85D219BCD27EF184BCBC2C18C➍
在这里,lockscreen.password_salt 设置 ➊ 存储了 64 位(表示为 Java long 类型)盐值,lockscreen.password_type_alternate 设置 ➋ 包含当前解锁方法的备份(也叫备用)解锁方法类型(0 表示无)。lockscreen.password_type ➌ 存储了当前选定的密码类型,由 DevicePolicyManager 类中定义的相应 PASSWORD_QUALITY 常量的值表示。在这个示例中,131072(十六进制为 0x00020000)对应 PASSWORD_QUALITY_NUMERIC 常量,这是数字 PIN 提供的密码质量。最后,lockscreen.passwordhistory ➍ 存储了密码历史记录,保存为以前的 PIN 或密码哈希值的序列,用逗号分隔。只有当通过 DevicePolicyManager 类中的 setPasswordHistoryLength() 方法将历史记录长度设置为大于零的值时,历史记录才会被保存。当密码历史记录可用时,输入与历史记录中任何密码相同的新密码是禁止的。
密码哈希可以通过将密码或 PIN 字符串(在本例中为 1234)与盐值(在本例中为 5fe37a926983d657,格式为十六进制字符串)连接起来,然后计算结果字符串的 SHA-1 和 MD5 哈希值,轻松计算出来,如 示例 10-11 所示。
示例 10-11。使用 sha1sum 和 md5sum 计算 PIN 或密码的哈希值
$ **SHA1=`echo -n '12345fe37a926983d657'|sha1sum|cut -d- -f1|tr '[a-z]' '[A-Z]'**➊
$ **MD5=`echo -n '12345fe37a926983d657'|md5sum|cut -d- -f1|tr '[a-z]' '[A-Z]'`**➋
$ **echo "$SHA1$MD5"|tr -d ' '**➌
9B93A9A846FE2FC11D49220FC934445DBA277EB0AF4C9E324D84FFC0120D7BAE1041FAAC
在这个示例中,哈希值是通过使用 sha1sum ➊ 和 md5sum ➋ 命令计算的。当这两个命令的输出结果 ➌ 被连接时,它会生成一个字符串,该字符串包含在 示例 10-9 中所示的 password.key 文件。
请注意,虽然使用随机哈希使得无法使用单一的预计算表来暴力破解任何设备的 PIN 码或密码,但计算密码或哈希仍然需要一次哈希调用,因此为特定设备生成目标哈希表(假设盐值也可用)仍然相对便宜。此外,尽管 Android 计算了 PIN 码或密码的 SHA-1 和 MD5 哈希值,但这并没有提供额外的安全性,因为只需针对较短的哈希(MD5)即可破解 PIN 码或密码。
输入的密码将通过LockPatternUtils.checkPassword()方法与存储的哈希值进行比对,并且使用该类中的saveLockPassword()方法之一计算并保存用户提供的密码的哈希值。调用saveLockPassword()会更新目标(或当前)用户的password.key文件。像gesture.key一样,这个文件属于system用户,并具有 0600 权限。除了更新密码哈希外,saveLockPassword()还会计算输入密码的复杂度,并使用计算出的复杂度值更新locksettings.db中与lockscreen.password_type键对应的value列(➌,见示例 10-10)。如果启用了密码历史功能,saveLockPassword()还会将 PIN 码或密码的哈希值添加到locksettings表中(➍,见示例 10-11)。
请记住,当设备被加密时,PIN 码或密码用于推导出一个 KEK,KEK 用于加密磁盘加密密钥。因此,修改拥有者用户的 PIN 码或密码也会通过调用系统的MountService中的changeEncryptionPassword()方法重新加密磁盘加密密钥。(修改二级用户的 PIN 码或密码不会影响磁盘加密密钥。)
PIN 码和 PUK 解锁
PIN 码和 PUK 安全模式本身并不是锁屏解锁方法,因为它们依赖于设备 SIM 卡的状态,只有在 SIM 卡处于锁定状态时才会显示。SIM 卡可能要求用户输入预配置的 PIN 码才能解锁卡片,并访问存储在其中的任何网络认证密钥,这些密钥用于与移动网络注册和拨打非紧急电话。
由于 SIM 卡在重置之前保持解锁状态,因此 PIN 码通常只在设备首次启动时需要输入。如果输入错误的代码超过三次,SIM 卡会被锁定,用户需要输入一个单独的代码来解锁它,这个代码称为PIN 解锁密钥(PUK),或个人解锁码(PUC)。
当锁屏显示时,Android 会检查 SIM 卡的状态,如果是State.PIN_REQUIRED(在IccCardConstants类中定义),则显示图 10-12 所示的 SIM 解锁键盘视图。当用户输入 SIM 解锁 PIN 时,该 PIN 会传递给ITelephony接口的supplyPinReportResult()方法(在TeleService系统应用程序中实现),然后通过无线接口守护进程(rild)将其传递给设备的基带处理器(实现移动网络通信的设备组件,有时也称为调制解调器或无线)。最后,基带处理器直接与 SIM 卡连接,将 PIN 发送到 SIM 卡,并接收一个状态码作为响应。状态码通过相同的路径返回到解锁视图。如果状态码表明 SIM 卡接受了 PIN 且未配置屏幕锁定,则会显示主屏幕(启动器)。另一方面,如果已配置屏幕锁定,则在解锁 SIM 卡后会显示屏幕锁定,用户必须输入凭据才能解锁设备。

图 10-12. SIM 解锁屏幕
如果 SIM 卡被锁定(即处于PUK_REQUIRED状态),Android 会显示 PUK 输入屏幕,并在解锁卡后允许用户设置新的 PIN。PUK 和新 PIN 会传递给ITelephony接口的supplyPukReportResult()方法,该方法将其传递到 SIM 卡。如果已配置屏幕锁定,则在验证 PUK 并配置新 PIN 后,屏幕锁定会显示出来。
Keyguard系统应用程序通过注册TelephonyIntents.ACTION_SIM_STATE_CHANGED广播来监视 SIM 卡状态的变化,并在卡被锁定或永久禁用时显示锁屏。如果用户想要切换 SIM 卡的 PIN 保护,可以通过进入设置▸安全性▸设置 SIM 卡锁,并勾选锁定 SIM 卡复选框来操作。

图 10-13. 五次连续身份验证失败后的速率限制
暴力破解攻击防护
由于复杂的密码在触摸屏键盘上输入起来比较麻烦,用户通常使用相对较短的解锁凭据,这些凭据容易被猜测或通过暴力破解获取。Android 通过要求用户在每五次连续的身份验证失败后等待 30 秒,来防止直接在设备上进行的暴力破解攻击(在线攻击),如图 10-13 所示。这种技术称为速率限制。
为了进一步防止暴力破解攻击,可以通过 DevicePolicyManager API 设置并强制执行密码复杂性、过期时间和历史规则,正如在第九章中讨论的那样。如果设备存储或允许访问敏感的企业数据,设备管理员还可以使用 DevicePolicyManager.setMaximumFailedPasswordsForWipe() 方法设置允许的失败认证尝试阈值。当达到阈值时,设备上的所有用户数据将被自动删除,防止攻击者未经授权访问设备。
安全的 USB 调试
Android 成功的一个原因是其应用开发的低门槛;应用可以在任何操作系统上使用高级语言开发,无需投资开发工具或硬件(使用 Android 模拟器时)。为嵌入式或其他专用设备开发软件传统上一直很困难,因为通常很难(或者在某些情况下不可能)检查程序的内部状态或以其他方式与设备进行交互,从而调试程序。
自 Android 最早版本以来,Android 就包含了一个强大的设备交互工具包,允许进行交互式调试和检查设备状态,称为 Android 调试桥(ADB)。ADB 通常在消费类设备上处于关闭状态,但可以通过系统 UI 打开,以启用设备上的应用开发和调试。由于 ADB 提供了对设备文件系统和应用程序的特权访问,它可以被用来获取未经授权的数据访问权限。在接下来的章节中,我们将讨论 ADB 的架构,然后讨论近期 Android 版本采取的限制 ADB 访问的措施。
ADB 概述
ADB 跟踪所有连接到主机的设备(或模拟器),并为其客户端(命令行客户端、IDE 等)提供各种服务。它由三个主要组件组成:ADB 服务器、ADB 守护进程 (adbd) 和默认的命令行客户端(adb)。ADB 服务器作为后台进程在主机上运行,将客户端与实际设备或模拟器解耦。它监控设备的连接状态,并根据情况设置其状态(CS_CONNECTED、CS_OFFLINE、CS_RECOVERY 等)。
ADB 守护进程运行在 Android 设备(或模拟器)上,并提供客户端实际使用的服务。它通过 USB 或 TCP/IP 连接到 ADB 服务器,并接收和处理来自服务器的命令。adb 命令行客户端允许你向特定设备发送命令。在实践中,它与 ADB 服务器实现于同一个二进制文件中,因此共享大量的代码。图 10-14 显示了 ADB 架构的概览。

图 10-14. ADB 架构
注意
除了在 adb 命令中的本地实现和在 Android 开发工具(ADT)Eclipse 插件中的基于 Java 的实现外,还可以使用各种第三方的 ADB 协议实现,包括一个 Python 客户端^([108]) 和一个用 JavaScript 实现的 ADB 服务器,^([109]) 可以作为扩展嵌入 Chrome 浏览器中。
客户端通过 TCP(通常是 localhost:5037)与本地 ADB 服务器通信,使用基于文本的命令,并返回 OK 或 FAIL 响应。一些命令,比如列举设备、端口转发或守护进程重启,由本地守护进程处理,而其他命令(如 shell 或日志访问)则需要与目标 Android 设备建立连接。设备访问通常通过将输入输出流转发到/从主机来实现。实现这一功能的传输层使用简单的消息,具有一个 24 字节的头部,其中包含命令标识符、两个参数、可选负载的长度和 CRC32 校验码,以及一个魔数,该魔数仅仅是将命令的所有位翻转。消息结构在 system/core/adb/adb.h 中定义,并在 示例 10-12 中提供参考。消息进一步封装在数据包中,通过 USB 或 TCP 链接发送到运行在设备上的 ADB 服务器。
示例 10-12。ADB 消息结构
struct amessage {
unsigned command; /* command identifier constant */
unsigned arg0; /* first argument */
unsigned arg1; /* second argument */
unsigned data_length; /* length of payload (0 is allowed) */
unsigned data_check; /* checksum of data payload */
unsigned magic; /* command ^ 0xffffffff */
};
我们不会详细讨论 ADB 协议,除了提到为了实现安全的 USB 调试,协议中添加了身份验证命令。(有关 ADB 的更多详细信息,请参阅 Android 源代码树中的system/core/adb/protocol.txt 文件中的协议描述。)
注意
你可以通过在主机上设置 ADB_TRACE 环境变量为 1,以及在设备上设置 persist.adb.trace_mask 系统属性,来启用所有 ADB 服务的跟踪日志。通过将 ADB_TRACE 或 persist.adb.trace_mask 的值设置为以逗号或空格分隔的(列或分号作为分隔符也支持)服务标签列表,可以选择性地跟踪某些服务。有关支持的标签的完整列表,请参见 system/core/adb/adb.c 。
安全 ADB 的必要性
如果你做过开发,你就会知道,“调试”通常与“安全”是完全相反的。调试通常涉及检查(有时甚至修改)程序的内部状态,向日志文件中转储加密的通信数据,通用的 root 权限访问,以及其他一些既可怕又必要的活动。调试本身就已经够难的了,如果还要考虑安全问题,那岂不是雪上加霜?所以,为什么要通过增加额外的安全层来进一步复杂化事情呢?通过 ADB 提供的 Android 调试非常灵活,当启用时,它几乎可以让你完全控制设备。当然,这个功能在开发或测试应用程序(或操作系统本身)时非常受欢迎,但它也可以用于其他目的。
以下是 ADB 让你可以做的一些选择性操作:
-
将文件复制到设备或从设备复制文件
-
调试设备上运行的应用(使用 JWDP 或
gdbserver) -
在设备上执行 shell 命令
-
获取系统和应用日志
-
安装和卸载应用
如果设备启用了调试功能,你只需通过 USB 电缆将设备连接到计算机,就可以做上述所有操作以及更多操作(例如,注入触摸事件或在 UI 中输入文本)。因为 ADB 不依赖于设备的屏幕锁定,你不需要解锁设备就可以执行 ADB 命令,在大多数提供 root 访问权限的设备上,通过 ADB 连接可以访问和更改所有文件,包括系统文件和密码数据库。更糟的是,你实际上不需要一台具有开发工具的计算机就可以通过 ADB 访问 Android 设备;另一台 Android 设备和一根 USB On-The-Go (OTG) 数据线就足够了。现在有许多 Android 工具可以在短时间内从另一个设备提取尽可能多的数据^([110])。如果设备已经 root,这些工具可以提取你的所有凭据、禁用或暴力破解屏幕锁甚至登录你的 Google 账户。但即使没有 root,外部存储中的任何东西,尤其是照片,也能被访问,包括你的联系人和短信。
加固 ADB
Android 4.2 是第一个尝试通过隐藏开发者选项设置屏幕来使 ADB 访问更加困难的版本,它要求你使用“秘密敲击”(连续点击构建号七次)才能启用该选项。尽管这并不是一种非常有效的访问保护方法,但它可以确保大多数用户不会不小心启用 ADB 访问。当然,这仅仅是一个权宜之计,一旦你成功开启了 USB 调试功能,设备再次处于易受攻击的状态。

图 10-15. USB 调试授权对话框
Android 4.2.2 引入了一种正式的解决方案,称为安全 USB 调试功能。这里的“安全”指的是,只有用户明确授权的主机才能连接到设备上的 adbd 守护进程并执行调试命令。因此,如果有人试图通过 USB 将设备连接到另一台设备以访问 ADB,他们必须先解锁目标设备,并通过点击确认对话框中的 OK 按钮授权调试主机的访问,如图 10-15 所示。
你可以通过勾选始终允许此计算机连接复选框来使你的决策保持持久,并且只要你在同一台机器上,调试就会像以前一样正常工作。
自然,这种安全的 USB 调试功能只有在你设置了足够安全的锁屏密码的情况下才有效。
注意
在支持多用户的平板电脑上,确认对话框仅会显示给主用户(所有者)。
安全 ADB 实现
默认情况下,当ro.adb.secure系统属性设置为 1 时,启用 ADB 主机认证功能,并且无法通过系统接口禁用它。当设备连接到主机时,它最初处于CS_UNAUTHORIZED状态,只有在主机完成认证后才会进入CS_DEVICE状态。主机使用 RSA 密钥进行认证,以便向设备上的 ADB 守护进程进行身份验证,通常遵循以下三步过程:
-
当主机尝试连接时,设备会发送
A_AUTH消息,并带有ADB_AUTH_TOKEN类型的参数,其中包含一个 20 字节的随机值(从/dev/urandom/读取)。 -
主机通过发送
A_AUTH消息并带有ADB_AUTH_SIGNATURE类型的参数进行响应,该参数包括使用主机私钥之一对随机令牌的SHA1withRSA签名。 -
设备尝试验证收到的签名,如果签名验证成功,它会响应一个
A_CNXN数据包并进入CS_DEVICE状态。如果验证失败,无论是因为签名值不匹配,还是因为没有对应的公钥来进行验证,设备将发送另一个ADB_AUTH_TOKEN并带有新的随机值,以便主机可以重新进行认证(如果失败次数超过某个阈值,速度会变慢)。
签名验证通常在第一次将设备连接到新主机时失败,因为设备尚未获取主机的公钥。在这种情况下,主机通过A_AUTH消息并带有ADB_AUTH_RSAPUBLICKEY参数发送其公钥。设备对该公钥进行 MD5 哈希并在允许 USB 调试确认对话框中显示该哈希,见图 10-15。由于adbd是本地守护进程,因此必须将密钥传递给主 Android 操作系统,以便其哈希值可以在屏幕上显示。这是通过将密钥写入一个本地套接字(也命名为adbd)来完成的,该套接字由adbd守护进程监控。
当你在开发者设置屏幕上启用 ADB 调试时,会启动一个线程来监听该adbd套接字。当线程收到以PK开头的消息时,它将其视为公钥,解析该公钥,计算 MD5 哈希并显示确认对话框(该对话框实现于一个专用的活动UsbDebuggingActivity,是 SystemUI 包的一部分)。如果你点击“确定”,该活动会向adbd发送一个简单的OK响应,adbd使用该公钥验证认证消息。如果你勾选了“始终允许来自此计算机的连接”复选框,该公钥将被写入磁盘,并在下次连接同一主机时自动用于签名验证。
注意
从 4.3 版本开始,Android 允许你清除所有已保存的主机认证密钥。你可以通过选择“设置”▸“开发者选项”▸“撤销 USB 调试授权”来触发此功能。
UsbDeviceManager 类提供了公开的方法,允许和拒绝 USB 调试、清除缓存的认证密钥,以及启动和停止 adbd 守护进程。这些方法通过系统 UsbService 的 IUsbManager AIDL 接口提供给其他应用程序。调用修改设备状态的 IUsbManager 方法需要 MANAGE_USB 系统签名权限。
ADB 认证密钥
虽然我们已经描述了 ADB 认证协议,但尚未详细介绍过程中使用的实际密钥:由本地 ADB 服务器生成的 2048 位 RSA 密钥。这些密钥通常存储在 $HOME/.android(在 Windows 上为 %USERPROFILE%.android)中,分别为 adbkey(私钥)和 adbkey.pub(公钥)。可以通过设置 ANDROID_SDK_HOME 环境变量来覆盖默认的密钥目录。如果设置了 ADB_VENDOR_KEYS 环境变量,则会搜索它指向的目录中的密钥。如果在上述任何位置都没有找到密钥,则会生成并保存一对新的密钥。
私钥文件(adbkey)仅存储在主机上,采用标准的 OpenSSL PEM 格式。公钥文件(adbkey.pub)包含公钥的 Base 64 编码的 mincrypt 兼容表示形式,基本上是 mincrypt 的 RSAPublicKey 结构的序列化(参见 “启用验证引导”),后跟一个 user@host 用户标识符,二者以空格分隔。根据目前的情况,用户标识符似乎未被使用,仅在基于 Unix 的操作系统中有意义;在 Windows 上,它始终是 unknown@unknown。
密钥存储在设备上的 /data/misc/adb/adb_keys/ 文件中,接受新授权的密钥时会附加到同一文件中。只读的“厂商密钥”存储在 /adb_keys 文件中,但目前似乎在 Nexus 设备上不存在。公钥与主机上的格式相同,这使得它能够轻松加载到 libmincrypt 中,而 adbd 静态链接该库。示例 10-13 显示了一些示例 adb_keys。该文件归 system 用户所有,组设置为 shell,权限为 0640。
示例 10-13. adb_keys 文件的内容
# cat data/misc/adb/adb_keys
QAAAAJs1UDFt17wyV+Y2GNGF+EgWoiPfsByfC4frNd3s64w3IGt25fKERnl7O8/A+iVPGv1W
--*snip*--
yZ61cFd7R6ohLFYJRPB6Dy7tISUPRpb+NF4pbQEAAQA= unknown@unknown
QAAAAKFLvP+fp1cB4Eq/6zyV+hnm1S1eV9GYd7cYe+tmwuQZFe+O4vpeow6huIN8YbBRkr7
--*snip*--
m7+bGd6F0hRkO82gopy553xywXU7rI/aMl6FBAEAAQA= user1@host2
验证主机密钥指纹
虽然 USB 调试确认对话框会显示一个密钥指纹,帮助你验证是否连接到预期的主机,但 adb 客户端没有一个方便的命令来打印主机密钥的指纹。虽然在运行几个虚拟机时看起来似乎不容易混淆(毕竟只有一根电缆插入到一台机器上),但事情可能会变得有些模糊。示例 10-14 展示了一种显示主机密钥指纹的方式,格式与在图 10-15 中显示的确认对话框相同(运行在 $HOME/.android 目录下,或者指定公钥文件的完整路径)。
示例 10-14. 显示主机密钥的指纹
$ **cut -d' ' -f1 adbkey.pub|openssl base64 -A -d -a | \**
**openssl md5 -c|cut -d' ' -f2|tr '[a-z]' '[A-Z]'**
69:D4:AC:0D:AF:6B:17:88:BA:6B:C4:BE:0C:F7:75:9A
Android 备份
Android 包含一个备份框架,允许应用程序数据备份到 Google 的云存储,并支持将已安装的 APK 文件、应用程序数据和外部存储文件通过 USB 连接到主机的方式进行完全备份。虽然设备备份不完全是一个安全功能,但备份允许从设备中提取应用程序数据,这可能会带来安全问题。
Android 备份概述
Android 的备份框架在 Android 2.2 中公开宣布,但它可能在内部更早就已经可用。该框架让应用程序声明一种特殊的组件,称为 备份代理,在为应用程序创建备份和恢复数据时,系统会调用这些代理。尽管备份框架内部支持可插拔的备份传输,但最初唯一可以实际使用的传输方式是一个专有的传输方式,它将应用程序数据存储在 Google 的云存储中。
云备份
由于备份与用户的 Google 账户相关联,当用户在新设备上安装带有备份代理的应用程序时,如果用户注册了与备份创建时相同的 Google 账户,应用程序的数据可以自动恢复。备份和恢复由系统管理,通常无法由用户触发或控制(尽管可以通过 Android shell 使用开发者命令触发云备份)。默认情况下,备份会定期触发,恢复仅在应用首次安装到设备时进行。
本地备份
Android 4.0 添加了一种新的本地备份传输方式,允许用户将备份保存到桌面计算机上的文件中。本地备份(也称为完全备份)需要启用并授权 ADB 调试,因为备份数据是通过与 ADB(通过 adb pull)传输设备文件到主机的相同方式流式传输到主机计算机的。

图 10-16. 备份确认对话框
完整备份通过在终端执行adb backup命令启动。该命令在设备上启动一个新的 Java 进程,该进程绑定到系统的BackupManagerService并请求进行备份,使用传递给adb backup的参数。BackupManagerService随后会启动一个确认活动,如图 10-16 所示,提示用户授权备份并在需要时指定备份加密密码。如果设备已加密,用户必须输入设备加密密码才能继续。该密码还将用于加密备份,因为不支持使用专用的备份加密密码。当用户按下“备份我的数据”按钮时,完整备份过程开始。
完整备份会调用每个目标包的备份代理,以获取其数据副本。如果未定义备份代理,BackupManagerService将使用内部的FullBackupAgent类,该类会复制包的所有文件。完整备份会遵循包中<application>标签的allowBackup属性,如果allowBackup设置为false,则不会提取包数据。
除了应用数据外,完整备份还可以包括用户安装的和系统应用的 APK 文件,以及外部存储内容,但有一些限制:完整备份不会备份受保护(带有 DRM)的应用,并且会跳过一些系统设置,如移动网络 APN 和 Wi-Fi 接入点的连接详情。
备份通过adb restore命令恢复。备份恢复功能非常有限,并且不允许指定任何选项,因为它只能执行完全恢复。
备份文件格式
Android 备份文件以几行文本开头,后面是二进制数据。这些行是备份头,指定了用于创建备份的备份格式和加密参数(如果指定了备份密码)。未加密备份的头部如示例 10-15 所示。
示例 10-15. 未加密备份头
ANDROID BACKUP➊
1➋
1➌
none➍
第一行 ➊ 是文件魔术(格式标识符),第二行 ➋ 是备份格式版本(Android 4.4.2 及以前为版本 1,之后版本为 2;版本 2 表示密钥派生方法发生了变化,现在考虑了多字节密码字符),第三行 ➌ 是压缩标志(如果压缩,则为 1),最后一行 ➍ 是所使用的加密算法(none 或 AES-256)。
实际的备份数据是一个压缩的、可选加密的 tar 文件,其中包含一个备份清单文件,接着是应用 APK(如果有的话),以及应用数据(文件、数据库和共享偏好设置)。数据使用 deflate 算法进行压缩,可以通过 OpenSSL 的 zlib 命令解压缩,如 示例 10-16 中所示。
示例 10-16:使用 OpenSSL 解压 Android 备份
$ **dd if=mybackup.ab bs=24 skip=1|openssl zlib -d > mybackup.tar**
在备份解压后,您可以使用标准的 tar 命令查看其内容或提取内容,如 示例 10-17 中所示。
示例 10-17:使用 tar 查看未压缩备份的内容
$ **tar tvf mybackup.tar**
-rw------- 1000/1000 1019 apps/org.myapp/_manifest➊
-rw-r--r-- 1000/1000 1412208 apps/org.myapp/a/org.myapp-1.apk➋
-rw-rw---- 10091/10091 231 apps/org.myapp/f/share_history.xml➌
-rw-rw---- 10091/10091 0 apps/org.myapp/db/myapp.db-journal➍
-rw-rw---- 10091/10091 5120 apps/org.myapp/db/myapp.db
-rw-rw---- 10091/10091 1110 apps/org.myapp/sp/org.myapp_preferences.xml➎
在 tar 文件内部,应用数据存储在 apps/ 目录中,该目录为每个备份的包提供一个子目录。每个包目录的根目录中包含一个 _manifest 文件 ➊,APK 文件(如果有请求)存放在 a/ ➋,应用文件存放在 f/ ➌,数据库存放在 db/ ➍,共享偏好设置存放在 sp/ ➎。清单文件包含应用的包名和版本号、平台的版本号、一个标志,指示存档是否包含应用的 APK,以及应用的签名证书。
BackupManagerService 在恢复应用时使用这些信息,以检查它是否与当前安装的应用使用相同的证书进行签名。如果证书不匹配,它将跳过安装 APK,系统包除外,因为它们可能在不同设备上使用不同的(制造商拥有的)证书进行签名。此外,BackupManagerService 期望文件按 示例 10-17 中所示的顺序排列,如果顺序不对,恢复将失败。例如,如果清单文件声明备份包含 APK,BackupManagerService 会先尝试读取并安装 APK,然后再恢复应用的其他文件。此恢复顺序是必需的,因为无法恢复未安装的应用文件。然而,BackupManagerService 不会在存档中搜索 APK,如果 APK 不紧跟在清单文件之后,所有其他文件将被跳过。
如果用户请求了外部存储备份(通过将 -shared 选项传递给 adb backup),存档中还会包含一个 shared/ 目录,包含外部存储文件。
备份加密
如果用户在请求备份时提供了加密密码,则备份文件会使用从密码派生的密钥进行加密。该密码用于通过 10,000 轮 PBKDF2 算法与随机生成的 512 位盐值一起生成 256 位的 AES 密钥。这个密钥接着用来加密另一个随机生成的 256 位 AES 主密钥,该主密钥随后用于在 CBC 模式下加密实际的归档数据(使用AES/CBC/PKCS5Padding Cipher 转换)。还会计算并保存主密钥的校验和到备份文件头部。为了生成校验和,生成的原始主密钥会通过将每个字节转换为char的方式转化为 Java 字符数组,结果作为密码字符串,并通过 PBKDF2 函数生成另一个 AES 密钥,之后其字节将作为校验和。
注意
由于 AES 密钥本质上是随机字节序列,因此原始密钥通常包含几个无法映射到可打印字符的字节。由于 PKCS#5 未指定密码字符串的实际编码方式,Android 的加密校验和生成方法会产生依赖于实现和版本的结果。
校验和用于在实际解密备份数据之前验证用户提供的解密密码是否正确。当主密钥被解密时,会使用上述方法计算其校验和,并与归档头中的校验和进行比较。如果校验和不匹配,则认为密码不正确,恢复过程将被中止。示例 10-18 展示了加密归档文件的示例备份头。
示例 10-18. 加密备份头
ANDROID BACKUP
1
1
AES-256➊
68404C30DF8CACA5FA004F49BA3A70...➋
909459ADCA2A60D7C2B117A6F91E3D...➌
10000➍
789B1A01E3B8FA759C6459AF1CF1F0FD ➎
8DC5E483D3893EC7F6AAA56B97A6C2...➏
这里,AES-256 ➊ 是使用的备份加密算法,接下来一行 ➋ 是用户密码盐的十六进制字符串,随后是主密钥校验和盐 ➌、用于派生密钥的 PBKDF2 轮数 ➍,以及用户密钥 IV ➎。最后一行 ➏ 是主密钥数据块,包含归档数据加密 IV、实际主密钥及其校验和,所有这些都使用从用户提供的密码派生的密钥进行加密。示例 10-19 展示了主密钥数据块的详细格式。
示例 10-19. 主密钥数据块格式
byte Niv➊
byte[Niv] IV➋
byte Nmk➌
byte [Nmk] MK➍
byte Nck➎
byte [Nck] MKck➏
第一个字段 ➊ 是 IV 长度,接着是 IV 值 ➋、主密钥(MK)长度 ➌ 和实际主密钥 ➍。最后两个字段存储主密钥校验和哈希长度 ➎ 和主密钥校验和哈希本身 ➏。
控制备份范围
Android 的安全模型保证每个应用程序都在其自己的沙箱中运行,并且其文件无法被其他应用程序或设备用户访问,除非应用程序显式允许访问。因此,大多数应用程序在将数据存储到磁盘之前并不加密数据。然而,无论是合法用户还是通过某种方式获得设备解锁密码的攻击者,都可以轻松地通过 Android 的完整备份功能提取应用程序数据。因此,存储敏感数据的应用程序应当对其进行加密,或提供一个明确的备份代理,限制可导出的数据,以确保敏感数据无法通过备份轻易提取。
如在“Android 备份概述”中所提到的,如果不需要或不希望备份应用数据,应用程序可以通过在 AndroidManifest.xml 中将 allowBackup 属性设置为 false 来完全禁止备份,如示例 10-20 所示。
示例 10-20. 禁止在 AndroidManifest.xml 中备份应用数据
<xml version="1.0" encoding="utf-8"?>
<manifest
package="org.example.app"
android:versionCode="1"
android:versionName="1.0" >
--*snip*--
<application
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:allowBackup="false">
--*snip*-
</application>
</manifest>
总结
Android 采取各种措施来保护用户数据和应用程序,并确保操作系统的完整性。在生产设备上,引导加载程序被锁定,恢复操作系统仅允许安装设备制造商签名的 OTA 更新,从而确保只有经过授权的操作系统构建可以启动或闪存到设备上。当启用时,基于 dm-verity 的验证启动确保 系统 分区未被修改,通过检查每个设备块的哈希值与受信任的哈希树进行比对,从而防止恶意程序(如 rootkit)在 系统 分区上安装。Android 还可以加密 userdata 分区,使得直接访问存储设备时更难提取应用程序数据。
Android 支持各种屏幕锁定方法,并对不成功的身份验证尝试应用速率限制,从而有效防止针对已启动设备的在线攻击。解锁 PIN 码或密码的类型和复杂性可以由设备管理员应用程序指定并强制执行。也支持一种设备策略,即在多次失败的身份验证尝试后清除设备数据。安全的 USB 调试要求调试主机必须得到用户明确授权并添加到白名单中,从而防止通过 USB 提取信息。
最终,完整设备备份可以通过由用户提供的密码派生的密钥进行加密,从而使得访问已提取到备份中的设备数据变得更加困难。为了实现更高水平的设备安全性,应启用并根据需要配置所有支持的安全措施。
^([100]) Milan Broz, “dm-verity:设备映射块完整性检查目标”, code.google.com/p/cryptsetup/wiki/DMVerity
^([101]) Red Hat, Inc.,“设备映射资源页面”, www.sourceware.org/dm/
^([102]) Google, “dm-verity 启动时”, source.android.com/devices/tech/security/dm-verity.html
^([103]) Google, Android 4.4 兼容性定义,“9.9. 全盘加密”, static.googleusercontent.com/media/source.android.com/en//compatibility/4.4/android-4.4-cdd.pdf
^([104]) Milan Broz, “dm-crypt:Linux 内核设备映射加密目标”, code.google.com/p/cryptsetup/wiki/DMCrypt
^([105]) Jakob Lell, “针对 CBC 加密 LUKS 分区的实用可塑性攻击”, www.jakoblell.com/blog/2013/12/22/practical-malleability-attack-against-cbc-encrypted-luks-partitions/
^([106]) C. Percival 和 S. Josefsson, The scrypt 基于密码的密钥派生函数, tools.ietf.org/html/draft-josefsson-scrypt-kdf-01/
^([107]) 由 viaForensics 在 DEF CON 20 的“Into The Droid”演讲中演示。演示幻灯片可在 www.defcon.org/images/defcon-20/dc-20-presentations/Cannon/DEFCON-20-Cannon-Into-The-Droid.pdf 获取
^([108]) Anthony King, “PyAdb:使用 TCP 的 Python 基础 ADB 核心”, github.com/cybojenix/PyAdb/
^([109]) Kenny Root, “adb-on-chrome: ADB(Android 调试桥)服务器作为 Chrome 扩展”, github.com/kruton/adb-on-chrome/
^([110]) Kyle Osborn, “p2p-adb 框架”, github.com/kosborn/p2p-adb/
第十一章 NFC 与安全元素
本章简要概述了近场通信(NFC)和安全元素(SE),并解释了它们如何集成到移动设备中。虽然 NFC 有许多用途,但我们将重点介绍其卡片仿真模式,该模式用于提供与集成在移动设备中的 SE 的接口。安全元素为私密数据提供受保护的存储,例如身份验证密钥,并提供一个安全执行环境,以保护安全关键代码。我们将描述 Android 支持的 SE 类型,并介绍 Android 应用程序可以使用的与 SE 通信的 API。最后,我们将讨论基于主机的卡片仿真(HCE)及其在 Android 中的实现,并演示如何实现 HCE 应用程序。
NFC 概述
NFC是一种允许处于近距离(通常为 10 厘米或更短)的设备之间建立无线通信并交换数据的技术。NFC 不是单一的标准,而是基于一组标准,这些标准定义了射频、通信协议和数据交换格式。NFC 基于射频识别(RFID)技术,并在 13.56 MHz 频率下工作,支持 106kbps、212kbps 和 424kbps 等不同的数据传输速率。
NFC 通信涉及两个设备:发起者和目标。在主动模式下,发起者和目标都有各自的电源,并且每个设备都可以发射无线信号以便与对方进行通信。在被动模式下,目标设备没有自己的电源,而是通过发起者发射的电磁场来激活并供电。
在被动模式下进行通信时,发起者通常被称为读卡器,而目标则被称为标签。读卡器可以是专用设备,也可以嵌入到通用设备中,例如个人电脑或手机。标签有各种形状和大小,从简单的、内存非常有限的贴纸到内嵌 CPU 的非接触智能卡。
NFC 设备可以在三种不同模式下工作:读写(R/W)、点对点(P2P)和卡片仿真(CE)。在 R/W 模式下,设备作为主动发起者,可以读取和写入外部标签的数据。在 P2P 模式下,两台 NFC 设备可以使用双向通信协议进行主动的数据交换。CE 模式允许 NFC 设备仿真标签或非接触式智能卡。Android 支持这三种模式,但存在一些限制。我们将在下一节概述 Android 的 NFC 架构,并展示如何使用每种模式。
Android NFC 支持
Android 在 2.3 版本中引入了 NFC 支持,相关架构和功能在版本 4.4 之前基本保持不变,而版本 4.4 引入了 HCE 支持。
Android 的 NFC 实现位于NfcService系统服务中,属于Nfc系统应用(包名为com.android.nfc)。它封装了驱动每个支持的 NFC 控制器所需的本地库;实现了访问控制、标签发现和分发;并控制卡模拟。Android 并未向外部暴露NfcService的低级 API,而是提供了一个事件驱动框架,允许感兴趣的应用程序注册 NFC 事件。这种事件驱动的方法在所有三种 NFC 操作模式中均有使用。
读/写模式
启用 NFC 的 Android 应用程序不能直接将设备设置为读/写模式。相反,它们声明自己感兴趣的标签类型,当 Android 的标签分发系统发现标签时,选择并启动匹配的应用程序。
标签分发系统既使用标签技术(稍后讨论),又解析标签内容,以决定将标签分发到哪个应用程序。标签分发系统使用三个意图动作来通知应用程序发现的标签:ACTION_NDEF_DISCOVERED、ACTION_TECH_DISCOVERED和ACTION_TAG_DISCOVERED。ACTION_NDEF_DISCOVERED意图具有最高优先级,当 Android 发现一个使用标准 NFC 数据交换格式(NDEF)格式化的标签,并且该标签包含已识别的数据类型时,便会发送该意图。ACTION_TECH_DISCOVERED意图则在扫描的标签不包含 NDEF 数据或数据格式不被能够处理已发现标签技术的应用程序所识别时发送。如果没有应用程序可以处理ACTION_NDEF_DISCOVERED或ACTION_TECH_DISCOVERED,NfcService会发送通用的ACTION_TAG_DISCOVERED意图。标签分发事件仅传递给活动,因此无法在没有用户交互的情况下在后台处理。
注册标签分发
应用程序通过声明 NFC 启用的活动所支持的意图,在AndroidManifest.xml中使用标准的意图过滤器系统注册 NFC 事件,如示例 11-1 所示。
示例 11-1. NFC 启用应用程序的清单文件
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.nfc" ...>
--*snip*--
<uses-permission android:name="android.permission.NFC" />➊
--*snip*-
<application ...>
<activity
android:name=".NfcActivity"➋
android:launchMode="singleTop" >
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>➌
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED" />➍
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.TAG_DISCOVERED" />➎
</intent-filter>
<meta-data
android:name="android.nfc.action.TECH_DISCOVERED"➏
android:resource="@xml/filter_nfc" >
</meta-data>
</activity>
--*snip*--
</application>
</manifest>
如该清单所示,应用程序首先请求android.permission.NFC权限 ➊,该权限用于访问 NFC 控制器,然后声明一个处理 NFC 事件的活动NfcActivity ➋。该活动注册了三个意图过滤器,每个过滤器对应一个标签发现事件。应用程序声明它可以通过在NDEF_DISCOVERED意图过滤器的<data>标签中指定mimeType属性,来处理具有text/plain MIME 类型的 NDEF 数据 ➌。NfcActivity还声明它可以处理TECH_DISCOVERED意图 ➍,如果扫描的标签使用关联的元数据 XML 资源文件中指定的某种技术,则会发送此意图 ➏。最后,应用程序通过添加通配符TAG_DISCOVERED意图过滤器 ➎,请求在发现所有 NFC 标签时得到通知。
如果找到多个支持扫描标签的活动,Android 会显示一个选择对话框,允许用户选择哪个活动处理该标签。已经在前台的应用程序可以通过调用NfcAdapter.enableForegroundDispatch()方法来绕过此选择。这样的应用程序将在所有其他匹配的应用程序中优先处理,并且当应用程序处于前台时,会自动接收 NFC 意图。
标签技术
标签技术是一个抽象术语,用于描述具体的 NFC 标签。标签技术由标签使用的通信协议、其内部结构或其提供的功能决定。例如,使用 NFC-A 协议(基于 ISO 14443-3A)进行通信的标签与NfcA技术匹配,而包含 NDEF 格式数据的标签则与Ndef技术匹配,无论其底层通信协议如何。(有关 Android 支持的所有标签技术的完整列表,请参见TagTechnology类参考文档。)
一个指定了TECH_DISCOVERED意图过滤器的活动必须提供一个 XML 资源文件,该文件进一步指定它支持的具体技术,并使用<tech-list>元素。 如果一个活动声明的技术列表是标签所支持的技术的子集,则认为该活动与标签匹配。 可以声明多个技术列表,以匹配不同的标签,如示例 11-2 所示。
示例 11-2. 使用技术列表声明匹配的技术
<?xml version="1.0" encoding="utf-8"?>
<resources>
<tech-list>➊
<tech>android.nfc.tech.IsoDep</tech>
<tech>android.nfc.tech.NfcA</tech>
</tech-list>
<tech-list>➋
<tech>android.nfc.tech.NfcF</tech>
</tech-list>
</resources>
在这里,第一个技术列表 ➊ 将匹配提供与 ISO 14443-4(ISO-DEP)兼容的通信接口的标签,这些标签使用 NFC-A 技术实现(通常用于 NXP 非接触智能卡);第二个技术列表 ➋ 匹配使用 NFC-F 技术的标签(通常是 Felica 卡)。由于这两个技术列表是独立定义的,我们的示例NfcActivity(参见示例 11-1)将在扫描到 NXP 非接触智能卡或 Felica 卡或标签时收到通知。
读取标签
在标签调度系统选择一个活动来处理扫描到的标签后,它会创建一个 NFC 意图对象,并将其传递给选中的活动。活动可以通过EXTRA_TAG额外数据来获取一个表示扫描到标签的Tag对象,并调用其方法以读取或写入标签。(包含 NDEF 数据的标签还会提供EXTRA_NDEF_MESSAGES额外数据,其中包含从标签解析出来的 NDEF 消息数组。)
可以使用相应技术类的静态get()方法获取表示底层标签技术的具体Tag对象,如示例 11-3 所示。如果Tag对象不支持请求的技术,get()方法将返回null。
示例 11-3. 从 NFC 意图中获取具体的Tag实例
protected void onNewIntent(Intent intent) {
setIntent(intent);
Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
IsoDep isoDep = IsoDep.get(tag);
if (isoDep != null) {
isoDep.connect();
byte[] command = {...};
byte[] response = isoDep.transceive(command);
--*snip*--
}
}
使用阅读器模式
除了基于意图的标签调度系统,Android 4.4 还添加了一种新的方法,活动可以用来获取一个实时的Tag对象,称为阅读器模式。阅读器模式确保在目标活动处于前景时,NFC 控制器支持的所有其他操作模式(如点对点模式和卡片模拟模式)都会被禁用。当扫描一个活跃的 NFC 设备时,这种模式非常有用,比如另一台处于主机模拟模式的 Android 设备,它可能会触发点对点通信,从而将控制权转移给当前的前景活动。
活动可以通过调用NfcAdapter类的enableReaderMode()方法来启用阅读器模式,如示例 11-4 所示。
示例 11-4. 启用阅读器模式并使用ReaderCallback获取Tag对象
public class NfcActivity extends Activity implements NfcAdapter.ReaderCallback {
private NfcAdapter adapter;
--*snip*--
@Override
public void onResume() {
super.onResume();
if (adapter != null) {
adapter.enableReaderMode(this, this, NfcAdapter.FLAG_READER_NFC_A➊
| NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null);
}
}
@Override
public void onTagDiscovered(Tag tag) {➋
IsoDep isoDep = IsoDep.get(tag);
if (isoDep != null) {
isoDep.connect();
byte[] command = {...};
byte[] response = isoDep.transceive(command);
--*snip*--
}
}
--*snip*--
}
在这种情况下,活动通过调用enableReaderMode()方法 ➊ 来启用前景模式中的阅读器模式(当活动离开前景时,应该使用相应的disableReaderMode()方法禁用阅读器模式),并通过onTagDiscovered()回调 ➋ 直接获取一个Tag实例(无需中间的意图)。然后,Tag对象的使用方式与基于意图的调度方式相同。
点对点模式
Android 实现了一种有限的 NFC P2P 模式数据交换,通过专有的 NDEF 推送和标准的简单 NDEF 交换协议(SNEP)协议进行交换。^([115]) Android 设备可以与支持这两种协议的任何设备交换单个 NDEF 消息,但 P2P 模式通常与其他 Android 设备一起使用,以实现所谓的 Android Beam 功能。
除了 NDEF 消息,Android Beam 还允许传输更大的数据对象,如照片和视频,这些数据无法通过单个 NDEF 消息传输。通过在设备之间创建一个临时的蓝牙连接,可以实现这一过程。这一过程称为NFC 交接,并在 Android 4.1 中加入了此功能。
在 P2P 模式下的 NDEF 消息交换是通过调用 NfcAdapter 类的 setNdefPushMessage() 或 setNdefPushMessageCallback() 方法来启用的。(有关更多详情和示例代码,请参见官方的 NFC API 指南^([116])。)
卡模拟模式
如在 “NFC 概述” 中所述,CE 模式允许 Android 设备模拟一张非接触式智能卡或 NFC 标签。在 CE 模式下,设备通过 NFC 接收命令,处理这些命令,并通过 NFC 返回响应。负责处理命令的组件可以是一个硬件安全元件(如下一节所讨论)连接到设备的 NFC 控制器,或者是一个运行在设备上的 Android 应用程序(当处于基于主机的卡模拟模式,HCE 时)。
在接下来的章节中,我们将讨论移动设备中的安全元件,以及应用程序可以用来与安全元件通信的 Android API。我们还将描述 Android 如何实现 HCE,并展示如何创建一个使能卡模拟的应用程序。
安全元件
一个 安全元件 (SE) 是一个防篡改的智能卡芯片,能够以一定的安全性和隔离性运行智能卡应用(称为 小程序 或 卡片)。智能卡本质上是一个包含 CPU、ROM、EEPROM、RAM 和 I/O 端口的最小计算环境。现代智能卡还包括加密协处理器,能够实现常见的算法,如 AES 和 RSA。
智能卡采用各种技术来实现防篡改,使得通过拆解或分析芯片来提取数据变得非常困难。现代智能卡预装了多应用操作系统,利用硬件的内存保护功能确保每个应用程序的数据仅对其自身可用。应用程序的安装和(可选的)访问通过要求每次操作使用加密密钥来控制。
SE 可以集成在移动设备中,作为通用集成电路卡(UICC,通常称为SIM 卡),嵌入在手机中或连接到 SD 卡槽。如果设备支持 NFC,SE 通常与 NFC 控制器连接(或嵌入其中),使得可以无线与 SE 通信。
智能卡自 1970 年代以来就已经出现,并且现在被广泛用于从预付费电话和交通票务到信用卡和 VPN 凭证存储等各种应用中。由于安装在移动设备中的 SE 具有与智能卡等效或更强的功能,因此理论上可以用于目前智能卡所使用的任何应用程序。此外,由于 SE 可以托管多个应用程序,它有潜力用一个设备替代人们日常使用的多张卡片。此外,由于 SE 可以由设备的操作系统控制,可以通过要求额外的身份验证(如 PIN 码、密码短语或代码签名)来限制对其的访问。
SE 在移动设备中的一个主要应用是模拟非接触支付卡,而启用移动支付的目标确实是推动 SE 部署的动力。除了金融应用外,移动 SE 还可以用于模拟其他广泛使用的非接触卡,如门禁卡、忠诚卡等。
移动 SE 也可以用来增强处理敏感信息或算法的应用程序的安全性:应用程序的安全关键部分,如凭证存储或许可证验证,可以在 SE 内部实现,从而确保其不易受到逆向工程和信息提取的攻击。其他可以从 SE 实现中受益的应用程序包括一次性密码(OTP)生成器,当然还有凭证存储(用于共享密钥或 PKI 中的私钥)。
尽管今天可以使用标准工具和技术实现支持 SE 的应用程序,但在当前的商业 Android 设备上实际使用它们并不简单。我们将在“Android SE 执行环境”中详细讨论这一点,但首先让我们探索一下移动设备上可用的 SE 类型,以及它们在 Android 中的支持级别。
移动设备中的 SE 形态
图 11-1 展示了一个简化的块图,描述了与 NFC 和 SE 支持相关的 Android 设备组件,包括嵌入式 SE(eSE)和 UICC。在我们讨论本章其余部分的安全元件和基于主机的卡模拟时,将引用此图中的组件。
在接下来的小节中,我们简要回顾了 Android 设备上可用的 SE 类型、它们与其他设备组件的连接方式,以及操作系统如何与每种 SE 进行通信。

图 11-1. Android NFC 和 SE 组件
UICC
目前大多数移动设备都配备了某种类型的 UICC。虽然 UICC 是可以托管应用的智能卡,但由于 UICC 传统上只与基带处理器连接(而不是运行主设备操作系统的应用处理器),因此无法直接从 Android 进行访问。所有通信都通过无线接口层(RIL)进行,RIL 本质上是一个专有的 IPC 接口,用于与基带进行通信。
与 UICC SE 的通信通过扩展的 AT 命令(AT+CCHO、AT+CCHC、AT+CGLA,如 3GPP TS 27.007 定义)进行,^([117]),当前的 Android 电话管理器不支持这些命令。Android 的 SEEK 项目^([118])提供了补丁来实现所需的命令,从而通过 SmartCard API 与 UICC 进行通信,SmartCard API 是 SIMalliance 开放移动 API 规范的参考实现^([119])(详见“使用 OpenMobile API”)。然而,正如与 Android 中的大多数硬件直接通信的组件一样,RIL 由一个开源部分(rild)和一个专有库(libXXX-ril.so)组成。为了支持与 UICC 安全元件的通信,必须同时在rild和底层专有库中添加支持。是否添加该支持的决定权由硬件厂商来决定。
截至目前,SmartCard API 尚未集成到主线 Android 中(尽管 AOSP 源码树中包含一个空的packages/apps/SmartCardService/目录)。然而,来自主要厂商的 Android 设备配备了 SmartCard API 的实现,允许 UICC 与第三方应用进行通信(受各种访问限制的约束)。
单线协议(SWP)提供了一种将 UICC 用作 SE 的替代方式。SWP 用于将 UICC 连接到 NFC 控制器,允许 NFC 控制器在卡模拟模式下将 UICC 暴露给外部读卡器。近期的 Nexus 设备(如 Nexus 5 中的 Broadcom BCM20793M)内置的 NFC 控制器支持 SWP,但此功能默认是禁用的。(通过修改 Nexus 5 上libnfc-brcm库的配置文件,可以启用此功能。)目前没有公开的标准 API 来在卡模拟模式下切换 UICC、嵌入式 SE(如果有的话)和 HCE,但 Android 4.4 中可用的“离线”路由功能理论上可以将命令路由到 UICC(详情请参见“APDU 路由”)。
基于 microSD 的 SE
SE 的另一种形式因子是高级安全 SD 卡(ASSD),^([120]),它基本上是一张带有嵌入式 SE 芯片的 SD 卡。当连接到带有 SD 卡插槽的 Android 设备时,并且运行 SEEK 修改版的 Android 系统时,可以通过智能卡 API 访问 SE。然而,带有 SD 卡插槽的 Android 设备已经成为例外而非常态,因此 ASSD 在 Android 上的支持不太可能成为主流。此外,即使在可用的情况下,最近的 Android 版本将 SD 卡视为次级存储设备,只能通过非常高层次、有限制的 API 进行访问。
嵌入式 SE
嵌入式 SE(eSE)并不是一个独立的设备,而通常与 NFC 控制器集成在一起,并被封装在同一个外壳中。eSE 的一个例子是 NXP 的 PN65N 芯片,它将 PN544 NFC 无线电控制器与 P5CN072 SE(智能 MX 系列的一部分)结合在一起。
第一款搭载嵌入式 SE 的主流 Android 设备是 Nexus S,它也首次为 Android 引入了 NFC 支持,并且采用了 PN65N 控制器。其后继产品,Galaxy Nexus 和 Nexus 4,也都配备了 eSE。然而,最近的 Google 品牌设备,如 Nexus 5 和 Nexus 7(2013 款),已不再使用 eSE,而是采用主机卡模拟,并且不再包括 eSE。
嵌入式 SE 通过 SignalIn/SignalOut 连接(S2C)与 NFC 控制器连接,并标准化为 NFC 无线接口(NFC-WI),^([121]),具有三种操作模式:关闭模式、无线模式和虚拟模式。在关闭模式下,与 SE 没有通信。在无线模式下,SE 对 Android 操作系统可见,就像它是一个连接到 NFC 读卡器的非接触式智能卡一样。在虚拟模式下,SE 对外部读卡器可见,就像手机是一个非接触式智能卡。这些模式是互斥的,因此我们可以通过非接触式接口(即,从外部读卡器)或通过有线接口(即,从 Android 应用程序)与 SE 进行通信。下一节将展示如何使用有线模式从 Android 应用程序与 eSE 通信。
访问嵌入式 SE
截至本文写作时,没有公开的 Android SDK API 允许与嵌入式 SE 进行通信,但最近的 Android 版本包括一个名为nfc_extras的可选库,提供了与 eSE 的稳定接口。本节将演示如何配置 Android,以允许某些 Android 应用程序访问 eSE,并展示如何使用nfc_extras库。
卡模拟和随之而来的用于访问嵌入式 SE 的内部 API 在 Android 2.3.4(引入了 Google Wallet 的版本)中引入。这些 API 对 SDK 应用程序是隐藏的,使用它们需要在 Android 2.3.4 及其后续版本 2.3.x,以及初始的 Android 4.0 发布(API 级别 14)中使用系统签名权限(WRITE_SECURE_SETTINGS 或 NFCEE_ADMIN)。签名权限非常限制,因为它仅允许控制平台签名密钥的方分发能够使用 eSE 的应用程序。
Android 4.0.4(API 级别 15)通过在操作系统级别将签名权限替换为签名证书白名单,从而取消了此限制。虽然这仍然需要修改核心操作系统文件,因此需要厂商合作,但无需使用厂商密钥对 SE 应用程序进行签名,这大大简化了分发。此外,由于白名单存储在文件中,可以通过 OTA 轻松更新,以便为更多的 SE 应用程序添加支持。
授予 eSE 访问权限
新的白名单访问控制方法通过 NfceeAccessControl 类实现,并由系统 NfcService 强制执行。NfceeAccessControl 类从 /etc/nfcee_access.xml 读取白名单,该 XML 文件存储了允许访问 eSE 的签名证书和包名列表。可以授予所有由特定证书的私钥签名的应用程序访问权限(如果未指定包名),也可以仅授予单个包(应用)的访问权限。示例 11-5 展示了 nfcee_access.xml 文件的内容可能如下所示:
示例 11-5. nfcee_access.xml 文件的内容
<?xml version="1.0" encoding="utf-8"?>
<resources >
<signer android:signature="308204a830820390a003020102020900b399...">➊
<package android:name="com.example.nfc">➋
</package>
</signer>
</resources>
此配置允许 com.example.nfc 包访问 SE ➋,前提是该包由指定的签名证书 ➊ 签名。在生产设备上,该文件通常仅包含 Google Wallet 应用的签名证书,从而将 eSE 访问权限限制为 Google Wallet。
注意
截至 2014 年 4 月,Google Wallet 仅支持 Android 4.4 及更高版本,并使用 HCE 而非 eSE。
在应用程序的签名证书已添加到 nfcee_access.xml 后,除了标准的 NFC 权限外,不需要其他权限来访问 eSE。除了将应用程序的签名证书列入白名单外,还必须显式将 nfc_extras 库添加到应用的清单文件中,并使用 <uses-library> 标签将其标记为必需,以启用 eSE 访问(因为该库是可选的,默认情况下不会加载),如示例 11-6 所示 ➊。
示例 11-6. 将 nfc_extras 库添加到 AndroidManifest.xml
<manifest
package="com.example.nfc" ...>
--*snip*--
<uses-permission android:name="android.permission.NFC" />
<application ...>
--*snip*--
<uses-library
android:name="com.android.nfc_extras"➊
android:required="true" />
</application>
</manifest>
使用 NfcExecutionEnvironment API
Android 的 eSE 访问 API 并非基于标准智能卡通信 API,如 JSR 177^([122])或 Open Mobile API,而是提供了一个非常基础的通信接口,实现在NfcExecutionEnvironment类中。该类只有三个公共方法,具体方法参见示例 11-7。
示例 11-7. NfcExecutionEnvironment API
public class NfcExecutionEnvironment {
public void open() throws EeIOException {...}
public void close() throws IOException {...}
public byte[] transceive(byte[] in) throws IOException {...}
}
这个简单的接口足以与 SE 进行通信,但要使用它,首先需要获取NfcExecutionEnvironment类的一个实例。可以通过NfcAdapterExtras类获取一个实例,而该类可以通过其静态方法get()进行访问,具体方法参见示例 11-8。
示例 11-8. 使用 NfcExecutionEnvironment API
NfcAdapterExtras adapterExtras =
NfcAdapterExtras.get(NfcAdapter.getDefaultAdapter(context));➊
NfcExecutionEnvironment nfceEe =
adapterExtras.getEmbeddedExecutionEnvironment();➋
nfcEe.open();➌
byte[] emptySelectCmd = { 0x00, (byte) 0xa4, 0x04, 0x00, 0x00 };
byte[] response = nfcEe.transceive(emptySelectCmd);➍
nfcEe.close();➎
在这里,我们首先获取一个NfcAdapterExtras实例 ➊,然后调用它的getEmbeddedExecutionEnvironment()方法以获取 eSE 接口 ➋。为了与 eSE 进行通信,我们首先打开一个连接 ➌,然后使用transceive()方法发送命令并获取响应 ➍。最后,使用close()方法关闭连接 ➎。
eSE 相关广播
启用 SE 的应用需要接收 NFC 事件通知,如射频场检测以及与 eSE 和安装在其上的小程序相关的事件,例如通过 NFC 接口选择小程序,以便能够相应地改变状态。由于将这些事件泄露给恶意应用可能导致敏感信息泄露和拒绝服务攻击,因此对 eSE 相关事件的访问必须限制为仅信任的应用。
在 Android 中,全局事件是通过广播实现的,应用可以创建并注册广播接收器,接收应用感兴趣的广播。对 eSE 相关广播的访问可以通过标准的 Android 基于签名的权限来控制,但这种方法的缺点是只有使用平台证书签名的应用才能接收 eSE 事件,从而将启用 SE 的应用限制为设备制造商或移动网络运营商(MNO)创建的应用。为避免这种限制,Android 使用了与控制 eSE 访问相同的机制;即,通过白名单应用程序证书,控制可以接收 eSE 相关广播的应用范围。任何在nfcee_access.xml中注册了签名证书(以及可选的包名)的应用,都可以通过注册接收器接收 eSE 相关广播,接收器的声明方式如示例 11-9 所示。
示例 11-9. 在 AndroidManifest.xml 中声明用于 eSE 相关事件的广播接收器
<receiver android:name="com.example.nfc.SEReceiver" >
<intent-filter>
<action android:name="com.android.nfc_extras.action.RF_FIELD_ON_DETECTED" />➊
<action android:name="com.android.nfc_extras.action.RF_FIELD_OFF_DETECTED" />➋
<action android:name="com.android.nfc_extras.action.APDU_RECEIVED" />➌
<action android:name="com.android.nfc_extras.action.AID_SELECTED" />➍
<action android:name="com.android.nfc_extras.action.MIFARE_ACCESS_DETECTED" />➎
<action android:name="com.android.nfc_extras.action.EMV_CARD_REMOVAL" />➏
<action android:name="com.android.nfc.action.INTERNAL_TARGET_DESELECTED" />➐
<action android:name="android.intent.action.MASTER_CLEAR_NOTIFICATION" />➑
</intent-filter>
</receiver>
如您所见,Android 提供了用于低级别通信事件的通知,如射频场检测 ➊➋、APDU 接收 ➌ 和应用程序小程序选择 ➍,以及用于高级别事件的通知,如 MIFARE 扇区访问 ➎ 和 EMV 卡移除 ➏。(APDU 是 应用协议数据单元,智能卡协议的基本构建块;请参见 “SE 通信协议”。APDU_RECIEVED 广播未实现,因为实际上 NFC 控制器会将传入的 APDU 直接路由到 eSE,这使得它们对操作系统不可见。)支持 SE 的应用程序会注册这些广播,以便在每个事件发生时能够更改其内部状态或启动相关活动(例如,当选择 EMV 小程序时启动 PIN 输入活动)。INTERNAL_TARGET_DESELECTED 广播 ➐ 在卡模拟被停用时发送,MASTER_CLEAR_NOTIFICATION 广播 ➑ 在 eSE 内容被清除时发送。(Google Wallet 的 HCE 之前版本允许用户在设备丢失或被盗时远程清除 eSE 内容。)
Android SE 执行环境
Android SE 本质上是一个不同包装的智能卡,因此最初为智能卡开发的大多数标准和协议依然适用。我们来简要回顾一下最重要的几个。
智能卡传统上是面向文件系统的,其操作系统的主要作用是处理文件访问和强制执行访问权限。更新的卡片支持在原生操作系统上运行的虚拟机,允许执行名为小程序的“平台无关”应用程序,这些小程序使用一个定义良好的运行时库来实现其功能。尽管存在不同的实现方式,但迄今为止最流行的实现是 Java Card 运行时环境(JCRE)。小程序是在受限版的 Java 语言中实现的,并使用一个有限的运行时库,该库提供了基本的 I/O 类、消息解析类和加密操作类。虽然 JCRE 规范^([123]) 完全定义了小程序的运行时环境,但并未指定如何在实际的物理卡上加载、初始化和删除小程序(只提供了用于 JCRE 模拟器的工具)。
由于智能卡的主要应用之一是各种支付服务,因此应用加载和初始化过程(通常称为卡片个性化)需要受到控制,并且只有授权实体能够更改卡片及其已安装应用程序的状态。Visa 最初开发了一种用于安全管理小程序的规范,称为开放平台(Open Platform),该规范现在由 GlobalPlatform(GP)组织以 GlobalPlatform Card Specification 的名义进行维护和开发。该规范的核心内容是,每个符合 GP 标准的卡片都有一个强制性的发行者安全域(ISD)组件(非正式地称为卡片管理器),该组件为卡片和应用生命周期管理提供了一个明确定义的接口。执行 ISD 操作需要使用存储在卡片上的加密密钥进行身份验证,因此,只有知道这些密钥的实体才能更改卡片的状态(如OP_READY、INITIALIZED、SECURED、CARD_LOCKED或TERMINATED)或管理小程序。此外,GP 卡片规范定义了多种安全通信协议(称为安全通道),在与卡片通信时,提供身份验证、保密性和消息完整性。
SE 通信协议
如在《使用 NfcExecutionEnvironment API》中所讨论,Android 与 SE 通信的接口是byte[] transceive(byte[] command)方法,该方法属于NfcExecutionEnvironment类。通过此 API 交换的信息实际上是 APDU,它们的结构在ISO/IEC 7816-4: 交换的组织、安全性和命令标准中有定义。读卡器(也称为卡片接受设备,或CAD)发送命令 APDU(有时称为C-APDU)到卡片,命令包括一个强制性的四字节头部,其中包含命令类(CLA)、指令(INS)和两个参数(P1和P2)。接着是可选的命令数据长度(Lc)、实际数据,最后是最大期望响应字节数(如果有的话,Le)。卡片返回一个响应 APDU(R-APDU),其包含一个强制性的状态字(SW,由两字节组成:SW1和SW2),以及可选的响应数据。
历史上,命令 APDU 数据的长度限制为 255 字节(总 APDU 长度为 261 字节),响应 APDU 数据的长度限制为 256 字节(总 APDU 长度为 258 字节)。近年来的卡片和读卡器支持数据长度可达 65536 字节的扩展 APDU,但扩展 APDU 并不总是可用,主要是出于兼容性原因。读卡器与卡片之间的低层通信是通过多种传输协议之一来进行的,其中最常用的是 T=0(字节导向)和 T=1(块导向)。这两者都在 ISO 7816-3: 带接触的卡片 — 电气接口和传输协议 中有所定义。APDU 交换并非完全与协议无关,因为 T=0 无法直接发送响应数据,而只能通知读卡器可用字节数。需要发送额外的命令 APDU(GET RESPONSE)以检索响应数据。
原始的 ISO 7816 标准是为接触式卡片制定的,但相同的基于 APDU 的通信模型也被用于非接触式卡片。它是建立在 ISO/IEC 14443-4 定义的无线传输协议之上的,其行为类似于接触式卡片的 T=1。
查询 eSE 执行环境
如在“嵌入式 SE”中讨论的那样,Galaxy Nexus 中的 eSE 是 NXP SmartMX 系列的芯片。它运行一个兼容 Java Card 的操作系统,并配备一个符合 GlobalPlatform 标准的 ISD。该 ISD 被配置为需要进行身份验证才能执行大多数卡片管理操作,而身份验证密钥自然是无法公开的。此外,若发生多次身份验证失败(通常为 10 次),ISD 将被锁定,无法安装或移除小应用程序,因此暴力破解身份验证密钥不可行。然而,ISD 确实提供了一些关于其自身及卡片运行环境的信息,无需身份验证,目的是使客户端能够动态调整其行为,并兼容不同的卡片。
因为 Java Card 和 GlobalPlatform 都定义了多应用环境,所以每个应用都需要一个唯一标识符,称为 应用标识符(AID)。AID 由一个 5 字节的注册应用提供商标识符(RID,也称为资源标识符)和一个最多可长达 11 字节的专有标识符扩展(PIX)组成。因此,AID 的长度可以从 5 字节到 16 字节不等。在能够向特定小应用发送命令之前,必须通过发出 SELECT(CLA=00,INS=A4)命令并带上其 AID,使其激活或选中。像所有应用一样,ISD 也有一个 AID,该 AID 在不同的卡片制造商和 GP 实现中可能不同。我们可以通过发送一个空的 SELECT 命令来查找 ISD 的 AID,该命令不仅选中 ISD,还返回有关卡片和 ISD 配置的信息。空的 SELECT 命令就是没有指定 AID 的选择命令,因此 SELECT 命令的 APDU 格式为 00 A4 04 00 00。如果我们使用 NfcExecutionEnvironment 类的 transcieve() 方法发送这个命令(示例 11-8 在 ➍),返回的响应可能类似于 示例 11-10 ➋(➊ 是 SELECT 命令)。
示例 11-10。Galaxy Nexus eSE 对空 SELECT 的响应
--> 00A4040000➊
<-- 6F658408A000000003000000A5599F6501FF9F6E06479100783300734A06072A86488
6FC6B01600C060A2A864886FC6B02020101630906072A864886FC6B03640B06092A86488
6FC6B040215650B06092B8510864864020103660C060A2B060104012A026E0102 9000➋
响应包括一个成功的状态(0x9000)和一串长字节。该数据的格式在“APDU 命令参考”中定义,见 GlobalPlatform 卡片规范的 第九章,并且与智能卡世界中的大多数内容一样,采用标签-长度-值(TLV)格式。在 TLV 格式中,每个数据单元由一个唯一的标签描述,后面跟着数据的字节长度,最后是实际数据。大多数结构都是递归的,因此数据可以包含另一个 TLV 结构,后者又封装了另一个,以此类推。示例 11-10 中显示的结构被称为 文件控制信息(FCI),在这种情况下,它封装了一个安全域管理数据结构,该结构描述了 ISD。当解析时,FCI 可能看起来像 示例 11-11。
示例 11-11。Galaxy Nexus 上 eSE 的 ISD 解析后的 FCI
SD FCI: Security Domain FCI
AID: a0 00 00 00 03 00 00 00➊
RID: a0 00 00 00 03 (Visa International [US])
PIX: 00 00 00
Data field max length: 255
Application prod. life cycle data: 479100783300
Tag allocation authority (OID): globalPlatform 01
Card management type and version (OID): globalPlatform 02020101
Card identification scheme (OID): globalPlatform 03
Global Platform version: 2.1.1➋
Secure channel version: SC02 (options: 15)➌
Card config details: 06092B8510864864020103➍
Card/chip details: 060A2B060104012A026E0102➎
这里,ISD 的 AID 是 A0 00 00 00 03 00 00 00 ➊,GlobalPlatform 实现的版本是 2.1.1 ➋,支持的安全通道协议是 SC02 ➌,结构的最后两个字段包含一些关于卡片配置的专有数据(➍ 和 ➎)。唯一一个不需要认证的 GP 命令是 GET DATA,可以用于返回有关 ISD 配置的附加数据。
UICC 作为安全元件
如在“移动设备中的 SE 形态”中讨论的那样,移动设备中的 UICC 可以作为通用的 SE 使用,当通过 Open Mobile API 或类似的编程接口访问时。本节简要概述了 UICC 及其通常托管的应用程序,并展示了如何通过 Open Mobile API 访问 UICC。
SIM 卡和 UICC
UICC 的前身是 SIM 卡,UICC 仍然被俗称为“SIM 卡”。SIM 代表 Subscriber Identity Module(用户身份模块),指的是一种智能卡,用于安全地存储用户标识符和用于识别和验证设备与移动网络连接的相关密钥。SIM 卡最初用于 GSM 网络,原始的 GSM 标准后来扩展以支持 3G 和 LTE。由于 SIM 卡是智能卡,它们符合 ISO-7816 标准的物理特性和电气接口要求。最初的 SIM 卡与“常规”智能卡(全尺寸,FF)大小相同,但如今最流行的尺寸是 Mini-SIM(2FF)和 Micro-SIM(3FF),而 Nano-SIM(4FF)则于 2012 年推出,市场份额也在逐步增加。
当然,并非每一张适合插入 SIM 卡槽的智能卡都可以在移动设备中使用,那么下一个问题是:什么使得一张智能卡成为 SIM 卡?从技术上讲,它需要符合如 3GPP TS 11.11 等移动通信标准,并经过 SIMalliance 的认证。在实践中,它需要能够运行一个允许其与手机(在相关标准中称为 Mobile Equipment 或 Mobile Station)进行通信并连接到移动网络的应用程序。虽然最初的 GSM 标准并未区分物理智能卡和用于连接移动网络所需的软件,但随着 3G 标准的引入,二者之间做出了明确区分。物理智能卡被称为 Universal Integrated Circuit Card (UICC),并定义了在其上运行的不同移动网络应用程序:GSM、CSIM、USIM、ISIM 等。UICC 可以托管并运行多个网络应用程序(因此得名 universal),因此可以用于连接不同的网络。虽然网络应用功能依赖于具体的移动网络,但它们的核心特性是相似的:安全存储网络参数并向网络提供身份认证,以及(可选)进行用户认证并存储用户数据。
UICC 应用程序
以 GSM 为例,我们简要回顾一下网络应用是如何工作的。对于 GSM,主要的网络参数有网络身份(国际移动用户身份,IMSI;与 SIM 卡绑定)、电话号码(MSISDN,用于路由呼叫并且可以更改)和共享网络认证密钥Ki。为了连接到网络,手机需要进行认证并协商会话密钥。认证密钥和会话密钥都是通过Ki生成的,而Ki也为网络所知,并通过 IMSI 查找。手机发送一个连接请求,其中包含其 IMSI,网络使用 IMSI 来查找相应的Ki。网络然后利用Ki生成一个挑战(RAND)、预期的挑战响应(SRES)和会话密钥Kc。当这些参数生成后,网络将RAND发送给手机,SIM 卡上的 GSM 应用程序开始起作用:手机将RAND传递给 SIM 卡,SIM 卡生成自己的SRES和Kc。SRES被发送到网络,如果与预期值匹配,则通过会话密钥Kc建立加密通信。
如你所见,该协议的安全性完全依赖于Ki的保密性。由于所有涉及Ki的操作都在 SIM 卡内部实现,并且Ki从不与手机或网络直接接触,因此该方案保持了合理的安全性。当然,安全性也取决于使用的加密算法,且在 A5/1 流密码的早期版本中发现了主要的漏洞,这些漏洞使得可以使用现成的硬件解密被拦截的 GSM 通话(A5/1 流密码最初是保密的)。
在 Android 中,网络认证是由基带处理器实现的(更多内容见下面的“访问 UICC”),并且从未直接暴露给主操作系统。
UICC 应用程序的实现与安装
我们已经看到 UICCs 需要运行应用程序;现在让我们来看看这些应用程序是如何实现和安装的。最初的智能卡是基于文件系统模型的,其中文件(称为基本文件,或EF)和目录(称为专用文件,或DF)用一个两字节的标识符命名。因此,开发“一个应用程序”就涉及选择一个 ID,用于承载应用程序文件的 DF(称为ADF),并指定存储数据的 EF 的格式和名称。例如,GSM 应用程序位于7F20 ADF 下,而 USIM ADF 承载着EF_imsi、EF_keys、EF_sms等必要文件。
因为目前使用的几乎所有 UICCs 都是基于 Java Card 技术并实现了 GlobalPlatform 卡规范,所以所有网络应用程序都作为 Java Card 小程序来实现,并为了向后兼容而模拟传统的基于文件的结构。小程序根据 GlobalPlatform 规范通过认证到 ISD 并发出LOAD和INSTALL命令进行安装。
一个特定于 SIM 卡的应用管理功能是支持通过二进制 SMS 进行 OTA 更新。并非所有运营商都使用此功能,但它允许运营商远程在其发放的 SIM 卡上安装小程序。OTA 通过将卡命令(APDU)封装在 SMS T-PDU(传输协议数据单元)中实现,手机将其转发到 UICC。在大多数 UICC 中,这也是加载小程序到卡上的唯一方式,即使是在初始个性化过程中。
此 OTA 功能的主要用途是安装和维护 SIM 工具包(STK)应用程序,这些应用程序可以通过标准的“主动”命令与手机交互(这些命令实际上是通过轮询实现的),并显示菜单,甚至打开网页和发送短信。Android 支持 STK,并配有专用的 STK 系统应用,如果 UICC 卡上没有安装 STK 小程序,该应用会自动禁用。
访问 UICC
如我们在“UICC 应用”中讨论的,Android 中与移动网络相关的功能,包括 UICC 访问,是通过基带软件实现的。主操作系统(Android)对 UICC 的操作受限于基带所暴露的功能。Android 支持 STK 应用程序,并可以查找和存储 SIM 卡上的联系人,因此可以明确看出,它内部支持与 SIM 卡的通信。然而,Android 安全概述明确指出,“第三方应用无法访问 SIM 卡的低级功能。”^([126]) 那么我们如何将 SIM 卡(UICC)作为 SE 使用呢?来自主要厂商的某些 Android 版本,尤其是三星,提供了 SIMalliance 开放移动 API 的实现,且 SEEK for Android 项目提供了该 API 的开源实现(适用于兼容设备)。开放移动 API 旨在提供统一的接口,用于访问 Android 上的 SE,包括 UICC。
为了理解开放移动 API 的工作原理以及其限制的原因,让我们回顾一下 Android 中如何实现对 SIM 卡的访问。在 Android 设备上,所有与移动网络相关的功能(拨打电话、发送短信等)都是由基带处理器(也称为 调制解调器 或 无线电)提供的。Android 应用程序和系统服务仅通过无线电接口层(RIL)守护进程(rild)间接与基带进行通信。rild 又通过使用制造商提供的 RIL HAL 库与实际硬件进行通信,该库封装了基带所提供的专有接口。UICC 卡通常仅与基带处理器连接(有时也通过 SWP 连接到 NFC 控制器),因此所有通信都必须通过 RIL 进行。
虽然专有的 RIL 实现总是能够访问 UICC,以进行网络识别和身份验证,并读取和写入联系人以及访问 STK 应用程序,但透明的 APDU 交换支持并不总是可用。如我们在 UICC 中提到的,提供此功能的标准方法是使用扩展的 AT 命令,如 AT+CSIM(通用 SIM 访问)和 AT+CGLA(通用 UICC 逻辑通道访问),但某些厂商使用专有扩展实现 APDU 交换,因此支持必要的 AT 命令并不自动提供对 UICC 的访问。
SEEK for Android 实现了一个资源管理服务(SmartCardService),该服务可以连接任何支持的 SE(eSE、ASSD 或 UICC)并扩展 Android 电话框架,允许与 UICC 进行透明的 APDU 交换。由于通过 RIL 的访问依赖于硬件和 HAL,你需要一个兼容的设备以及包含 SmartCardService 和相关框架扩展的构建版本,例如大多数最新的三星 Galaxy 设备中包含的那些。
使用 OpenMobile API
OpenMobile API 相对较小,定义了表示与 SE 连接的读卡器(Reader)、与 SE 的通信会话(Session)以及与 SE 的基本(通道 0,按 ISO 7816-4 规范)或逻辑通道(Channel)的类。Channel 类允许应用程序使用 transmit() 方法与 SE 交换 APDU。API 的入口点是 SEService 类,该类连接到远程资源管理服务(SmartcardService)并提供一个方法,返回设备上可用的 Reader 对象列表。(有关 OpenMobile API 和 SmartcardService 架构的更多信息,请参见 SEEK for Android Wiki。^([127]))
为了能够使用 OpenMobile API,应用程序需要请求 org.simalliance.openmobileapi.SMARTCARD 权限,并将 org.simalliance.openmobileapi 扩展库添加到其清单中,如示例 11-12 所示。
示例 11-12. 配置 AndroidManifest.xml 以使用 OpenMobile API
<manifest ...>
--*snip*--
<uses-permission android:name="org.simalliance.openmobileapi.SMARTCARD" />
<application ...>
<uses-library
android:name="org.simalliance.openmobileapi"
android:required="true" />
--*snip*--
</application>
</manifest>
示例 11-13 演示了应用程序如何使用 OpenMobile API 连接并向设备上的第一个 SE 发送命令。
示例 11-13. 使用 OpenMobile API 向第一个 SE 发送命令
Context context = getContext();
SEService.CallBack callback = createSeCallback();
SEService seService = new SEService(context, callback);➊
Reader[] readers = seService.getReaders();➋
Session session = readers[0].openSession();➌
Channel channel = session.openLogicalChannel(aid);➍
byte[] command = { ... };
byte[] response = channel.transmit(command);➎
在这里,应用程序首先创建一个 SEService ➊ 实例,该实例异步连接到 SmartCardService,并通过 SEService.CallBack 接口中的 serviceConnected() 方法(未显示)在连接建立时通知应用程序。然后,应用程序可以使用 getReaders() 方法 ➋ 获取可用的 SE 读卡器列表,接着使用 openSession() 方法 ➌ 打开与所选读卡器的会话。如果设备不包含 eSE(或除 UICC 外的其他 SE 形式),或者 SmartCardService 没有配置以使用它,则读卡器列表只包含一个 Reader 实例,代表设备中的内置 UICC 读卡器。当应用程序与目标 SE 建立了开放的 Session 之后,它调用 openLogicalChannel() 方法 ➍ 以获得一个 Channel,然后利用该通道发送 APDU 并通过其 transmit() 方法 ➎ 接收响应。
软件卡仿真
软件卡仿真(也称为 基于主机的卡仿真 或 HCE)允许 NFC 控制器接收到的命令被传送到应用处理器(主操作系统),并由常规 Android 应用程序处理,而不是由安装在硬件 SE 上的小程序处理。然后,响应通过 NFC 发送回读卡器,使应用程序能够充当虚拟的非接触式智能卡。
在正式加入 Android API 之前,HCE 最初作为 CyanogenMod Android 发行版的实验性功能提供。^([128]) 从版本 9.1 开始,CyanogenMod 集成了一组补丁(由 Doug Yeager 开发),解锁了流行的 PN544 NFC 控制器的 HCE 功能,并提供了一个框架接口来支持 HCE。为了支持 HCE,NFC 框架中增加了两种新的标签技术(IsoPcdA 和 IsoPcdB,分别代表基于 NFC A 型和 B 型技术的外部非接触式读卡器)。(Pcd 代表 Proximity Coupling Device,即非接触式读卡器的另一技术术语。)
IsoPcdA 和 IsoPcdB 类反转了 Android NFC API 中 Tag 对象的角色:由于外部非接触式读卡器作为“标签”呈现,从手机发送的“命令”实际上是对读卡器发起通信的回应。与 Android NFC 堆栈的其他部分不同,这种架构不是事件驱动的,需要应用程序在等待读卡器发送下一条命令时处理阻塞 I/O。Android 4.4 引入了一个标准的事件驱动框架,用于开发 HCE 应用程序,我们接下来将讨论这一点。
Android 4.4 HCE 架构
与仅对活动可用的 R/W 和 P2P 模式不同,HCE 应用可以在后台工作,并通过定义一个服务来处理来自外部读卡器的命令并返回响应。此类 HCE 服务扩展了 HostApduService 抽象框架类,并实现了其 onDeactivated() 和 processCommand() 方法。HostApduService 本身是一个非常轻量的中介类,通过使用 Messenger 对象与系统 NfcService 进行双向通信。^([129]) 例如,当 NfcService 收到一个需要被路由(APDU 路由将在下一节讨论)到 HCE 服务的 APDU 时,它会向 HostApduService 发送一个 MSG_COMMAND_APDU,后者从消息中提取 APDU 并通过调用 processCommand() 方法将其传递给具体实现。如果 processCommand() 返回一个 APDU,HostApduService 会将其封装在 MSG_RESPONSE_APDU 消息中并发送回 NfcService,后者再将其转发到 NFC 控制器。如果具体的 HCE 服务无法立即返回响应 APDU,它会返回 null 并稍后通过调用 sendResponseApdu() 发送响应(当响应可用时),该方法将响应包装在 MSG_RESPONSE_APDU 消息中并发送回 NfcService。
APDU 路由
当设备处于卡模拟模式时,NFC 控制器会接收所有来自外部读卡器的 APDU,并根据其内部的 APDU 路由表决定是将它们发送到物理 SE(如果有的话),还是发送到 HCE 服务。路由表是基于 AID 的,并且通过其应用清单中声明的支持 SE 的应用和 HCE 服务的元数据进行填充。当外部读卡器发送一个 SELECT 命令,该命令未直接路由到 SE 时,NFC 控制器会将其转发给 NfcService,后者从命令中提取目标 AID,并通过调用 RegisteredAidCache 类的 resolveAidPrefix() 方法在路由表中查找匹配的 HCE 服务。
如果找到匹配的服务,NfcService 将绑定该服务并获取一个 Messenger 实例,然后它将使用该实例发送后续的 APDU(如上一节所讨论的,封装在 MSG_COMMAND_APDU 消息中)。为了使其正常工作,应用的 HCE 服务需要在 AndroidManifest.xml 中声明,具体如 示例 11-14 所示。
示例 11-14. 在 AndroidManifest.xml 中声明 HCE 服务
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.hce" ...>
--*snip*--
<uses-permission android:name="android.permission.NFC" />
<application ...>
--*snip*--
<service
android:name=".MyHostApduService"➊
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE" >➋
<intent-filter>
<action
android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />➌
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"➍
android:resource="@xml/apduservice" />
</service>
--*snip*--
</application>
</manifest>
应用程序按惯例声明其 HCE 服务 ➊,使用 <service> 标签,但有一些额外的要求。首先,服务必须使用 BIND_NFC_SERVICE 系统签名权限 ➋ 进行保护,以保证只有系统应用程序(实际上是 NfcService)可以绑定到该服务。接下来,服务需要声明一个意图过滤器,该过滤器匹配 android.nfc.cardemulation.action.HOST_APDU_SERVICE 动作 ➌,以便在扫描已安装的程序包时能够识别为 HCE 服务,并在接收到匹配的 APDU 时绑定该服务。最后,服务必须在名为 android.nfc.cardemulation.host_apdu_service ➍ 的 XML 资源元数据条目下声明,指向一个列出该服务可以处理的 AID 的 XML 资源文件。该文件的内容用于构建 AID 路由表,NFC 堆栈在接收到 SELECT 命令时会参考该表。
为 HCE 服务指定路由
对于 HCE 应用程序,XML 文件必须包含如示例 11-15 所示的 <host-apdu-service> 根元素。
示例 11-15. HCE 服务 AID 元数据文件
<host-apdu-service
android:description="@string/servicedesc"
android:requireDeviceUnlock="false">➊
<aid-group android:description="@string/aiddescription"➋
android:category="other">➌
<aid-filter android:name="A0000000010101"/>➍
</aid-group>
</host-apdu-service>
<host-apdu-service> 标签具有一个 description 属性和一个 requireDeviceUnlock 属性 ➊,它指定在设备锁定时是否应该激活相应的 HCE 服务。(设备屏幕必须开启才能使 NFC 工作。)根元素包含一个或多个 <aid-group> 条目 ➋,每个条目都有一个 category 属性 ➌,并包含一个或多个 <aid-filter> ➍ 标签,它们在 name 属性中指定一个 AID(例如本示例中的 A0000000010101)。
一个 AID 组定义了一组始终由特定 HCE 服务处理的 AID。NFC 框架保证,如果一个 AID 被一个 HCE 服务处理,那么该组中的所有其他 AID 也会由同一个服务处理。如果两个或多个 HCE 服务定义了相同的 AID,系统会显示一个选择对话框,允许用户选择哪个应用程序应处理传入的 SELECT 命令。当用户选择一个应用程序后,所有后续命令将路由到该应用程序,用户通过点击在图 11-2 中显示的对话框来确认选择。
每个 AID 组都与一个类别相关联(通过 category 属性指定),这使得系统可以按类别设置默认处理程序,而不是按 AID 设置。应用程序可以通过调用 CardEmulation 类的 isDefaultServiceForCategory() 方法来检查特定服务是否为某个类别的默认处理程序,并通过调用 getSelectionModeForCategory() 方法来获取类别的选择模式。截至本文写作时,已定义了两个类别:CATEGORY_PAYMENT 和 CATEGORY_OTHER。
Android 强制执行单一活动的支付类别,以确保用户明确选择了应该处理支付事务的应用程序。支付类别的默认应用程序是在系统设置应用的 Tap & pay 屏幕中选择的,如 图 11-3 所示。 (有关支付应用程序的更多信息,请参阅官方 HCE 文档^([130])。)

图 11-2. HCE 应用程序选择确认对话框

图 11-3. 在 Tap & pay 屏幕中选择默认支付应用程序
为 SE 应用程序指定路由
如果设备支持 HCE 并且具有物理 SE,外部读卡器发送的 SELECT 命令可以指向 HCE 服务或安装在 SE 上的应用程序。因为 Android 4.4 会将 AID 路由表中未列出的所有 AID 路由到主机,所以安装在 SE 上的应用程序的 AID 必须显式地添加到 NFC 控制器的路由表中。通过与注册 HCE 服务时相同的机制完成这一操作:通过在应用程序的清单中添加服务条目,并将其链接到一个指定了应该路由到 SE 的 AID 列表的元数据 XML 文件。当路由建立时,命令 APDU 将直接发送到 SE(SE 处理它们并通过 NFC 控制器返回响应),因此该服务仅作为标记使用,不提供任何功能。
Android SDK 包含一个辅助服务(OffHostApduService),可以用来列出应该直接路由到 SE 的 AID。这个 OffHostApduService 类定义了一些有用的常量,但除此之外是空的。应用程序可以扩展它,并在其清单中声明生成的服务组件,如 示例 11-16 所示。
示例 11-16. 在 AndroidManifest.xml 中声明一个 off-host APDU 服务
<manifest
package="com.example.hce" ...>
--*snip*--
<uses-permission android:name="android.permission.NFC" />
<application ... >
--*snip*--
<service android:name=".MyOffHostApduService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action
android:name="android.nfc.cardemulation.action.OFF_HOST_APDU_SERVICE"/>➊
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.off_host_apdu_service"➋
android:resource="@xml/apduservice"/>
</service>
--*snip*--
</application>
</manifest>
服务声明与 示例 11-14 类似,区别在于声明的意图动作是 android.nfc.cardemulation.action.OFF_HOST_ APDU_SERVICE ➊,而 XML 元数据名称是 android.nfc.cardemulation.off_host_apdu_service ➋。元数据文件也略有不同,如 示例 11-17 所示。
示例 11-17. Off-host APDU 服务元数据文件
<offhost-apdu-service
android:description="@string/servicedesc">➊
<aid-group android:description="@string/se_applets"
android:category="other">➋
<aid-filter android:name="F0000000000001"/>➌
<aid-filter android:name="F0000000000002"/>➍
</aid-group>
</offhost-apdu-service>
如你所见,该格式与 HCE 服务相同,但文件的根元素是<offhost-apdu-service> ➊,而不是<host-apdu-service>。另一个细微的区别是,<offhost-apdu-service>不支持requireDeviceUnlock属性,因为事务是直接发送到 SE 的,因此无论锁屏状态如何,主机都无法干预。驻留在 SE 上的 Applet 的 AID(➌和➍)包含在<aid-group> ➋中。这些 AID 直接发送到 NFC 控制器,NFC 控制器将其保存在内部路由表中,以便能够直接向 SE 发送匹配的 APDU,而无需与 Android 操作系统交互。如果接收到的 APDU 不在 NFC 控制器的路由表中,它会将其转发到NfcService,后者会将其发送到匹配的 HCE 服务,或者如果没有匹配项,则返回错误。
编写 HCE 服务
当应用程序的 HCE 服务在其清单中声明时,如示例 11-14 所示,可以通过扩展HostApduService基类并实现其抽象方法来添加 HCE 功能,如示例 11-18 所示。
示例 11-18:实现HostApduService
public class MyHostApduService extends HostApduService {
--*snip*--
static final int OFFSET_CLA = 0;➊
static final int OFFSET_INS = 1;
static final int OFFSET_P1 = 2;
static final int OFFSET_P2 = 3;
--*snip*--
static final short SW_SUCCESS = (short) 0x9000;➋
static final short SW_CLA_NOT_SUPPORTED = 0x6E00;
static final short SW_INS_NOT_SUPPORTED = 0x6D00;
--*snip*--
static final byte[] SELECT_CMD = { 0x00, (byte) 0xA4,
0x04, 0x00, 0x06, (byte) 0xA0,
0x00, 0x00, 0x00, 0x01, 0x01, 0x01 };➌
static final byte MY_CLA = (byte) 0x80;➍
static final byte INS_CMD1 = (byte) 0x01;
static final byte INS_CMD2 = (byte) 0x02;
boolean selected = false;
public byte[] processCommandApdu(byte[] cmd, Bundle extras) {
if (!selected) {
if (Arrays.equals(cmd, SELECT_CMD)) {➎
selected = true;
return toBytes(SW_SUCCESS);
}
--*snip*-
}
if (cmd[OFFSET_CLA] != MY_CLA) {➏
return toBytes(SW_CLA_NOT_SUPPORTED);
}
byte ins = cmd[OFFSET_INS];➐
switch (ins) {
case INS_CMD1:➑
byte p1 = cmd[OFFSET_P1];
byte p2 = cmd[OFFSET_P2];
--*snip*--
return toBytes(SW_SUCCESS);
case INS_CMD2:
--*snip*--
return null;➒
default:
return toBytes(SW_INS_NOT_SUPPORTED);
}
}
@Override
public void onDeactivated(int reason) {
--*snip*--
selected = false;➊
}
--*snip*--
}
在这里,示例 HCE 服务首先声明了一些常量,这些常量在访问 APDU 数据➊和返回标准状态结果➋时会很有帮助。该服务定义了用于激活它的SELECT命令,包括 AID ➌。接下来的几个常量➍声明了服务可以处理的指令类(CLA)和指令。
当 HCE 服务接收到 APDU 时,它将其作为字节数组传递给processCommandApdu()方法,该方法分析该数组。如果服务尚未被选择,processCommandApdu()方法会检查 APDU 是否包含SELECT命令➎,如果包含,则设置selected标志。如果 APDU 包含其他命令,代码会检查它是否包含服务支持的类字节(CLA)➏,然后提取命令中包含的指令字节(INS)➐。若命令 APDU 包含INS_CMD1指令➑,服务会提取P1和P2参数,可能会解析 APDU 中包含的数据(未显示),设置一些内部状态,并返回成功状态。
如果命令包括 INS_CMD2,在我们的示例中,这映射到一个假设的需要一定时间来处理的操作(例如,非对称密钥生成),服务将启动一个工作线程(未显示),并返回 null ➒ 以避免阻塞应用的主线程。当工作线程完成执行时,它可以使用继承自 HostApduService 类的 sendResponseApdu() 方法返回结果。当选择另一个服务或 SE 小程序时,系统会调用 onDeactivated() 方法,该方法应在返回之前释放任何已使用的资源,但在我们的示例中,它只是将 selected 标志设置为 false ➓。
由于 HCE 服务本质上是解析命令 APDU 并返回响应,因此编程模型与 Java Card 小程序非常相似。然而,由于 HCE 服务存在于常规的 Android 应用程序内,它并不运行在受限的环境中,可以利用所有可用的 Android 功能。这使得实现复杂功能变得容易,但也影响了 HCE 应用的安全性,接下来将讨论这一点。
HCE 应用的安全性
由于任何 Android 应用都可以声明 HCE 服务并接收和处理 APDU,因此系统通过要求 BIND_NFC_SERVICE 系统签名权限才能绑定到 HCE 服务,保证恶意应用无法向 HCE 服务注入伪造的 APDU 命令。此外,Android 的沙箱模型确保其他应用无法通过读取 HCE 应用的文件或调用其可能公开的数据访问 API 来访问 HCE 应用存储的敏感数据,前提是这些 API 已正确加固(当然,假设这些 API 已得到了妥善保护)。
然而,能够获取设备 root 权限的恶意应用(例如,通过利用权限提升漏洞)既可以检查也可以注入针对 HCE 服务的 APDU,还可以读取其私有数据。HCE 应用可以采取一些措施来检测这种情况,例如检查调用其 processCommandApdu() 方法的调用者的身份和签名证书,但考虑到对操作系统的无限制访问,这些措施最终可能会被绕过。像所有存储敏感数据的应用一样,HCE 应用也应该采取措施保护存储的数据,例如通过加密存储在磁盘上的数据,或在加密密钥的情况下将其存储在系统凭证存储中。另一种保护 HCE 应用代码和数据的方法是将所有接收到的命令通过加密通道转发到远程服务器,并仅转发其回复。然而,由于大多数这些措施是通过软件实现的,它们最终可能会被一个足够复杂的具有 root 权限的恶意应用禁用或绕过。
相比之下,硬件安全元件提供物理篡改防护、由于功能受限而减少的攻击面以及对已安装小程序的严格控制。因此,物理 SE 更难以受到攻击,并且在典型的卡片仿真场景(如非接触支付)中提供更强的敏感数据保护,即使默认的主机操作系统安全保证已被绕过。
注意
有关卡片仿真应用程序在安全元件中实现与通过 HCE 在软件中实现时安全级别差异的详细讨论,请参见 Cem Paya(曾参与原始 eSE 支持的 Google Wallet 实现)的“HCE 与嵌入式安全元件”博客系列。^([131])
摘要
Android 支持三种 NFC 模式:读写模式、点对点模式和卡片仿真模式。在读写模式下,Android 设备可以访问 NFC 标签、非接触式卡片和 NFC 仿真设备,而点对点模式提供简单的数据交换功能。卡片仿真模式可以通过物理安全元件(SE)如 UICC、与 NFC 控制器集成的安全元件(嵌入式 SE)或从 Android 4.4 开始的常规 Android 应用程序来支持。硬件安全元件提供最高的安全性,通过提供物理篡改防护和对 SE 应用程序(通常以 Java Card 小程序实现)管理的严格控制,确保安全。然而,由于安装应用程序所需的认证密钥通常由单一实体(如设备制造商或移动网络运营商)控制,因此分发 SE 应用程序可能会遇到问题。Android 4.4 引入的基于主机的卡片仿真(HCE)简化了开发和分发卡片仿真模式应用程序的过程,但它仅依赖操作系统来执行安全性,因此对敏感应用程序代码和数据的保护较弱。
^([111]) NDEF 格式及其使用各种标签技术的实现已在 NFC 论坛规范中描述,规范可在其官网获取:* nfc-forum.org/our-work/specifications-and-application-documents/specifications/nfc-forum-technical-specifications/ *
^([112]) 所有 ISO 标准的正式版本可以在其官方网站上购买,* www.iso.org/iso/home/store/catalogue_ics.htm *。标准草案版本通常可以从标准工作组的官网获得。
^([113]) Google, Android API 参考, “TagTechnology,” developer.android.com/reference/android/nfc/tech/TagTechnology.html
^([114]) 谷歌,Android API 参考,“NfcAdapter”,developer.android.com/reference/android/nfc/NfcAdapter.html
^([115]) NFC 联盟,“NFC 联盟技术规范”,nfc-forum.org/our-work/specifications-and-application-documents/specifications/nfc-forum-technical-specifications/
^([116]) 谷歌,Android API 指南,“NFC 基础”,developer.android.com/guide/topics/connectivity/nfc/nfc.html#p2p
^([117]) 3GPP,用户设备(UE)AT 命令集,www.3gpp.org/ftp/Specs/html-info/27007.htm
^([118]) “Android 平台的安全元件评估工具包”,code.google.com/p/seek-for-android/
^([119]) SIMalliance 有限公司,Open Mobile API 规范 v2.05,www.simalliance.org/en?t=/documentManager/sfdoc.file.supply&fileID=1392314878580
^([120]) SD 协会,“高级安全 SD 卡:ASSD”,www.sdcard.org/developers/overview/ASSD/
^([121]) ECMA 国际,ECMA-373:近场通信有线接口 (NFC-WI),www.ecma-international.org/publications/files/ECMA-ST/ECMA-373.pdf
^([122]) 甲骨文公司,“JSR 177:J2METM 的安全性和信任服务 API”,jcp.org/en/jsr/detail?id=177
^([123]) 甲骨文公司,“Java Card 经典平台规范 3.0.4”,www.oracle.com/technetwork/java/javacard/specs-jsp-136430.html
^([124]) GlobalPlatform,“卡片规范”,www.globalplatform.org/specificationscard.asp
^([125]) ISO 7816 和其他智能卡相关标准的总结可以在 CardWerk 网站找到:www.cardwerk.com/smartcards/smartcard_standards.aspx
^([126]) 谷歌,Android 安全概述,“SIM 卡访问”,source.android.com/devices/tech/security/#sim-card-access
^([127]) SEEK for Android,“SmartCardAPI*”,code.google.com/p/seek-for-android/wiki/SmartcardAPI
^([128]) CyanogenMod, www.cyanogenmod.org/
^([129]) Google,Android API 参考,“Messenger,” developer.android.com/reference/android/os/Messenger.html
^([130]) Google,基于主机的卡片模拟,“支付应用程序,” developer.android.com/guide/topics/connectivity/nfc/hce.html#PaymentApps
^([131]) Cem Paya,随机 Oracle,“HCE 与嵌入式安全元件,”第一至第六部分,* randomoracle.wordpress.com/2014/03/08/hce-vs-embedded-secure-element-comparing-risks-part-i/*
第十二章. SELinux
虽然前几章提到了增强型 Linux 安全(SELinux)及其在 Android 中的集成,但我们到目前为止讨论的 Android 安全模型主要集中在 Android 的“传统”沙盒实现上,它严重依赖于 Linux 的默认 DAC(自主访问控制)。Linux 的 DAC 机制轻量且易于理解,但它有一些缺点,最显著的缺点是 DAC 权限的粒度粗糙、程序配置错误可能导致数据泄漏,以及无法对作为 root 用户运行的进程应用细粒度的权限约束。(虽然 POSIX 能力(作为 Linux 中传统 DAC 的扩展实现)提供了一种仅向 root 进程授予特定权限的方法,但 POSIX 能力的粒度相当粗糙,且所授予的权限扩展到该进程访问的所有对象。)
强制访问控制(MAC),由 SELinux 实现,旨在通过实施一个全系统范围、更加细粒度的安全策略,来克服 Linux 默认的 DAC(自主访问控制)的局限性,该策略只能由系统管理员更改,而不能由无特权的用户和程序更改。本章首先简要概述 SELinux 使用的架构和概念,然后描述为支持 Android 所做的主要修改。最后,我们概述了当前版本 Android 中部署的 SELinux 策略。
SELinux 简介
SELinux 是一种为 Linux 内核设计的强制访问控制机制,作为 Linux 安全模块实现。Linux 安全模块(LSM)框架允许将第三方访问控制机制链接到内核中,并修改默认的 DAC 实现。LSM 通过一系列安全功能钩子(上行调用)和相关数据结构实现,这些功能和结构被集成到负责访问控制的 Linux 内核各个模块中。
一些主要的内核服务已插入 LSM 钩子,包括程序执行、文件和 inode 操作、netlink 消息传递和套接字操作。如果没有安装安全模块,Linux 使用其内置的 DAC 机制来规范访问这些服务管理的内核对象。如果安装了安全模块,Linux 除了参考 DAC 外,还会咨询该安全模块,以便在请求访问内核对象时做出最终的安全决策。
除了为主要内核服务提供钩子外,LSM 框架还扩展了 procfs 虚拟文件系统(/proc),以包含每个进程和每个任务(线程)的安全属性,并增加了支持使用文件系统扩展属性作为持久化安全属性存储的功能。SELinux 是第一个集成到 Linux 内核中的 LSM 模块,并从 2.6 版本开始正式提供(之前的 SELinux 实现是作为一组补丁发布的)。自 SELinux 集成以来,其他安全模块也已被接受到主线内核中,截至本文写作时,包括 AppArmor、Smack 和 TOMOYO Linux 等。这些模块提供了替代的 MAC 实现,并基于与 SELinux 不同的安全模型。
我们将在接下来的章节中探索 SELinux 的安全模型和架构。
SELinux 架构
虽然 SELinux 架构相当复杂,但从高层次看,它由四个主要组件组成:对象管理器(OM)、访问向量缓存(AVC)、安全服务器和安全策略,如图 12-1 所示。
当主体请求对 SELinux 对象执行操作(例如,当一个进程尝试读取一个文件)时,相关的对象管理器会查询 AVC,看看是否允许该操作。如果 AVC 中已经缓存了此请求的安全决策,AVC 会将其返回给 OM,OM 根据该决策执行操作,允许或拒绝(如图 12-1 中的步骤 1、2 和 5 所示)。如果缓存中没有匹配的安全决策,AVC 会联系安全服务器,安全服务器基于当前加载的策略做出安全决策,并将其返回给 AVC,AVC 将其缓存。然后 AVC 将决策返回给 OM,OM 最终执行该决策(如图 12-1 中的步骤 1、2、3、4 和 5 所示)。安全服务器是内核的一部分,而策略则通过一系列函数从用户空间加载,这些函数包含在支持的用户空间库中。

图 12-1. SELinux 组件
OM 和 AVC 可以驻留在内核空间(当 OM 管理内核级对象时)或用户空间(当 OM 是所谓的 SELinux 感知应用程序的一部分时,该应用程序具有内置的 MAC 支持)。
强制访问控制
SELinux 的 MAC 模型基于三个主要概念:主体、对象和操作。在这个模型中,主体是执行操作的主动角色,对象是操作的目标,只有在安全策略允许的情况下,操作才会执行。
实际上,主体通常是正在运行的进程(进程也可以是对象),而对象是由内核管理的操作系统级资源,例如文件和套接字。主体和对象都有一组安全属性(统称为 安全上下文,将在下一节讨论),操作系统查询这些属性来决定是否允许所请求的操作。当 SELinux 启用时,主体不能绕过或影响策略规则;因此,策略是强制性的。
注意事项
只有在 DAC 允许访问资源的情况下,才会咨询 MAC 策略。如果 DAC 拒绝访问(例如,基于文件权限),拒绝将作为最终安全决策。
SELinux 支持两种形式的 MAC:类型强制(TE) 和 多级安全(MLS)。MLS 通常用于强制对受限信息的不同访问级别,并且在 Android 中不使用。SELinux 实现的类型强制要求所有主体和对象都有一个关联的类型,SELinux 使用此类型来执行其安全策略的规则。
在 SELinux 中,类型 只是一个在策略中定义的字符串,与对象或主体相关联。主体类型指代进程或进程组,也称为 域。指代对象的类型通常指定对象在策略中的角色,例如系统文件、应用数据文件等。类型(或域)是安全上下文的重要组成部分,如下面的“安全上下文”所述。
SELinux 模式
SELinux 有三种操作模式:禁用、宽容和强制。当 SELinux 被禁用时,未加载任何策略,仅执行默认的 DAC 安全性。在宽容模式下,策略被加载并检查对象访问,但拒绝访问仅被记录,而不执行。在强制模式下,安全策略既被加载又被强制执行,并且违规行为会被记录。
在 Android 中,可以使用 getenforce 和 setenforce 命令检查和更改 SELinux 模式,如 示例 12-1 所示。但是,使用 setenforce 设置的模式不是持久的,设备重启时会恢复为默认模式。
示例 12-1. 使用 getenforce 和 setenforce 命令
# getenforce
Enforcing
# setenforce 0
# getenforce
Permissive
此外,即使 SELinux 处于强制模式,策略仍可以使用 permissive 语句为每个域(进程)指定宽容模式。(参见“对象类和权限语句”了解示例。)
安全上下文
在 SELinux 中,安全上下文(也称为 安全标签,或简称 标签)是一个由四个字段组成的字符串,用冒号分隔:用户名、角色、类型和一个可选的 MLS 安全范围。SELinux 用户名通常与用户组或用户类别相关联;例如,user_u 表示非特权用户,admin_u 表示管理员。
用户可以与一个或多个角色关联,以实现基于角色的访问控制,每个角色都与一个或多个域类型关联。该类型用于将进程分组到某个域中,或者指定对象的逻辑类型。
安全范围(或级别)用于实现 MLS,并指定主体允许访问的安全级别。截至目前,Android 只使用安全上下文的类型字段,而用户和安全范围始终设置为 u 和 s0。角色设置为 r(用于域,即进程)或内置的 object_r 角色(用于对象)。
通过为 ps 命令指定 -Z 选项,可以显示进程的安全上下文,如在示例 12-2 中所示(在 LABEL 列中)。
示例 12-2. 在 Android 中处理进程安全上下文
# ps -Z
LABEL USER PID PPID NAME
u:r:init:s0➊ root 1 0 /init
u:r:kernel:s0 root 2 0 kthreadd
u:r:kernel:s0 root 3 2 ksoftirqd/0
--*snip*--
u:r:healthd:s0➋ root 175 1 /sbin/healthd
u:r:servicemanager:s0➌ system 176 1 /system/bin/
servicemanager
u:r:vold:s0➍ root 177 1 /system/bin/vold
u:r:init:s0 nobody 178 1 /system/bin/rmt_storage
u:r:netd:s0 root 179 1 /system/bin/netd
u:r:debuggerd:s0 root 180 1 /system/bin/debuggerd
u:r:rild:s0 radio 181 1 /system/bin/rild
--*snip*--
u:r:platform_app:s0 u0_a12 950 183 com.android.systemui
u:r:media_app:s0 u0_a5 1043 183 android.process.media
u:r:radio:s0 radio 1141 183 com.android.phone
u:r:nfc:s0 nfc 1163 183 com.android.nfc
u:r:untrusted_app:s0 u0_a7 1360 183 com.google.android.gms
--*snip*--
类似地,可以通过将 -Z 参数传递给 ls 命令来查看文件的上下文,如在示例 12-3 中所示。
示例 12-3. 文件和目录安全上下文在 Android 中的应用
# ls -Z
drwxr-xr-x root root u:object_r:cgroup:s0 acct
drwxrwx--- system cache u:object_r:cache_file:s0 cache
-rwxr-x--- root root u:object_r:rootfs:s0 charger
--*snip*--
drwxrwx--x system system u:object_r:system_data_file:s0 data
-rw-r--r-- root root u:object_r:rootfs:s0 default.prop
drwxr-xr-x root root u:object_r:device:s0 dev
lrwxrwxrwx root root u:object_r:rootfs:s0 etc -> /system/etc
-rw-r--r-- root root u:object_r:rootfs:s0 file_contexts
dr-xr-x--- system system u:object_r:sdcard_external:s0 firmware
-rw-r----- root root u:object_r:rootfs:s0 fstab.hammerhead
-rwxr-x--- root root u:object_r:rootfs:s0 init
--*snip*--
安全上下文分配与持久化
我们已经确定所有的主体和对象都有一个安全上下文,那么这个上下文是如何分配和持久化的呢?对于对象(通常与文件系统中的文件关联),安全上下文是持久的,通常存储为文件元数据中的扩展属性。
扩展属性不由文件系统解释,可以包含任意数据(尽管这些数据的大小通常是有限制的)。ext4 文件系统(大多数 Linux 发行版和当前版本的 Android 的默认文件系统)支持以名称-值对形式的扩展属性,其中名称是一个以空字符结尾的字符串。SELinux 使用 security.selinux 名称来存储文件对象的安全上下文。对象的安全上下文可以作为文件系统初始化的一部分显式设置(也称为 标签化),或者在对象创建时隐式分配。对象通常继承其父对象的类型标签(例如,目录中新创建的文件继承目录的标签)。但是,如果安全策略允许,对象可以获得与其父对象不同的标签,这一过程被称为 类型转换。
与对象一样,主体(进程)会继承其父进程的安全上下文,或者如果安全策略允许的话,它们可以通过 域转换 来改变自己的上下文。策略还可以指定自动域转换,这会根据父进程的域和已执行二进制文件的类型自动设置新启动进程的域。例如,由于所有系统守护进程都是由 init 进程启动的,而 init 进程的安全上下文是 u:r:init:s0(➊ 在 示例 12-2 中),这些进程通常会继承此上下文,但 Android 的 SELinux 策略使用自动域转换,根据需要为每个守护进程设置专用域(➋、➌ 和 ➍ 在 示例 12-2 中)。
安全策略
SELinux 安全策略由内核中的安全服务器使用,用于在运行时允许或拒绝访问内核对象。为了提高性能,策略通常以二进制形式存在,这是通过编译多个策略源文件生成的。策略源文件使用专门的策略语言编写,该语言由语句和规则组成。语句定义了策略实体,如类型、用户和角色。规则允许或拒绝访问对象(访问向量规则);指定允许的类型转换(类型强制规则);并指定如何分配默认用户、角色和类型(默认规则)。对 SELinux 策略语法的详细讨论超出了本书的范围,但以下部分将介绍一些最常用的语句和规则。
策略语句
SELinux 策略语言支持各种类型的语句,但类型、属性和权限语句构成了安全策略的主要部分。我们将在以下部分介绍这三种类型的语句。
类型和属性语句
type 和 attribute 语句声明了类型及其属性,如 示例 12-4 所示。
示例 12-4. type 和 attribute 语句
attribute file_type;➊
attribute domain;➋
type system_data_file, file_type, data_file_type;➌
type untrusted_app, domain;➍
在这里,第一条 ➊ 和第二条 ➋ 声明了 file_type 和 domain 属性,接下来的语句 ➌ 声明了 system_data_file 类型,并将其与 file_type 和 data_file_type 属性关联。第四行代码 ➍ 声明了 untrusted_app 类型,并将其与 domain 属性关联(该属性标记了所有用于进程的类型)。
根据粒度的不同,SELinux 策略可能包含数十个甚至数百个类型和属性声明,这些声明分布在多个源文件中。然而,由于需要在运行时对所有内核对象进行策略检查,大型策略可能会对性能产生负面影响。特别是在计算资源有限的设备上运行时,性能影响尤为明显,这也是 Android 力求保持 SELinux 策略相对简小的原因。
用户和角色声明
user 声明定义了一个 SELinux 用户标识符,将其与角色关联,并可选地指定其默认安全级别和用户可以访问的安全级别范围。示例 12-5 显示了 Android 中默认的唯一用户标识符声明。
示例 12-5. Android 中默认 SELinux 用户标识符的声明
user u roles { r } level s0 range s0 - mls_systemhigh;
如在示例 12-5 中看到的,u 用户与 r 角色(大括号内)关联,后者通过 role 声明 ➊ 进行声明,如示例 12-6 所示。
示例 12-6. Android 中默认 SELinux 角色的声明
role r;➊
role r types domain;➋
第二个声明 ➋ 将 r 角色与 domain 属性关联,这标记它为分配给进程(域)的角色。
对象类和权限声明
permissive 声明允许一个命名域以宽松模式运行(该模式仅记录 MAC 策略违规,但不实际强制执行该策略,后续将讨论),即使 SELinux 正在强制模式下运行。如我们将在“强制域”中看到的那样,Android 当前基础策略中的大多数域都是宽松模式。例如,adbd 域中的进程(实际上是 adbd 守护进程)以宽松模式运行,如示例 12-7 ➊ 所示。
示例 12-7. 将命名域设置为宽松模式
type adbd, domain;
permissive adbd;➊
--*snip*--
class 声明定义了一个 SELinux 对象类,如示例 12-8 所示。对象类及其相关权限由 Linux 内核中各自的对象管理器实现来确定,并在策略中保持静态。对象类通常定义在 security_classes 策略源文件中。
示例 12-8. 安全类 文件 中的对象类声明
--*snip*--
# file-related classes
class filesystem
class file
class dir
class fd
class lnk_file
class chr_file
class blk_file
class sock_file
class fifo_file
--*snip*--
SELinux 权限(也称为 访问向量)通常在名为 access_vectors 的策略源文件中定义并与对象类关联。权限可以是特定类的(使用 class 关键字定义)或可被一个或多个对象类继承的,在这种情况下,它们使用 common 关键字定义。示例 12-9 显示了所有文件对象共享的权限集的定义 ➊,以及 dir 类(表示目录)与所有通用文件权限的关联(使用 inherits 关键字),以及一组特定于目录的权限(add_name、remove_name 等) ➋。
示例 12-9. 访问向量文件中的权限定义
--*snip*--
common file
{
ioctl
read
write
create
getattr
setattr
lock
--*snip*--
}➊
--*snip*--
class dir
inherits file
{
add_name
remove_name
reparent
search
rmdir
--*snip*--
}➋
--*snip*--
类型过渡规则
类型强制规则和访问向量规则(在“域过渡规则”和“访问向量规则”中讨论)通常构成 SELinux 策略的主要部分。反过来,最常用的类型强制规则是type_transition规则,它指定了何时允许域和类型的过渡。例如,管理 Android 中 Wi-Fi 连接的 wpa_supplicant 守护进程,使用了在 示例 12-10") 中显示的类型过渡规则 ➍,该规则将它在 /data/misc/wifi/ 目录中创建的控制套接字与 wpa_socket 类型关联起来。如果没有这个规则,这些套接字将继承它们父目录的类型:wifi_data_file。
示例 12-10. wpa 域中的类型过渡(来自 wpa_supplicant.te)
# wpa - wpa supplicant or equivalent
type wpa, domain;
permissive wpa;➊
type wpa_exec, exec_type, file_type;
init_daemon_domain(wpa)➋
unconfined_domain(wpa)➌
type_transition wpa wifi_data_file:sock_file wpa_socket;➍
在这里,wpa、wifi_data_file:sock_file 和 wpa_socket 分别是源类型(在此案例中是 wpa_supplicant 进程的域)、目标类型和类(过渡前对象的类型和类)以及过渡后的对象类型。
注意
为了能够创建套接字文件并更改其标签,wpa 域需要在父目录和套接字文件本身上拥有额外的权限——仅有 type_transition 规则是不够的。然而,由于 wpa 域既是宽松的 ➊ 又是不受限制的(默认授予大多数权限) ➌,因此可以在不显式允许每个所需权限的情况下完成过渡。
域过渡规则
在 Android 中,像 wpa_supplicant 这样的本地系统守护进程由 init 进程启动,因此默认情况下继承其安全上下文。然而,大多数守护进程都与一个专用域相关联,并使用域转换在启动时切换其域。这通常通过使用 init_daemon_domain() 宏(示例 12-10 中的 ➋)来实现,其内部通过使用 type_transition 关键字实现,就像类型转换一样。
二进制 SELinux 策略构建过程使用 m4 宏预处理器^([132]) 在合并所有源文件之前展开宏,以创建二进制策略文件。init_daemon_domain() 宏接受一个参数(进程的新域),并在 te_macros 文件中使用两个其他宏定义:domain_trans() 和 domain_auto_trans(),分别用于允许转换到新域和自动执行转换。示例 12-11 展示了这三个宏的定义(➊、➋ 和 ➌)。以 allow 关键字开头的行是访问向量(AV)规则,我们将在下一节中讨论。
示例 12-11. 在 te_macros 文件 中定义的域转换宏
# domain_trans(olddomain, type, newdomain)
define(`domain_trans', `
allow $1 $2:file { getattr open read execute };
allow $1 $3:process transition;
allow $3 $2:file { entrypoint read execute };
allow $3 $1:process sigchld;
dontaudit $1 $3:process noatsecure;
allow $1 $3:process { siginh rlimitinh };
')➊
# domain_auto_trans(olddomain, type, newdomain)
define(`domain_auto_trans', `
domain_trans($1,$2,$3)
type_transition $1 $2:process $3;
')➋
# init_daemon_domain(domain)
define(`init_daemon_domain', `
domain_auto_trans(init, $1_exec, $1)
tmpfs_domain($1)
')➌
--*snip*--
访问向量规则
AV 规则通过指定进程在运行时对其目标对象的权限集,定义了进程所具有的特权。示例 12-12 展示了 AV 规则的一般格式。
示例 12-12. AV 规则的格式
rule_name source_type target_type : class perm_set;
rule_name 可以是 allow、dontaudit、auditallow 或 neverallow。要形成一个规则,source_type 和 target_type 元素会被一个或多个先前定义的 type 或 attribute 标识符替换,其中 source_type 是主体(进程)的标识符,target_type 是进程尝试访问的目标对象的标识符。class 元素会被目标的对象类别替换,perm_set 指定了源进程对目标对象拥有的权限集。你可以通过将它们包围在大括号({})中来指定多个类型、类别和权限。此外,一些规则支持使用通配符(*)和补集(~)运算符,分别允许你指定应包含所有类型或应包含所有类型,除了那些明确列出的类型。
allow 规则
最常用的规则是 allow,它指定了指定源类型的主体(进程)可以对目标类型和类的对象执行的操作。以 vold 守护进程的 SELinux 策略为例(参见 示例 12-13),我们来说明如何使用 allow 规则。
示例 12-13. allow 规则用于 vold 域(来自 vold.te)
type vold, domain;
type vold_exec, exec_type, file_type;
init_daemon_domain(vold)
--*snip*--
allow vold sdcard_type:filesystem { mount remount unmount };➊
--*snip*--
allow vold self:capability { sys_ptrace kill };➋
--*snip*--
在这个列出规则的示例中,规则 ➊ 允许 vold 守护进程(在 vold 域中运行)挂载、卸载和重新挂载类型为 sdcard_type 的文件系统。规则 ➋ 允许该守护进程使用 CAP_SYS_PTRACE(允许对任何进程调用 ptrace())和 CAP_KILL(允许向任何进程发送信号)Linux 能力,这些能力与规则中指定的权限集(位于 {} 内)相对应。在规则 ➋ 中,self 关键字表示目标域与源域相同,在此例中是 vold。
auditallow 规则
auditallow 规则与 allow 配合使用,以便在操作被允许时记录审计事件。这很有用,因为默认情况下,SELinux 仅记录访问被拒绝的事件。然而,auditallow 本身并不授予访问权限,因此必须使用匹配的 allow 规则来授予必要的权限。
dontaudit 规则
dontaudit 规则用于抑制当已知某个事件是安全时,拒绝消息的审计。例如,示例 12-14 中的规则 ➊ 指定如果 installd 守护进程被拒绝 CAP_SYS_ADMIN 能力,则不创建审计日志。然而,dontaudit 规则可能会掩盖程序错误,因此不建议使用 dontaudit。
示例 12-14. dontaudit 规则用于 installd 域(来自 installd.te)
type installd, domain;
--*snip*--
dontaudit installd self:capability sys_admin;➊
--*snip*--
neverallow 规则
neverallow 规则表示即使存在显式的 allow 规则允许某个操作,也永远不允许执行该操作。例如,示例 12-15 中的规则禁止了除 init 域外的所有域加载 SELinux 策略。
示例 12-15. neverallow 规则禁止除 init 以外的所有域加载 SELinux 策略(来自 domain.te)
--*snip*--
neverallow { domain -init } kernel:security load_policy;
注意
本节仅简要概述了 SELinux,重点讨论了在 Android 中使用的功能。如需更详细的 SELinux 架构和实现讨论,以及其策略语言,请参见 SELinux Notebook.^([133])
Android 实现
如 第一章 和 第二章 中所讨论的,Android 的沙箱安全模型在很大程度上依赖于为系统守护进程和应用程序使用不同的 Linux UID。进程隔离和访问控制最终由 Linux 内核基于进程的 UID 和 GID 强制执行。由于 SELinux 也是 Linux 内核的一部分,因此 SELinux 是使用 MAC 策略加强 Android 沙箱模型的自然候选者。
由于 SELinux 已集成到主线 Linux 内核中,看起来在 Android 中启用它应该只是配置内核和设计适当的 MAC 策略的简单问题。然而,由于 Android 对 Linux 内核引入了一些独特的扩展,并且其用户空间结构与桌面和服务器 Linux 发行版的结构大不相同,因此需要对内核和用户空间进行多次修改,以便将 SELinux 集成并启用到 Android 中。虽然将 SELinux 集成的初步工作由 Google 启动,但大多数所需的更改是在 Android 安全增强项目(正式名称为安全增强 Android,或 SEAndroid)中实现的,并且后来被集成到主线 Android 源代码树中。以下部分将概述这些主要更改。有关更改的详细列表及其背后的理由,请参见 SEAndroid 项目原作者所撰写的论文 Security Enhanced (SE) Android: Bringing Flexible MAC to Android。
内核更改
回想一下,SELinux 是一个安全模块,实现在与对象访问控制相关的内核服务中插入的各种 LSM 钩子。Android 的 Binder IPC 机制也作为内核驱动实现,但由于其最初的实现并未包含任何 LSM 钩子,其运行时行为无法通过 SELinux 策略进行控制。为了将 SELinux 支持添加到 Binder 中,LSM 钩子被插入到 Binder 驱动程序中,并且 binder 对象类及相关权限的支持被添加到 SELinux 代码中。
SELinux 安全钩子在 include/linux/security.h 中声明,示例 12-16 显示了为支持 Android 而添加的与 Binder 相关的声明。
示例 12-16. 在 include/linux/security.h 中声明的 Binder 安全钩子
--*snip*--
int security_binder_set_context_mgr(struct task_struct *mgr);➊
int security_binder_transaction(struct task_struct *from,
struct task_struct * to);➋
int security_binder_transfer_binder(struct task_struct *from,
struct task_struct *to);➌
int security_binder_transfer_file(struct task_struct *from,
struct task_struct *to, struct file *file);➍
--*snip*--
第一个钩子 ➊ 控制哪个进程可以成为 Binder 上下文管理器,第二个钩子 ➋ 控制一个进程调用 Binder 事务的能力。接下来的两个函数用于调节谁可以将 Binder 引用传递给另一个进程 ➌,以及通过 Binder 将打开的文件传递给另一个进程 ➍。
为了允许 SELinux 策略为 Binder 设置限制,内核中还添加了对 binder 对象类及其权限(impersonate、call、set_context_mgr 和 transfer)的支持,具体如 示例 12-17 所示。
示例 12-17。selinux/include/classmap.h 中的 Binder 对象类和权限声明
--*snip*--
struct security_class_mapping secclass_map[] = {
--*snip*--
{"binder", {"impersonate", "call", "set_context_mgr", "transfer", NULL} },
{ NULL }
};
用户空间更改
除了内核更改外,还需要进行许多用户空间修改和扩展,才能将 SELinux 集成到 Android 中。其中最重要的更改包括:核心 C 库(bionic)对文件系统标签的支持;init 以及核心本地守护进程和可执行文件的扩展;框架级别的 SELinux API;以及对核心框架服务的修改,使其能够识别 SELinux。本节将描述每个更改以及它是如何集成到 Android 运行时中的。
库和工具
因为 SELinux 使用扩展属性来存储文件系统对象的安全上下文,所以首先将用于管理扩展属性(listxattr()、getxattr()、setxattr() 等)的系统调用的包装函数添加到了 Android 的 C 库中,以便能够获取和设置文件和目录的安全标签。
为了能够从用户空间利用 SELinux 的功能,SEAndroid 添加了一个兼容 Android 的 libselinux 库移植版本,以及一组用于管理标签、安全策略,并在强制模式和宽松模式之间切换的实用命令。像大多数 Android 命令行工具一样,SELinux 工具实现于 toolbox 二进制文件中,并作为其符号链接进行安装。表 12-1 总结了添加或修改的命令行工具。
表 12-1。SELinux 命令行工具
| 命令 | 描述 |
|---|---|
chcon |
更改文件的安全上下文 |
getenforce |
获取当前的 SELinux 模式 |
getsebool |
获取策略布尔值 |
id |
显示进程的安全上下文 |
load_policy |
加载策略文件 |
ls -Z |
显示文件的安全上下文 |
ps -Z |
显示正在运行的进程的安全上下文 |
restorecon |
恢复文件的安全上下文 |
runcon |
在指定的安全上下文中运行程序 |
setenforce |
设置强制模式 |
setsebool |
设置策略布尔值 |
系统初始化
与传统的 Linux 系统一样,在 Android 中,所有用户空间守护进程和程序都是由 init 进程启动的,这是内核启动的第一个进程(PID=1)。然而,不像其他基于 Linux 的系统,Android 的初始化脚本(init.rc 及其变体)不是由通用 shell 解释的,而是由 init 本身解释执行。每个初始化脚本都包含 init 执行时所需的内置命令。SEAndroid 扩展了 Android 的 init 语言,添加了许多新的命令,用于初始化 SELinux 和设置服务及文件的安全上下文,这些命令在表 12-2 中进行了总结。
表 12-2. init 内置命令用于 SELinux 支持
| init 内置命令 | 描述 |
|---|---|
seclabel |
设置服务的安全上下文 |
restorecon |
恢复文件或目录的安全上下文 |
setcon |
设置 init 进程的安全上下文 |
setenforce |
设置强制模式 |
setsebool |
设置策略布尔值 |
当 init 启动时,它从 /sepolicy 二进制策略文件加载 SELinux 策略,然后根据 ro.boot.selinux 系统属性的值设置强制模式(init 根据 androidboot.selinux 内核命令行参数的值来设置该属性)。当属性值为 permissive 时,SELinux 进入宽松模式;当设置为其他任何值或未设置时,模式则设置为强制模式。
接下来,init 加载并解析 init.rc 文件,并执行文件中指定的命令。示例 12-18 显示了 init.rc 的一部分,重点展示了负责 SELinux 初始化的部分。
示例 12-18. SELinux 在 init.rc 中的初始化
--*snip*--
on early-init
--*snip*--
setcon u:r:init:s0➊
start ueventd
--*snip*--
on post-fs-data
chown system system /data
chmod 0771 /data
restorecon /data➋
--*snip*--
service ueventd /sbin/ueventd
class core
critical
seclabel u:r:ueventd:s0➌
--*snip*--
on property:selinux.reload_policy=1➍
restart ueventd
restart installd
--*snip*--
在这个示例中,init 在启动核心系统守护进程之前,使用 setcon 命令➊设置其自身的安全上下文。由于子进程会继承其父进程的安全上下文,init 显式地将 ueventd 守护进程(第一个启动的守护进程)的安全上下文设置为 u:r:ueventd:s0 ➌,通过 seclabel 命令来实现。其他大多数本地服务会根据策略中定义的类型转换规则自动设置其域(如在示例 12-10 中所示)。(seclabel 命令仅用于设置在系统初始化过程中非常早期启动的进程的安全上下文。)
当可写文件系统被挂载时,init 使用 restorecon 命令恢复其挂载点的默认标签,因为出厂重置可能会清除它们的标签。示例 12-18 展示了该命令 ➋,它给 userdata 分区的挂载点(/data)打上标签。
最后,因为通过将 selinux.reload_policy 系统属性设置为 1 ➍ 可以触发策略重载,init 在该属性设置时会重启 ueventd 和 installd 守护进程,以便新策略生效。
标签文件
记住,持久化 SELinux 对象,如文件,具有持久的安全上下文,该上下文通常保存在文件的扩展属性中。在 Android 中,所有文件的初始安全上下文在一个名为 file_contexts 的文本文件中定义,其内容可能如下所示:示例 12-19.
示例 12-19. file_contexts 文件内容
/ u:object_r:rootfs:s0➊
/adb_keys u:object_r:rootfs:s0
/default.prop u:object_r:rootfs:s0
/fstab\..* u:object_r:rootfs:s0
--*snip*--
/dev(/.*)? u:object_r:device:s0➋
/dev/akm8973.* u:object_r:akm_device:s0
/dev/accelerometer u:object_r:accelerometer_device:s0
--*snip*--
/system(/.*)? u:object_r:system_file:s0➌
/system/bin/ash u:object_r:shell_exec:s0
/system/bin/mksh u:object_r:shell_exec:s0
--*snip*--
/data(/.*)? u:object_r:system_data_file:s0➍
/data/backup(/.*)? u:object_r:backup_data_file:s0
/data/secure/backup(/.*)? u:object_r:backup_data_file:s0
--*snip*--
如你所见,文件包含了一系列路径(有时使用通配符)及其关联的安全上下文,每个路径占一行。file_contexts 文件会在 Android 的构建和启动过程中多次被查阅。例如,因为像 Android 根文件系统(挂载于 /)和设备文件系统(挂载于 /dev)这样的内存文件系统是非持久的,所有文件通常都会与 genfs_contexts 文件中指定的相同安全上下文相关联,或者通过 context= 挂载选项进行分配。为了将特定文件分配给这些文件系统中的独立安全上下文,init 使用 restorecon 命令在 file_contexts 中查找每个文件的安全上下文(➊ 对于根文件系统,➋ 作为设备文件系统的默认值),并根据查找到的内容进行设置。在从源代码构建 Android 时,make_ext4fs 命令也会查阅 file_contexts,以设置 system(挂载于 /system ➌)和 userdata 分区(挂载于 /data ➍)映像中文件的初始上下文。数据分区挂载点的安全上下文也会在每次启动时恢复(如 示例 12-18 所示),以确保它们保持一致的状态。最后,Android 的恢复操作系统也包含一个 file_contexts 文件的副本,用于在系统更新过程中设置恢复创建的文件的正确标签。这确保了系统在每次更新后都保持安全标签状态,并避免了每次更新后都需要重新标记。
标签系统属性
Android 使用全局系统属性,这些属性对所有进程可见,用于各种目的,如传达硬件状态、启动或停止系统服务、触发磁盘加密,甚至重新加载 SELinux 策略。只读系统属性的访问没有限制,但由于更改关键读写属性的值会改变系统行为,因此对这些属性的写访问受到限制,仅允许在特权 UID 下运行的系统进程访问,例如 system 和 radio。SEAndroid 通过添加 MAC 规则来增强这种基于 UID 的访问控制,规定了基于进程域的系统属性写访问权限,允许进程修改属性。为了使其正常工作,系统属性(这些属性不是原生的 SELinux 对象)必须与安全上下文关联。这是通过在 property_contexts 文件中列出属性的安全上下文来完成的,方式与 file_contexts 指定文件的安全标签类似。该文件由 property_service(init 的一部分)加载到内存中,生成的安全上下文查找表将用于根据进程(主体)和属性(对象)的安全上下文判断是否允许进程访问特定属性。SELinux 策略定义了一个新的 property_service 对象类,具有一个权限 set,用于指定访问规则,如 示例 12-20 所示。
示例 12-20. vold.te 中的系统属性访问规则
type vold, domain;
--*snip*--
allow vold vold_prop:property_service set;➊
allow vold powerctl_prop:property_service set;➋
allow vold ctl_default_prop:property_service set;➌
--*snip*--
在此列表中,vold 域被允许设置类型为 vold_prop ➊、powerctl_prop ➋ 和 ctl_default_prop ➌ 的系统属性。
这些类型与实际属性相关联,依据属性名称在 property_contexts 中进行匹配,如 示例 12-21 所示。
示例 12-21. 属性名称与其安全上下文在 property_contexts 中的关联
--*snip*--
vold. u:object_r:vold_prop:s0➊
sys.powerctl u:object_r:powerctl_prop:s0➋
ctl. u:object_r:ctl_default_prop:s0➌
--*snip*--
该策略的效果是,vold 可以设置所有属性值,这些属性的名称以 vold. ➊、sys.powerctl ➋ 或 ctl. ➌ 开头。
标记应用程序进程
回想一下 第二章 中提到的,Android 中所有的应用程序进程都是从 zygote 进程派生的,以减少内存使用并提高应用启动速度。system_server 进程也从 zygote 派生,尽管是通过稍有不同的接口,但它以 system 用户身份运行,并托管大多数系统服务。
合子过程以根用户身份运行,负责设置每个应用程序进程的 DAC 凭证(UID、GID 和附加 GID),以及其能力和资源限制。为了支持 SELinux,合子被扩展为检查其客户端的安全上下文(在ZygoteConnection类中实现),并设置每个由其分叉的应用程序进程的安全上下文。安全上下文根据seapp_contexts配置文件中指定的分配规则确定,该规则依据应用程序的 UID、包名、标记系统服务器进程的标志以及一个名为seinfo的 SELinux 特定字符串属性来进行确定。seapp_contexts配置文件包含安全上下文分配规则(每行一个),这些规则由输入选择器属性和输出属性组成。为了匹配规则,所有输入选择器必须匹配(逻辑与)。示例 12-22 显示了截至版本 4.4.3 的参考 Android SELinux 策略中的seapp_contexts文件内容。
注意
seapp_contexts文件与参考策略中的所有文件一样,可以在 Android 源代码树的external/sepolicy/目录中找到。查看文件中的注释以获取输入选择器的完整列表、选择器匹配优先级规则和输出。
示例 12-22. seapp_contexts 文件的内容
isSystemServer=true domain=system➊
user=system domain=system_app type=system_data_file➋
user=bluetooth domain=bluetooth type=bluetooth_data_file
user=nfc domain=nfc type=nfc_data_file
user=radio domain=radio type=radio_data_file
user=_app domain=untrusted_app type=app_data_file levelFrom=none➌
user=_app seinfo=platform domain=platform_app type=platform_app_data_file➍
user=_app seinfo=shared domain=shared_app type=platform_app_data_file➎
user=_app seinfo=media domain=media_app type=platform_app_data_file
user=_app seinfo=release domain=release_app type=platform_app_data_file
user=_isolated domain=isolated_app➏
user=shell domain=shell type=shell_data_file
此列表中的第一行➊指定了系统服务器的域(system),因为isSystemServer选择器(只能使用一次)被设置为true。由于 Android 使用固定的 SELinux 用户标识符、角色和安全级别,结果安全上下文变为u:r:system:s0。
第二个分配规则➋将user选择器与目标进程的用户名进行匹配,用户名来源于其 UID。如果进程以内建的 Android Linux 用户之一运行(system、radio、nfc等,定义在android_filesystem_config.h中),则在匹配user选择器时使用关联的名称。孤立服务会分配一个*_isolated*用户名字符串,其他任何进程则分配*_app*用户名字符串。因此,匹配此选择器的系统应用程序会被分配system_app域。
type属性指定分配给目标进程所拥有的文件的对象类型。由于在此情况下类型为system_data_file,因此系统文件的安全上下文变为u:object_r:system_data_file:s0。
规则 ➌ 匹配所有在非系统 UID 下执行的应用,并将其进程分配到 untrusted_app 域。每个不受信任应用的私有应用数据目录递归地被分配为 app_data_file 对象类型,从而形成 u:object_r:app_data_file:s0 安全上下文。数据目录的安全上下文由 installd 守护进程在创建应用安装过程中的一部分时设置(参见 第三章)。
规则 ➍ 和 ➎ 使用 seinfo 选择器区分非系统应用并将其分配到不同的域:匹配 seinfo=platform 的应用进程被分配到 platform_app 域,匹配 seinfo=shared 的进程则分配到 shared_app 域。(正如我们将在下一节中看到的,应用的 seinfo 属性由其签名证书决定,因此实际上,规则 ➍ 和 ➎ 将每个应用的签名证书用作进程域选择器。)
最后,规则 ➏ 将 isolated_app 域分配给所有隔离服务。(隔离服务在与其托管应用的 UID 分离的 UID 下运行,不能访问任何系统服务。)
中间件 MAC
上一节中介绍的 seinfo 属性是 SEAndroid 的一项功能,称为 中间件 MAC (MMAC),这是一种更高级别的访问控制方案,独立于内核级的 MAC(该功能由 SELinux LSM 模块实现)。
MMAC 的设计目的是对 Android 的权限模型提供 MAC 限制,该模型在框架级别工作,无法轻易映射到默认的内核级 MAC。原始实现包括一项安装时 MAC 功能,根据应用包名和签名证书限制可以授予每个包的权限,而不管用户的权限授予决定。也就是说,即使用户决定授予应用所有请求的权限,只要策略不允许授予某些权限,安装过程仍然可能被 MMAC 阻止。
SEAndroid 的 MMAC 实现还包括一项意图 MMAC 功能,该功能使用策略来控制应用之间可以交换的意图。另一个 SEAndroid 功能是内容提供者 MMAC,它定义了内容提供者数据访问的策略。然而,原始的 SEAndroid MMAC 实现仅部分地合并到主线 Android 中,目前唯一支持的功能是基于应用签名证书的 seinfo 分配。
注意
从版本 4.3 开始,Android 引入了一项实验性的意图防火墙功能,该功能通过“防火墙”样式的规则限制可以发送和接收的意图。这项功能类似于 SEAndroid 的意图 MMAC,但并未与 SELinux 实现集成。
MMAC 配置文件名为mac_permission.xml,并存储在设备的/system/etc/security/目录中。示例 12-23 显示了用于生成此文件的模板,通常存储在 Android 源树中的external/sepolicy/mac_permission.xml中。
示例 12-23. mac_permission.xml 文件模板
<?xml version="1.0" encoding="utf-8"?>
<policy>
<!-- Platform dev key in AOSP -->
<signer signature="@PLATFORM" >➊
<seinfo value="platform" />
</signer>
<!-- Media dev key in AOSP -->
<signer signature="@MEDIA" >➋
<seinfo value="media" />
</signer>
<!-- shared dev key in AOSP -->
<signer signature="@SHARED" >➌
<seinfo value="shared" />
</signer>
<!-- release dev key in AOSP -->
<signer signature="@RELEASE" >➍
<seinfo value="release" />
</signer>
<!-- All other keys -->
<default>➎
<seinfo value="default" />
</default>
</policy>
这里,@PLATFORM ➊、@MEDIA ➋、@SHARED ➌和@RELEASE ➍宏代表 Android 中使用的四个平台签名证书(platform、media、shared和release),在构建 SELinux 策略时,它们会被替换为各自的证书,并以十六进制字符串形式编码。
在扫描每个已安装的软件包时,系统的PackageManagerService会将其签名证书与mac_permission.xml文件的内容进行匹配,如果找到匹配项,它将把指定的seinfo值分配给该软件包。如果未找到匹配项,则会分配default seinfo值,如
设备策略文件
Android 的 SELinux 策略由一个二进制策略文件和四个支持的配置文件组成,这些文件用于进程、应用、系统属性和文件标记,以及 MMAC 初始化。表 12-3 显示了这些文件在设备上的位置,并简要描述了文件的目的和内容。
表 12-3. Android SELinux 策略文件
| 策略文件 | 描述 |
|---|---|
| /sepolicy | 二进制内核策略 |
| /file_contexts | 文件安全上下文,用于标记文件系统 |
| /property_contexts | 系统属性安全上下文 |
| /seapp_contexts | 用于推导应用进程和文件的安全上下文 |
| /system/etc/security/mac_permissions.xml | 将应用签名证书映射到seinfo值 |
注意
SELinux 启用的 Android 版本在 4.4.3 之前支持通过/data/security/current/和/data/system/(MMAC 配置文件)目录中的相应文件覆盖表 12-3 中显示的默认策略文件,从而实现在线策略更新,而无需完整的 OTA 更新。然而,Android 4.4.3 取消了这一功能,因为这可能会导致文件系统上设置的安全标签与新策略中引用的标签之间产生差异。现在,策略文件仅从表 12-3 中显示的默认只读位置加载。
策略事件日志
具有匹配 auditallow 规则的访问拒绝和访问授权会被记录到内核日志缓冲区,并可以通过 dmesg 查看,如 示例 12-24 所示。
示例 12-24. SELinux 访问拒绝记录在内核日志缓冲区
# dmesg |grep 'avc:'
--*snip*--
<5>[18743.725707] type=1400 audit(1402061801.158:256): avc: denied { getattr
} for pid=9574 comm="zygote" path="socket:[8692]" dev="sockfs" ino=8692
scontext=u:r:untrusted_app:s0 tcontext=u:r:zygote:s0 tclass=unix_stream_socket
--*snip*--
在这里,审计日志显示一个第三方应用程序(源安全上下文 u:r:untrusted_app:s0)被拒绝访问 zygote Unix 域套接字的 getattr 权限(目标上下文 u:r:zygote:s0,对象类别 unix_stream_socket)。
Android 4.4 SELinux 策略
Android 4.2 是首个包含 SELinux 代码的版本,但在发布构建中 SELinux 在编译时被禁用。Android 4.3 在所有构建中启用了 SELinux,但其默认模式设置为宽容模式。此外,所有域也都单独设置为宽容模式,并基于 unconfined 域,基本上允许它们完全访问(在 DAC 范围内),即使全局 SELinux 模式设置为强制模式。
Android 4.4 是首个以强制模式启用 SELinux 的版本,并且为核心系统守护进程包含了强制执行的域。本节概述了 Android 4.4 中部署的 SELinux 策略,并介绍了一些构成该策略的主要域。
策略概述
Android 基础 SELinux 策略的源代码托管在 Android 源代码树的 external/sepolicy/ 目录中。除了本章迄今介绍的文件(access_vectors、file_contexts、mac_permissions.xml 等)外,策略源代码主要由类型强制(TE)语句和规则组成,这些语句和规则被分割到多个 .te 文件中,通常每个定义的域有一个文件。这些文件会合并生成二进制策略文件 sepolicy,该文件包含在启动镜像的根目录下,路径为 /sepolicy。
你可以使用标准的 SELinux 工具(如 seinfo、sesearch、sedispol 等)来检查二进制策略文件。例如,我们可以使用 seinfo 命令来获取策略对象和规则数量的总结,如 示例 12-25 所示。
示例 12-25. 使用 seinfo 命令查询二进制策略文件
$**seinfo sepolicy**
Statistics for policy file: sepolicy
Policy Version & Type: v.26 (binary, mls)
Classes: 84 Permissions: 249
Sensitivities: 1 Categories: 1024
Types: 267 Attributes: 21
Users: 1 Roles: 2
Booleans: 1 Cond. Expr.: 1
Allow: 1140 Neverallow: 0
Auditallow: 0 Dontaudit: 36
Type_trans: 132 Type_change: 0
Type_member: 0 Role allow: 0
Role_trans: 0 Range_trans: 0
Constraints: 63 Validatetrans: 0
Initial SIDs: 27 Fs_use: 14
Genfscon: 10 Portcon: 0
Netifcon: 0 Nodecon: 0
Permissives: 42 Polcap: 2
如你所见,策略相当复杂:它定义了 84 个类别,267 个类型和 1,140 条允许规则。
你可以通过为 seinfo 命令指定过滤选项来获取有关策略对象的更多信息。例如,由于所有域都与 domain 属性相关联,示例 12-26 中的命令列出了策略中定义的所有域。
示例 12-26. 使用 seinfo 命令获取所有定义的域的列表
$ **seinfo -adomain -x sepolicy**
domain
nfc
platform_app
media_app
clatd
netd
sdcardd
zygote
--*snip*--
你可以使用 sesearch 命令搜索策略规则。例如,可以使用 示例 12-27 中显示的命令,列出所有以 zygote 域为源的 allow 规则。
示例 12-27. 使用 sesearch 命令搜索策略规则
$**sesearch --allow -s zygote -d sepolicy**
Found 40 semantic av rules:
allow zygote zygote_exec : file { read execute execute_no_trans entrypoint open } ;
allow zygote init : process sigchld ;
allow zygote rootfs : file { ioctl read getattr lock open } ;
allow zygote rootfs : dir { ioctl read getattr mounton search open } ;
allow zygote tmpfs : filesystem mount ;
allow zygote tmpfs : dir { write create setattr mounton add_name search } ;
--*snip*--
注意
有关构建和自定义 SELinux 策略的详细信息,请参阅 在 Android 中验证增强安全的 Linux 文档。^([136])
强制执行的域
尽管 SELinux 在 Android 4.4 中处于强制模式,但目前只有分配给少数核心守护进程的域正在执行强制策略,即:installd(负责创建应用数据目录)、netd(负责管理网络连接和路由)、vold(负责挂载外部存储和安全容器)以及 zygote。这些守护进程都以 root 身份运行或被授予特权,因为它们需要执行一些管理操作,比如更改目录所有权(installd)、操作数据包过滤和路由规则(netd)、挂载文件系统(vold)以及代表其他进程更改进程凭证(zygote)。
由于这些守护进程具有提升的特权,它们成为了各种权限提升漏洞的目标,这些漏洞允许非特权进程获得设备的 root 访问权限。因此,为与这些系统守护进程相关联的域指定严格的 MAC 策略是加强 Android 沙盒安全模型并防止未来类似漏洞的一个重要步骤。
让我们来看一下为 installd 域定义的类型强制规则(在 installd.te 中),以了解 SELinux 如何限制系统守护进程的访问权限(参见 示例 12-28)。
示例 12-28. installd 类型强制策略(来自 installd.te)
type installd, domain;
type installd_exec, exec_type, file_type;
init_daemon_domain(installd)➊
relabelto_domain(installd)➋
typeattribute installd mlstrustedsubject;➌
allow installd self:capability { chown dac_override fowner fsetid setgid setuid };➍
--*snip*--
allow installd dalvikcache_data_file:file create_file_perms;➎
allow installd data_file_type:dir create_dir_perms;➏
allow installd data_file_type:dir { relabelfrom relabelto };➐
allow installd data_file_type:{ file_class_set } { getattr unlink };➑
allow installd apk_data_file:file r_file_perms;➒
--*snip*--
allow installd system_file:file x_file_perms;➓
--*snip*--
在此列表中,installd 守护进程在启动时首先通过 init_daemon_domain() 宏自动转换到一个专用域(也命名为 installd)➊。然后,它被授予 relabelto 权限,以便它可以设置其创建的文件和目录的安全标签 ➋。接下来,该域与 mlstrustedsubject 属性关联 ➌,允许它绕过 MLS 访问规则。由于 installd 需要将其创建的文件和目录的所有者设置为相应应用的所有者,它被授予 chown、dac_override 以及与文件所有权相关的其他能力 ➍。
作为应用安装过程的一部分,installd 还触发 DEX 优化过程,该过程会在 /data/dalvik-cache/ 目录中创建 ODEX 文件(安全上下文 u:object_r:dalvikcache_data_file:s0),这也是为什么安装守护进程被授予在该目录中创建文件的权限 ➎。接下来,由于 installd 在 /data/ 目录下为应用程序创建私有数据目录,它被授予创建和重新标记目录的权限(➏ 和 ➐),以及获取属性和删除 /data/ 下文件的权限 ➑(该目录与 data_file_type 属性相关联)。由于 installd 还需要读取下载的 APK 文件以进行 DEX 优化,因此它被授予访问存储在 /data/app/ 目录下的 APK 文件的权限 ➒,该目录与 apk_data_file 类型(安全上下文 u:object_r:apk_data_file:s0)相关联。
最后,installd 被允许执行系统命令(安全上下文 u:object_r:system_file:s0) ➓ 以启动 DEX 优化过程。示例 12-28 忽略了其中的一些,但剩余的策略规则遵循相同的原则:只允许 installd 完成软件包安装所需的最少权限。因此,即使守护进程被攻破,恶意程序在 installd 的权限下执行,它也只能访问有限数量的文件和目录,并且任何未被 MAC 策略明确允许的权限都会被拒绝。
注意
虽然 Android 4.4 只有四个强制执行的域,但随着平台的发展和基础 SELinux 策略的完善,最终所有域可能都会以强制模式部署。例如,截至目前,在 Android 开源项目(AOSP)主分支的基础策略中,所有域都被设置为在发布版本中强制模式,而宽容域仅用于开发版本。
即使一个域处于强制模式,它仍然可以通过从一个授予了所有或大多数访问权限的基础域派生而被有效地允许完全无限制的访问。在 Android 的 SELinux 策略中,这样的域就是 unconfineddomain 域,接下来我们将讨论这个域。
无约束域
Android 的 SELinux 策略包含一个名为 unconfineddomain 的基础(也称为模板)域,该域几乎被授予所有系统特权,并且作为其他策略域的父域。自 Android 4.4 以来,unconfineddomain 已按 示例 12-29 中所示的方式进行定义。
示例 12-29. unconfineddomain 域定义(Android 4.4 中)
allow unconfineddomain self:capability_class_set *;➊
allow unconfineddomain kernel:security ~load_policy;➋
allow unconfineddomain kernel:system *;
allow unconfineddomain self:memprotect *;
allow unconfineddomain domain:process *;➌
allow unconfineddomain domain:fd *;
allow unconfineddomain domain:dir r_dir_perms;
allow unconfineddomain domain:lnk_file r_file_perms;
allow unconfineddomain domain:{ fifo_file file } rw_file_perms;
allow unconfineddomain domain:socket_class_set *;
allow unconfineddomain domain:ipc_class_set *;
allow unconfineddomain domain:key *;
allow unconfineddomain fs_type:filesystem *;
allow unconfineddomain {fs_type dev_type file_type}:{ dir blk_file lnk_file sock_file fifo_file
} ~relabelto;
allow unconfineddomain {fs_type dev_type file_type}:{ chr_file file } ~{entrypoint relabelto};
allow unconfineddomain node_type:node *;
allow unconfineddomain node_type:{ tcp_socket udp_socket rawip_socket } node_bind;
allow unconfineddomain netif_type:netif *;
allow unconfineddomain port_type:socket_class_set name_bind;
allow unconfineddomain port_type:{ tcp_socket dccp_socket } name_connect;
allow unconfineddomain domain:peer recv;
allow unconfineddomain domain:binder { call transfer set_context_mgr };
allow unconfineddomain property_type:property_service set;
如您所见,unconfineddomain领域被允许具有所有内核能力 ➊,完全访问 SELinux 安全服务器 ➋(除了加载 MAC 策略之外),所有与进程相关的权限 ➌,等等。其他领域通过unconfined_domain()宏“继承”此领域的权限,该宏将unconfineddomain属性分配给作为参数传递的领域。在 Android 4.4 的 SELinux 策略中,所有宽松领域也是 unconfined,因此被授予几乎不受限制的访问权限(在 DAC 的限制范围内)。
注意
虽然unconfineddomain在 AOSP 的主分支中仍然存在,但它已经被大大限制,并不再作为一个不受限制的领域使用,而是作为系统守护进程和其他特权 Android 组件的基础策略。随着更多领域转为强制模式并对其策略进行细化,预计unconfineddomain将被移除。
应用领域
回顾一下,SEAndroid 根据应用进程的 UID 或签名证书为应用进程分配多个不同的领域。这些应用领域通过继承基础的appdomain并使用app_domain()宏来分配共同的权限,正如在app.te中定义的那样,包括允许所有 Android 应用所需的常见操作的规则。示例 12-30 展示了app.te文件的一个摘录。
示例 12-30. appdomain策略摘录(来自 app.te)
--*snip*--
allow appdomain zygote:fd use;➊
allow appdomain zygote_tmpfs:file read;➋
--*snip*--
allow appdomain system:fifo_file rw_file_perms;
allow appdomain system:unix_stream_socket { read write setopt };
binder_call(appdomain, system)➌
allow appdomain surfaceflinger:unix_stream_socket { read write setopt };
binder_call(appdomain, surfaceflinger)➍
allow appdomain app_data_file:dir create_dir_perms;
allow appdomain app_data_file:notdevfile_class_set create_file_perms;➎
--*snip*--
此策略允许appdomain接收并使用来自zygote的文件描述符 ➊;读取由zygote管理的系统属性 ➋;通过管道、局部套接字或 Binder 与system_server进行通信 ➌;与负责屏幕绘制的surfaceflinger守护进程进行通信 ➍;并在其沙箱数据目录中创建文件和目录 ➎。其余策略定义了允许其他必需权限的规则,如网络访问、对下载文件的访问,以及对核心系统服务的 Binder 访问。应用通常不需要的操作,如原始块设备访问、内核内存访问和 SELinux 领域转换,则通过neverallow规则明确禁止。
具体的应用领域,如untrusted_app(根据seapp_contexts中显示的分配规则,所有非系统应用都会被分配此域,详见示例 12-22)扩展了appdomain并根据目标应用的要求添加了额外的访问规则。示例 12-31 展示了untrusted_app.te文件的一个摘录。
示例 12-31. untrusted_app领域策略摘录(来自 untrusted_app.te)
type untrusted_app, domain;
permissive untrusted_app;➊
app_domain(untrusted_app)➋
net_domain(untrusted_app)➌
bluetooth_domain(untrusted_app)➍
allow untrusted_app tun_device:chr_file rw_file_perms;➎
allow untrusted_app sdcard_internal:dir create_dir_perms;
allow untrusted_app sdcard_internal:file create_file_perms;➏
allow untrusted_app sdcard_external:dir create_dir_perms;
allow untrusted_app sdcard_external:file create_file_perms;➐
allow untrusted_app asec_apk_file:dir { getattr };
allow untrusted_app asec_apk_file:file r_file_perms;➑
--*snip*--
在这个策略文件中,untrusted_app域被设置为宽松模式➊,之后它继承了appdomain➋、netdomain➌和bluetoothdomain➍的策略。然后,允许该域访问隧道设备(用于 VPN)➎、外部存储(SD 卡,➏和➐)以及加密应用容器➑。其余的规则(未显示)授予访问套接字、伪终端和一些其他所需的操作系统资源。
所有其他应用程序域(在 4.4 版本中包括isolated_app、media_app、platform_app、release_app和shared_app)也继承自appdomain并添加额外的allow规则,可能是直接添加,或通过扩展其他域。在 Android 4.4 中,所有应用程序域都被设置为宽松模式。
注意
AOSP 主分支中的 SELinux 策略通过移除专用的media_app、shared_app和release_app域,并将它们合并到untrusted_app域中,从而简化了应用程序领域层次结构。此外,只有system_app域没有限制。
概述
从 4.3 版本开始,Android 已将 SELinux 集成,以加强使用 Linux 内核中的强制访问控制(MAC)的默认沙箱模型。与默认的自主访问控制(DAC)不同,MAC 提供了精细的对象和权限模型,并且是一种灵活的安全策略,恶意进程无法覆盖或更改(只要内核本身未被破坏)。
Android 4.4 是第一个将 SELinux 切换为强制模式的版本,但除了少数高度特权的核心守护进程之外,所有领域都被设置为宽松模式,以保持与现有应用程序的兼容性。Android 的基础 SELinux 策略随着每个版本的发布而不断完善,未来的版本可能会将大多数领域切换为强制模式,并移除当前由大多数与特权服务相关的领域继承的支持性unconfined域。
^([132]) 自由软件基金会,Inc., “GNU M4 - GNU 项目 - 自由软件基金会(FSF)”,www.gnu.org/software/m4/
^([133]) Richard Haines,The SELinux Notebook: The Foundations,第 3 版,2012 年,www.freetechbooks.com/efiles/selinuxnotebook/The_SELinux_Notebook_The_Foundations_3rd_Edition.pdf
^([134]) Android 的安全增强,bitbucket.org/seandroid/manifests/
^([135]) Craig Smalley,Security Enhanced (SE) Android:将灵活的 MAC 引入 Android,www.internetsociety.org/sites/default/files/02_4.pdf
^([136]) Google, “在 Android 中验证安全增强型 Linux,” source.android.com/devices/tech/security/se-linux.html
第十三章 系统更新与 Root 权限
在前面的章节中,我们介绍了 Android 的安全模型,并讨论了将 SELinux 集成到 Android 中是如何加强这一安全模型的。在本章中,我们稍微转变方向,介绍了一些可以绕过 Android 安全模型的方法。
为了执行完整的操作系统更新或将设备恢复到出厂状态,必须突破安全沙盒并获得设备的完全访问权限,因为即使是最特权的 Android 组件也没有完全访问所有系统分区和存储设备的权限。此外,尽管在运行时拥有完整的管理员(root)权限显然违背了 Android 的安全设计,但使用 root 权限执行操作对于实现 Android 不提供的功能是有用的,例如添加自定义防火墙规则或进行完整(包括系统分区)的设备备份。事实上,定制的 Android 版本(通常被称为 ROMs)以及允许用户通过 root 权限扩展或替代操作系统功能的应用程序(通常被称为 root 应用)的广泛可用性,正是 Android 成功的原因之一。
在本章中,我们探讨了 Android 引导加载程序和恢复操作系统的设计,并展示了它们如何用于替换设备的系统软件。接着,我们展示了如何在工程版构建中实现 root 权限,以及如何修改 Android 生产版构建以允许通过安装 “superuser” 应用程序执行具有超级用户权限的代码。最后,我们讨论了定制的 Android 发行版如何实现和控制 root 权限。
引导加载程序
引导加载程序是一个在设备通电时执行的低级程序。它的主要目的是初始化硬件并找到并启动主操作系统。
正如在第十章中简要讨论的那样,Android 引导加载程序通常是锁定的,只允许启动或安装已由设备制造商签名的操作系统镜像。这是建立受验证启动路径的重要步骤,因为它确保设备上只能安装受信任的、未修改的系统软件。然而,尽管大多数用户并不关心修改设备的核心操作系统,安装第三方 Android 版本仍然是一个有效的用户选择,并且可能是运行已停止接收操作系统更新的设备上最新版本 Android 的唯一方法。这就是为什么大多数新设备提供解锁引导加载程序并安装第三方 Android 版本的方式。
注意
虽然 Android 的引导加载程序通常是封闭源代码的,但大多数基于 Qualcomm SoC 的 ARM 设备的引导加载程序是源自 Little Kernel (LK) 引导加载程序,^([137]) 该引导加载程序是开源的。^([138])
在接下来的章节中,我们将探讨如何与 Android 引导加载程序进行交互,以及如何在 Nexus 设备上解锁引导加载程序。然后,我们会描述通过引导加载程序更新设备时使用的 fastboot 协议。
解锁引导加载程序
Nexus 设备的引导加载程序通过在设备处于 fastboot 模式时发出 oem unlock 命令来解锁(将在下一节讨论)。因此,为了解锁设备,必须首先通过发出 adb reboot bootloader 命令(如果设备已允许 ADB 访问)或通过在设备启动时按下特定的按键组合进入 fastboot 模式。例如,在关闭电源的 Nexus 5 上同时按住音量下、音量上和电源按钮,会中断正常的启动过程,并显示图 13-1 中的 fastboot 屏幕。
引导加载程序具有一个简单的用户界面,可以通过音量上下和电源按钮进行操作。它允许用户继续启动过程、在 fastboot 或恢复模式下重新启动设备,并关闭设备电源。
通过 USB 电缆将设备连接到主机计算机可以使用 fastboot 命令行工具(Android SDK 的一部分)向设备发送额外的命令。发出 fastboot oem unlock 命令会显示确认屏幕,如图 13-2 所示。

图 13-1. Nexus 5 引导加载程序屏幕

图 13-2. Nexus 5 引导加载程序解锁屏幕
确认屏幕警告解锁引导加载程序将允许安装未经测试的第三方操作系统构建,并清除所有用户数据。由于第三方操作系统构建可能不遵循 Android 的安全模型,并可能允许对数据的无限制访问,因此清除所有用户数据是一个重要的安全措施;它确保在引导加载程序解锁后无法提取现有的用户数据。
通过发出 fastboot oem lock 命令可以重新锁定引导加载程序。重新锁定引导加载程序将其恢复到原始状态,并且不再能够加载或启动第三方操作系统镜像。然而,除了锁定/解锁标志之外,一些引导加载程序还会保持一个额外的“篡改”标志,该标志会在引导加载程序首次解锁时被设置。这个标志可以让引导加载程序检测它是否曾经被锁定,并在它处于锁定状态时拒绝某些操作或显示警告。
Fastboot 模式
虽然 fastboot 命令和协议可用于解锁引导加载程序,但它们的初衷是简化通过向引导加载程序发送分区镜像来清除或覆盖设备分区,之后这些镜像会写入指定的块设备。当将 Android 移植到新设备(称为“设备启动”)或使用设备制造商提供的分区镜像将设备恢复到出厂状态时,这特别有用。
Android 分区布局
Android 设备通常有多个分区,fastboot 通过名称(而不是对应的 Linux 设备文件)来引用这些分区。可以通过列出 by-name/ 目录中的文件来获取分区及其名称,该目录对应设备的 SoC,位于 /dev/block/platform/ 下。例如,由于 Nexus 5 基于高通 SoC,其中包含移动台基带处理器(MSM),因此对应的目录名为 msm_sdcc.1/,如 示例 13-1 中所示(省略时间戳)。
示例 13-1. Nexus 5 的分区列表
# ls -l /dev/block/platform/msm_sdcc.1/by-name
lrwxrwxrwx root root DDR -> /dev/block/mmcblk0p24
lrwxrwxrwx root root aboot -> /dev/block/mmcblk0p6➊
lrwxrwxrwx root root abootb -> /dev/block/mmcblk0p11
lrwxrwxrwx root root boot -> /dev/block/mmcblk0p19➋
lrwxrwxrwx root root cache -> /dev/block/mmcblk0p27➌
lrwxrwxrwx root root crypto -> /dev/block/mmcblk0p26
lrwxrwxrwx root root fsc -> /dev/block/mmcblk0p22
lrwxrwxrwx root root fsg -> /dev/block/mmcblk0p21
lrwxrwxrwx root root grow -> /dev/block/mmcblk0p29
lrwxrwxrwx root root imgdata -> /dev/block/mmcblk0p17
lrwxrwxrwx root root laf -> /dev/block/mmcblk0p18
lrwxrwxrwx root root metadata -> /dev/block/mmcblk0p14
lrwxrwxrwx root root misc -> /dev/block/mmcblk0p15➍
lrwxrwxrwx root root modem -> /dev/block/mmcblk0p1➎
lrwxrwxrwx root root modemst1 -> /dev/block/mmcblk0p12
lrwxrwxrwx root root modemst2 -> /dev/block/mmcblk0p13
lrwxrwxrwx root root pad -> /dev/block/mmcblk0p7
lrwxrwxrwx root root persist -> /dev/block/mmcblk0p16
lrwxrwxrwx root root recovery -> /dev/block/mmcblk0p20➏
lrwxrwxrwx root root rpm -> /dev/block/mmcblk0p3
lrwxrwxrwx root root rpmb -> /dev/block/mmcblk0p10
lrwxrwxrwx root root sbl1 -> /dev/block/mmcblk0p2➐
lrwxrwxrwx root root sbl1b -> /dev/block/mmcblk0p8
lrwxrwxrwx root root sdi -> /dev/block/mmcblk0p5
lrwxrwxrwx root root ssd -> /dev/block/mmcblk0p23
lrwxrwxrwx root root system -> /dev/block/mmcblk0p25➑
lrwxrwxrwx root root tz -> /dev/block/mmcblk0p4
lrwxrwxrwx root root tzb -> /dev/block/mmcblk0p9
lrwxrwxrwx root root userdata -> /dev/block/mmcblk0p28➒
如你所见,Nexus 5 有 29 个分区,其中大多数存储设备特定的专有数据,如 aboot ➊ 中的 Android 引导加载程序,modem ➎ 中的基带软件,以及 sbl1 ➐ 中的第二阶段引导加载程序。Android 操作系统托管在 boot ➋ 分区中,存储内核和 rootfs RAM 磁盘镜像,system 分区 ➑ 存储所有其他系统文件。用户文件存储在 userdata 分区 ➒ 中,临时文件,如下载的 OTA 镜像和恢复操作系统命令及日志,存储在 cache 分区 ➌ 中。最后,恢复操作系统镜像存储在 recovery 分区 ➏ 中。
Fastboot 协议
fastboot 协议通过 USB 工作,并由主机驱动。也就是说,通信是由主机发起的,主机使用 USB 批量传输将基于文本的命令和数据发送到引导加载程序。USB 客户端(引导加载程序)会响应一个状态字符串,如 OKAY 或 FAIL;一个以 INFO 开头的信息消息;或 DATA,表示引导加载程序准备好接收来自主机的数据。当所有数据接收完毕后,引导加载程序会通过 OKAY、FAIL 或 INFO 消息响应,描述命令的最终状态。
Fastboot 命令
fastboot 命令行工具实现了 fastboot 协议,允许你获取支持 fastboot 的已连接设备列表(使用 devices 命令),获取引导加载程序的信息(使用 getvar 命令),以各种模式重启设备(使用 continue、reboot、reboot-bootloader),并 erase 或 format 分区。
fastboot命令支持多种将磁盘镜像写入分区的方式。使用flash partition image-filename命令可以闪存单个命名分区,而使用update ZIP-filename命令可以一次性闪存 ZIP 文件中的多个分区镜像。
flashall命令会自动将其工作目录中的boot.img、system.img和recovery.img文件的内容分别闪存到设备的boot、system和recovery分区。最后,flash:raw boot kernel ramdisk命令会根据指定的内核和 RAM 磁盘自动创建一个启动镜像并将其闪存到boot分区。除了闪存分区镜像外,fastboot还可以在使用boot boot-image或boot kernel ramdisk命令时,启动一个镜像而无需将其写入设备。
修改设备分区的命令,如各种flash变体命令,以及启动自定义内核的命令,如boot命令,在启动加载程序被锁定时是不允许的。
示例 13-2 显示了一个fastboot会话的示例。
示例 13-2. 示例 fastboot 会话
**$ fastboot devices➊**
004fcac161ca52c5 fastboot
**$ fastboot getvar version-bootloader➋**
version-bootloader: MAKOZ10o
finished. total time: 0.001s
**$ fastboot getvar version-baseband➌**
version-baseband: M9615A-CEFWMAZM-2.0.1700.98
finished. total time: 0.001s
**$ fastboot boot custom-recovery.img➍**
downloading 'boot.img'...
OKAY [ 0.577s]
booting...
FAILED (remote: not supported in locked device)
finished. total time: 0.579s
在这里,第一个命令➊列出了连接到主机的设备的序列号,这些设备当前处于 fastboot 模式。命令➋和➌分别获取启动加载程序和基带版本字符串。最后,命令➍尝试启动一个自定义恢复镜像,但由于启动加载程序当前被锁定,操作失败。
恢复
恢复操作系统—也称为恢复控制台或简单的恢复—是一个最小化操作系统,用于执行不能直接从 Android 执行的任务,如恢复出厂设置(擦除userdata分区)或应用 OTA 更新。
与启动加载程序的 fastboot 模式类似,恢复操作系统可以通过在设备启动时按下特定的按键组合,或通过使用adb reboot recovery命令通过 ADB 启动。一些启动加载程序还提供一个菜单界面(见图 13-1),可以用来启动恢复。在接下来的部分,我们将介绍 Nexus 设备随附的“原生”Android 恢复以及包含在 AOSP 中的恢复系统,然后介绍自定义恢复,这些恢复提供了更丰富的功能,但需要解锁启动加载程序才能安装或启动。
存储恢复
Android 的原生恢复实现了满足Android 兼容性定义文档(CDD)中“可更新软件”部分所需的最小功能,该部分要求“设备实现必须包括一个机制来替换整个系统软件……”并且“所使用的更新机制必须支持在不擦除用户数据的情况下进行更新。”^([139])
也就是说,CDD(兼容性定义文档)并没有指定应使用哪种具体的更新机制,因此可能有不同的系统更新方法,而默认恢复模式实现了 OTA 更新和连接更新两种方式。对于 OTA 更新,主操作系统下载更新文件,然后指示恢复模式应用该更新。对于连接更新,用户在 PC 上下载更新包,并使用adb sideload otafile.zip命令将其推送到恢复模式。两种方法的实际更新过程是相同的;只有获取 OTA 包的方法不同。
默认恢复模式具有简单的菜单界面(如图 13-3 所示),通过设备的硬件按钮操作,通常是电源按钮和音量上下按钮。然而,菜单默认是隐藏的,需要通过按下特定的按键组合来激活。在 Nexus 设备上,通常通过同时按住电源和音量下按钮几秒钟即可显示恢复菜单。
系统恢复菜单有四个选项:重启、通过 ADB 应用更新、恢复出厂设置和清除缓存分区。通过 ADB 应用更新选项会在设备上启动 ADB 服务器,并启用连接更新(侧加载)模式。然而,如你所见,并没有应用 OTA 更新的选项,因为一旦用户选择从主操作系统应用 OTA 更新(见图 13-4),系统会自动应用更新,无需进一步的用户操作。Android 通过向恢复模式发送控制命令来实现这一点,这些命令会在恢复模式启动时自动执行。(我们将在下一节讨论用于控制恢复模式的机制。)

图 13-3. 默认恢复菜单

图 13-4. 从主操作系统应用系统更新
控制恢复模式
主操作系统通过android.os.RecoverySystem API 控制恢复模式,该 API 通过将每个选项字符串写入/cache/recovery/command文件中的新行,与恢复模式通信。command文件的内容会被recovery二进制文件(位于恢复操作系统的/sbin/recovery)读取,该文件会在恢复模式启动时从init.rc自动启动。选项会修改recovery二进制文件的行为,导致其擦除指定分区、应用 OTA 更新或仅仅重启。表 13-1 显示了默认recovery二进制文件支持的选项。
表 13-1. 默认恢复二进制的选项
| 恢复选项 | 描述 |
|---|---|
--send_intent=<string> |
完成后将指定的意图动作保存并传回主操作系统 |
--update_package=<OTA 包路径> |
验证并安装指定的 OTA 包 |
--wipe_data |
擦除用户数据和缓存分区,然后重启 |
--wipe_cache |
擦除缓存分区,然后重启 |
--show_text |
显示的消息 |
--just_exit |
退出并重启 |
--locale |
恢复消息和 UI 使用的语言环境 |
--stages |
设置恢复过程的当前阶段 |
为了确保指定的命令始终完成,recovery二进制文件将其参数复制到引导加载程序控制块(BCB),该控制块位于misc分区(➍处在示例 13-1 中)。BCB 用于将恢复过程的当前状态传递给引导加载程序。BCB 的格式在bootloader_message结构中定义,详见示例 13-3。
示例 13-3. BCB 格式结构定义
struct bootloader_message {
char command[32];➊
char status[32];➋
char recovery[768];➌
char stage[32];➍
char reserved[224];➎
};
如果设备在恢复过程中重启或断电,下次启动时,引导加载程序会检查 BCB,如果 BCB 包含boot-recovery命令,则会重新启动恢复过程。如果恢复过程成功完成,recovery二进制文件会在退出前清除 BCB(将所有字节设置为零),并且在下次重启时,引导加载程序会启动主 Android 操作系统。
在示例 13-3 中,➊处的命令是发送给引导加载程序的命令(通常是boot-recovery);➋是引导加载程序在执行平台特定操作后写入的状态文件;➌包含recovery二进制文件的选项(如--update_package、--wipe-data等);➍是描述 OTA 包安装阶段的字符串,若安装需要多次重启,则例如2/3表示安装需要三次重启。最后的字段➎是保留字段,目前未使用。
安装 OTA 包
除了由主操作系统下载外,OTA 包还可以直接从主机 PC 传递给恢复模式。为了启用这种更新模式,用户必须首先从恢复菜单中选择从 ADB 应用更新选项。这将启动一个简化版本的标准 ADB 守护进程,仅支持adb sideload命令。在主机上执行adb sideload OTA-package-file将 OTA 文件传输到设备上的/tmp/update.zip并进行安装(参见“应用更新”)。
OTA 签名验证
正如我们在 第三章 中所学,OTA 包是经过代码签名的,签名覆盖整个文件(与 JAR 和 APK 文件不同,后者为归档中的每个文件都包含单独的签名)。当从主 Android 操作系统启动 OTA 过程时,首先使用 RecoverySystem 类的 verifyPackage() 方法验证 OTA 包(ZIP 文件)。该方法接收 OTA 包的路径和一个包含允许签署 OTA 更新的 X.509 证书列表的 ZIP 文件作为参数。如果 OTA 包是使用与 ZIP 文件中任何证书对应的私钥签署的,则该 OTA 被视为有效,并且系统会重启进入恢复模式以应用该更新。如果未指定证书 ZIP 文件,则使用系统默认值 /system/etc/security/otacerts.zip。
恢复程序验证它将应用的 OTA 包,而无需依赖主操作系统,以确保在启动恢复之前 OTA 包没有被替换。验证通过一组内置在恢复映像中的公钥来执行。在构建恢复时,这些密钥从指定的 OTA 签名证书集中提取,使用 DumpPublicKey 工具转换为 mincrypt 格式,并写入到 /res/keys 文件中。当使用 RSA 作为签名算法时,这些密钥是 mincrypt 的 RSAPublicKey 结构,序列化为 C 字面量(如同在 C 源文件中出现的形式),可选择性地前面加上版本标识符,该标识符指定在签署 OTA 包时使用的哈希以及 RSA 密钥的公共指数。keys 文件可能如下所示:示例 13-4。
示例 13-4. 恢复操作系统中 /res/keys 文件的内容
{64,0xc926ad21,{1795090719,...,3599964420},{3437017481,...,1175080310}},➊
v2 {64,0x8d5069fb,{393856717,...,2415439245},{197742251,...,1715989778}},➋
--*snip*--
这里,第一行 ➊ 是一个序列化的版本 1 密钥(如果未指定版本标识符,则隐式使用该版本),该密钥具有公共指数 e=3,可以用于验证使用 SHA-1 创建的签名;第二行 ➋ 包含一个版本 2 密钥,具有公共指数 e=65537,也用于 SHA-1 签名。当前支持的签名算法有 2048 位 RSA 配合 SHA-1(密钥版本 1 和 2)或 SHA-256(密钥版本 3 和 4),以及 ECDSA 配合 SHA-256(密钥版本 5,AOSP 的 master 分支可用)和使用 NIST P-256 曲线的 256 位 EC 密钥。
启动系统更新过程
如果 OTA 包的签名验证通过,恢复程序通过执行 OTA 文件中包含的更新命令来应用系统更新。更新命令保存在恢复映像的 META-INF/com/google/android/ 目录下,文件名为 update-binary ➊,如 示例 13-5 中所示。
示例 13-5. 系统更新 OTA 包的内容
.
|-- META-INF/
| |-- CERT.RSA
| |-- CERT.SF
| |-- com/
| | |-- android/
| | | |-- metadata
| | | `-- otacert
| | `-- google/
| | `-- android/
| | |-- update-binary➊
| | `-- updater-script➋
| `-- MANIFEST.MF
|-- patch/
| |-- boot.img.p
| `-- system/
|-- radio.img.p
|-- recovery/
| |-- etc/
| | `-- install-recovery.sh
| `-- recovery-from-boot.p
`-- system/
|-- etc/
| |-- permissions/
| | `-- com.google.android.ble.xml
| `-- security/
| `-- cacerts/
|-- framework/
`-- lib/
恢复过程从 OTA 文件中提取update-binary到/tmp/update_binary并启动它,传递三个参数:恢复 API 版本(截至本文写作时为版本 3);update-binary用于与恢复进程通信的管道文件描述符;以及 OTA 包的路径。然后,update-binary进程提取更新脚本,该脚本作为META-INF/com/google/android/updater-script ➋包含在 OTA 包中,并执行它。更新脚本使用一种名为edify的专用脚本语言编写(自 1.6 版本起;以前的版本使用旧的变种,称为amend)。edify 语言支持简单的控制结构,如if和else,并且可以通过函数扩展,这些函数也可以充当控制结构(通过决定评估哪些参数)。更新脚本包含一系列函数调用,触发应用更新所需的操作。
应用更新
edify 实现定义并注册了用于复制、删除和修补文件;格式化和挂载卷;设置文件权限和 SELinux 标签等操作的各种函数。表 13-2 展示了最常用的 edify 函数的摘要。
表 13-2. 重要 edify 函数摘要
| 函数名称 | 描述 |
|---|---|
abort |
以错误信息中止安装过程。 |
apply_patch |
安全地应用二进制补丁。在替换原始文件之前,确保补丁文件具有预期的哈希值。也可以修补磁盘分区。 |
apply_patch_check |
检查文件是否具有指定的哈希值。 |
assert |
检查条件是否为真。 |
delete/delete_recursive |
删除文件/目录中的所有文件。 |
file_getprop |
从指定的属性文件中获取系统属性。 |
format |
使用指定的文件系统格式化一个卷。 |
getprop |
获取系统属性。 |
mount |
将一个卷挂载到指定路径。 |
package_extract_dir |
将指定的 ZIP 目录提取到文件系统的路径。 |
package_extract_file |
将指定的 ZIP 文件提取到文件系统的路径,或将其作为二进制数据返回。 |
run_program |
在子进程中执行指定程序,并等待其完成。 |
set_metadata/set_metadata_recursive |
设置文件/目录中所有文件的所有者、组、权限位、文件功能和 SELinux 标签。 |
show_progress |
向父进程报告进度。 |
symlink |
创建指向目标的符号链接,首先删除现有的符号链接文件。 |
ui_print |
向父进程发送一条消息。 |
umount |
卸载一个已挂载的卷。 |
write_raw_image |
将原始镜像写入指定的磁盘分区。 |
示例 13-6 展示了一个典型的系统更新 edify 脚本的(简略)内容。
示例 13-6。完整系统更新 OTA 包中的 updater-script 内容
mount("ext4", "EMMC", "/dev/block/platform/msm_sdcc.1/by-name/system", "/system");
file_getprop("/system/build.prop", "ro.build.fingerprint") == "google/...:user/release-keys" ||
file_getprop("/system/build.prop", "ro.build.fingerprint") == "google/...:user/release-keys" ||
abort("Package expects build fingerprint of google/...:user/release-keys; this device has " +
getprop("ro.build.fingerprint") + ".");
getprop("ro.product.device") == "hammerhead" ||
abort("This package is for \"hammerhead\" devices; this is a \"" +
getprop("ro.product.device") + "\".");➊
--*snip*--
apply_patch_check("/system/app/BasicDreams.apk", "f687...", "fdc5...") ||
abort("\"/system/app/BasicDreams.apk\" has unexpected contents.");➋
set_progress(0.000063);
--*snip*--
apply_patch_check("EMMC:/dev/block/platform/msm_sdcc.1/by-name/boot:8835072:21...:8908800:a3...")
|| abort("\"EMMC:/dev/block/...\" has unexpected contents.");➌
--*snip*--
ui_print("Removing unneeded files...");
delete("/system/etc/permissions/com.google.android.ble.xml",
--*snip*--
"/system/recovery.img");➍
ui_print("Patching system files...");
apply_patch("/system/app/BasicDreams.apk", "-",
f69d..., 32445,
fdc5..., package_extract_file("patch/system/app/BasicDreams.apk.p"));➎
--*snip*--
ui_print("Patching boot image...");
apply_patch("EMMC:/dev/block/platform/msm_sdcc.1/by-name/boot:8835072:2109...:8908800:a3bd...",
"-", a3bd..., 8908800,
2109..., package_extract_file("patch/boot.img.p"));➏
--*snip*--
delete("/system/recovery-from-boot.p",
"/system/etc/install-recovery.sh");
ui_print("Unpacking new recovery...");
package_extract_dir("recovery", "/system");➐
ui_print("Symlinks and permissions...");
set_metadata_recursive("/system", "uid", 0, "gid", 0, "dmode", 0755, "fmode", 0644,
"capabilities", 0x0, "selabel", "u:object_r:system_file:s0");➑
--*snip*--
ui_print("Patching radio...");
apply_patch("EMMC:/dev/block/platform/msm_sdcc.1/by-name/modem:43058688:7493...:46499328:52a...",
"-", 52a5..., 46499328,
7493..., package_extract_file("radio.img.p"));➒
--*snip*--
unmount("/system");➓
复制和修补文件
updater 脚本首先挂载 system 分区,然后检查设备型号及其当前构建是否符合预期 ➊。此检查是必需的,因为试图在不兼容的构建上安装系统更新可能会导致设备无法使用。(这通常被称为“软砖”,因为通常可以通过重新刷写所有分区并使用有效的构建恢复;而“硬砖”是无法恢复的。)
因为 OTA 更新通常不包含完整的系统文件,而只是对每个已更改文件的前一版本进行二进制补丁(使用 bsdiff 生成),^([140]) 所以只有在每个待修补的文件与生成相应补丁时所使用的文件一致的情况下,更新才能成功。为确保这一点,updater 脚本使用 apply_patch_check 函数 ➋ 检查每个待修补文件的哈希值是否符合预期。
除了系统文件外,更新过程还会修补不包含文件系统的分区,如 boot 和 modem 分区。为了确保修补这些分区能成功,updater 脚本还会检查目标分区的内容,如果它们不处于预期状态则会中止 ➌。验证完所有系统文件和分区后,updater 脚本会删除不必要的文件,以及那些将完全替换而不是修补的文件 ➍。接着,脚本会修补所有系统文件 ➎ 和分区 ➏。然后,它会删除任何先前的恢复补丁,并将新的恢复环境解包到 /system/ ➐。
设置文件所有权、权限和安全标签
下一步是使用 set_metadata_recursive 函数 ➑ 设置所有创建或修补的文件和目录的用户、所有者、权限以及文件能力。从版本 4.3 开始,Android 支持 SELinux(见 第十二章),因此所有文件必须正确标记,以便访问规则生效。这就是为什么 set_metadata_recursive 函数被扩展以设置文件和目录的 SELinux 安全标签(➑ 中的最后一个参数,u:object_r:system_file:s0)。
完成更新
接下来,updater 脚本修补设备的基带软件 ➒,基带软件通常存储在 modem 分区。脚本的最后一步是卸载系统分区 ➓。
在update-binary进程退出后,如果使用了–wipe_cache选项启动恢复操作系统,恢复会擦除缓存分区,并将执行日志复制到/cache/recovery/,以便从主操作系统访问。最后,如果没有报告错误,恢复操作系统会清除 BCB 并重启到主操作系统。
如果更新过程由于错误中止,恢复操作系统会向用户报告此错误,并提示用户重启设备以重新尝试。由于 BCB 未被清除,设备会自动以恢复模式重启,并从头开始更新过程。
更新恢复操作系统
如果你详细查看示例 13-6 中的整个更新脚本,你会注意到,尽管它会修补boot ➏和modem ➒分区,并为recovery分区 ➐(用于恢复操作系统)解压补丁,但它并不会应用解压后的补丁。这是设计使然。因为更新过程可能随时被中断,因此更新过程需要在设备下次开机时从相同的状态重新开始。例如,如果在写入recovery分区时电源中断,更新恢复操作系统会改变初始状态,可能会导致系统处于不可用的状态。因此,恢复操作系统仅在主操作系统更新完成且主操作系统成功启动后才会进行更新。
更新由 Android 的init.rc文件中的flash_recovery服务触发,如示例 13-7 所示。
示例 13-7. flash_recovery 服务在 init.rc 中的定义
--*snip*--
service flash_recovery /system/etc/install-recovery.sh➊
class main
oneshot
--*snip*--
如你所见,这个服务仅仅是启动/system/etc/install-recovery.sh shell 脚本 ➊。如果恢复需要更新,OTA 更新脚本会将这个 shell 脚本及恢复分区的补丁文件(见示例 13-6 中的➐)复制到设备中。install-recovery.sh的内容可能如示例 13-8 所示。
示例 13-8. install-recovery.sh 的内容
#!/system/bin/sh
if ! applypatch -c EMMC:/dev/block/platform/msm_sdcc.1/by-name/recovery:9506816:3e90...; then➊
log -t recovery "Installing new recovery image"
applypatch -b /system/etc/recovery-resource.dat \
EMMC:/dev/block/platform/msm_sdcc.1/by-name/boot:8908800:a3bd... \
EMMC:/dev/block/platform/msm_sdcc.1/by-name/recovery \
3e90... 9506816 a3bd...:/system/recovery-from-boot.p➋
else
log -t recovery "Recovery image already installed"➌
fi
脚本使用applypatch命令通过检查recovery分区的哈希值来判断恢复操作系统是否需要打补丁 ➊。如果设备的recovery分区的哈希值与创建补丁时所用版本的哈希值匹配,脚本将应用补丁 ➋。如果恢复分区已经更新或哈希值未知,脚本将记录一条消息并退出 ➌。
自定义恢复
自定义恢复是由第三方(而非设备制造商)创建的恢复操作系统版本。由于它是由第三方创建的,因此自定义恢复没有使用制造商的密钥签名,因此设备的引导加载程序需要解锁才能启动或刷入它。
自定义恢复可以通过fastboot boot custom-recovery.img 命令在不安装到设备上的情况下启动,或者可以使用fastboot flash recovery custom-recovery.img 命令永久刷入设备。
自定义恢复提供了通常在官方恢复中不可用的高级功能,如完整分区备份与恢复、具有完整设备管理工具集的根权限 shell、支持挂载外部 USB 设备等。自定义恢复还可以禁用 OTA 包签名检查,这允许安装第三方操作系统或进行修改,如框架或主题定制。
有多种自定义恢复可供选择,但截至目前,功能最全且维护最积极的是 Team Win Recovery Project (TWRP)。^([141]) 它基于 AOSP 官方恢复,且也是一个开源项目。^([142]) TWRP 拥有一个可定制的触摸屏界面,非常类似于原生 Android UI。它支持加密分区备份、从 USB 设备安装系统更新、从外部设备备份与恢复,并且内置文件管理器。TWRP 版本 2.7 的启动屏幕见于 图 13-5。

图 13-5. TWRP 恢复启动屏幕
与 AOSP 官方恢复类似,自定义恢复也可以从主操作系统中控制。除了通过 /cache/recovery/ 命令 文件传递参数外,自定义恢复通常允许从主操作系统触发其某些(或所有)扩展功能。例如,TWRP 支持一种简洁的脚本语言,用于描述在启动恢复时应执行的恢复操作。这允许 Android 应用通过便捷的 GUI 界面排队执行恢复命令。例如,请求压缩备份 boot、userdata 和 system 分区时,会生成 示例 13-9 中所示的脚本。
示例 13-9. TWRP 备份脚本示例
#**cat /cache/recovery/openrecoveryscript**
backup DSBOM 2014-12-14--01-54-59
警告
永久刷入一个可以忽略 OTA 包签名的自定义恢复可能会导致在设备有短暂物理接触的情况下,设备的系统软件被替换并且被植入后门。因此,不建议在您日常使用的设备上刷入自定义恢复,特别是那些存储个人或敏感信息的设备。
根权限访问
Android 的安全模型应用了最小权限原则,并通过将每个进程作为一个独立的用户来努力将系统和应用进程彼此隔离。然而,Android 也基于 Linux 内核,后者实现了标准的 Unix 风格的 DAC(除非启用了 SELinux;请参见 第十二章)。
这种 DAC 安全模型的最大缺点之一是,某个系统用户,通常称为 root(UID=0),也被称为 超级用户,被授予对系统的绝对控制权限。Root 可以读取、写入并更改任何文件或目录的权限位;杀死任何进程;挂载和卸载卷;等等。尽管这种不受限制的权限对于管理传统的 Linux 系统是必要的,但在 Android 设备上拥有超级用户权限,意味着可以有效绕过 Android 的沙盒,并读取或写入任何应用程序的私密文件。
Root 权限还允许通过修改本应为只读的分区、更改系统配置、随意启动或停止系统服务、以及删除或禁用核心系统应用程序。这可能会对设备的稳定性产生不利影响,甚至导致设备无法使用,这也是为什么生产设备通常不允许 root 权限的原因。
此外,Android 尝试限制以 root 权限执行的系统进程数量,因为任何此类进程中的编程错误都可能导致权限提升攻击,进而使第三方应用程序获得 root 权限。通过在强制模式下部署 SELinux,进程受到全局安全策略的限制,因此,即使破解了 root 进程,也不一定能获得对设备的无限制访问权限,但仍可能访问敏感数据或修改系统行为。此外,即使进程受到 SELinux 限制,也有可能通过利用内核漏洞绕过安全策略,或以其他方式获得无限制的 root 权限。
话虽如此,root 权限对于在开发设备上调试或逆向工程应用程序来说是非常方便的。此外,虽然允许第三方应用程序获得 root 权限会破坏 Android 的安全模型,但它也允许进行一些通常在生产设备上无法执行的系统定制。
由于 Android 的一个最大卖点一直是其易于定制性,用户对通过修改核心操作系统(也称为 modding)获得更大灵活性的需求一直很高,尤其是在 Android 的早期。除了定制系统外,获得 Android 设备的 root 权限还可以实现一些在不修改框架和添加系统服务的情况下无法实现的应用程序,例如防火墙、完整设备备份、网络共享等。
在接下来的章节中,我们将描述在开发(工程版)Android 构建和自定义 Android 构建(ROM)中如何实现 root 访问,以及如何将其添加到生产构建中。然后,我们展示需要超级用户访问(通常称为root 应用)的应用如何请求并使用 root 权限,以便作为 root 用户执行进程。
工程版构建中的 Root 访问
Android 的构建系统可以为特定设备生成多个构建变体,这些变体在包含的应用程序和工具的数量以及几个修改系统行为的关键系统属性值上有所不同。我们将在接下来的章节中展示,这些构建变体中的一些允许通过 Android shell 获得 root 访问权限。
以 Root 身份启动 ADB
商业设备使用用户构建变体(当前构建变体的值设置为ro.build.type系统属性的值),该变体不包含诊断和开发工具,默认禁用 ADB 守护进程,禁止调试未在其清单中明确将debuggable属性设置为true的应用,并且不允许通过 shell 访问 root。userdebug构建变体与用户变体非常相似,但它还包括一些额外的模块(带有debug模块标签的模块),允许调试所有应用,并默认启用 ADB。
工程版(或eng版)构建包括大多数可用模块,默认允许调试,默认启用 ADB,并将ro.secure系统属性设置为 0,这会改变设备上运行的 ADB 守护进程的行为。当设置为 1(安全模式)时,adbd进程最初作为 root 用户运行,除CAP_SETUID和CAP_SETGID(这些是实现run-as工具所必需的)外,丢弃其能力边界集中的所有权限。然后,它添加了几个附加的 GID,用于访问网络接口、外部存储和系统日志,最后将其 UID 和 GID 更改为AID_SHELL(UID=2000)。另一方面,当ro.secure设置为 0(工程版构建的默认值)时,adbd守护进程继续作为 root 用户运行,并拥有完整的能力边界集。示例 13-10 展示了用户构建中adbd进程的进程 ID 和权限。
示例 13-10:用户构建中的 adbd 进程细节
**$ getprop ro.build.type**
user
**$ getprop ro.secure**
1
**$ ps|grep adb**
shell 200 1 4588 220 ffffffff 00000000 S /sbin/adbd
**$ cat /proc/200/status**
Name: adbd
State: S (sleeping)
Tgid: 200
Pid: 200
Ppid: 1
TracerPid: 0
Uid: 2000 2000 2000 2000➊
Gid: 2000 2000 2000 2000➋
FDSize: 32
Groups: 1003 1004 1007 1011 1015 1028 3001 3002 3003 3006➌
--*snip*--
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: fffffff0000000c0➍
--*snip*--
如您所见,进程的 UID ➊和 GID ➋都被设置为 2000(AID_SHELL),并且adbd进程添加了若干附加的 GID ➌。最后,进程的能力边界集,决定了子进程可以使用哪些能力,被设置为 0x0000000c0(CAP_SETUID|CAP_SETGID) ➍。此能力设置保证了,在用户构建中,从 Android shell 启动的进程仅限于CAP_SETUID和CAP_SETGID权限,即使执行的二进制文件设置了 SUID 位,或其文件权限允许额外的特权。
相比之下,在 eng 或 userdebug 构建中,ADB 守护进程可以作为 root 执行,如 示例 13-11 中所示。
示例 13-11. 在 eng 版本构建中的 adbd 进程详情
# getprop ro.build.type
userdebug➊
# getprop ro.secure
1➋
# ps|grep adb
root 19979 1 4656 264 ffffffff 0001fd1c S /sbin/adbd
root@maguro:/ # cat /proc/19979/status
Name: adbd
State: S (sleeping)
Tgid: 19979
Pid: 19979
Ppid: 1
TracerPid: 0
Uid: 0 0 0 0➌
Gid: 0 0 0 0➍
FDSize: 256
Groups:➎
--*snip*--
CapInh: 0000000000000000
CapPrm: ffffffffffffffff➏
CapEff: ffffffffffffffff➐
CapBnd: ffffffffffffffff➑
--*snip*--
在这里,adbd 进程以 UID ➌ 和 GID ➍ 0(root)身份运行,没有附加的组 ➎,并且拥有完整的 Linux 能力集(➏、➐ 和 ➑)。然而,正如你在 ➋ 中所看到的,ro.secure 系统属性被设置为 1,这表明 adbd 不应该以 root 身份运行。
虽然在 userdebug 构建中 ADB 守护进程会丢失其 root 权限(如本示例 ➊ 所示),但它可以通过从主机发出 adb root 命令以不安全模式手动重启,如 示例 13-12 中所示。
示例 13-12. 在用户调试版构建中以 root 身份重启 adbd
$ **adb shell id**
uid=2000(shell) gid=2000(shell)➊ groups=1003(graphics),1004(input),1007
(log),1009(mount),1011(adb),1015(sdcard_rw),1028(sdcard_r),3001(net_bt_
admin),3002(net_bt),3003(inet),3006(net_bw_stats) context=u:r:shell:s0
$ **adb root**➋
restarting adbd as root $
**$ adb shell ps|grep adb**
root 2734 1 4644 216 ffffffff 0001fbec R /sbin/adbd➌
**$ adb shell id**
uid=0(root) gid=0(root) context=u:r:shell:s0➍
在这里,adbd 守护进程最初以 shell(UID=2000)身份运行,任何从主机启动的 shell 也具有 UID=2000 和 GID=2000 ➊。执行 adb root 命令 ➋(该命令内部将 service.adb.root 系统属性设置为 1)会将 ADB 守护进程重启为 root ➌,之后启动的任何 shell 的 UID 和 GID 都为 0 ➍。
注意
由于此设备启用了 SELinux,即使 shell 的 UID 和 GID 改变,其安全上下文(安全标签)依然保持不变: u:r🐚s0 在 ➊ 和 ➍ 中都相同。因此,即使通过 ADB 获取了 root shell,所有从 shell 启动的进程仍然受限于分配给 shell 域的权限(除非通过 MAC 策略允许转换到其他域;有关详细信息,请参见 第十二章)。实际上,从 Android 4.4 开始,shell 域是没有限制的,因此当以 root 身份运行时,属于该域的进程几乎可以完全控制设备。
使用 su 命令
在 userdebug 构建中,也可以在不将 ADB 以 root 身份重启的情况下获得 root 访问权限。可以通过使用 su(即 substitute user 的简称,也称为 switch user 和 superuser)命令来实现,该命令以 SUID 位设置安装,从而允许调用进程获取一个 root shell 或以指定的 UID(包括 UID=0)执行命令。默认的 su 实现非常基础,只允许 root 和 shell 用户使用,如 示例 13-13 中所示。
示例 13-13. 用户调试版构建中 su 的默认实现
int main(int argc, char **argv)
{
--*snip*--
myuid = getuid();
if (myuid != AID_ROOT && myuid != AID_SHELL) {➊
fprintf(stderr,"su: uid %d not allowed to su\n", myuid);
return 1;
}
if(argc < 2) {
uid = gid = 0;➋
} else {
--*snip*--
}
if(setgid(gid) || setuid(uid)) {➌
fprintf(stderr,"su: permission denied\n");
return 1;
}
--*snip*--
execlp("/system/bin/sh", "sh", NULL);➍
fprintf(stderr, "su: exec failed\n");
return 1;
}
主要功能首先检查调用的 UID 是否为 AID_ROOT (0) 或 AID_SHELL (2000) ➊,如果是由其他 UID 的用户调用,则退出。接着,它将进程的 UID 和 GID 设置为 0 (➋ 和 ➌),最后启动 Android shell ➍。从这个 shell 执行的任何命令默认继承它的权限,从而允许对设备进行超级用户访问。
生产版本中的 Root 权限
正如我们在“工程版本中的 Root 权限”中所学到的,商业 Android 设备通常基于 用户 构建变种。这意味着 ADB 守护进程以 shell 用户身份运行,并且设备上没有安装 su 命令。
这是一个安全配置,大多数用户应能够使用平台提供的工具或第三方应用程序(如自定义启动器、键盘或 VPN 客户端)来实现设备配置和定制任务。然而,修改 Android 的外观、感觉或核心配置是不可行的,底层 Linux 操作系统的低级访问也无法实现。只有通过运行某些具有 root 权限的命令,才能执行这些操作,这就是为什么许多高级用户寻求在设备上启用 root 权限的原因。
在安卓设备上获得 root 权限通常被称为 rooting,对于那些拥有可解锁 bootloader 的设备来说,这个过程相对简单;而对于那些不允许解锁 bootloader 并采取额外措施防止修改系统分区的设备,则几乎不可能。在接下来的章节中,我们将描述典型的 root 过程,并介绍一些最受欢迎的“超级用户”应用程序,这些应用程序可以启用和管理 root 权限。
通过更改启动或系统镜像进行 Root
在一些 Android 设备上,给定一个解锁的 bootloader,通过简单地刷入新的启动镜像(通常称为 kernel,或 custom kernel),一个 用户 构建可以轻松转变为工程版或 userdebug 构建,从而改变 ro.secure 和 ro.debuggable 系统属性的值。修改这些属性使得 ADB 守护进程可以作为 root 执行,并通过 Android shell 启用 root 访问,正如在“工程版本中的 Root 权限”中所描述的那样。然而,大多数当前的 Android 用户 构建在编译时禁用了这种行为(通过不定义 ALLOW_ADBD_ROOT 宏),并且 ro.secure 和 ro.debuggable 系统属性的值会被 adbd 守护进程忽略。
启用 root 权限的另一种方法是解包系统镜像,添加一个 SUID su 二进制文件或类似的工具,并用新的系统镜像覆盖 system 分区。这通常不仅允许从 shell 获取 root 权限,还允许第三方应用程序获取 root 权限。然而,Android 4.3^([143]) 及更高版本中的一些安全增强功能禁止应用程序执行 SUID 程序,通过从 Zygote 启动的进程的边界集合中删除所有能力,并将 system 分区挂载为 nosetuid 标志。
此外,在将 SELinux 设置为强制模式的 Android 版本上,执行具有 root 权限的进程通常不会改变其安全上下文,该进程仍然会受到 MAC 策略的限制。因此,在最新的 Android 版本上启用 root 权限可能不像改变一些系统属性或将 SUID 二进制文件复制到设备那么简单。当然,替换 boot 或 system 镜像可以禁用 SELinux 并恢复任何安全缓解措施,从而放宽设备的安全级别并启用 root 权限。然而,这种激进的方法不亚于替换整个操作系统,并且可能会阻止设备从设备制造商处接收系统更新。在大多数情况下,这是不可取的,因此已经开发了几种尝试与设备的原生操作系统共存的 root 方法。
通过闪存 OTA 包进行 Root
OTA 包可以添加或修改系统文件,而无需替换整个操作系统镜像,因此是向设备添加 root 权限的良好候选方法。大多数流行的超级用户应用程序以 OTA 包和配套的管理应用程序的组合形式分发,OTA 包只需安装一次,管理应用程序可以在线更新。
SuperSU
我们将使用 SuperSU OTA 包 ^([144]) 和应用程序 ^([145])(由 Jorrit “Chainfire” Jongma 开发)来演示这种方法是如何工作的。SuperSU 是目前最流行的超级用户应用程序,并且在积极维护中,与 Android 平台的最新修改保持同步。SuperSU OTA 包的结构类似于完整的系统更新包,但只包含少量文件,如 示例 13-14 所示。
示例 13-14。SuperSU OTA 包的内容
.
|-- arm/➊
| |-- chattr
| |-- chattr.pie
| `-- su
|-- common/
| |-- 99SuperSUDaemon➋
| |-- install-recovery.sh➌
| `-- Superuser.apk➍
|-- META-INF/
| |-- CERT.RSA
| |-- CERT.SF
| |-- com/
| | `-- google/
| | `-- android/
| | |-- update-binary➎
| | `-- updater-script➏
| `-- MANIFEST.MF
`-- x86/➐
|-- chattr
|-- chattr.pie
`-- su
该包包含一些为 ARM ➊ 和 x86 ➐ 平台编译的原生二进制文件,启动和安装 SuperSU 守护进程的脚本(➋ 和 ➌),管理 GUI 应用程序的 APK 文件 ➍,以及两个更新脚本(➎ 和 ➏),用于应用 OTA 包。
为了理解 SuperSU 如何启用 root 权限,我们需要首先检查它的安装过程。为此,让我们分析 update-binary 脚本 ➎ 的内容,见 示例 13-15。(SuperSU 使用常规的 shell 脚本而不是本地二进制文件,因此 updater-script 只是一个占位符。)
示例 13-15. SuperSU OTA 安装脚本
#!/sbin/sh
--*snip*--
ui_print "- Mounting /system, /data and rootfs"➊
mount /system
mount /data
mount -o rw,remount /system
--*snip*--
mount -o rw,remount /
--*snip*--
ui_print "- Extracting files"➋
cd /tmp
mkdir supersu
cd supersu
unzip -o "$ZIP"
--*snip*--
ui_print "- Placing files"
mkdir /system/bin/.ext
cp $BIN/su /system/xbin/daemonsu➌
cp $BIN/su /system/xbin/su
--*snip*--
cp $COM/Superuser.apk /system/app/Superuser.apk➍
cp $COM/install-recovery.sh /system/etc/install-recovery.sh➎
cp $COM/99SuperSUDaemon /system/etc/init.d/99SuperSUDaemon
echo 1 > /system/etc/.installed_su_daemon
--*snip*--
ui_print "- Setting permissions"
set_perm 0 0 0777 /system/bin/.ext➏
set_perm 0 0 $SUMOD /system/bin/.ext/.su
set_perm 0 0 $SUMOD /system/xbin/su
--*snip*--
set_perm 0 0 0755 /system/xbin/daemonsu
--*snip*--
ch_con /system/bin/.ext/.su➐
ch_con /system/xbin/su
--*snip*--
ch_con /system/xbin/daemonsu
--*snip*--
ui_print "- Post-installation script"
/system/xbin/su --install➑
ui_print "- Unmounting /system and /data"➒
umount /system
umount /data
ui_print "- Done !"
exit 0
更新脚本首先以读写模式挂载 rootfs 文件系统以及 system 和 userdata 分区 ➊,然后它提取 ➋ 并将包含的文件复制到文件系统中的预定位置。su 和 daemonsu 本地二进制文件 ➌ 被复制到 /system/xbin/,这是额外本地二进制文件的常见位置(这些文件对于运行 Android 操作系统不是必需的)。根访问管理应用被复制到 /system/app/ ➍ 并在设备重启时由包管理器自动安装。接下来,更新脚本将 install-recovery.sh 脚本复制到 /system/etc/ ➎。
注意
如在“更新恢复模式”中讨论的那样,这个脚本通常用于从主操作系统更新恢复镜像,因此你可能会疑惑为什么 SuperSU 安装脚本会尝试更新设备的恢复镜像。SuperSU 使用这个脚本在启动时启动其某些组件,我们稍后会讨论。
OTA 包安装过程的下一步是设置已安装二进制文件的权限 ➏ 和 SELinux 安全标签 ➐(ch_con 是一个调用 chcon SELinux 工具并设置 u:object_r:system_file:s0 标签的 shell 函数)。最后,脚本调用 su 命令并使用 --install 选项 ➑ 执行一些安装后的初始化,然后卸载 /system 和 /data ➒。当脚本成功退出时,恢复模式会重新启动设备并进入主 Android 操作系统。
SuperSU 初始化方式
为了理解 SuperSU 是如何初始化的,让我们查看 install-recovery.sh 脚本的内容(见 示例 13-16,省略了注释),该脚本在启动时由 init 自动执行。
示例 13-16. SuperSU 的 install-recovery.sh 脚本 内容
#!/system/bin/sh
/system/xbin/daemonsu --auto-daemon &➊
/system/etc/install-recovery-2.sh➋
脚本首先执行daemonsu二进制文件➊,它启动一个具有 root 权限的守护进程。接下来的步骤执行install-recovery-2.sh脚本➋,该脚本可能用于执行其他初始化,以便其他 root 应用程序使用。为了允许应用程序以 root 权限执行代码,在 Android 4.3 及更高版本中,必须使用守护进程,因为所有应用程序(从zygote派生)其能力边界被设置为零,这防止它们执行特权操作,即使它们设法以 root 身份启动进程。此外,从 Android 4.4 开始,SELinux 处于强制模式,因此任何由应用程序启动的进程都会继承其安全上下文(通常是untrusted_app),因此受到与应用程序本身相同的 MAC 限制。
SuperSU 通过让应用程序使用su二进制文件以 root 身份执行命令来绕过这些安全限制,命令通过 Unix 域套接字传输给daemonsu守护进程,后者最终在u:r:init:s0 SELinux 上下文中以 root 身份执行接收到的命令。相关进程如示例 13-17 所示。
示例 13-17。当应用程序通过 SuperSU 请求 root 权限时启动的进程
$ **ps -Z**
LABEL USER PID PPID NAME
u:r:init:s0 root 1 0 /init➊
--*snip*--
u:r:zygote:s0 root 187 1 zygote➋
--*snip*--
u:r:init:s0 root 209 1 daemonsu:mount:master➌
u:r:init:s0 root 210 209 daemonsu:master➍
--*snip*--
u:r:init:s0 root 3969 210 daemonsu:10292➎
--*snip*--
u:r:untrusted_app:s0 u0_a292 13637 187 com.example.app➏
u:r:untrusted_app:s0 u0_a209 15256 187 eu.chainfire.supersu➐
--*snip*--
u:r:untrusted_app:s0 u0_a292 16831 13637 su➑
u:r:init:s0 root 16835 3969 /system/bin/sleep➒
在这里,com.example.app应用程序➏(其父进程是zygote➋)通过将命令传递给su二进制文件并使用其-c选项请求 root 权限。如你所见,su进程➑以与请求应用相同的用户(u0_a292,UID=10292)和相同的 SELinux 域(untrusted_app)执行。然而,应用请求以 root 身份执行的命令的进程➒(在此示例中为sleep)确实在init SELinux 域中以 root 身份执行(安全上下文u:r:init:s0)。如果我们追踪其父 PID(PPID,在第四列),会发现sleep进程是由daemonsu:10292进程➎启动的,这是专为我们的示例应用程序(UID=10292)创建的daemonsu实例。daemonsu:10292进程➎从daemonsu:master实例➍继承其init SELinux 域,该实例又由第一个daemonsu实例➌启动。这个实例是通过install-recovery.sh脚本启动的(参见示例 13-16),并且在其父进程init(PID=1)的域内运行。
eu.chainfire.supersu进程➐属于 SuperSU 管理应用程序,该程序显示图 13-6 中显示的 root 访问授权对话框。

图 13-6. SuperSU root 访问请求授权对话框
超级用户访问权限可以只授予一次、一定时间内,或永久授予。SuperSU 保留一个内部白名单,列出已获得 root 访问权限的应用程序,如果请求的应用程序已经在白名单中,则不会显示授权对话框。
注意
SuperSU 有一个配套库, libsuperuser,^([146]) 它通过提供 Java 封装器来简化编写 root 应用程序的过程,适配不同的调用 su 二进制文件的模式。SuperSU 的作者还提供了一个全面的编写 root 应用程序的指南,名为 How-To SU.^([147])
自定义 ROM 上的 root 访问
提供 root 访问权限的自定义 ROM 不需要通过 install-recovery.sh 来启动其超级用户守护进程(相当于 SuperSU 的 daemonsu),因为它们可以随意定制启动过程。例如,流行的 CyanogenMod 开源 Android 发行版从 init.superuser.rc 启动其 su 守护进程,如示例 13-18 所示。
示例 13-18. CyanogenMod 中 su 守护进程的启动脚本
service su_daemon /system/xbin/su --daemon➊
oneshot
on property:persist.sys.root_access=0➋
stop su_daemon
on property:persist.sys.root_access=2➌
stop su_daemon
on property:persist.sys.root_access=1➍
start su_daemon
on property:persist.sys.root_access=3➎
start su_daemon
这个 init 脚本定义了 su_daemon 服务 ➊,可以通过更改 persist.sys.root_access 持久系统属性的值来启动或停止(➋ 到 ➎)。该属性的值还决定了是否仅授予应用程序、ADB shell 或两者 root 访问权限。默认情况下,root 访问被禁用,可以通过 CyanogenMod 的开发选项进行配置,如图 13-7 所示。
警告
尽管 SuperSU 和允许 root 访问的自定义 ROM 采取一定措施来规范哪些应用程序可以作为 root 执行命令(通常通过将它们添加到白名单中),但是实现缺陷可能会允许应用程序绕过这些措施并在没有用户确认的情况下获得 root 访问权限。因此,root 访问应在日常使用的设备上禁用,仅在开发或调试时需要时使用。

图 13-7. CyanogenMod 根访问选项
通过漏洞获取 Root 访问
在无法解锁引导程序的生产设备上,可以通过利用特权提升漏洞来获得 root 权限,该漏洞允许应用程序或 shell 进程启动一个 root shell(也称为 软 root),并修改系统。这些漏洞通常打包成“单击式”应用程序或脚本,试图通过安装 su 二进制文件或修改系统配置来保持 root 权限。例如,所谓的 towelroot 漏洞(它以 Android 应用程序的形式分发)利用 Linux 内核中的一个漏洞(CVE-2014-3153)获得 root 权限,并安装 SuperSU 以保持 root 权限。(Root 权限也可以通过覆盖 recovery 分区并安装自定义恢复镜像来持久化,从而允许安装任意软件,包括超级用户应用程序。然而,一些设备有额外的保护机制,防止修改 boot、system 和 recovery 分区,因此永久获得 root 权限可能不可行。)
注意
详见《Android 黑客手册》 (Wiley, 2014) 的第三章,该章详细描述了用于在不同 Android 版本中获取 root 权限的主要特权提升漏洞。第十二章介绍了 Android 中为防止特权提升攻击而实施的主要漏洞缓解技术,并总体上增强了系统的安全性。
总结
为了能够更新系统软件或将设备恢复到出厂状态,Android 设备通过引导程序(bootloader)允许对其存储进行不受限制的低级访问。引导程序通常实现一个管理协议,通常是 fastboot,允许从主机传输和刷写分区映像。生产设备上的引导程序通常是锁定的,只允许刷写签名的映像。然而,大多数引导程序可以解锁,从而允许刷写第三方的映像。
Android 使用一个专用分区来存储第二个最小化的操作系统,称为恢复模式(recovery),用于应用 OTA 更新包或清除设备上的所有数据。与引导程序类似,生产设备上的恢复模式通常只允许应用设备制造商签名的 OTA 更新包。如果引导程序已解锁,则可以启动或永久安装自定义恢复模式,这种模式允许安装由第三方签名的更新,或完全放弃签名验证。
Android 的工程或调试版允许通过 Android shell 获取 root 权限,但生产设备上通常会禁用 root 权限。在这些设备上,可以通过安装包含“superuser”守护进程和配套应用程序的第三方 OTA 包来启用 root 权限,从而控制对应用程序的 root 访问。第三方 Android 构建(ROM)通常开箱即用允许 root 权限,尽管也可以通过系统设置界面禁用。
^([137]) Code Aurora Forum, “基于(L)ittle (K)ernel 的 Android 引导加载程序,” www.codeaurora.org/blogs/little-kernel-based-android-bootloader/
^([138]) Code Aurora Forum, www.codeaurora.org/cgit/quic/la/kernel/lk/
^([139]) Google, Android 兼容性定义,* static.googleusercontent.com/media/source.android.com/en//compatibility/android-cdd.pdf*
^([140]) Colin Percival, “二进制差异/补丁工具,” www.daemonology.net/bsdiff/
^([141]) TeamWin, “TWRP 2.7,” teamw.in/project/twrp2/
^([142]) TeamWin, “Team Win Recovery Project (TWRP),” github.com/TeamWin/Team-Win-Recovery-Project/
^([143]) Google, “Android 4.3 中的安全增强功能,” source.android.com/devices/tech/security/enhancements43.html
^([144]) Jorrit “Chainfire” Jongma, “CF-Root 下载页面,” download.chainfire.eu/supersu/
^([145]) Jorrit “Chainfire” Jongma, “Google Play 应用:SuperSU,” play.google.com/store/apps/details?id=eu.chainfire.supersu&hl=en
^([146]) Jorrit “Chainfire” Jongma, libsuperuser, github.com/Chainfire/libsuperuser/
^([147]) Jorrit “Chainfire” Jongma, “无问题的 su 使用指南,” su.chainfire.eu/


浙公网安备 33010602011771号