Mais que fait Magisk ?


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 deux init_boot.img
  • Un répertoire overlay.d, contenant magisk64.xz et stub.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 de iptables-restore --noflush -w -v et ip6tables-restore --noflush -w -v
    • zygote64
    • zygote
    • ssgtzd
    • un grand nombre de android.quelquechose et de vendor.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 partition init_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’argument selinux_setup : Cet étage charge la politique SELinux.
  • enfin, /system/bin/init est exécuté avec l’argument second_stage. Cet instance d’init termine le processus de boot suivant la configuration spécifiée par les différents scripts init.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.


Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *