Home Debugging d'un noyau Linux ARM64
Post
Cancel

Debugging d'un noyau Linux ARM64

swaggy_logo

Dans cet article on va voir comment mettre en place un système Linux minimal pour y déboguer le noyau Linux sur une architecture ARM 64bits.

Pour ce faire je vais commencer par expliquer comment compiler un noyau Linux ainsi que Busybox pour cette même architecture, puis on utilisera l’émulateur QEMU pour charger le noyau et ainsi faire du kernel debugging avec GDB puis IDA.

Pour simplifier cet article, au lieu d’utiliser le terme “Aarch64” pour parler de l’architecture ARMv8 64 bits, j’utiliserai ARM64, qui est le plus répandu actuellement.

Linux pour ARM64

Pour cet article, j’ai décidé de cibler un noyau Linux pour l’architecture ARM64. Cette architecture est principalement utilisée pour les systèmes embarqués, les smartphones et tablettes, mais elle commence à voir le jour sur les serveurs et ordinateurs grand public comme les Macs M1 d’Apple.

Dépendances

Dans le cadre de cet article je vais utiliser Debian 11 pour compiler le noyau.

Ma machine hôte sur laquelle je vais compiler le noyau Linux a une architecture x86_64. Par défaut le compilateur GCC cible cette architecture. On va donc devoir faire de la compilation croisée (cross-compilation en anglais) pour compiler vers ARM64.

Pour cela, je vais installer gcc-aarch64-linux-gnu pour compiler avec l’architecture cible.

Il nous faut aussi quelques utilitaires en plus : bison, flex, bc, libncurses-dev, libssl-dev. Tous ces paquets sont disponibles via le gestionnaire de paquets APT.

Et bien sûr il faut télécharger le code source du kernel. Pour cet article j’utilise la dernière version disponible à ce jour sur kernel.org: 5.17-rc3. On télécharge l’archive et on la décompresse :

1
2
3
4
5
6
7
8
9
10
11
λ ~/dev » curl -LO https://git.kernel.org/torvalds/t/linux-5.17-rc3.tar.gz
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   162  100   162    0     0    496      0 --:--:-- --:--:-- --:--:--   498
100  192M    0  192M    0     0  10.4M      0 --:--:--  0:00:18 --:--:-- 11.1M
λ ~/dev » tar zxvf linux-5.17-rc3.tar.gz
...
λ ~/dev » ls linux-5.17-rc3
arch   COPYING  Documentation  include  Kbuild   lib          Makefile  README   security  usr
block  CREDITS  drivers        init     Kconfig  LICENSES     mm        samples  sound     virt
certs  crypto   fs             ipc      kernel   MAINTAINERS  net       scripts  tools

On peut voir que le code source du noyau a été décompressé dans le répertoire linux-5.17-rc3.

Compilation

On a maintenant tout ce qu’il faut pour compiler notre noyau. L’étape suivante est de générer le fichier de configuration .config pour ARM64. Ce fichier est ensuite parcouru par le Makefile pour prendre en compte les options nécessaires lors de la compilation.

Pour l’instant on génère une configuration générique pour ARM64, avec la commande make en spécifiant l’architecture et la configuration cible comme ci-dessous :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
λ ~/dev/linux-5.17-rc3 » make ARCH=arm64 defconfig
  HOSTCC  scripts/basic/fixdep
  HOSTCC  scripts/kconfig/conf.o
  HOSTCC  scripts/kconfig/confdata.o
  HOSTCC  scripts/kconfig/expr.o
  LEX     scripts/kconfig/lexer.lex.c
  YACC    scripts/kconfig/parser.tab.[ch]
  HOSTCC  scripts/kconfig/lexer.lex.o
  HOSTCC  scripts/kconfig/menu.o
  HOSTCC  scripts/kconfig/parser.tab.o
  HOSTCC  scripts/kconfig/preprocess.o
  HOSTCC  scripts/kconfig/symbol.o
  HOSTCC  scripts/kconfig/util.o
  HOSTLD  scripts/kconfig/conf
*** Default configuration is based on 'defconfig'
#
# configuration written to .config
#

Un nouveau fichier .config est créé avec les options ARM64 par défaut.

Il est possible d’avoir une interface ncurses pour sélectionner les options de compilation avec la commande suivante : make ARCH=arm64 menuconfig. Cela va ouvrir une interface dans le terminal ou vous pourrez naviguer avec les flèches et cocher/décocher les cases avec la barre espace.

menuconfig make menuconfig

Vous pouvez vous rendre dans Kernel hacking et vérifier que l’option Kernel debugging est activée. Dès que c’est fait il ne reste plus qu’à compiler le noyau.

Avec make cette fois-ci, en plus de spécifier l’architecture cible, il faut indiquer le compilateur qu’on a installé plus tôt. Et utiliser la cible Image qui va compiler une image non-compressée du noyau. Vous pouvez spécifier l’option -j pour faire tourner plusieurs jobs en même temps.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
λ ~/dev/linux-5.17-rc3 » make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- Image -j$(nproc)
  WRAP    arch/arm64/include/generated/uapi/asm/kvm_para.h
  WRAP    arch/arm64/include/generated/uapi/asm/errno.h
  WRAP    arch/arm64/include/generated/uapi/asm/ioctl.h
  WRAP    arch/arm64/include/generated/uapi/asm/ioctls.h
  WRAP    arch/arm64/include/generated/uapi/asm/ipcbuf.h
  WRAP    arch/arm64/include/generated/uapi/asm/msgbuf.h
  [..]
  LD      vmlinux.o
  MODPOST vmlinux.symvers
  MODINFO modules.builtin.modinfo
  GEN     modules.builtin
  LD      .tmp_vmlinux.kallsyms1
  KSYMS   .tmp_vmlinux.kallsyms1.S
  AS      .tmp_vmlinux.kallsyms1.S
  LD      .tmp_vmlinux.kallsyms2
  KSYMS   .tmp_vmlinux.kallsyms2.S
  AS      .tmp_vmlinux.kallsyms2.S
  LD      vmlinux
  SYSMAP  System.map
  SORTTAB vmlinux
  OBJCOPY arch/arm64/boot/Image

Sur ma machine, la compilation a pris une bonne dizaine de minutes. Lorsque la compilation est finie, les deux fichiers qui nous intéressent sont les suivants :

  • vmlinux le fichier que l’on va charger dans le debogueur.
  • arch/arm64/boot/Image le fichier chargé par QEMU.

Avec la commande file on vérifie que les fichiers Image et vmlinux ont bien été compilés pour la bonne architecture :

1
2
3
λ ~/dev/linux-5.17-rc3 » file vmlinux arch/arm64/boot/Image
vmlinux:               ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), statically linked, BuildID[sha1]=d67295124ceebdaa7022e1458f18062b1e35eab1, with debug_info, not stripped
arch/arm64/boot/Image: Linux kernel ARM64 boot executable Image, little-endian, 4K pages

Busybox

Busybox, est un utilitaire minimaliste qui regroupe tous les outils pour avoir un système Linux viable après le chargement du kernel. L’avantage de Busybox, c’est qu’un seul binaire regroupe tous ces outils.

Busybox va nous servir de ramdisk pour avoir un shell avec le stricte nécessaire après le démarrage du kernel. Pour ça, on télécharge Busybox :

1
$ curl -LO https://busybox.net/downloads/busybox-1.35.0.tar.bz2 && tar jxvf busybox-1.35.0.tar.bz2 && cd busybox-1.35.0

Il ne reste plus qu’à lancer l’étape de compilation, qui est similaire à celle du noyau Linux :

1
2
3
$ make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- defconfig
$ echo "CONFIG_STATIC=y" >> .config # build as static binary
$ make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- install CONFIG_PREFIX=rootfs -j

Les commandes ci-dessus permettent de générer la configuration générique pour ARM64, de spécifier que l’on va compiler le binaire de façon statique pour éviter toute dépendance, puis de compiler et préparer les fichiers dans un répertoire nommé rootfs.

On se retrouve avec un répertoire rootfs qui contient Busybox et ses liens symboliques :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
λ ~/dev/busybox-1.35.0 » ls -l rootfs | head   
total 12
drwxr-xr-x 2 mathieu mathieu 4096 Feb 12 22:05 bin
lrwxrwxrwx 1 mathieu mathieu   11 Feb 12 22:05 linuxrc -> bin/busybox
drwxr-xr-x 2 mathieu mathieu 4096 Feb 12 22:05 sbin
drwxr-xr-x 4 mathieu mathieu 4096 Feb 12 22:05 usr
λ ~/dev/busybox-1.35.0 » ls -l rootfs/bin | head  
total 1964
lrwxrwxrwx 1 mathieu mathieu       7 Feb 12 22:05 arch -> busybox
lrwxrwxrwx 1 mathieu mathieu       7 Feb 12 22:05 ash -> busybox
lrwxrwxrwx 1 mathieu mathieu       7 Feb 12 22:05 base32 -> busybox
lrwxrwxrwx 1 mathieu mathieu       7 Feb 12 22:05 base64 -> busybox
-rwxr-xr-x 1 mathieu mathieu 2010576 Feb 12 22:05 busybox
lrwxrwxrwx 1 mathieu mathieu       7 Feb 12 22:05 cat -> busybox
lrwxrwxrwx 1 mathieu mathieu       7 Feb 12 22:05 chattr -> busybox
lrwxrwxrwx 1 mathieu mathieu       7 Feb 12 22:05 chgrp -> busybox
lrwxrwxrwx 1 mathieu mathieu       7 Feb 12 22:05 chmod -> busybox

Il nous reste à créer le fichier init qui sera exécuté par le noyau Linux, pour ma part j’ai rajouté un petit logo avec figlet.

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
cat <<!
 ___  ___  ___ _    _
|   \| _ )/ __| |  (_)_ _ _  ___ __
| |) | _ \ (_ | |__| | ' \ || \ \ /
|___/|___/\___|____|_|_||_\_,_/_\_\

~matteyeux
!
exec /bin/sh

Créez ce fichier dans le répertoire rootfs et rendez-le exécutable avec la commande chmod +x rootfs/init.

Il ne reste plus qu’à créer la ramdisk : find rootfs -print0 | cpio --null -ov --format=newc | gzip -9 > /initramfs.cpio.gz.

QEMU

Qemu est l’outil qui nous permet d’émuler le kernel précédemment compilé pour ARM64. Rebelote, on télécharge et on compile le code :

1
2
3
4
5
6
λ ~/dev » curl -LO https://download.qemu.org/qemu-6.2.0.tar.xz
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  110M  100  110M    0     0  10.3M      0  0:00:10  0:00:10 --:--:-- 11.2M
λ ~/dev » tar Jxvf qemu-6.2.0.tar.xz
...

Pour compiler QEMU installez les paquets suivants : ninja-build libpixman-1-dev libglib2.0-dev qui sont disponibles dans votre gestionnaire de paquets.

Avant de se lancer dans la compilation, on génère le Makefile avec le script de configuration pour le support ARM64.

1
2
3
4
5
6
7
8
9
10
11
λ ~/dev/qemu-6.2.0 » ./configure --target-list=aarch64-softmmu              
Using './build' as the directory for build output
The Meson build system
Version: 0.59.3
...
Source dir: /home/mathieu/dev/qemu-6.2.0
  Subprojects
    libvhost-user                : YES

Found ninja-1.10.1 at /usr/bin/ninja

Dès que la génération du Makefile est finie, on peut lancer la compilation avec la commande make.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
λ ~/dev/qemu-6.2.0 » make -j$(nproc) 
changing dir to build for make ""...
make[1]: Entering directory '/home/mathieu/dev/qemu-6.2.0/build'
/usr/bin/ninja  build.ninja && touch build.ninja.stamp
ninja: no work to do.
/usr/bin/python3 -B /home/mathieu/dev/qemu-6.2.0/meson/meson.py introspect --targets --tests --benchmarks | /usr/bin/python3 -B scripts/mtest2make.py > Makefile.mtest
  AS      multiboot.o
  AS      multiboot_dma.o
  AS      linuxboot.o
  CC      linuxboot_dma.o
  AS      kvmvapic.o
...
[2628/2631] Compiling C object tests/qtest/qos-test.p/vhost-user-test.c.o
[2629/2631] Linking target tests/qtest/qos-test
[2630/2631] Compiling C object libqemu-aarch64-softmmu.fa.p/target_arm_sve_helper.c.o
[2631/2631] Linking target qemu-system-aarch64
make[1]: Leaving directory '/home/mathieu/dev/qemu-6.2.0/build'
changing dir to build for make ""...
make[1]: Entering directory '/home/mathieu/dev/qemu-6.2.0/build'
[1/51] Generating QAPI test (include) with a custom command
[2/18] Generating qemu-version.h with a custom command (wrapped by meson to capture output)
make[1]: Leaving directory '/home/mathieu/dev/qemu-6.2.0/build'

Puis dans la volée, faire un sudo make install pour installer QEMU. Dès que celui-ci est installé, il ne reste plus qu’à tester le chargement du noyau.

Démarrage du système Linux

Nous avons maintenant tout ce qu’il nous faut pour démarrer un système Linux minimal avec un shell. Avant toute chose, il faut regrouper les fichiers nécessaires dans un même répertoire, pour une question d’organisation :

1
2
3
 $ mkdir dbglinux
 $ cp linux-5.17-rc3/{System.map,arch/arm64/boot/Image} busybox-1.35.0/initramfs.cpio.gz dbglinux/
 $ cd dbglinux

Tentons un démarrage du noyau avec la commande suivante : qemu-system-aarch64 -cpu cortex-a53 -M virt -kernel Image -nographic.

kernelpanic Linux kernel panic dans QEMU

On voit le kernel fraichement compilé démarrer puis paniquer. C’est normal, il ne trouve pas de périphérique auquel accéder. Sortez de QEMU, en utilisant les touches CTRL+a puis x.

Il manque une option dans la ligne de commande que l’on passe pour spécifier la ramdisk à charger, on peut relancer la commande, cette fois-ci en spécifiant la ramdisk d’initialisation : qemu-system-aarch64 -cpu cortex-a53 -M virt -kernel Image -initrd initramfs.cpio.gz -nographic.

kernelboot DBGLinux

Et voilà ! On a démarré notre système Linux minimal. Grâce à ça, on va pouvoir mettre en place le débogueur.

GDB Debugging

On va enfin pouvoir configurer QEMU pour déboguer notre noyau avec GDB.

Pour permettre le debugging avec QEMU, il faut ajouter quelques paramètres pour désactiver KASLR et ainsi que les options de debug : -s pour le support de GDB en réseau (équivalent de -gdb tcp::1234). Et -S pour stopper le CPU dès le démarrage.

Voici la nouvelle commande : qemu-system-aarch64 -cpu cortex-a53 -M virt -kernel Image -initrd initramfs.cpio.gz -append "nokaslr" -nographic -s -S.

Deboguer le kernel et avoir une vue des sources, je vous conseil de lancer GDB dans le répertoire du kernel que vous avez compilé pour qu’il puisse afficher le contenu des fichiers source.

De plus, l’interface de GDB n’étant pas la plus idéale j’utilise gdb-dashboard permettant de visualiser les informations nécessaires.

kerneldbg gdb-multiarch et QEMU

Ci-dessus, on lance gdb-multiarch qui supporte le debbuging de l’architecture ARM64 et bien d’autres :

  • On se connecte à la cible locale : target remote :1234. Si vous utilisez une machine distante spécifiez l’adresse IP avant les deux points.
  • On place un hardware breakpoint au niveau de la fonction start_kernel.

Si vous tapez la commande continue, le kernel démarre et se stoppe au niveau de l’appel de la fonction set_task_stack_end_magic dans la fonction start_kernel.

kernelbp Breakpoint à la première instruction de la fonction kernel_start

On a donc une vue de l’assembleur et du code source en même temps dans le débogueur. Comme nous avons désactivé KASLR, il est aussi possible de placer des breakpoints à des adresses particulières comme par exemple 0xffff8000097a0c74 qui appelle l’instruction setup_arch().

bp_setup_arch Breakpoint à l’adresse 0xffff8000097a0c74

Ou encore on peut placer un breakpoint sur la fonction kmalloc_slab et le déclencher avec la commande cat qui appelle indirectement cette fonction.

kmalloc Breakpoint à la fonction kmalloc

Bref vous l’avez vu, GDB avec le noyau Linux fonctionne comme pour n’importe quel fichier binaire. Mais on atteint les limites de GDB s’il nous manque le code source ou que l’on veut interagir de façon interactive avec celui-ci.

IDA Debugging

La suite de cet article concerne IDA. Dans mon cas j’utilise IDA Home pour ARM sur Linux, mais la version Pro Windows fonctionne tout aussi bien et de la même façon. Contrairement à GDB, cette fois-ci il n’y a pas besoin du code source de Linux pour avoir un affichage des sources.

IDA décompile l’assembleur en pseudocode C comme on peut le voir ci-dessous avec la fonction start_kernel.

start_kernel_ida IDA vue de l’assembleur et du décompilateur

Dans la partie debuggging avec GDB, j’étais sur un serveur distant. Cette fois-ci avec IDA, j’utilise ma machine, celle-ci va se connecter à mon serveur local qui héberge mon instance QEMU.

J’ai rapatrié le fichier vmlinux sur ma machine pour l’ouvrir dans IDA. En fonction des performances de la machine, cela peut prendre du temps à analyser vu la taille du fichier. Une astuce pour accélérer l’analyse est de fermer tous les onglets dans IDA.

La première étape, tout comme avec GDB, c’est de se connecter au débogueur de QEMU. Allez dans Debugger -> Debugger options.

start_kernel_ida Options pour le *débogueur*

Une nouvelle boite de dialogue s’ouvre comme ci-dessus. Dans notre cas, il n’y a qu’à spécifier le Hostname, qui pour moi correspond à l’IP de mon serveur ainsi que le port sur lequel écoute QEMU.

Avant de lancer le mode debug, plaçons un breakpoint au niveau de l’appel de la fonction set_task_stack_end_magic tout comme on l’a fait avec GDB.

start_kernel_ida breakpoint dans la fonction start_kernel

Lançons le débogueur. IDA nous dit que celui-ci a été attaché au processus et qu’on peut naviguer et poser des breakpoints. Si vous pressez la touche F9, cela correspond à la commande continue sur GDB. On arrive à l’instruction ou le breakpoint a été placé.

Ci-dessous IDA est en mode debug, on a de nouvelles vues notamment la liste des registres généraux et une vue de la stack. Il y a toujours la possibilité d’aller à une autre adresse, de renommer un variable ou fonction en même temps de déboguer notre kernel.

start_kernel_ida Mode debug de IDA

Une autre fonctionnalité intéressante, c’est la possibilité de faire du traçage d’évènements pendant l’éxecution du kernel. IDA va mémoriser ces evenements et les afficher dans la fenêtre Tracing. L’outil mémorise les appels et ce que retournent des fonctions ainsi que les accès à des adresses spécifiques, etc…

start_kernel_ida IDA tracing et la vue graphique

IDA reste un bon compagnon pour faire du debugging avec une vue du pseudocode. Il permet aussi d’avoir une vue globale du binaire que l’on debug en pouvant afficher plusieurs fenêtres en même temps.

Je reste d’avis qu’on peut s’en passer pour faire du debugging, mais ça reste un outil incontournable.


On arrive à la fin de cet article. Donc pour résumer on a vu comment compiler et exécuter un kernel avec un minimum de composants pour ainsi le déboguer. Je voulais absolument regrouper ces informations dans un seul et même article car je n’ai pas trouvé d’article qui regroupait l’entièreté du sujet.

J’ai même ajouté une bannière en haut de l’article que j’ai faite en prenant des screenshot du code source de Linux.

J’espère que ce post vous a plu, n’hésitez pas à me contacter sur Twitter ou même par mail si vous avez des questions.


Liens et sources :

This post is licensed under CC BY 4.0 by the author.