APEX:开启Android系统新篇章的应用扁平化技术
Android Pony Express (APEX) 是在 Android Q 中引入的一种容器格式,用于安装流程中较低级系统模块的更新。该格式方便了系统组件的更新,这些组件不适合标准的 Android 应用程序模型。一些示例组件包括原生服务和库、硬件抽象层(HALs)、运行时(ART)和类库。
术语 “APEX” 也可以指代 APEX 文件。
背景
尽管 Android 支持通过包安装程序应用(例如 Google Play 商店应用)来更新符合标准应用模型的模块(例如服务、活动),但对于较低级的操作系统组件使用类似的模型存在以下缺点:
- 基于 APK 的模块无法在引导序列的早期使用。包管理器是关于应用程序的信息的中央存储库,并且只能从活动管理器启动,而活动管理器在引导过程的较后阶段变为可用。
- APK 格式(特别是清单)是为 Android 应用程序设计的,而系统模块并不总是适合这种格式。
设计
本部分介绍 APEX 文件格式和 APEX 管理器的高级设计,其中 APEX 管理器是管理 APEX 文件的服务。
APEX 格式
这是 APEX 文件的格式。
在顶层,APEX 文件是一个 zip 文件,其中文件以未压缩方式存储,并位于 4 KB 边界处。
APEX 文件中的四个文件包括:
apex_manifest.json
AndroidManifest.xml
apex_payload.img
apex_pubkey
apex_manifest.json
文件包含标识 APEX 文件的包名和版本信息。
AndroidManifest.xml
文件允许 APEX 文件使用与 APK 相关的工具和基础设施,例如 ADB、PackageManager 和包安装程序应用(例如 Play Store)。例如,APEX 文件可以使用现有的工具(如 aapt
)从文件中提取基本元数据。该文件包含包名和版本信息,这些信息通常也可以在 apex_manifest.json
中找到。AndroidManifest.xml
可能包含其他定位信息,这些信息可以被现有的应用发布工具使用。
对于处理 APEX 的新代码和系统,建议使用 apex_manifest.json
而不是 AndroidManifest.xml
。
apex_payload.img
是由 dm-verity 支持的 ext4 文件系统映像。该映像在运行时通过循环设备挂载。具体来说,哈希树和元数据块使用 libavb 创建。文件系统负载不需要解析(因为映像应该可以在原地挂载)。常规文件包含在 apex_payload.img
文件中。
apex_pubkey
是用于签名文件系统映像的公钥。在运行时,此密钥确保下载的 APEX 与内置分区中相同的实体签名了相同的 APEX。
APEX 管理器
APEX 管理器(或 apexd
)是负责验证、安装和卸载 APEX 文件的本地守护进程。这个进程在引导序列的早期启动并准备好工作。APEX 文件通常预先安装在设备的 /system/apex
目录下。如果没有更新可用,APEX 管理器默认使用这些包。
APEX 的更新序列使用 PackageManager class,具体如下:
-
通过包安装程序应用、ADB 或其他源下载 APEX 文件。
-
包管理器启动安装过程。一旦识别出文件是 APEX 文件,包管理器将控制权转移到 APEX 管理器。
-
APEX 管理器验证 APEX 文件。
-
如果 APEX 文件经过验证,APEX 管理器的内部数据库会更新以反映该 APEX 文件将在下一次启动时被激活。
-
安装请求者在包成功验证后接收一个广播信号。
-
为了继续安装,系统自动重新启动设备。
-
在重新启动时,APEX 管理器启动,读取内部数据库,并对每个列出的 APEX 文件执行以下操作:
- 验证 APEX 文件。
- 从 APEX 文件创建一个循环设备。
- 在循环设备顶部创建一个设备映射器块设备。
- 将设备映射器块设备安装到一个唯一的路径上(例如,
/apex/name@ver
)。
当内部数据库中列出的所有 APEX 文件都被挂载时,APEX 管理器为其他系统组件提供一个绑定器服务,以查询安装的 APEX 文件的信息。例如,其他系统组件可以查询设备中安装的 APEX 文件列表或查询特定 APEX 安装的确切路径,以便访问这些文件。
APEX 文件是 APK 文件
APEX 文件是有效的 APK 文件,因为它们是使用 APK 签名方案签名的 Zip 归档文件,其中包含一个 AndroidManifest.xml
文件。这使得 APEX 文件可以使用 APK 文件的基础设施,如软件包安装程序应用、签名实用工具和包管理器。
APEX 文件内部的 AndroidManifest.xml
文件非常简单,只包含包名、版本号和可选的 targetSdkVersion
、minSdkVersion
和 maxSdkVersion
,用于定位到更精细的 SDK 版本。这些信息允许通过现有的通道(例如软件包安装程序应用和 ADB)传递 APEX 文件。
支持的文件类型
APEX 格式支持以下文件类型:
- 本地共享库
- 本地可执行文件
- JAR 文件
- 数据文件
- 配置文件
APEX 格式只能更新其中的一些文件类型,能否更新某个文件类型取决于平台以及文件类型的接口定义的稳定程度。
签名
APEX 文件以两种方式进行签名。首先,apex_payload.img
文件(特别是附加在 apex_payload.img
上的 vbmeta 描述符)使用密钥进行签名。然后,整个 APEX 使用 APK 签名方案 V3 进行签名。这个过程中使用了两个不同的密钥。
在设备端,会安装与用于签署 vbmeta 描述符的私钥对应的公钥。APEX 管理器使用该公钥来验证请求安装的 APEX。每个 APEX 必须使用不同的密钥进行签名,在构建时和运行时都会强制执行此要求。
内置分区中的 APEX
APEX 文件可以位于内置分区(如 /system
)中。分区已经过 dm-verity,因此 APEX 文件直接挂载在循环设备上。
如果内置分区中存在一个 APEX 文件,则可以通过提供具有相同包名和更高版本号的 APEX 包来更新该 APEX。新的 APEX 存储在 /data
中,并且类似于 APK 文件,较新版本会遮盖内置分区中已有的版本。但与 APK 文件不同的是,较新版本的 APEX 只有在重新启动后才会被激活使用。
内核要求
为了支持 Android 设备上的 APEX 主线模块,需要满足以下 Linux 内核特性:loop 驱动程序和 dm-verity。loop 驱动程序用于挂载 APEX 模块中的文件系统映像,dm-verity 用于验证 APEX 模块。
在使用 APEX 模块时,loop 驱动程序和 dm-verity 的性能对于实现良好的系统性能非常重要。
支持的内核版本
支持在使用内核版本 4.4 或更高的设备上运行 APEX 主线模块。新设备在使用 Android Q 或更高版本时,必须使用内核版本 4.9 或更高版本来支持 APEX 模块。
必需的内核补丁
支持 APEX 模块的必需内核补丁已包含在 Android 公共树中。要获取支持 APEX 的补丁,请使用最新版本的 Android 公共树。
内核版本 4.4
此版本仅支持从 Android 9 升级到 Android Q 并希望支持 APEX 模块的设备。建议从 android-4.4
分支进行 down-merge 以获取所需的补丁。以下是内核版本 4.4 所需的个别补丁列表。
- UPSTREAM: loop: add ioctl for changing logical block size
(4.4{: .external}) - BACKPORT: block/loop: set hw_sectors
(4.4{: .external}) - UPSTREAM: loop: Add LOOP_SET_BLOCK_SIZE in compat ioctl
(4.4{: .external}) - ANDROID: mnt: Fix next_descendent
(4.4{: .external}) - ANDROID: mnt: remount should propagate to slaves of slaves
(4.4{: .external}) - ANDROID: mnt: Propagate remount correctly
(4.4{: .external}) - Revert “ANDROID: dm verity: add minimum prefetch size”
(4.4{: .external}) - UPSTREAM: loop: drop caches if offset or block_size are changed
(4.4{: .external})
内核版本 4.9/4.14/4.19
要获得内核版本 4.9/4.14/4.19 的所需补丁,请从 android-common
分支进行 down-merge。
必需的内核配置选项
以下列表显示了支持在 Android Q 中引入的 APEX 模块所需的基本配置要求。带有星号(*)的项目是 Android 9 及更低版本的现有要求。
(*) CONFIG_AIO=Y # 支持 AIO(用于循环设备上的直接 I/O)
CONFIG_BLK_DEV_LOOP=Y # 支持循环设备
CONFIG_BLK_DEV_LOOP_MIN_COUNT=16 # 预创建 16 个循环设备
(*) CONFIG_CRYPTO_SHA1=Y # DM-verity 的 SHA1 哈希函数支持
(*) CONFIG_CRYPTO_SHA256=Y # DM-verity 的 SHA256 哈希函数支持
CONFIG_DM_VERITY=Y # 支持 DM-verity
内核命令行参数要求
为了支持 APEX,确保内核命令行参数满足以下要求。
loop.max_loop
不能设置loop.max_part
必须小于等于 8
构建 APEX
注意:由于 APEX 的实现细节仍在开发中,本节内容可能会有所变动。
本节描述了如何使用 Android 构建系统构建 APEX。以下是一个名为 apex.test
的 APEX 的 Android.bp
示例。
apex {
name: "apex.test",
manifest: "apex_manifest.json",
file_contexts: "file_contexts",
// libc.so 和 libcutils.so 包含在 APEX 中
native_shared_libs: ["libc", "libcutils"],
binaries: ["vold"],
java_libs: ["core-all"],
prebuilts: ["my_prebuilt"],
compile_multilib: "both",
key: "apex.test.key",
certificate: "platform",
}
apex_manifest.json
示例:
{
"name": "com.android.example.apex",
"version": 1
}
file_contexts
示例:
(/.*)? u:object_r:system_file:s0
/sub(/.*)? u:object_r:sub_file:s0
/sub/file3 u:object_r:file3_file:s0
APEX 中的文件类型和位置
文件类型 | 在 APEX 中的位置 |
---|---|
共享库 | /lib 和 /lib64 (/lib/arm 用于在 x86 上的翻译 ARM) |
可执行文件 | /bin |
Java 库 | /javalib |
预构建文件 | /etc |
传递依赖关系
APEX 文件自动包括本地共享库或可执行文件的传递依赖关系。例如,如果 libFoo
依赖于 libBar
,那么当只有 libFoo
在 native_shared_libs
属性中列出时,这两个库都会被包含进来。
处理多个 ABI
针对设备的主要 ABI 和次要 ABI 都要安装 native_shared_libs
属性。如果一个 APEX 针对只支持单个 ABI 的设备(即仅支持 32 位或 64 位),那么只会安装相应 ABI 的库。
只为设备的主要 ABI 安装 binaries
属性,如下所示:
- 如果设备仅支持 32 位,只会安装 32 位二进制文件的变体。
- 如果设备支持 32/64 位双 ABI,但配置了
TARGET_PREFER_32_BIT_EXECUTABLES=true
,那么只会安装 32 位二进制文件的变体。 - 如果设备仅支持 64 位,只会安装 64 位二进制文件的变体。
- 如果设备支持 32/64 位双 ABI,但未配置
TARGET_PREFER_32_BIT_EXECUTABLES
=true`,那么只会安装 64 位二进制文件的变体。
为了对本地库和二进制文件的 ABI 进行精细控制,可以使用 multilib.[first|lib32|lib64|prefer32|both].[native_shared_libs|binaries]
属性。
first
:与设备的主要 ABI 匹配。这是二进制文件的默认配置。lib32
:与设备的 32 位 ABI 匹配(如果支持)。lib64
:与设备的 64 位 ABI 匹配(如果支持)。prefer32
:与设备的 32 位 ABI 匹配(如果支持)。如果不支持 32 位 ABI,则与设备的 64 位 ABI 匹配。both
:同时匹配两个 ABI。这是native_shared_libraries
的默认配置。
java
、libraries
和 prebuilts
属性与 ABI 无关。
以下示例适用于支持 32/64 位并且不偏好 32 位的设备:
apex {
// 其他属性省略
native_shared_libs: ["libFoo"], // 安装 32 位和 64 位
binaries: ["exec1"], // 只安装 64 位,32 位不安装
multilib: {
first: {
native_shared_libs: ["libBar"], // 只安装 64 位,32 位不安装
binaries: ["exec2"], // 与没有 multilib.first 的 binaries 一样
},
both: {
native_shared_libs: ["libBaz"], // 与没有 multilib 的 native_shared_libs 一样
binaries: ["exec3"], // 安装 32 位和 64 位
},
prefer32: {
native_shared_libs: ["libX"], // 只安装 32 位,64 位不安装
},
lib64: {
native_shared_libs: ["libY"], // 只安装 64 位,32 位不安装
},
},
}
vbmeta签名
使用不同的密钥对每个APEX进行签名。当需要新的密钥时,创建一个公私钥对,并制作一个apex_key
模块。使用key
属性使用密钥对APEX进行签名。公钥会自动包含在具有名称avb_pubkey
的APEX中。
创建一个RSA密钥对。
$ openssl genrsa -out foo.pem 4096
从密钥对中提取公钥。
$ avbtool extract_public_key --key foo.pem --output foo.avbpubkey
在Android.bp文件中:
apex_key {
name: "apex.test.key",
public_key: "foo.avbpubkey",
private_key: "foo.pem",
}
在上述例子中,公钥的名称(foo
)成为该密钥的ID。用于签署APEX的密钥的ID会写入APEX中。运行时,apexd
会使用设备中具有相同ID的公钥验证APEX。
ZIP签名
以与APK相同的方式对APEX进行签名。对APEX进行两次签名,一次是对迷你文件系统(apex_payload.img
文件)的签名,一次是对整个文件的签名。
要在文件级别对APEX进行签名,可以通过以下三种方式之一设置certificate
属性:
- 未设置:如果未设置任何值,则使用位于
PRODUCT_DEFAULT_DEV_CERTIFICATE
路径下的证书对APEX进行签名。如果没有设置标志,则路径默认为build/target/product/security/testkey
。 <name>
:使用与PRODUCT_DEFAULT_DEV_CERTIFICATE
位于相同目录中的<name>
证书对APEX进行签名。:<name>
:使用由Soong模块命名为<name>
的证书进行签名。证书模块可以定义如下:
android_app_certificate {
name: "my_key_name",
certificate: "dir/cert",
// this will use dir/cert.x509.pem (the cert) and dir/cert.pk8 (the private key)
}
注意:key
和certificate
的值不需要来自相同的公私钥对。APK签名(由certificate
指定)是必需的,因为APEX是一个APK。
安装APEX
要安装APEX,请使用ADB。
$ adb install apex_file_name
$ adb reboot
使用APEX
重启后,APEX会挂载在/apex/<apex_name>@<version>
目录下。可以同时挂载多个版本的相同APEX。在挂载路径中,与最新版本对应的路径会以绑定挂载的方式出现在/apex/<apex_name>
下。
客户端可以使用绑定挂载的路径从APEX中读取或执行文件。
通常的APEX使用方式如下所示:
- OEM或ODM在设备出货时在
/system/apex
下预装APEX。 - 可通过
/apex/<apex_name>/
路径访问APEX中的文件。 - 当在
/data/apex
中安装了更新版本的APEX后,路径在重启后指向新的APEX。
使用APEX更新服务
要使用APEX更新服务:
-
将系统分区中的服务标记为可更新。在服务定义中添加选项
updatable
。/system/etc/init/myservice.rc: service myservice /system/bin/myservice class core user system ... updatable
-
为更新后的服务创建一个新的
.rc
文件。使用override
选项重新定义现有服务。/apex/my.apex@1/etc/init.rc: service myservice /apex/my.apex@1/bin/myservice class core user system ... override
服务定义只能在APEX的.rc
文件中定义。不支持在APEX中使用Action触发器。
如果标记为可更新的服务在APEX被激活之前启动,启动将延迟,直到APEX的激活完成。
配置系统以支持APEX更新
将以下系统属性设置为true
以支持APEX文件更新。
<device.mk>:
PRODUCT_PROPERTY_OVERRIDES += ro.apex.updatable=true
BoardConfig.mk:
TARGET_FLATTEN_APEX := false
或者只需要:
<device.mk>:
$(call inherit-product, $(SRC_TARGET_DIR)/product/updatable_apex.mk)
扁平化 APEX
对于一些旧设备来说,更新内核以完全支持 APEX 有时是不可能或不现实的。例如,内核可能是没有使用 CONFIG_BLK_DEV_LOOP=Y
编译的,而这对于在 APEX 中挂载文件系统映像是至关重要的。
扁平化 APEX 是一个特别构建的 APEX,它可以在具有旧内核的设备上激活。扁平化 APEX 中的文件直接安装到内置分区下的目录中。例如,扁平化 APEX my.apex
中的 lib/libFoo.so
被安装到 /system/apex/my.apex/lib/libFoo.so
。
激活扁平化 APEX 不涉及环回设备。整个目录 /system/apex/my.apex
直接绑定到 /apex/name@ver
。
无法通过从网络下载已更新的 APEX 的方式更新扁平化 APEX,因为下载的 APEX 无法被扁平化。扁平化 APEX 只能通过常规 OTA 进行更新。
请注意,目前扁平化 APEX 是默认配置。这意味着除非您明确地配置设备以支持可更新的 APEX(如上所述),否则默认情况下所有 APEX 都是扁平化的。
还要注意,在设备中混合扁平化和非扁平化的 APEX 是不受支持的。它应该是全部非扁平化或全部扁平化。这在为 Mainline 等项目提供预签名 APEX 预构建时尤其重要。那些不是预签名的 APEX(即从源代码构建的)也应该是非扁平化的,并在这种情况下使用正确的密钥进行签名。设备应该继承 updatable_apex.mk
,如上所述。
在开发 APEX 时考虑的替代方案
以下是在设计 APEX 文件格式时我们考虑过的一些选项,以及为什么包含或排除它们。
常规软件包管理系统
Linux 发行版有像 dpkg
和 rpm
这样的软件包管理系统,它们功能强大、成熟并且稳健。但是,我们没有采用它们作为 APEX 的管理系统,因为它们无法在安装之后保护软件包的完整性。只有在安装软件包时才会进行验证。攻击者可以在不被察觉的情况下破坏已安装软件包的完整性。这是 Android 的一个退化,因为所有系统组件都被存储在只读文件系统中,每个 I/O 都由 dm-verity 保护。任何对系统组件的篡改都必须被禁止或可检测,以便设备在受损时拒绝启动。
用于完整性的 dm-crypt
APEX 容器中的文件来自内置分区(例如 /system
分区),这些分区由 dm-verity 保护,即使在装载分区后也禁止对文件进行修改。为了向文件提供相同的安全性,所有 APEX 中的文件都存储在与哈希树和 vbmeta 描述符配对的文件系统映像中。如果没有了 dm-verity,在 /data
分区中的 APEX 就容易遭受在验证和安装之后的意外修改攻击。
事实上,/data
分区也受到加密层(如 dm-crypt)的保护。虽然这提供了一定程度的篡改保护,但它的主要目的是隐私保护,而不是完整性保护。当攻击者获得访问 data
分区的权限时,就再也没有进一步的保护了,这再次退化了与每个系统组件位于 /system
分区有所不同的情况。APEX 文件中的哈希树加上 dm-verity 提供了相同级别的内容保护。
重定向路径从 /system
到 /apex
APEX 包中打包的系统组件文件可以通过新路径访问,比如 /apex/<name>/lib/libfoo.so
。当文件是 /system
分区的一部分时,它们可以通过 /system/lib/libfoo.so
这样的路径访问。APEX 文件的客户端(其他 APEX 文件或平台)应使用新路径。这种路径变更可能需要对现有代码进行更新。
避免路径变化的一种方法是在 APEX 文件中将文件内容覆盖到 /system
分区上。然而,我们决定不在 /system
分区上覆盖文件,因为我们认为随着被覆盖(甚至堆叠在一起)的文件数量的增加,这将对性能产生负面影响。
另一种选择是劫持文件访问函数,如 open
、stat
和 readlink
,以便以 /system
开头的路径重定向到其在 /apex
下对应的路径。我们放弃了这个选项,因为实际上很难改变接受路径的所有函数。例如,有些应用程序静态链接 Bionic,而 Bionic 实现了这些函数。在这种情况下,重定向不会发生在应用上。