À l’occasion du SSTIC 2010, l’ANSSI a conçu un challenge de forensics. Le but était d’analyser une copie de la mémoire physique (dump) d’un téléphone Android, afin d’y retrouver une adresse e-mail. Plusieurs solutions ont été trouvées pour résoudre ce challenge [SOL{1,2,3,4}]. L’une d’entre elles consiste à reconstituer la mémoire virtuelle de chaque application. Cette étape est certes difficile, mais non nécessaire, et a été contournée par un bon nombre de compétiteurs. En effet, l’outil de référence dans le domaine, Volatility [VY]Volatility, https://www.volatilesystems.com/default/volatility , ne fonctionne que pour les dumps mémoire provenant de Windows XP ; il ne gère pas les systèmes Linux. Cet article présente comment il est néanmoins possible d’y arriver, et détaille l’implémentation de Volatilitux [VUX]Volatilitux: Physical memory analysis of Linux systems, http://www.segmentationfault.fr/projets/volatilitux-physical-memory-analysis-linux-systems/ , outil open source réalisé par l’auteur à cette occasion.
1. Introduction
La mémoire physique correspond à la RAM d'une machine, qui contient l'ensemble des objets manipulés par le système au moment où l'acquisition est effectuée. On y retrouve notamment les fichiers ouverts (mappés), mais de par le mécanisme de pagination, ceux-ci ne sont pas forcément contigus en mémoire.
Lors de l'analyse de la RAM, la première difficulté est que l'on ne dispose pas des registres du processeur. Nous n'avons donc pas directement accès aux tables de traduction permettant de retrouver les espaces mémoire virtuels des différents processus. Ce problème peut être partiellement contourné en ce qui concerne la mémoire noyau, car fort heureusement, le noyau Linux est mappé toujours au début de la RAM de façon linéaire. Cela permet d'accéder aux principales structures et, par exemple, de lister les processus et leurs propriétés (nom, PID, ...).
Une autre difficulté, certes moindre, est de déterminer la taille des pages mémoire. Celle-ci dépend principalement de l'architecture utilisée. S'agissant ici d'un processeur ARM, la taille standard est de 4 kilo-octets.
Vient alors un troisième obstacle : les offsets des champs contenus dans ces structures sont susceptibles de varier en fonction de la version du noyau et de sa configuration, a priori inconnues. Cela est dû à la présence de nombreuses macros et autres directives de compilation conditionnelle dans le code source du noyau. Retrouver les valeurs de ces différents offsets nécessite donc d'explorer un grand nombre de combinaisons possibles, qui dépend de la taille du dump mémoire. Celle-ci peut d'ailleurs être assez conséquente, variant d'une centaine de méga-octets (comme c'est le cas pour le challenge) à plusieurs giga-octets, ce qui peut s'avérer décourageant.
Pour être en mesure d'analyser un dump, on suit donc une méthodologie en deux temps. La première est de déterminer les offsets des champs contenus dans les structures noyau. Deux méthodes permettant d'y parvenir sont détaillées ci-après. Une fois ces offsets calculés, nous pouvons alors localiser les structures, les parcourir, et en extraire des informations. Ces deux étapes ont été implémentées dans l'outil Volatilitux [VUX]Volatilitux: Physical memory analysis of Linux systems, http://www.segmentationfault.fr/projets/volatilitux-physical-memory-analysis-linux-systems/ , qui est présenté plus loin.
2. Structures du noyau
2.1. Processus
Sous Linux, la structure noyau task_struct représente un processus. Voici un extrait de sa définition dans sched.h :
|
|
struct task_struct { ... struct mm_struct *mm; ... pid_t pid; ... struct task_struct *parent; ... char comm[TASK_COMM_LEN]; ... struct list_head tasks; } |
Les champs pid et comm correspondent respectivement au PID du processus et au nom de l'exécutable. Le parent du processus est pointé par parent. Ces structures forment une liste doublement chaînée, chacune d'entre elles possédant une structure list_head. Celle-ci contient deux pointeurs, next et prev, qui pointent vers les éléments suivant et précédent. À vrai dire, ils pointent en réalité vers le début des structures list_head ; pour récupérer la task_struct correspondante, il faut soustraire son offset à la valeur du pointeur.
2.2. Mémoire virtuelle
Le champ mm de task_struct pointe vers une structure de type mm_struct, qui décrit les propriétés de l'espace mémoire du processus. Elle est définie dans mm_types.h et comporte deux champs particulièrement intéressants :
|
|
struct mm_struct { struct vm_area_struct * mmap; ... pgd_t * pgd; ... } |
Le champ pgd contient la valeur du registre CPU correspondant à l'adresse physique de la table de traduction d'adresses de premier niveau. Il s'agit typiquement du registre CR3 pour les architectures x86, et TTBR0 ou TTBR1 pour les processeurs ARM.
Le tout premier champ de cette structure, mmap, pointe vers une liste simplement chaînée de structures vm_area_struct. Chacune d'entre elles correspond à une zone de mémoire contiguë (un ensemble de pages). La définition de cette structure se trouve également dans mm_types.h :
|
|
struct vm_area_struct { struct mm_struct * vm_mm; unsigned long vm_start; unsigned long vm_end; struct vm_area_struct *vm_next; ... unsigned long vm_flags; ... unsigned long vm_pgoff; struct file * vm_file; ... } |
Son premier champ désigne l'espace mémoire auquel la zone correspond, les autres recensent diverses propriétés de la zone mémoire. On y retrouve ses adresses de début et de fin (vm_start et vm_end), les droits d'accès (vm_flags), le fichier correspondant à la zone (vm_file) s'il s'agit d'un fichier mappé, ainsi que l'offset de cette zone au sein du fichier (vm_pgoff). Le champ vm_next pointe vers la zone suivante.
Notons que la cartographie de la mémoire virtuelle d'un processus se limite à l'espace utilisateur, c'est-à -dire aux adresses virtuelles en dessous de la constante PAGE_OFFSET (qui vaut en général 0xC0000000). L'espace noyau situé virtuellement au-dessus est le même pour tous les processus et est mappé physiquement du début de la RAM.
2.3. Fichiers
La structure file est quant à elle définie dans fs.h. Son seul champ qui nous intéresse est un pointeur vers une structure de type dentry. Notons que pour les versions du noyau supérieures à 2.6.20, ce pointeur se retrouve au sein d'une structure path, elle-même intégrée dans file. Une macro définie au sein de cette structure permet d'accéder à ce membre en gardant la compatibilité avec les anciennes versions :
|
|
struct file { ... struct path f_path; #define f_dentry f_path.dentry ... } |
Enfin, la structure dentry recense le nom du fichier en utilisant une structure intermédiaire, qstr, qui contient un tableau de caractères correspondant au nom du fichier :
|
|
struct dentry { ... struct qstr d_name; } ... struct qstr { ... const unsigned char *name; }; |
L'ensemble des structures ainsi que leurs relations sont illustrées sur la figure 1.
Fig. 1 : Relations entre les structures noyau
3. Détection automatique des offsets
En parcourant toutes ces structures, il devient possible d'extraire la liste des processus du système et la cartographie mémoire de chacun d'entre eux, comprenant leurs fichiers ouverts. Ce point est détaillé plus loin. En attendant, nous faisons face à une difficulté principale : les adresses de ces structures ne sont pas connues a priori, ni les offsets des champs qu'elles contiennent.