Dans les deux articles précédents, nous avons vu comment rooter un téléphone Android avec Magisk avant de décortiquer le contenu du firmware d’un Pixel, constatant au passage que le contenu de certains fichiers img
correspondait peu ou prou au contenu de certaines partitions du téléphone, notamment pour les partitions boot
, init_boot
, system
et vendor
.
Mais tout ceci ne nous renseigne pas sur la magie de Magisk et comment il permet d’obtenir un accès root.
Pour obtenir quelques éléments de réponse, commençons par revenir sur la procédure suivie pour rooter un terminal.
Modification de init_boot.img
Pour examiner les différences entre l’init_boot.img
initial et la version patchée par Magisk, on procède comme dans l’article précédent, c’est-à-dire que l’on utilise successivement android_system_tools_mkbootimg
, lz4
et cpio
.
On obtient deux arborescences – une par fichier img – que l’on peut comparer avec un outil comme meld
:

Deux différences apparaissent :
- L’exécutable
init
diffère entre les deuxinit_boot.img
- Un répertoire
overlay.d
, contenantmagisk64.xz
etstub.xz
, ajouté par Magisk
Regardons les tailles des fichiers présents dans l’init_boot
patché :
thomas@ankou:/tmp/plop/output_magisk$ ls -l
total 5948
drwxr-xr-x 2 root root 4096 11 mars 22:35 debug_ramdisk
drwxr-xr-x 2 root root 4096 11 mars 22:35 dev
drwxr-xr-x 9 root root 4096 11 mars 22:35 first_stage_ramdisk
-rwxr-x--- 1 root root 680904 11 mars 22:35 init
-rw-r--r-- 1 thomas thomas 0 11 mars 22:31 kernel
drwxr-xr-x 2 root root 4096 11 mars 22:35 metadata
drwxr-xr-x 2 root root 4096 11 mars 22:35 mnt
drwxr-x--- 3 root root 4096 11 mars 22:35 overlay.d
drwxr-xr-x 2 root root 4096 11 mars 22:35 proc
drwxr-xr-x 2 root root 4096 11 mars 22:35 second_stage_resources
drwxr-xr-x 2 root root 4096 11 mars 22:35 sys
drwxr-xr-x 4 root root 4096 11 mars 22:35 system
Et dans la version initiale :
thomas@ankou:/tmp/plop/output$ ls -l
total 9732
drwxr-xr-x 2 root root 4096 11 mars 20:49 debug_ramdisk
drwxr-xr-x 2 root root 4096 11 mars 20:49 dev
drwxr-xr-x 9 root root 4096 11 mars 20:49 first_stage_ramdisk
-rwxr-x--- 1 root root 3192656 11 mars 20:49 init
-rw-r--r-- 1 thomas thomas 0 11 mars 20:22 kernel
drwxr-xr-x 2 root root 4096 11 mars 20:49 metadata
drwxr-xr-x 2 root root 4096 11 mars 20:49 mnt
drwxr-xr-x 2 root root 4096 11 mars 20:49 proc
drwxr-xr-x 2 root root 4096 11 mars 20:49 second_stage_resources
drwxr-xr-x 2 root root 4096 11 mars 20:49 sys
drwxr-xr-x 4 root root 4096 11 mars 20:49 system
L’exécutable init
patché est donc sensiblement plus petit (environ 680 Ko contre environ 3,2 Mo) que l’init
présent dans l’init_boot
original.
Si l’on calcule le haché md5 du binaire init modifié par Magisk :
thomas@ankou:/tmp/test/normal/output$ md5sum ../../magisk/output/init
b6f881244cb3e9a912d0bcf82b2a2bb2 ../../magisk/output/init
On retrouve un binaire, /debug_ramdisk/magiskinit
, de même haché md5 sur le terminal rooté :
lynx:/ # ls -lh /debug_ramdisk/magiskinit
-rwxr-x--- 1 root root 665K 1970-01-01 01:00 /debug_ramdisk/magiskinit
lynx:/ # ls -l /debug_ramdisk/magiskinit
-rwxr-x--- 1 root root 680904 1970-01-01 01:00 /debug_ramdisk/magiskinit
lynx:/ # md5sum /debug_ramdisk/magiskinit
b6f881244cb3e9a912d0bcf82b2a2bb2 /debug_ramdisk/magiskinit
Il y a donc deux binaires init
sur le terminal !
Le premier init
(version patchée ou non init_boot.img
) est un binaire statique :
thomas@ankou:/tmp/test/normal/output$ file ../../magisk/output/init
../../magisk/output/init: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, BuildID[sha1]=85102ca3bf7424bc5e0b9af32cf228041f941b43, stripped
thomas@ankou:/tmp/test/normal/output$ file init
init: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, BuildID[md5/uuid]=2993eb986c6854343f67603d49ccd6c9, stripped
À contrario, le deuxième est un exécutable dynamique :
lynx:/ # file /system/bin/init
/system/bin/init: ELF shared object, 64-bit LSB arm64, dynamic (/system/bin/bootstrap/linker64), for Android 34, BuildID=ed3a94b65330c9e44f2523b0f35a796d, stripped
lynx:/ #
Que contient le répertoire overlay.d ajouté par Magisk ?
Le fichier magisk64.xz
est compressé avec xz
:
root@ankou:/tmp/plop/output_magisk/overlay.d/sbin# file magisk64.xz
magisk64.xz: XZ compressed data
On le décompresse :
root@ankou:/tmp/plop/output_magisk/overlay.d/sbin# unxz magisk64.xz
On obtient un exécutable magisk64
.
Examinons maintenant stub.xz
:
root@ankou:/tmp/plop/output_magisk/overlay.d/sbin# ls
magisk64 stub.xz
Décompressons-le :
root@ankou:/tmp/plop/output_magisk/overlay.d/sbin# unxz stub.xz
root@ankou:/tmp/plop/output_magisk/overlay.d/sbin# ls
magisk64 stub
Le fichier stub
est une archive zip :
root@ankou:/tmp/plop/output_magisk/overlay.d/sbin# file *
magisk64: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /system/bin/linker64, BuildID[sha1]=b4c7d85e83f5b20e40d07cc293f9467b13eb1132, stripped
stub: Zip archive data, at least v0.0 to extract
Décompressons-la :
root@ankou:/tmp/plop/output_magisk/overlay.d/sbin# unzip stub
Archive: stub
version=27.0
versionCode=27000
stubVersion=38
inflating: AndroidManifest.xml
inflating: META-INF/CERT.RSA
inflating: META-INF/CERT.SF
inflating: META-INF/MANIFEST.MF
inflating: classes.dex
root@ankou:/tmp/plop/output_magisk/overlay.d/sbin#
On voit que l’on a quelque chose qui ressemble fortement à un apk !
magisk64 et magisk sur le téléphone
On constate que le haché du binaire magisk64
présent dans le init_boot.img
patché :
thomas@ankou:/tmp/test/magisk/output/overlay.d/sbin$ md5sum magisk64
73cd4fe8227b60125c391a6af01a376f magisk64
et le haché du binaire magisk
présent sur le téléphone rooté :
lynx:/ # md5sum /system/bin/magisk
73cd4fe8227b60125c391a6af01a376f /system/bin/magisk
sont identiques.
On retrouve donc dans /system/bin
l’exécutable magisk
présent dans le init_boot.img
patché sur le téléphone rooté.
Que fait ce binaire ?
lynx:/ # /system/bin/magisk
Magisk - Multi-purpose Utility
Usage: magisk [applet [arguments]...]
or: magisk [options]...
Options:
-c print current binary version
-v print running daemon version
-V print running daemon version code
--list list all available applets
--remove-modules [-n] remove all modules, reboot if -n is not provided
--install-module ZIP install a module zip file
Advanced Options (Internal APIs):
--daemon manually start magisk daemon
--stop remove all magisk changes and stop daemon
--[init trigger] callback on init triggers. Valid triggers:
post-fs-data, service, boot-complete, zygote-restart
--unlock-blocks set BLKROSET flag to OFF for all block devices
--restorecon restore selinux context on Magisk files
--clone-attr SRC DEST clone permission, owner, and selinux context
--clone SRC DEST clone SRC to DEST
--sqlite SQL exec SQL commands to Magisk database
--path print Magisk tmpfs mount path
--denylist ARGS denylist config CLI
--preinit-device resolve a device to store preinit files
Available applets:
su, resetprop
Concrètement, lorsque l’on ouvre un shell avec adb
sur un téléphone rooté et que l’on se su
, c’est le binaire magisk
qui est exécuté derrière.
Pour résumer :
- L’
init
de la partition init_boot est patché - un binaire
magisk
y est ajouté - une application android y est ajoutée
Une question subsiste : quel lien existe entre l’action de patcher l’init
de la partition init_boot
et l’obtention d’un accès root ?
Pour cela, nous allons nous intéresser à l’arborescence des processus dans Android et en particulier au processus init
.
L’arborescence des processus sur un terminal Android
Pour se faire une idée de quel processus lance quel autre processus, on exécute la commande ps -elf
sur un téléphone non-rooté, en l’occurrence un Fairphone 4 sous Android 13.
Les principales observations sont les suivantes :
- Le processus 1 est
init second_stage
. Son père est le processus 0 - Le processus 0 est aussi père de multiples
[kthreadd]
, qui est un thread noyau - Le processus 2 est le père de multiples
[quelquechose]
, de threads noyau, donc - Le processus 1 est le père de nombreux processus :
init subcontext u:r:vendor_init:s0 14
,ueventd
,prng_seeder
logd
lmkd
servicemanager
hwservicemanager
vndservicemanager
keystore2 /data/misc/keystore
netd
, père deiptables-restore --noflush -w -v
etip6tables-restore --noflush -w -v
zygote64
zygote
ssgtzd
- un grand nombre de
android.quelquechose
et devendor.quelquechose
- du démon
adbd
- de
wpa_supplicant
- etc
zygote64
est le père de :system_server
- d’un grand nombre d’autres processus
- des applications proprement dites :
org.thoughtcrime.securesms
tv.twitch.android.app
com.google.android.youtube
com.fullsix.android.labanquepostale.accountaccess
com.android.chrome
com.whatsapp
org.mozilla.firefox
- …
En conclusion, init
est le premier binaire lancé, et est le père, direct ou indirect, de la très grande majorité des processus exécuté.
init
et séquence de démarrage
Dixit https://android.googlesource.com/platform/system/core/+/master/init/README.md, la séquence de boot d’android est une fusée à 3 étages :
first stage init
,init
de la partitioninit_boot
, qui réalise diverses opérations essentielles permettant d’obtenir un état fonctionnel, parmi lequelles monter la partition system sur/
- une fois que le
first stage init
a fini son travail, il exécute/system/bin/init
(c’est-à-dire l’init
de la partition system) avec l’argumentselinux_setup
: Cet étage charge la politique SELinux. - enfin,
/system/bin/init
est exécuté avec l’argumentsecond_stage
. Cet instance d’init termine le processus de boot suivant la configuration spécifiée par les différents scriptsinit.rc
Que fait l’init vanilla ?
En lançant strings
sur /system/bin/init
, on tombe sur la chaîne de caractère SELINUX_STARTED_AT
.
Sur cs.android.com
, on peut voir que cette chaîne est déclarée dans system/core/init/selinux.h
.
Comme /system/bin/init
étant un exécutable dynamique, une chaîne de caractères présente dans ce binaire fait nécessairement partie du code du binaire en question.
Le code de /system/bin/init
est donc dans le répertoire system/core/init du dépôt de code.
On regarde donc system/core/init/main.cpp
: Ce fichier contient une fonction main
:
int main(int argc, char** argv) {
#if __has_feature(address_sanitizer)
__asan_set_error_report_callback(AsanReportCallback);
#elif __has_feature(hwaddress_sanitizer)
__hwasan_set_error_report_callback(AsanReportCallback);
#endif
// Boost prio which will be restored later
setpriority(PRIO_PROCESS, 0, -20);
if (!strcmp(basename(argv[0]), "ueventd")) {
return ueventd_main(argc, argv);
}
if (argc > 1) {
if (!strcmp(argv[1], "subcontext")) {
android::base::InitLogging(argv, &android::base::KernelLogger);
const BuiltinFunctionMap& function_map = GetBuiltinFunctionMap();
return SubcontextMain(argc, argv, &function_map);
}
if (!strcmp(argv[1], "selinux_setup")) {
return SetupSelinux(argv);
}
if (!strcmp(argv[1], "second_stage")) {
return SecondStageMain(argc, argv);
}
}
return FirstStageMain(argc, argv);
}
Le binaire init
est d’abord exécuté avec l’argument selinux_setup
, ce qui provoque l’exécution de la fonction SetupSelinux
(system/core/init/selinux.cpp
).
Cette fonction initialise SELinux et exécute init
avec l’argument second_stage
(je paraphrase le commentaire de la déclaration de la fonction dans system/core/init/selinux.h
).
int SetupSelinux(char** argv) {
SetStdioToDevNull(argv);
InitKernelLogging(argv);
if (REBOOT_BOOTLOADER_ON_PANIC) {
InstallRebootSignalHandlers();
}
boot_clock::time_point start_time = boot_clock::now();
SelinuxSetupKernelLogging();
// TODO(b/287206497): refactor into different headers to only include what we need.
if (IsMicrodroid()) {
LoadSelinuxPolicyMicrodroid();
} else {
LoadSelinuxPolicyAndroid();
}
SelinuxSetEnforcement();
if (IsMicrodroid() && android::virtualization::IsOpenDiceChangesFlagEnabled()) {
// We run restorecon of /microdroid_resources while we are still in kernel context to avoid
// granting init `tmpfs:file relabelfrom` capability.
const int flags = SELINUX_ANDROID_RESTORECON_RECURSE;
if (selinux_android_restorecon("/microdroid_resources", flags) == -1) {
PLOG(FATAL) << "restorecon of /microdroid_resources failed";
}
}
// We're in the kernel domain and want to transition to the init domain. File systems that
// store SELabels in their xattrs, such as ext4 do not need an explicit restorecon here,
// but other file systems do. In particular, this is needed for ramdisks such as the
// recovery image for A/B devices.
if (selinux_android_restorecon("/system/bin/init", 0) == -1) {
PLOG(FATAL) << "restorecon failed of /system/bin/init failed";
}
setenv(kEnvSelinuxStartedAt, std::to_string(start_time.time_since_epoch().count()).c_str(), 1);
// SetupOverlays does not return if overlays exist, instead it execs overlay_remounter
// which then execs second stage init
SetupOverlays();
const char* path = "/system/bin/init";
const char* args[] = {path, "second_stage", nullptr};
execv(path, const_cast<char**>(args));
// execv() only returns if an error happened, in which case we
// panic and never return from this function.
PLOG(FATAL) << "execv(\"" << path << "\") failed";
return 1;
}
Le binaire init
est ensuite exécuté avec l’argument second_stage
, c’est alors la fonction SecondStageMain
(system/core/init/init.cpp
) qui est exécutée.
Cette fonction :
- Monte les systèmes de fichiers nécessaires au second stage et non encore montés :
// Mount extra filesystems required during second stage init
MountExtraFilesystems();
- Fait diverses actions avant d’entrer dans une boucle infinie :
while (true) {
// By default, sleep until something happens. Do not convert far_future into
// std::chrono::milliseconds because that would trigger an overflow. The unit of boot_clock
// is 1ns.
const boot_clock::time_point far_future = boot_clock::time_point::max();
boot_clock::time_point next_action_time = far_future;
auto shutdown_command = shutdown_state.CheckShutdown();
if (shutdown_command) {
LOG(INFO) << "Got shutdown_command '" << *shutdown_command
<< "' Calling HandlePowerctlMessage()";
HandlePowerctlMessage(*shutdown_command);
}
if (!(prop_waiter_state.MightBeWaiting() || Service::is_exec_service_running())) {
am.ExecuteOneCommand();
// If there's more work to do, wake up again immediately.
if (am.HasMoreCommands()) {
next_action_time = boot_clock::now();
}
}
// Since the above code examined pending actions, no new actions must be
// queued by the code between this line and the Epoll::Wait() call below
// without calling WakeMainInitThread().
if (!IsShuttingDown()) {
auto next_process_action_time = HandleProcessActions();
// If there's a process that needs restarting, wake up in time for that.
if (next_process_action_time) {
next_action_time = std::min(next_action_time, *next_process_action_time);
}
}
std::optional<std::chrono::milliseconds> epoll_timeout;
if (next_action_time != far_future) {
epoll_timeout = std::chrono::ceil<std::chrono::milliseconds>(
std::max(next_action_time - boot_clock::now(), 0ns));
}
auto epoll_result = epoll.Wait(epoll_timeout);
if (!epoll_result.ok()) {
LOG(ERROR) << epoll_result.error();
}
if (!IsShuttingDown()) {
HandleControlMessages();
SetUsbController();
}
}
Que fait l’init patché ?
Dans le cas d’un téléphone rooté, c’est magiskinit
qui est exécuté en premier lieu.
Clônons le dépôt github de Magisk (https://github.com/topjohnwu/Magisk) et recherchons-y les occurrences de « second_stage ».
Examinons la fin de la méthode start
dans init.rs : Si init
est exécuté avec l’argument selinux_setup
, la méthode second_stage
est exécutée :
let argv1 = unsafe { *self.argv.offset(1) };
if !argv1.is_null() && unsafe { CStr::from_ptr(argv1) == c"selinux_setup" } {
self.second_stage();
Que fait cette méthode second_stage
?
pub(crate) fn second_stage(&mut self) {
info!("Second Stage Init");
unsafe {
umount2(raw_cstr!("/init"), MNT_DETACH);
umount2(raw_cstr!("/system/bin/init"), MNT_DETACH); // just in case
path!("/data/init").remove().ok();
// Make sure init dmesg logs won't get messed up
*self.argv = raw_cstr!("/system/bin/init") as *mut _;
// Some weird devices like meizu, uses 2SI but still have legacy rootfs
let mut sfs: statfs = std::mem::zeroed();
statfs(raw_cstr!("/"), &mut sfs);
if sfs.f_type == 0x858458f6 || sfs.f_type as c_long == TMPFS_MAGIC {
// We are still on rootfs, so make sure we will execute the init of the 2nd stage
let init_path = path!("/init");
init_path.remove().ok();
path!("/system/bin/init").symlink_to(init_path).log_ok();
self.patch_rw_root();
} else {
self.patch_ro_root();
}
}
}
Ainsi, la fonction second_stage
exécute patch_ro_root
de rootdir.cpp
. Cette fonction patche les scripts rc :
load_overlay_rc(ROOTOVL);
if (access(ROOTOVL "/sbin", F_OK) == 0) {
// Move files in overlay.d/sbin into tmp_dir
mv_path(ROOTOVL "/sbin", ".");
}
// Patch init.rc
bool p;
if (access(NEW_INITRC_DIR "/" INIT_RC, F_OK) == 0) {
// Android 11's new init.rc
p = patch_rc_scripts(NEW_INITRC_DIR, tmp_dir.data(), false);
} else {
p = patch_rc_scripts("/", tmp_dir.data(), false);
}
if (p) patch_fissiond(tmp_dir.data());
Comme son nom le suggère, la fonction patch_rc_scripts
modifie les scripts rc :
// Inject custom rc scripts
for (auto &script : rc_list) {
// Replace template arguments of rc scripts with dynamic paths
replace_all(script, "${MAGISKTMP}", tmp_path);
fprintf(dest.get(), "\n%s\n", script.data());
}
rc_list.clear();
// Inject Magisk rc scripts
rust::inject_magisk_rc(fileno(dest.get()), tmp_path);
fclone_attr(fileno(src.get()), fileno(dest.get()));
}
// Then patch init.zygote*.rc
La fonction patch_ro_root
modifie ensuite la configuration SELinux via la fonction handle_sepolicy
:
handle_sepolicy();
unlink("init-ld");
// Mount rootdir
mount_overlay("/");
chdir("/");
}
On a donc ici deux vecteurs possibles pour conférer des droits élevés à un processus arbitraire :
Patcher la politique SELinux et les scripts rc utilisés par init
.
Revenons sur la méthode start
. Avant de lancer (ou non, en fonction de argv[1]
) second_stage
, cette méthode commence par monter, si cela n’a pas été fait, différents systèmes de fichiers :
fn start(&mut self) -> LoggedResult<()> {
if !path!("/proc/cmdline").exists() {
path!("/proc").mkdir(0o755)?;
unsafe {
mount(
raw_cstr!("proc"),
raw_cstr!("/proc"),
raw_cstr!("proc"),
0,
null(),
)
}
.as_os_err()?;
self.mount_list.push("/proc".to_string());
}
if !path!("/sys/block").exists() {
path!("/sys").mkdir(0o755)?;
unsafe {
mount(
raw_cstr!("sysfs"),
raw_cstr!("/sys"),
raw_cstr!("sysfs"),
0,
null(),
)
}
.as_os_err()?;
self.mount_list.push("/sys".to_string());
}
Après ces opérations, start
exécute self.config.init();
, soit la fonction BootConfig::init()
de getinfo.cpp
.
Enfin, la fonction exec_init
est lancée :
// Finally execute the original init
self.exec_init();
Conclusions
Examiner le contenu des partitions system et init_boot, le code de l’init
vanilla et de la version patchée aura permis de se faire une petite idée du fonctionnement de Magisk.