终于开了一直想写的这篇文章,再不写点东西就真的是年更博客了……

本文可以认为是 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 所做的重要的事情大概有这些:

  1. 挂载 /dev /proc /sys 等重要文件系统,创建 /dev/urandom 等设备
  2. 加载 SELinux policy 进 kernel
  3. 启动 property service 处理 setprop 等事件
  4. 执行 init.rc,完成系统剩余的启动流程,如解密 userdata、启动 zygote 等

旧 rootfs 时代纯粹的 init

Android 5.0.2 init.c,点进去搜索 main 查看源码,整个逻辑非常清晰,大概就是上面的列表做的事情,没什么好讲的。

在这个时候,启动流程大概如下所示:

  1. Bootloader 加载 boot.img
  2. 解压运行里面的 kernel
  3. kernel 初始化后,调用 populate_rootfs() 将 boot.img 内的 ramdisk 释放到 /
  4. 运行 /init,此时因为 / 这个根文件系统的文件是从 boot.img 的 ramdisk 中来的,所以会运行 boot ramdisk 里的 init
  5. 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 SARLegacy SAR with 2SI,而本节中提到的会改变 rootdir 的启动方式称为 modern 2SI2SI ramdisk SAR2SI 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int main(int argc, char *argv[]) {
umask(0);

auto name = basename(argv[0]);
if (name == "magisk"sv)
return magisk_proxy_main(argc, argv);

if (getpid() != 1)
return 1;

BaseInit *init;
BootConfig config{};

if (argc > 1 && argv[1] == "selinux_setup"sv) {
rust::setup_klog();
init = new SecondStageInit(argv);
} else {
// This will also mount /sys and /proc
load_kernel_info(&config);

if (config.skip_initramfs)
init = new LegacySARInit(argv, &config);
else if (config.force_normal_boot)
init = new FirstStageInit(argv, &config);
else if (access("/sbin/recovery", F_OK) == 0 || access("/system/bin/recovery", F_OK) == 0)
init = new RecoveryInit(argv, &config);
else if (check_two_stage())
init = new FirstStageInit(argv, &config);
else
init = new RootFSInit(argv, &config);
}

// Run the main routine
init->start();
exit(1);
}

我们重点关注几个 if 分支。

RootFSInit

看名字就可以看出来,这就是用来处理上面提到的第一种分区布局 RootFS 的分支。类的定义在 init.hpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/************
* Initramfs
************/

class RootFSInit : public MagiskInit {
private:
void prepare();
public:
RootFSInit(char *argv[], BootConfig *config) : MagiskInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
}
void start() override {
prepare();
patch_rw_root();
exec_init();
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
void RootFSInit::prepare() {
prepare_data();
LOGD("Restoring /init\n");
/** 把备份的原来的 init 重命名为 /init ,这样接下来我们执行 /init 的时候就会执行原本的 init **/
rename(backup_init(), "/init");
}

#define PRE_TMPSRC "/magisk"
#define PRE_TMPDIR PRE_TMPSRC "/tmp"

void MagiskInit::patch_rw_root() {
mount_list.emplace_back("/data");
parse_config_file();

// Create hardlink mirror of /sbin to /root
mkdir("/root", 0777);
clone_attr("/sbin", "/root");
link_path("/sbin", "/root");

// Handle overlays
load_overlay_rc("/overlay.d");
mv_path("/overlay.d", "/");
rm_rf("/data/overlay.d");
rm_rf("/.backup");

// Patch init.rc
/** 修补 init.rc,植入我们的代码,让 init 启动我们的服务 **/
patch_rc_scripts("/", "/sbin", true);

bool treble;
{
auto init = mmap_data("/init");
treble = init.contains(SPLIT_PLAT_CIL);
}

/** 因为 rootfs 是可写的,创建 /magisk,然后把 /magisk/tmp 当成 magisk 内部的 tmpfs,初始化 magisk 内部文件**/
xmkdir(PRE_TMPSRC, 0);
xmount("tmpfs", PRE_TMPSRC, "tmpfs", 0, "mode=755");
xmkdir(PRE_TMPDIR, 0);
setup_tmp(PRE_TMPDIR);
chdir(PRE_TMPDIR);

// Extract magisk
/** 释放 magisk32 magisk64 等文件 **/
extract_files(true);

/** 修补 SELinux policy,注入我们的规则 **/
if ((!treble && access("/sepolicy", F_OK) == 0) || !hijack_sepolicy()) {
patch_sepolicy("/sepolicy", "/sepolicy");
}

chdir("/");

// Dump magiskinit as magisk
/** 把 magiskinit 当成 magisk 复制到 /sbin/magisk。init 第一次启动我们的服务的时候,会进入 magisk_proxy_main **/
cp_afc(REDIR_PATH, "/sbin/magisk");
}

int magisk_proxy_main(int argc, char *argv[]) {
rust::setup_klog();
LOGD("%s\n", __FUNCTION__);

// Mount rootfs as rw to do post-init rootfs patches
xmount(nullptr, "/", nullptr, MS_REMOUNT, nullptr);

unlink("/sbin/magisk");

// Move tmpfs to /sbin
// make parent private before MS_MOVE
/** 选择 /sbin 作为新的 magisk internal tmpfs(因为 /sbin 在 PATH 里,把 su 等可执行文件放在里面,运行 su 的时候直接就能找到这个文件并执行) **/
xmount(nullptr, PRE_TMPSRC, nullptr, MS_PRIVATE, nullptr);
xmount(PRE_TMPDIR, "/sbin", nullptr, MS_MOVE, nullptr);
xumount2(PRE_TMPSRC, MNT_DETACH);
rmdir(PRE_TMPDIR);
rmdir(PRE_TMPSRC);

// Create symlinks pointing back to /root
/** 恢复 sbin 里原本就有的文件 **/
recreate_sbin("/root", false);

// Tell magiskd to remount rootfs
setenv("REMOUNT_ROOT", "1", 1);
/** 带上参数运行真正的 magisk32/magisk64 **/
execv("/sbin/magisk", argv);
return 1;
}

patch_rc_scripts() 里最重要的就是把这段 rc 脚本注入进了 init.rc,使得 init 会在系统开机过程中自动执行 magisk:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
on post-fs-data
start logd
exec %2$s 0 0 -- %1$s/magisk --post-fs-data

on property:vold.decrypt=trigger_restart_framework
exec %2$s 0 0 -- %1$s/magisk --service

on nonencrypted
exec %2$s 0 0 -- %1$s/magisk --service

on property:sys.boot_completed=1
exec %2$s 0 0 -- %1$s/magisk --boot-complete

on property:init.svc.zygote=stopped
exec %2$s 0 0 -- %1$s/magisk --zygote-restart

对 init.rc 语法规则不熟的同学,可以参考 Android Init Language

整个逻辑还是比较清晰的,忽略一些过于内部的细节,大概如下所示:

  1. kernel 执行 init,magiskinit 被运行,检测系统环境,进入 RootFSInit
  2. 修补 init.rc,注入我们的代码
  3. 初始化 magisk 内部要用到的 tmpfs
  4. 修补 sepolicy,注入我们的规则(细节后面再谈)
  5. 把 magiskinit 复制到 /sbin/magisk,这样 init 第一次执行 magisk 时会进入 magisk_proxy_main() 做剩余工作
  6. 执行系统原来的 init,继续开机流程
  7. init 会解析并执行 init.rc,运行 magisk,这个时候运行的其实是 magiskinit,进入 magisk_proxy_main()
  8. 把 /sbin 用作 magisk internal tmpfs,然后因为我们 mount 了 tmpfs 在 sbin 上,要把里面本来就有的文件恢复出来要不然就没了
  9. 运行真正的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/***************
* 2 Stage Init
***************/

class FirstStageInit : public BaseInit {
private:
void prepare();
public:
FirstStageInit(char *argv[], BootConfig *config) : BaseInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
};
void start() override {
prepare();
exec_init();
}
};

class SecondStageInit : public MagiskInit {
private:
bool prepare();
public:
SecondStageInit(char *argv[]) : MagiskInit(argv) {
LOGD("%s\n", __FUNCTION__);
};

void start() override {
bool is_rootfs = prepare();
if (is_rootfs)
patch_rw_root();
else
patch_ro_root();
exec_init();
}
};

FirstStageInit 的 prepare 非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
#define INIT_PATH  "/system/bin/init"
#define REDIR_PATH "/data/magiskinit"

void FirstStageInit::prepare() {
prepare_data();
restore_ramdisk_init();
auto init = mmap_data("/init", true);
// Redirect original init to magiskinit
for (size_t off : init.patch(INIT_PATH, REDIR_PATH)) {
LOGD("Patch @ %08zX [" INIT_PATH "] -> [" REDIR_PATH "]\n", off);
}
}

这个函数做了什么呢?首先,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
bool SecondStageInit::prepare() {
/** 这一行 unmount 是给 Legacy SAR with 2SI 使用的 **/
umount2("/init", MNT_DETACH);
unlink("/data/init");

// Make sure init dmesg logs won't get messed up
argv[0] = (char *) INIT_PATH;

// Some weird devices like meizu, uses 2SI but still have legacy rootfs
struct statfs sfs{};
statfs("/", &sfs);
/** 魅族设备的 2SI 不会 switch root,所以依然在 rootfs,需要判断这种特殊情况 **/
if (sfs.f_type == RAMFS_MAGIC || sfs.f_type == TMPFS_MAGIC) {
// We are still on rootfs, so make sure we will execute the init of the 2nd stage
unlink("/init");
xsymlink(INIT_PATH, "/init");
return true;
}
return false;
}

#define ROOTMIR MIRRDIR "/system_root"
#define NEW_INITRC_DIR "/system/etc/init/hw"

void MagiskInit::patch_ro_root() {
mount_list.emplace_back("/data");
parse_config_file();

/** Android 11+ sbin 可能不存在,使用 /debug_ramdisk 代替 **/
string tmp_dir;
if (access("/sbin", F_OK) == 0) {
tmp_dir = "/sbin";
} else {
tmp_dir = "/debug_ramdisk";
xmkdir("/data/debug_ramdisk", 0);
xmount("/debug_ramdisk", "/data/debug_ramdisk", nullptr, MS_MOVE, nullptr);
}

/** 初始化 magisk internal tmpfs **/
setup_tmp(tmp_dir.data());
chdir(tmp_dir.data());

if (tmp_dir == "/sbin") {
// Recreate original sbin structure
xmkdir(ROOTMIR, 0755);
xmount("/", ROOTMIR, nullptr, MS_BIND, nullptr);
recreate_sbin(ROOTMIR "/sbin", true);
xumount2(ROOTMIR, MNT_DETACH);
} else {
// Restore debug_ramdisk
xmount("/data/debug_ramdisk", "/debug_ramdisk", nullptr, MS_MOVE, nullptr);
rmdir("/data/debug_ramdisk");
}

xrename("overlay.d", ROOTOVL);

extern bool avd_hack;
// Handle avd hack
if (avd_hack) {
// Android API 28 AVD 模拟器相关,跳过
}

load_overlay_rc(ROOTOVL);
if (access(ROOTOVL "/sbin", F_OK) == 0) {
// Move files in overlay.d/sbin into tmp_dir
mv_path(ROOTOVL "/sbin", ".");
}

/** 修补 init.rc,注入 magisk 自己的服务 **/
// Patch init.rc
if (access(NEW_INITRC_DIR, F_OK) == 0) {
// Android 11's new init.rc
patch_rc_scripts(NEW_INITRC_DIR, tmp_dir.data(), false);
} else {
patch_rc_scripts("/", tmp_dir.data(), false);
}

// Extract magisk
extract_files(false);

/** 修补 sepolicy,注入我们的规则 **/
// Oculus Go will use a special sepolicy if unlocked
if (access("/sepolicy.unlocked", F_OK) == 0) {
patch_sepolicy("/sepolicy.unlocked", ROOTOVL "/sepolicy.unlocked");
} else if ((access(SPLIT_PLAT_CIL, F_OK) != 0 && access("/sepolicy", F_OK) == 0) ||
!hijack_sepolicy()) {
patch_sepolicy("/sepolicy", ROOTOVL "/sepolicy");
}

/** 用 bind mount 将需要修改的文件给一个个 mount 上去,这样就实现了不修改真实分区而修改文件 **/
// Mount rootdir
magic_mount(ROOTOVL);
int dest = xopen(ROOTMNT, O_WRONLY | O_CREAT, 0);
write(dest, magic_mount_list.data(), magic_mount_list.length());
close(dest);

chdir("/");
}

大致逻辑还是差不多的,只不过多了一些不同系统的处理(比如魅族设备需要特殊处理,这里因为 rootfs 仍然可写所以直接走 rootfs 逻辑就好了),还有由于 switch root 后 / 并不可写,所以所有对文件的修改都是先把修改过的文件放在 magisk internal tmpfs 内,然后 bind mount 到它原始的路径。
大致流程为:

  1. kernel 执行 init,magiskinit 被运行,检测系统环境,判断为 2SI 设备第一阶段,进入 FirstStageInit
  2. 挂载 /data 为 tmpfs,释放 /data/magiskinit
  3. 修补原始 init,重定向 /system/bin/init 到 /data/magiskinit,然后执行原始 init
  4. 原始 init 会挂载 /system 然后 switch root 进去,接下来它执行 /system/bin/init(被我们 patch 了所以会执行 /data/magiskinit)进行 selinux_setup
  5. magiskinit 被再次执行,看见 selinux_setup 知道这是二阶段,switch root 已经完成,开始 patch_ro_root()
  6. 修补 init.rc,注入我们的代码
  7. 修补 sepolicy,注入我们的规则(细节后面再谈)
  8. 利用 bind mount 替换 所有需要修改的文件
  9. 执行系统原来的 init,继续开机流程
  10. init 会解析并执行 init.rc,运行 magisk

Legacy SAR

终于讲到 Legacy SAR 这个坑人的玩意了。。。
这玩意坑人的地方在于:

  1. 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 用吧,要不然我也插不进去啊~
  2. 即使我们成功把 init 偷梁换柱成了 magiskinit,/ 也是 system.img,写不了,很难干活。

来看看 magiskinit 的代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/*************
* Legacy SAR
*************/

class LegacySARInit : public MagiskInit {
private:
bool mount_system_root();
void first_stage_prep();
public:
LegacySARInit(char *argv[], BootConfig *config) : MagiskInit(argv, config) {
LOGD("%s\n", __FUNCTION__);
};
void start() override {
prepare_data();
bool is_two_stage = mount_system_root();
if (is_two_stage)
first_stage_prep();
else
patch_ro_root();
exec_init();
}
};

bool LegacySARInit::mount_system_root() {
LOGD("Mounting system_root\n");

// there's no /dev in stub cpio
xmkdir("/dev", 0777);

strcpy(blk_info.block_dev, "/dev/root");

do {
// Try legacy SAR dm-verity
strcpy(blk_info.partname, "vroot");
auto dev = setup_block();
if (dev > 0)
goto mount_root;

// Try NVIDIA naming scheme
strcpy(blk_info.partname, "APP");
dev = setup_block();
if (dev > 0)
goto mount_root;

sprintf(blk_info.partname, "system%s", config->slot);
dev = setup_block();
if (dev > 0)
goto mount_root;

// Poll forever if rootwait was given in cmdline
} while (config->rootwait);

// We don't really know what to do at this point...
LOGE("Cannot find root partition, abort\n");
exit(1);

mount_root:
xmkdir("/system_root", 0755);

if (xmount("/dev/root", "/system_root", "ext4", MS_RDONLY, nullptr)) {
if (xmount("/dev/root", "/system_root", "erofs", MS_RDONLY, nullptr)) {
// We don't really know what to do at this point...
LOGE("Cannot mount root partition, abort\n");
exit(1);
}
}

switch_root("/system_root");

// Make dev writable
xmount("tmpfs", "/dev", "tmpfs", 0, "mode=755");
mount_list.emplace_back("/dev");

// Use the apex folder to determine whether 2SI (Android 10+)
bool is_two_stage = access("/apex", F_OK) == 0;
LOGD("is_two_stage: [%d]\n", is_two_stage);

// For API 28 AVD, it uses legacy SAR setup that requires
// special hacks in magiskinit to work properly.
if (!is_two_stage && config->emulator) {
// AVD,不重要
}

return is_two_stage;
}

void LegacySARInit::first_stage_prep() {
// Patch init binary
int src = xopen("/init", O_RDONLY);
int dest = xopen("/data/init", O_CREAT | O_WRONLY, 0);
{
mmap_data init("/init");
for (size_t off : init.patch(INIT_PATH, REDIR_PATH)) {
LOGD("Patch @ %08zX [" INIT_PATH "] -> [" REDIR_PATH "]\n", off);
}
write(dest, init.buf(), init.sz());
fclone_attr(src, dest);
close(dest);
close(src);
}
xmount("/data/init", "/init", nullptr, MS_BIND, nullptr);
}

总结如下:

  1. magiskinit 被运行,检测系统环境,判断为 Legacy SAR,进入 LegacySARInit
  2. 因为 kernel 被我们 patch 了,原本 switch root 应该由 kernel 来做的,现在没有了,需要手动 mount /system 然后 switch root 进去
  3. 检测有没有 /apex,有的话代表 Android 10+,init 有 selinux_setup 阶段,可以劫持,走 first_stage_prep();没有的话直接 patch_ro_boot() 然后执行原来的 init
  4. first_stage_prep() 里会像 Modern 2SI 那样修补 init,将 /system/bin/init 重定向到 /data/magiskinit,然后把它挂载到 /init 以供后面执行;执行原来的 init。
  5. 原来的 init 执行 selinux_setup(若有),转到 magiskinit,magiskinit 识别到二阶段,自动 patch_ro_boot()
  6. patch_ro_boot() 完成之后,执行 init 交还控制权,继续开机

修补 SEPolicy

由于这一块过于复杂,所以单独放一节讲。

如果你仔细看了上面的代码,你会发现修补 SEPolicy 有 patch_sepolicy() 以及 hijack_sepolicy() 两个函数,那么它们有什么不同呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
void MagiskInit::patch_sepolicy(const char *in, const char *out) {
LOGD("Patching monolithic policy\n");
auto sepol = unique_ptr<sepolicy>(sepolicy::from_file(in));

/** 修补 sepolicy **/
sepol->magisk_rules();

LOGD("Dumping sepolicy to: [%s]\n", out);
sepol->to_file(out);

// Remove OnePlus stupid debug sepolicy and use our own
if (access("/sepolicy_debug", F_OK) == 0) {
unlink("/sepolicy_debug");
link("/sepolicy", "/sepolicy_debug");
}
}

#define MOCK_COMPAT SELINUXMOCK "/compatible"
#define MOCK_LOAD SELINUXMOCK "/load"
#define MOCK_ENFORCE SELINUXMOCK "/enforce"

bool MagiskInit::hijack_sepolicy() {
xmkdir(SELINUXMOCK, 0);

if (access("/system/bin/init", F_OK) == 0) {
// On 2SI devices, the 2nd stage init file is always a dynamic executable.
// This meant that instead of going through convoluted methods trying to alter
// and block init's control flow, we can just LD_PRELOAD and replace the
// security_load_policy function with our own implementation.
dump_preload();
setenv("LD_PRELOAD", "/dev/preload.so", 1);
}

// Hijack the "load" and "enforce" node in selinuxfs to manipulate
// the actual sepolicy being loaded into the kernel
auto hijack = [&] {
LOGD("Hijack [" SELINUX_LOAD "]\n");
close(xopen(MOCK_LOAD, O_CREAT | O_RDONLY, 0600));
xmount(MOCK_LOAD, SELINUX_LOAD, nullptr, MS_BIND, nullptr);
LOGD("Hijack [" SELINUX_ENFORCE "]\n");
mkfifo(MOCK_ENFORCE, 0644);
xmount(MOCK_ENFORCE, SELINUX_ENFORCE, nullptr, MS_BIND, nullptr);
};

string dt_compat;
if (access(SELINUX_ENFORCE, F_OK) != 0) {
// selinuxfs not mounted yet. Hijack the dt fstab nodes first
// and let the original init mount selinuxfs for us.
// This only happens on Android 8.0 - 9.0

char buf[4096];
ssprintf(buf, sizeof(buf), "%s/fstab/compatible", config->dt_dir);
dt_compat = full_read(buf);
if (dt_compat.empty()) {
// Device does not do early mount and uses monolithic policy
return false;
}

// Remount procfs with proper options
xmount(nullptr, "/proc", nullptr, MS_REMOUNT, "hidepid=2,gid=3009");

LOGD("Hijack [%s]\n", buf);

// Preserve sysfs and procfs for hijacking
mount_list.erase(std::remove_if(
mount_list.begin(), mount_list.end(),
[](const string &s) { return s == "/proc" || s == "/sys"; }), mount_list.end());

mkfifo(MOCK_COMPAT, 0444);
xmount(MOCK_COMPAT, buf, nullptr, MS_BIND, nullptr);
} else {
hijack();
}

// Create a new process waiting for init operations
if (xfork()) {
// In parent, return and continue boot process
return true;
}

if (!dt_compat.empty()) {
// This open will block until init calls DoFirstStageMount
// The only purpose here is actually to wait for init to mount selinuxfs for us
int fd = xopen(MOCK_COMPAT, O_WRONLY);

char buf[4096];
ssprintf(buf, sizeof(buf), "%s/fstab/compatible", config->dt_dir);
xumount2(buf, MNT_DETACH);

hijack();

xwrite(fd, dt_compat.data(), dt_compat.size());
close(fd);
}

// This open will block until init calls security_getenforce
int fd = xopen(MOCK_ENFORCE, O_WRONLY);

// Cleanup the hijacks
umount2("/init", MNT_DETACH);
xumount2(SELINUX_LOAD, MNT_DETACH);
xumount2(SELINUX_ENFORCE, MNT_DETACH);

// Load and patch policy
auto sepol = unique_ptr<sepolicy>(sepolicy::from_file(MOCK_LOAD));
sepol->magisk_rules();
sepol->load_rules(rules);

// Load patched policy into kernel
sepol->to_file(SELINUX_LOAD);

// Write to the enforce node ONLY after sepolicy is loaded. We need to make sure
// the actual init process is blocked until sepolicy is loaded, or else
// restorecon will fail and re-exec won't change context, causing boot failure.
// We (ab)use the fact that init reads the enforce node, and because
// it has been replaced with our FIFO file, init will block until we
// write something into the pipe, effectively hijacking its control flow.

string enforce = full_read(SELINUX_ENFORCE);
xwrite(fd, enforce.data(), enforce.length());
close(fd);

// At this point, the init process will be unblocked
// and continue on with restorecon + re-exec.

// Terminate process
exit(0);
}

最直观的感觉就是,两个函数代码量不一样……

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 的过程。
具体来说,大致分为以下几步:

  1. 判断 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 这一步可以拦截,所以只能手动进行特殊处理。
  2. 用 bind mount 把 /sys/fs/selinux/load 覆盖成一个普通的文件,这样 init 往里写入 sepolicy 时其实是写进了我们的文件,而不是内核里
  3. 调用 mkfifo() 创建一个 FIFO,然后 bind mount 到 /sys/fs/selinux/enforce 上面
  4. fork 一个子进程,父进程继续执行剩余步骤,然后执行原始 init;子进程会以 write-only 的方式打开这个 FIFO,此时因为 FIFO 的特性,这个进程会阻塞,直到有另一个进程以 read 方式打开同样的 FIFO。在我们的例子当中,子进程会阻塞,直到 init 调用 security_getenforce() 读取了 /sys/fs/selinux/enforce,此时 init 阻塞,等待我们的子进程往 FIFO 内写入内容,而此时 init 已经把要加载的 sepolicy 写入到了 /sys/fs/selinux/load
  5. 读取 init 原本要加载的 sepolicy,进行 patch 注入自己的规则,然后手动加载进内核里(因为之前 init 尝试加载的时候被我们给拦了)
  6. 往 FIFO 写入内容,唤醒对端的 init 进程,做一些 cleanup 然后可以退出子进程。

虽然整个 hijack 还使用了 LD_PRELOAD 等技术来保证 sepolicy hijack 能够正常工作,但大致流程就是如上所示。整个机制非常精妙,非常依赖 init 的实际行为,需要结合 android init 及 libselinux 的源码反复琢磨才能搞清其中逻辑。