À 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'entres elle 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], 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], 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 traductions 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], 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.
3.1 Méthodes et outils existants
Pour déterminer ces inconnues, une technique détaillée dans [SOL1] et [SOL2] fait intervenir un module noyau (LKM) chargé sur la machine dont provient le dump. Bien que cette méthode fonctionne parfaitement, elle a l’inconvénient de nécessiter non seulement un accès à la machine cible, mais également la possibilité de charger un module noyau. Dans le cadre du challenge, cela n'était possible qu'en recompilant le noyau (la ROM Android de base ne supportant pas les LKM) et en supposant que la configuration du noyau n'avait pas été modifiée.
L'outil ramparser [RP1] [RP2] implémente une technique se basant sur un désassemblage de certaines portions du noyau pour en extraire les offsets de façon dynamique. Il nécessite toutefois le fichier System.map du système pour localiser certaines structures. D'autre part, cet outil paraît n'avoir jamais été publié par ses concepteurs.
Enfin, draugr [DR1] [DR2] semble être le seul outil existant au moment du challenge qui effectue une détection automatique des offsets, puisqu'il ne nécessite que le dump mémoire. On pourrait juste lui reprocher un manque de modularité et de documentation.
3.2 Volatilitux
L'outil open source de référence en matière d'analyse de mémoire physique est probablement le framework Volatility. Cependant, celui-ci ne gère que les dumps mémoire réalisés sur des machines Windows XP. À l'occasion du challenge, l'auteur a donc trouvé intéressant de concevoir un équivalent de cet outil pour les systèmes Linux, nommé Volatilitux. Comme son grand frère, il est développé en Python.
Ce framework implémente deux techniques de détection des offsets. La première nécessite de charger un LKM, qui génère un fichier de configuration au format XML. La seconde ne nécessite quant à elle que le dump de la RAM et se veut générique, dans le sens où elle fonctionne quelle que soit la configuration, la version du noyau et l'architecture. C'est donc cette deuxième méthode qu'on utilise.
La méthode utilisée est similaire à celle de draugr. Elle fait intervenir une recherche exhaustive (force brute) et consiste à parcourir le dump mémoire à la recherche des différents champs de structures, puis vérifie leur cohérence à l'aide d'heuristiques. Celles-ci vérifient des équations relativement simples basées sur des tests d'égalité et d'inégalité concernant certains champs et offsets. Typiquement, on vérifiera que l'offset d'un champ au sein de deux structures supposées de même type est constant, et qu'une adresse virtuelle noyau est bien supérieure à PAGE_OFFSET.
Afin d'optimiser la recherche et de limiter le nombre de boucles imbriquées dans l'algorithme, seules quelques inconnues sont testées simultanément. Une fois leurs valeurs confirmées, les inconnues suivantes sont testées à l'aide de nouvelles heuristiques, et ainsi de suite.
3.3 Grandes lignes de l'algorithme
Le point de départ permettant de parcourir les structures noyau est le premier processus lancé par le système, nommé swapper.(valable pour tout Linux). L'algorithme commence donc par rechercher la chaîne de caractères « swapper » dans le dump, qui correspond au champ task_struct.comm. L'algorithme tente ensuite de localiser l'offset du champ tasks.next (noté O) qui pointe vers le champ tasks.next du deuxième processus, init. Une première vérification est effectuée en comparant le delta (D) entre comm et tasks au niveau de ces deux structures, qui doivent être égaux. On vérifie également que le précédent d'init pointe bien sur swapper. Pour trouver le début de ces structures, on recherche le parent d'init et celui de swapper, qui est swapper lui-même.
Fig. 2 : Relations entre les champs de task_struct
L'algorithme recense ensuite toutes les task_struct en parcourant la liste chaînée. On cherche le pid de chacune d'entre elles, qui doit valoir 0, 1 et 2 pour les 3 premières structures. Le champ mm est également cherché en vérifiant qu'il est nul pour le premier et le troisième processus, mais non nul pour le deuxième.
L'outil tente ensuite de détecter les offsets liés aux zones mémoire et fichiers ouverts. Il commence par bruteforcer l'offset de vm_file, puis celui de vm_pgoff et f_dentry.
Après cela, on détecte l'architecture matérielle et l'offset du champ pgd au sein de mm_struct. Pour le moment, l'outil supporte les architectures x86 32 bits avec et sans PAE, ainsi qu'ARM. Pour déterminer ces deux paramètres, l'outil bruteforce dans un premier temps le champ pgd, et utilise son contenu pour traduire une adresse virtuelle noyau dont l'adresse physique est connue, puisqu'il suffit d'y retrancher la constante PAGE_OFFSET.
Enfin, on calcule les offsets de différents champs liés aux fichiers (vm_file, f_dentry et vm_pgoff). Sans rentrer dans le détail, on parcourt la liste des zones pour le processus init et on effectue des vérifications au niveau des noms de fichiers mappés. On suppose toutefois que certains champs, tels que vm_next, ont des offsets fixes quel que soit le noyau. Ces offsets sont codés en dur dans l'algorithme.
Au final, l'analyse complète du dump est très rapide, de l'ordre de la seconde sur une machine relativement récente (en l’occurrence un Intel Core 2 duo). Notons que ce temps exclut le chargement du dump en mémoire, qui peut être un peu plus long.
3.4 Résultats
Nous avons testé l'algorithme en comparant ses résultats avec une détection exacte effectuée manuellement à l'aide du LKM. Le but original de Volatilitux étant d'aider à la résolution du challenge, il a donc dans un premier lieu été testé sur le dump Android 2.1 fourni, puis sur différentes distributions Linux : Debian 5, Fedora 5 et 8, CentOS 5 et Ubuntu 10.10 en ayant désactivé puis activé la PAE.
Nous observons que les offsets sont bien détectés sur toutes les plateformes, sauf sur Ubuntu 10.10 ayant la PAE activée. Cela est dû au fait que l'offset du champ vm_flags est hardcodé dans l'outil, alors que l'utilisation de la PAE provoque un changement de cette valeur. Ce bogue devrait être corrigé prochainement par l'auteur.
4. Extraction des informations
Après avoir détecté les offsets et adresses des différentes structures, Volatilitux est en mesure de les parcourir pour en extraire des informations. La volonté de l'auteur était de rendre cet outil extensible et modulaire, en facilitant l'ajout de structures à analyser, de commandes, et de nouvelles architectures.
4.1 Structures
Dans Volatilitux, une structure noyau hérite de la classe KernelStruct. Son nom Linux est précisé lors de sa déclaration à l'aide d'un décorateur Python. Ses champs sont définis en surchargeant la méthode de classe initclass(). Pour chacun d'entre eux, on précise son nom ainsi que son type. L'exemple suivant montre un extrait de la déclaration de task_struct :
@unix_name("task_struct")
class Task(KernelStruct):
@classmethod
def initclass(cls):
cls.fields_classes = {
"pid": Field(int),
"comm": Field(str),
"parent": Pointer(Task),
"tasks": ListHead(Task),
"mm": Pointer(UserSpace),
}
Chaque classe de ce type hérite du constructeur de KernelStruct, qui prend comme unique paramètre l'adresse virtuelle de la structure.
Les champs sont définis à l'aide des fonctions Field(), Pointer() ou ListHead(), qui prennent en paramètre un type. Grâce à la surcharge des opérateurs de Python, chaque champ devient ensuite accessible en tant qu'attribut de classe. Ces trois fonctions retournent en réalité des classes, qui sont instanciées en même temps que la classe qui les contient. Par ce biais, il est possible de définir des relations entre les structures. Pour reprendre l'exemple ci-dessus, on retrouve le champ mm qui correspond à un pointeur vers mm_struct, structure gérée par la classe UserSpace.
Il est possible de définir des méthodes supplémentaires pour chaque classe gérant une structure. Par exemple, la méthode de classe getTasks() retourne la liste des processus sous forme de couples (PID, Task).
@classmethod
def getTasks(cls):
l = []
t = Task(Config.init_task)
var = True
while var or t.comm != "swapper":
l.append((int(t.pid), t))
t = t.next()
var = False
return l
4.2 Commandes
Le dossier commands contient l'ensemble des commandes disponibles dans l'interface console. Chaque commande correspond à un module Python, ceux-ci étant importés automatiquement. Voici l'exemple de la commande pslist, qui utilise la méthode Task.getTasks() :
from init import *
options = None
def desc():
return "Print the list of all process"
def run(module_options=None):
process_list = Task.getTasks()
print Task.getColumns()
print "\n".join([str(c[1]) for c in process_list])
En plus de pslist, l'outil dispose de commandes relatives à la mémoire (memmap pour la cartographie d'un processus, memdmp pour le dump), ainsi que filelist et filedmp, commandes similaires pour les fichiers.
4.3 Architectures
De la même manière, chaque architecture gérée est située dans un module au sein du répertoire core/mm/arch. Pour ajouter une architecture, il suffit de définir la fonction va_to_pa() dont le rôle est de traduire une adresse virtuelle en adresse physique à l'aide du registre pointant vers les tables de traduction.
5. Exemples d'utilisation
Pour finir, voyons deux exemples illustrant les fonctionnalités de l'outil.
5.1 Ligne de commandes
L'utilisation la plus classique de l'outil se fait à l'aide des commandes définies précédemment. Voyons comment extraire l'application com.anssi.textviewer à partir du dump fourni lors du challenge.
$ volatilitux.py -f challv2 pslist
Name PID PPID
swapper 0 0
init 1 0
[...]
anssi.textviewer 227 30
com.anssi.secret 233 30
$ volatilitux.py -f challv2 memmap -p 227
Begin End Flags File
00008000-00009000 r-xp app_process
00009000-0000a000 rwxp app_process
[...]
beada000-beaef000 rwxp [stack]
$ volatilitux.py -f challv2 filelist -p 227 | grep apk
com.anssi.textviewer.apk
data@app@com.anssi.textviewer.apk@classes.dex
framework-res.apk
$ volatilitux.py -f challv2 filedmp -p 227 -t com.anssi.textviewer.apk -o output.apk
Dumping from 426f3000 to 426f8000...
20480 bytes dumped to output.apk
On notera que la deuxième application développée pour le challenge, com.anssi.secret, ne peut pas être récupérée via cette méthode, car deux de ses pages mémoire ne sont plus considérées comme valides (certainement car elles ont été swappées). Cependant, elles sont toujours mappées et il est possible de la récupérer en utilisant la méthode décrite dans [SOL2].
5.2 Module Python
Volatilitux est également utilisable en tant que module. Les tâches, zones mémoire et fichiers peuvent ainsi être récupérés et manipulés comme des objets Python. Il est donc envisageable d'automatiser l'analyse du dump. Voici un exemple de code permettant de lister les processus sous forme d'arbre :
from volatilitux import *
def printTree(pid = 0, depth = 0):
print "| " * (depth-1) + "|-" +
" %s (%d)" % (tasks[pid].comm, pid)
c = childs[pid]
for p in c:
if int(p) > pid:
printTree(p, depth+1)
Config.setDumpFile("challv2")
Config.fingerprint()
tasks = dict(Task.getTasks())
childs = {}
for pid, task in tasks.items():
childs[pid] = map(lambda t: t.pid,
filter(lambda t: t.parent.pid == pid,
tasks.values()))
printTree()
Le résultat est alors le suivant :
|- swapper (0)
|- init (1)
| |- sh (25)
| |- servicemanager (26)
| |- vold (27)
| |- debuggerd (28)
| |- rild (29)
| |- zygote (30)
| | |- d.process.media (147)
| | |- com.android.mms (170)
| | |- roid.alarmclock (136)
| | |- system_server (52)
| | |- m.android.email (183)
| | |- com.svox.pico (207)
| | |- putmethod.latin (96)
| | |- m.android.phone (98)
| | |- nssi.textviewer (227)
| | |- d.process.acore (101)
[…]
Le résultat est similaire à la sortie de la commande pstree. On retrouve dans le code l'utilisation de la méthode Tasks.getTasks() détaillée précédemment.
Conclusion
Volatilitux permet d'automatiser l'analyse de mémoire physique en extrayant des informations à partir des structures importantes du noyau. Il implémente deux techniques de détection des offsets, l'une nécessitant le chargement d'un LKM, l'autre se basant sur des heuristiques. Cette dernière permet d'extraire facilement une des deux applications Android contenues dans le dump fourni lors du challenge. Notons qu'il existe plusieurs autres méthodes pour y parvenir, notamment le zip carving [SOL3], et l'analyse des inodes mappés en mémoire [SOL4].
Quelques points sont encore à améliorer, tels que la détection de certains offsets, la gestion de l'endianness (l'outil ne gérant pour le moment que le little-endian), ainsi que des architectures 64 bits. Et bien entendu, l'ajout de plugins et de structures permettant d'étendre ses fonctionnalités, pour éventuellement devenir un jour un véritable équivalent de Volatility pour Linux. D'ailleurs, il est sans doute envisageable de fusionner les deux outils, étant donné la similarité des concepts sur lesquels ils se basent. Dans tous les cas, l'analyse forensique de mémoire physique sous Linux n'en est certainement qu'à ses débuts...
Remerciements
Je remercie vivement toute l'équipe de Sysdream pour ses remarques apportées lors de la relecture de l'article.
Références
[VY] Volatility, https://www.volatilesystems.com/default/volatility
[VUX] Volatilitux: Physical memory analysis of Linux systems, http://www.segmentationfault.fr/projets/volatilitux-physical-memory-analysis-linux-systems/
[SOL1] http://static.sstic.org/challenge2010/duverger.pdf
[SOL2] http://pentester.fr/blog/index.php?post/2010/06/03/Challenge-SSTIC-2010-in-a-nutshell
[SOL3] http://static.sstic.org/challenge2010/campana_bedrune.pdf
[SOL4] http://pentester.fr/blog/index.php?post/2010/06/15/Challenge-SSTIC-2010-M%C3%A9thode-alternative
[RP1] FACE: Automated digital evidence discovery and correlation (2008), A. Case, A. Cristina, L. Marziale, C. G. Richard, V. Roussev, http://www.dfrws.org/2008/proceedings/p65-case.pdf
[RP2] Dynamic recreation of kernel data structures for live forensics (2010), A. Case, L. Marziale, C. G. Richard), http://www.dfrws.org/2010/proceedings/2010-304.pdf
[DR1] http://code.google.com/p/draugr/
[DR2] Live Memory Forensics, Anthony Desnos, http://www.esiea-recherche.eu/~desnos/papers/slidesdraugr.pdf