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.
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 -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é _install
.
On se retrouve avec un répertoire _install
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 _install
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 _install/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
Ensuite il faut créer le repertoire initramfs
dans lequel on va copier les fichiers présents dans le répertoire _install
:
1
2
3
4
5
6
7
8
9
10
11
λ ~/dev/busybox-1.35.0 » mkdir initramfs; cd initramfs
λ ~/dev/busybox-1.35.0/initramfs » mkdir -pv {bin,sbin,etc,proc,sys,usr/{bin,sbin}}
mkdir: created directory 'bin'
mkdir: created directory 'sbin'
mkdir: created directory 'etc'
mkdir: created directory 'proc'
mkdir: created directory 'sys'
mkdir: created directory 'usr'
mkdir: created directory 'usr/bin'
mkdir: created directory 'usr/sbin'
λ ~/dev/busybox-1.35.0/initramfs » cp -a ../_install/* .
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 initramfs
et rendez-le exécutable avec la commande chmod +x init
.
Il ne reste plus qu’à créer la ramdisk : find . -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
.
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
.
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.
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
.
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()
.
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.
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.
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
.
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.
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.
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…
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 :