盒子里的面具

Intro

最近接到了一个新需求,要求在Android In Docker产品中提供Xposed环境(令人怀念的名词)。在现代Android环境中想运行Xposed就不得不和Magisk打交道,然而主流的面具刷入姿势都是修补Boot镜像或者利用第三方Recovery自动修补刷入,在Docker容器的Android环境中很明显并不存在Boot镜像或者Recovery,经过查阅资料大概解决了问题,把解决方案和后面对Magisk原理的探索记录在下面,也算给我逝去的搞机生涯补下课。注:为了配合Docker环境,我们使用了Magisk Delta社区分支,而且选择不替换init进程.

Magisk Delta Internals

Boot with Init

由于AID环境比较特殊,我们的Init进程行为与标准Init有一些出入,为了降低后续维护难度,我们没有选择通常的“使用MagiskInit 替换Init”的安装行为,而是尝试自己来挂载目录,处理initRC配置等。因此对Magisk Init的行为进行了梳理。

Android 启动类型

Magisk的终极目标就是以劫持系统分区文件的方式来实现对系统的更改行为,因此它选择在Init进程中完成文件的替换(Mount方式或直接替换)并按照模块中的规则进行系统分区的更改。但是随着Android系统的发展,Google提出了多种启动行为,从RAMFS到System-as-root,Data分区也从设备全盘加密进化到FBE加密(这也使得Magisk必须依赖系统原生Init才能解密Data从而获取模块目录信息),Magisk也随之演变出了多种策略。Magisk官方文档专门有个专题阐述了这一点,这里也给出了很详细的说明,总的来讲,MagiskInit最重要的事情就是要劫持原本Init,并让Magisk参与系统的Init行为,采用的方式有注入init.rc脚本配置*等。

MagiskInit启动后的首要任务就是识别Boot方式来决定进一步的处理方式。Magisk会从argv中读取传入的参数,也会挂载/proc分区,并读取/proc/cmdline与/proc/bootconfig来获取Bootloader的传参。目前MagiskInit支持所有Android启动方式。

早期的Android设备使用initrd的Ramdisk启动,由Bootloader来将指定的Ramdisk镜像(Initramfs)加载到内存挂载到根,并启动/init进程(Ramdisk可为来自Recovery分区或来自正常Boot分区),在init进程中挂载分区(如/system等),执行rc脚本。这种启动方式中,根挂载点不会变化,在MagiskInit代码中为RootFSInit.

  • 在这种情况下,Magisk采用的Patch方式是替换Init进程为MagiskInit.然而当Magisk一开始接管Init时,根目录是没有任何分区挂载的,因此Magisk首先挂载一个基于tmpFS的data分区,将部分文件备份复制到分区,再尝试挂载/system。此外,Magisk还将原来的/sbin内容挂载到了/root.
  • 创建Magisk 临时分区到 /magisk-tmp ,这个分区是Magisk运行过程中配置和产物的存储位置,各个分区的原内容以及Magisk对这些分区的更改也会被分别挂载到这个分区,这个分区包括(.magisk->Magisk自身的文件,mirror->挂载各个分区原来的内容,workdir等文件夹与OverlayFS相关)等。此后,MagiskInit会依次尝试挂载未加密的Data分区[/data/unencrypted or /data/adb],/cache分区,/metadata分区,/persist分区,成功会将分区根目录下的magisk和early-mount.d作为selinux规则与early_mount配置的存储位置。最后,将这两个文件夹(symbol link)挂载到magisk-tmp的mirror下面,至此完成了MagiskInit所需配置文件的挂载。此外,还会寻找指定位置下面有没有.disable_magisk文件。这里的过程在所有启动方式中都是相同的。
  • early_mount.d中的文件进行处理,具体操作为:遍历early_mount.d中System,vendor,system_ext,product分区内的文件,并采用bind mount的方式无损覆盖原文件。如此便完成了Magisk模块在early_mount阶段对系统分区的更改。

    Bind Mount可为当前挂载点绑定一个新的挂载点。绑定后的两个目录类似于硬链接,无论读写 Source 还是读写 Destination目录,都会反应在另一方,内核在底层所操作的都是同一个物理位置。
  • 在Boot阶段会遍历System下面的overlay.d(里面应该是对init.rc的修改,大概)以及magisk模块中的initrc.d,最后会将所有的Overlay文件拼接起来,塞进修改后的init.rc里面,完成对init.rc的注入。
  • 最后从sbin中解压Magisk的可执行文件,并根据系统架构symbol link magisk32/magisk64magisk。注意,这时候,MagsikInit还把自己symbol link到了/sbin/magisk,这个行为的目的是为了劫持第一次启动Magisk时的行为,做一些post-init的操作,比如将/magisk-tmp挂在到/sbin,并将/root下的内容重新还原回/sbin。
  • 清理现场,依次卸载挂载的分区,执行Init.(那么其他Magisk的功能是怎么实现的呢?是通过第二步中对Init脚本的修改实现的,因为设备加密可能导致MagiskInit无法很好的读取加密Data,因此Magisk会通过向Init.rc中注入不同启动阶段的回调脚本来监听系统启动并完成相关工作(下面给出的init.rc注入模板可能会让你更理解这个行为的实现)。但是这个就不是MagiskInit的工作啦)

后来为了支持PT(Project Treble)计划和无缝更新,Android将系统部分(System)和设备制造商提供的部分(vendor,product)尽可能解耦并期望系统的变更不依赖设备制造商更新。SystemAsRoot特性就是将System分区直接挂载到根,/init进程自然也是改为由System提供.此外,Recovery的执行很有意思,在正常启动的过程中不需要Initramfs来生成根目录,因此Boot分区不需要包含Ramdisk,但是Recovery的执行仍需要Ramdisk。为了节省空间,一些设备将Recovery分区砍掉,(实际上前述的早期设备Recovery分区和Boot的区别仅仅在于Ramdisk文件)将Rec的Ramdisk放到Boot里面。Bootloader会传递do_skip_initramfs 来标志是否挂载Ramdisk并进入Rec,否则则尝试挂载System.这个被Magisk标志为LegacySARInit。MagiskInit的处理与RootFS类型大部分相同,但是有几点区别:

  • 在这种情况下,Magisk在安装时会替换System中的Init.此外第一步与RootFS中完全一致。
  • Magisk尝试挂载System分区,并根据System分区下是否有/apex来决定是不是下文所述的2阶段启动。[注意,这里Magisk会通过只读方式挂载System来保持系统的完整性,对系统分区的修改通过Overlay或者BindMount实现]
MagiskInit最开始判断启动方式是依赖于。由于Android的跨版本OTA,导致有些出厂低版本的Android设备仍然保留System分区和旧版Bootloader,但是实际上已经应用了Two_Stage_Init 来支持动态分区、APEx[Android Pony EXpress]等高版本特性,因此Magisk会在识别到apex分区时判断应当进入二阶段启动流程。
  • 若存在/sbin,则将magisk_tmp分区创建到/sbin否则在/dev下创建一个随机名称目录存储,这是为了隐蔽性考量。并将根目录bind mount到magisk_tmp下的mirror Dir.
  • 读取early_mount.d和overlay.d的init.rc片段,并对 /system/etc/init/hw/init.rc(Android 11的新init.rc配置文件) 或 init.rc 进行修改,但是这个修改是在magisk_tmp分区中的映射中作修改的,修改后MagiskInit会通过BindMount方式shadow掉原有的文件。

但是诸如动态分区(Device Mapper根据指定的offset和大小动态划分分区并挂载)和EROFS等花活进入Android系统,Init需要支持的特性也越来越复杂,因此谷歌再次为Boot改回了Ramdisk,这样的优势在于启动的最开始不依赖于设备上任何实际分区(因为System分区也是动态的),挂载Ramdisk到根后执行Init,这个Init负责挂载所有分区,重新将System分区挂载到根目录,最后再次执行新根目录的/init. Magisk在每个Init分别进行不同的操作,在FirstStageInit中,主要替换/init中的下一阶段要执行的Init文件路径,/system/bin/init改为/data/magiskinit(正好字符串长度是相同的).第二阶段的Init文件会指向MagiskInit,从而被Magisk接管。

下一阶段

MagiskInit最终会注入以下的Init.rc 模板文件,在Init进行不同阶段时向Magisk传递回调,从而使得Magisk Daemon监听Data的挂载和解密等,并执行模块所需配置。

on post-fs-data
    start logd
    rm  $UNBLOCKFILE 
    exec u:r: $SEPOL_PROC_DOMAIN :s0 root root -- %1$s/magisk --post-fs-data
    wait  $UNBLOCKFILE   str(POST_FS_DATA_WAIT_TIME) 
    rm  UNBLOCKFILE 


on nonencrypted
    exec u:r: $SEPOL_PROC_DOMAIN :s0 root root -- %1$s/magisk --service


on property:sys.boot_completed=1
    exec u:r: $SEPOL_PROC_DOMAIN :s0 root root -- %1$s/magisk --boot-complete


on property:init.svc.zygote=restarting
    exec u:r: $SEPOL_PROC_DOMAIN :s0 root root -- %1$s/magisk --zygote-restart


on property:init.svc.zygote=stopped
    exec u:r: $SEPOL_PROC_DOMAIN :s0 root root -- %1$s/magisk --zygote-restart

TBD

Edit with markdown