终于开了一直想写的这篇文章,再不写点东西就真的是年更博客了……
本文可以认为是 Android Booting Shenanigans 的中文补充说明,同时添加了 magiskinit 的一些处理细节。即使你对 magiskinit 没兴趣也可以看看,说不定就有一些你平时从来没注意到的细节呢 :)
Android Init
在介绍 init 执行过程前,我们先来介绍一下 init 是什么。
init 由 Linux 内核直接启动,是 Android 启动时用户空间的第一个进程,它的 pid 为 1。它承担了挂载关键系统分区、加载 SELinux policy、启动 property service、加载并执行启动脚本(init.rc)等重要工作。我们所熟知的 zygote、service manager 等进程就是被写在了 init.rc 内由 init 负责启动的。由于 init 进程的重要性,在安全层面它被视为内核的等效组件之一。
抛开细节不谈,init 所做的重要的事情大概有这些:
- 挂载
/dev
/proc
/sys
等重要文件系统,创建/dev/urandom
等设备 - 加载 SELinux policy 进 kernel
- 启动 property service 处理 setprop 等事件
- 执行
init.rc
,完成系统剩余的启动流程,如解密 userdata、启动 zygote 等
旧 rootfs 时代纯粹的 init
Android 5.0.2 init.c,点进去搜索 main 查看源码,整个逻辑非常清晰,大概就是上面的列表做的事情,没什么好讲的。
在这个时候,启动流程大概如下所示:
- Bootloader 加载 boot.img
- 解压运行里面的 kernel
- kernel 初始化后,调用
populate_rootfs()
将 boot.img 内的 ramdisk 释放到/
- 运行
/init
,此时因为/
这个根文件系统的文件是从 boot.img 的 ramdisk 中来的,所以会运行 boot ramdisk 里的 init - init 完成用户空间的初始化工作,继续开机
到了 Android 6.0 init.cpp,事情发生了一些变化:init 分了阶段。kernel 直接启动时,init 处在“一阶段”,此时 init 需要做一些用户空间的初始化工作,加载 SELinux policy,而此时因为 init 由 kernel 直接启动,它的 SELinux context 会是 u:r:kernel:s0
,所以它会 exec 它本身来 transition 到它专属的 SELinux domain(u:r:init:s0
)。也就是说,上面的第 5 步被拆分为下面几小步:
- 进行挂载
/dev
等必要的初始化 - 加载 SELinux policy
- 重新执行 init 本身,让进程的 SELinux domain 由 kernel 转换到 init
- 完成剩下的初始化,继续开机
这就是以前 Android 设备启动的方式。如果你在这些设备开机完成后查看 mounts 你会看见这样的东西:
1 | rootfs / rootfs ro,relatime 0 0 |
这代表 /
的文件系统类型是 rootfs,而 rootfs 是由 kernel 挂载 boot.img 内的 ramdisk 上去的。
Project Treble 带来的旧式 system-as-root 分区布局
到了 Android 8.0 时代(其实从 7.1 就开始准备了),Google 推出了 Project Treble。为了实现 Project Treble 及相关的 Generic System Image,/
需要和系统相绑定。Google 选择了推出名为 system-as-root (以下简称为 SAR)的新分区布局。简单来说,/
这个根文件系统中的内容不再来自 boot.img 内的 ramdisk,而是来自 system.img。为了实现这一行为,设备的 bootloader 在启动内核时会传递启动参数 skip_initramfs
,设备的内核会看见这一参数,从而跳过 boot.img 内的 ramdisk 而是直接挂载 system.img 到 /
。如果你在这些设备上查看 mounts,你会发现 /
的文件系统类型变成了 ext4
(或 erofs
?)而不是 rootfs。
同时,还出现了一个新的东西,A/B (Seamless) System Update (即所谓的 A/B 分区)。设备的 boot/system/vendor 等关键分区现在其实有两个,一个会被使用,而另一个用作后备。当系统更新的时候,更新包其实会被写入后备分区。这样的好处时,当更新导致无法开机的时候,因为有另一个分区存储着可以开机的系统,设备可以自动回退到上一个可以开机的版本。副作用也很明显:这些分区本来只需要一个的,现在每个都需要两个,直接 double 了,需要更大的存储空间(之后 Google 还引入了 Virtual A/B,不过这都是后话了,对我们不重要)。
怎么减少要用的存储空间呢?在之前,Android 设备需要一个 recovery 分区,里面存储着一个小型的 Linux 系统,用于在设备无法开机时可以进入 recovery 模式,从而恢复系统。虽然引入了 A/B 分区,但是 recovery 模式仍然需要保留,而 recovery 分区则不一定。对于 A/B 设备来说,由于 boot
现在有两个,即使其中一个分区因为不完整的系统更新被破坏,另一个也存储着完整的、未受破坏的 boot.img,所以 boot 至少有一个是可用的;而正常开机的时候,内核会直接挂载 system.img
到 /
,boot 内的 ramdisk 未被使用,所以可以用来放 recovery 的 ramdisk。此时,设备的开机流程如下所示:
- bootloader 判断设备是正常启动,还是要启动到 recovery
- 如果是正常启动,传递参数
skip_initramfs
- 内核如果看见了 bootloader 传递过来的
skip_initramfs
,就代表设备要正常启动,直接挂载 system.img 到/
;否则就是要启动到 recovery,因为 recovery 的 ramdisk 现在同样放在 boot,释放 boot.img 里的 ramdisk 到/
- 执行 init,继续启动流程。对于正常开机,这里 init 直接来自 system.img。
而对于非 A/B 的设备,因为 boot 只有一个,可能会在 OTA 中被破坏,为了保证设备始终有可用的 recovery,它们仍然保留了 recovery 分区。boot 内的 ramdisk 根本不会被使用到,所以 boot.img 内根本没有 ramdisk,同时设备的 bootloader 可能会直接把 boot ramdisk 给排除掉(看 OEM 怎么实现的,像三星等大部分厂商就会排除,但是小米等小部分厂商就不会)。
虽然 Google 在推出它时将其直接称为 system-as-root,但为了与下文的 2-Stage-Init System-as-root 区分开来,我们把它称为 Legacy SAR。Legacy SAR 对于出厂版本在 Android 9.0 以下的设备是可选的,但对于出厂 Android 9.0 的设备是强制的。
Android 10+:有三步的两步启动!
随着时间继续推移,到 Android 10 时,Google 推出了动态分区。对于上文提到的 Legacy SAR 来说,这是不可能的,因为 Linux 内核无法直接理解这种新的分区格式,无法将 system
挂载到 /
。
Google 的解决办法是:继续重写,发明新的启动方式!新的启动方式分为以下几步:
- 像旧式 rootfs 一样,boot.img 内的 ramdisk 会被释放到
/
- 执行
/init
,所以 boot.img 内的/init
会被执行 init
进入“第一阶段”,初始化用户空间,挂载/system
- 执行一个 switch root 操作,将 rootdir 切换到
/system
。现在的/
其实是 switch root 之前的/system
。 - 此时,设备的分区布局由旧式的 rootfs 变为 system-as-root。接下来是加载 SELinux policy,而这一步应该和 system 绑定,所以 init 选择执行 system 内的 init 来完成这一步。
- system 内的 init 收到前辈传来的
selinux_setup
参数,进入“第二阶段”,加载 SELinux policy。然后,为了把 SELinux domain 从kernel
切到init
,init 再次执行自己。 - init 再次被 exec,进入“第三阶段”,完成剩下的初始化工作,继续开机。
(可以查看 Android 10.0 init main.cpp,里面的代码很详细)。
我们把这种新的启动方式称为 2-Stage-Init (简称为 2SI)。由于开机完成后,设备的 rootdir 和 Legacy SAR 一样是 system,所以我们仍然把这种分区布局看作是 system-as-root(虽然以 Google 的标准,只有 Legacy SAR 才被看作 SAR)。
……慢着!明明 init 会被执行三次了,为什么把它称为“两步启动”?
这是因为,这种启动方式会在用户空间改变 rootdir,从 rootfs 改变为 system-as-root,而上面提到的其他两种都不会改变 rootdir。我们把 switch root 之前称为“第一步”,把 switch root 之后称为“第二步”,就得到和其他两种启动方式所区分开的名字:2SI。
对于出厂 Android 10+ 的设备来说,这种启动方式是强制的;从使用 rootfs 的旧系统更新到 Android 10+ 时,也需要使用这种新的启动方式;但对于使用 Legacy SAR 的设备,它们可以继续使用 Legacy SAR。典型的例子是 Google Pixel 3 & 3a 系列,出厂时它们使用 Legacy SAR,但 Google 对其进行了改进,使得升级到 Android 10 后它们转为使用 2SI 方式启动。
由于 Legacy SAR 和 2SI 最后都会使用 system-as-root 分区布局,所以搭载 Android 10+ 的设备其实都会使用 SAR。文档 中也提到 All devices running Android 10 must use a system-as-root partition layout to enable system-only OTAs.
。
注:在 Android 10+ 上使用 Legacy SAR 时,由于 Android 10 的 init 写死了会执行三次,所以也会有 first stage、setup selinux、second stage 这三步;虽然 rootdir 没有发生改变,为了与 Android 9.0 Legacy SAR 区别,我们把这种情况称为 2SI legacy SAR
或 Legacy SAR with 2SI
,而本节中提到的会改变 rootdir 的启动方式称为 modern 2SI
、2SI ramdisk SAR
或 2SI from initramfs
。当我们使用简称 2SI
时,我们默认指的是 Modern 2SI
。
关于更多细节,可以查阅官方的 Android Init - Early Init Boot Sequence
魅族:2SI,但是从 rootfs 到 rootfs
(注:此种特殊行为在原版 Magisk - Android Booting Shenanigans 文档中并未列出)
上文提到,“搭载 Android 10+ 的设备其实都会使用 SAR”,但对于魅族 16 系列来说并非如此。搭载 Android 10 的魅族 16 系列设备启动时会经过上面 2SI 的所有步骤,除了 switch root 这一步。由于并没有 switch root,所以设备会一直使用 rootfs。
MagiskInit
先来介绍一下 magiskinit 是什么:magiskinit 是 magisk 的重要组件之一,magisk 正是通过替换 init 为自己的 magiskinit 实现劫持设备启动过程的。
可以通过查看文档来大概了解 magiskinit 做了什么。接下来我们结合上面的知识以及 magiskinit 源码来具体分析。
注:截至发稿,本文分析的源码为 Magisk master 分支上的最新代码。最新 commit 为 cfb20b0,点进去可以浏览对应的文件。
!!!硬核警告,非战斗人员请迅速逃离现场!!!
由于 magiskinit 替换了 init,所以当内核执行 init 时,magiskinit 的 main() 会被执行。main() 的代码位于 init.cpp:
1 | int main(int argc, char *argv[]) { |
我们重点关注几个 if 分支。
RootFSInit
看名字就可以看出来,这就是用来处理上面提到的第一种分区布局 RootFS 的分支。类的定义在 init.hpp:
1 | /************ |
1 | void RootFSInit::prepare() { |
patch_rc_scripts()
里最重要的就是把这段 rc 脚本注入进了 init.rc,使得 init 会在系统开机过程中自动执行 magisk:
1 | on post-fs-data |
对 init.rc 语法规则不熟的同学,可以参考 Android Init Language
整个逻辑还是比较清晰的,忽略一些过于内部的细节,大概如下所示:
- kernel 执行 init,magiskinit 被运行,检测系统环境,进入 RootFSInit
- 修补 init.rc,注入我们的代码
- 初始化 magisk 内部要用到的 tmpfs
- 修补 sepolicy,注入我们的规则(细节后面再谈)
- 把 magiskinit 复制到 /sbin/magisk,这样 init 第一次执行 magisk 时会进入
magisk_proxy_main()
做剩余工作 - 执行系统原来的 init,继续开机流程
- init 会解析并执行 init.rc,运行 magisk,这个时候运行的其实是 magiskinit,进入
magisk_proxy_main()
- 把 /sbin 用作 magisk internal tmpfs,然后因为我们 mount 了 tmpfs 在 sbin 上,要把里面本来就有的文件恢复出来要不然就没了
- 运行真正的 magisk32/magisk64
Modern 2SI
由于劫持 Legacy SAR 的启动过程过于复杂且存在太多种情况,我们先讲 Modern 2SI。
劫持 Modern 2SI 启动过程的难点在于:由于一阶段 init 来自 boot.img 内 ramdisk,所以 magiskinit 会被执行;但是,一旦运行原来的 init,她就会挂载 /system、switch root 到 /system,我们对原来 rootfs 所做的修改 全 部 木 大。而 init 一阶段运行时,/system 还未被挂载,直接对 /system 做修改也行不通。所以我们必须找到一种方法,在 init 挂载 /system 之后做修改,且 /system 往往是 read-only 的,由于 system.img 可能是 de-duplicate 过的 ext4 或者干脆就是 EROFS,也不可能将其 remount 为 read-write,任何方式直接修改都行不通,只能用 bind-mount 这种方法变相修改。
我们注意到,init 在 switch root 之前,会把现在的 mounts (除了 / 和 /system 本身)给 move 到新的 root 下面,然后再 switch root(具体代码可见这里)。通过利用这个特性,我们似乎可以让我们的某些东西在 switch root 之后仍然保留下来,但是怎么利用呢?
我们还是来看看 magiskinit 的代码吧。magiskinit 的基本思想是,first stage 做一些 hack,执行原本的 init 让它为我们挂载 /system 并 switch root,然后通过前面做的 hack 想办法让 init 再次触发自己。也就是说,类似 2SI,它也是分两步走的。
1 | /*************** |
FirstStageInit 的 prepare 非常简单:
1 | #define INIT_PATH "/system/bin/init" |
这个函数做了什么呢?首先,prepare_data()
给 /data 挂上了 tmpfs,释放文件到 /data,把 magiskinit 复制到 /data/magiskinit;接着将原本 init 里的 /system/bin/init
patch 成了 /data/magiskinit
。
为什么可以这样做呢?还是得看回上面的特性。我们知道,switch root 前的 mount 会保留到 switch root 之后,所以可以用这种方法保留某些东西;但是,mount 要求目标文件(夹)必须存在,而 /system 并不可写,所以自己 mkdir 一个文件夹,mount tmpfs 然后指望 init 帮我们 move mount 的做法行不通。为了保留某些东西,我们必须选一个 init first stage 到 selinux_setup 期间都不会被使用到的文件夹来帮我们放东西,而 magisk 选择的是 /data。这让 patch init 使得 init 执行 /system/bin/init 进行 selinux_setup 时转为执行 magiskinit。
第二阶段的代码:
1 | bool SecondStageInit::prepare() { |
大致逻辑还是差不多的,只不过多了一些不同系统的处理(比如魅族设备需要特殊处理,这里因为 rootfs 仍然可写所以直接走 rootfs 逻辑就好了),还有由于 switch root 后 /
并不可写,所以所有对文件的修改都是先把修改过的文件放在 magisk internal tmpfs 内,然后 bind mount 到它原始的路径。
大致流程为:
- kernel 执行 init,magiskinit 被运行,检测系统环境,判断为 2SI 设备第一阶段,进入 FirstStageInit
- 挂载 /data 为 tmpfs,释放 /data/magiskinit
- 修补原始 init,重定向 /system/bin/init 到 /data/magiskinit,然后执行原始 init
- 原始 init 会挂载 /system 然后 switch root 进去,接下来它执行
/system/bin/init(被我们 patch 了所以会执行 /data/magiskinit)进行 selinux_setup - magiskinit 被再次执行,看见 selinux_setup 知道这是二阶段,switch root 已经完成,开始
patch_ro_root()
- 修补 init.rc,注入我们的代码
- 修补 sepolicy,注入我们的规则(细节后面再谈)
- 利用 bind mount 替换 所有需要修改的文件
- 执行系统原来的 init,继续开机流程
- init 会解析并执行 init.rc,运行 magisk
Legacy SAR
终于讲到 Legacy SAR 这个坑人的玩意了。。。
这玩意坑人的地方在于:
- boot.img 内 ramdisk 正常情况不会用到,magiskinit 根本没机会执行。对于 A/B 设备,boot 里放着的是 recovery ramdisk,bootloader 通过向内核传递
skip_initramfs
来让内核知道该不该挂载 initramfs。Magisk 的解决办法是,安装的时候 patch 一下内核,把skip_initramfs
patch 成别的东西这样内核就认不出来 bootloader 传的是什么了,然后就会乖乖的挂载 initramfs。但对于 A-only 设备,boot.img 里根本没有 ramdisk,就算手动加一个 ramdisk,bootloader 也很有可能并不识别它,根本没办法插一脚。Magisk 表示臣妾无能为力,patch recovery.img 然后每次都重启到 recovery 用吧,要不然我也插不进去啊~ - 即使我们成功把 init 偷梁换柱成了 magiskinit,
/
也是 system.img,写不了,很难干活。
来看看 magiskinit 的代码吧:
1 | /************* |
总结如下:
- magiskinit 被运行,检测系统环境,判断为 Legacy SAR,进入 LegacySARInit
- 因为 kernel 被我们 patch 了,原本 switch root 应该由 kernel 来做的,现在没有了,需要手动 mount /system 然后 switch root 进去
- 检测有没有 /apex,有的话代表 Android 10+,init 有 selinux_setup 阶段,可以劫持,走
first_stage_prep()
;没有的话直接patch_ro_boot()
然后执行原来的 init first_stage_prep()
里会像 Modern 2SI 那样修补 init,将 /system/bin/init 重定向到 /data/magiskinit,然后把它挂载到 /init 以供后面执行;执行原来的 init。- 原来的 init 执行 selinux_setup(若有),转到 magiskinit,magiskinit 识别到二阶段,自动
patch_ro_boot()
patch_ro_boot()
完成之后,执行 init 交还控制权,继续开机
修补 SEPolicy
由于这一块过于复杂,所以单独放一节讲。
如果你仔细看了上面的代码,你会发现修补 SEPolicy 有 patch_sepolicy()
以及 hijack_sepolicy()
两个函数,那么它们有什么不同呢?
1 | void MagiskInit::patch_sepolicy(const char *in, const char *out) { |
最直观的感觉就是,两个函数代码量不一样……
Android 上,sepolicy 可以以单个的 sepolicy 文件的形式存储,也可以存储多个拆分的 cil 文件;前者被称为 monolithic policy,后者被称为 split sepolicy。Linux 内核只接受 monolithic policy,所以加载 split sepolicy 时,由 init 进程负责将 cil 文件编译为 monolithic policy 并加载进内核。
对于 monolithic policy,magiskinit patch 起来非常轻松惬意:直接加载 /sepolicy,修改,放回去,然后 init 加载的时候就会加载我们修改过的 sepolicy。
而对于 split policy 可就麻烦了,init 运行之前可能根本没有 /sepolicy 给你 patch,怎么办呢?因为 init 会把 cil 全部编译成 monolithic policy 然后再传进内核里(具体是往 /sys/fs/selinux/load
写入),要是有方法拦截这个写入过程就好了!
有吗?还真有。
先快速介绍一下 FIFO 的概念:FIFO 看起来就像一个普通的文件,但是它用起来类似 pipe,就是一根管子,当一个进程往里写内容的时候,另一个进程能读取到。反过来,一个进程读它的时候,默认会阻塞,直到有另外的进程打开了这个 FIFO 并且写入内容。说白了就是一个专门用来进程间通信的管子,只不过长得是个文件而已。
Magisk 的 hijack_policy()
正是利用了 FIFO 的特性,才能拦截到 init 加载 sepolicy 的过程。
具体来说,大致分为以下几步:
- 判断 selinuxfs 有没有被挂载,如果没有需要先等 init 挂载 selinuxfs。这里我们同样利用 FIFO,选择了一个 init 挂载完 selinuxfs 之后会读取的文件,来卡住时机。里面的注释
This only happens on Android 8.0 - 9.0
其实是因为更旧的版本没有 split policy,直接 patch monolithic policy 就好了,不用走 hijack;而 Android 10+ init 始终分为三步,我们永远在 selinux_setup 时进行 hijack,此时 first stage 已经走完了,selinuxfs 肯定已经被 init mount 上了;只有 Android 8.0-9.0,有可能有 split policy 又没有单独的 selinux_setup 这一步可以拦截,所以只能手动进行特殊处理。 - 用 bind mount 把
/sys/fs/selinux/load
覆盖成一个普通的文件,这样 init 往里写入 sepolicy 时其实是写进了我们的文件,而不是内核里 - 调用
mkfifo()
创建一个 FIFO,然后 bind mount 到/sys/fs/selinux/enforce
上面 - fork 一个子进程,父进程继续执行剩余步骤,然后执行原始 init;子进程会以 write-only 的方式打开这个 FIFO,此时因为 FIFO 的特性,这个进程会阻塞,直到有另一个进程以 read 方式打开同样的 FIFO。在我们的例子当中,子进程会阻塞,直到 init 调用
security_getenforce()
读取了/sys/fs/selinux/enforce
,此时 init 阻塞,等待我们的子进程往 FIFO 内写入内容,而此时 init 已经把要加载的 sepolicy 写入到了/sys/fs/selinux/load
- 读取 init 原本要加载的 sepolicy,进行 patch 注入自己的规则,然后手动加载进内核里(因为之前 init 尝试加载的时候被我们给拦了)
- 往 FIFO 写入内容,唤醒对端的 init 进程,做一些 cleanup 然后可以退出子进程。
虽然整个 hijack 还使用了 LD_PRELOAD
等技术来保证 sepolicy hijack 能够正常工作,但大致流程就是如上所示。整个机制非常精妙,非常依赖 init 的实际行为,需要结合 android init 及 libselinux 的源码反复琢磨才能搞清其中逻辑。