Dans cet article, nous allons illustrer ce qu’est l’introspection des machines virtuelles et comment on peut l’utiliser dans le but d’analyser des malwares.
1. Qu’est-ce que l’introspection ?
De façon générale, le terme introspection désigne l’observation et l’examen de son propre état mental et émotionnel. Il est considéré comme l’acte de se regarder soi-même. Le meilleur exemple pour illustrer cela est l’œil de judas qui permet d’observer le monde extérieur sans avoir besoin d’en faire partie. Toutefois, l’introspection des machines virtuelles est l’art de monitorer les machines virtuelles depuis l’hyperviseur et y accéder sans être dedans (aucun agent n’est installé sur la machine virtuelle).
2. Pourquoi l’introspection ?
Grâce à la technologie d’introspection, il n’y a nul besoin de faire partie de l’environnement du malware pour l’analyser. Tout simplement parce que le comportement des processus à surveiller sera réalisé en dehors de la machine virtuelle, depuis l’hyperviseur. De plus, un malware employant des techniques de détection de débogueurne marchera pas étant donné que le système d’introspection interagit uniquement avec la mémoire de la machine virtuelle et ne s’attache en aucun cas aux processus lancés sur la machine. De même, il est possible de tromper un malware employant des techniques de détection d’environnement d’analyse (modifier à la volée l’accès à une clé de registre VirtualBox par exemple). De ce fait, on ne peut nier que l’application de l’introspection à l’analyse de malware est bien meilleure que les technologies traditionnelles d’analyse automatisée.
3. Architecture de l’introspection
3.1 Types d’hyperviseurs
On considère généralement deux types d’hyperviseurs :
- Hyperviseur natif : connu aussi sous le nom de l’hyperviseur type-1 ou encore « Bare Metal ». Ce type d’hyperviseur fonctionne directement sur le matériel de la machine afin de contrôler et gérer les machines virtuelles. Exemples d’hyperviseurs de type-1 : Xen, KVM...
- Hyperviseur hébergé : connu aussi sous le nom de l’hyperviseur de type-2 ou encore « Hosted ». Ce type d’hyperviseur s’exécute à l’intérieur d’un autre système d’exploitation. Exemples d’hyperviseurs de type-2 : VirtualBox, VMware...
La figure suivante illustre le fonctionnement de l’introspection sur l’hyperviseur de type-1. Les outils d’introspection installés sur l’hyperviseur permettent de surveiller et de contrôler tout ce qui se passe sur la machine virtuelle (activité système, réseau, applicative…).
Fig 1 : Exemple d’hyperviseur natif et d’hyperviseur hébergé.
On peut bien imaginer le même fonctionnement pour l’hyperviseur de type-2.
3.2 Mappage mémoire
De façon générale, il existe deux niveaux de mémoire ; mémoire virtuelle et mémoire physique de la machine physique. Et trois niveaux de mémoire quand on parle d’hyperviseur (mémoire virtuelle et mémoire physique de la machine virtuelle, et mémoire physique de la machine hôte. La mémoire virtuelle de la machine hôte est abstraite dans ce cas). Il faut garder en tête que les hyperviseurs allouent uniquement de la mémoire à la machine virtuelle. Les hyperviseurs par défaut n’ont aucune connaissance de ce qui se passe dans la mémoire virtuelle de la machine virtuelle. Pour ce faire, des outils additionnels doivent être installés. Ci-dessous un exemple simplifié du partage mémoire avec la machine virtuelle.
Fig 2 : Exemple des trois niveaux d’adressage mémoire sous hyperviseur.
L’un des objectifs des outils d’introspection est de réaliser la traduction des adresses mémoire de la mémoire virtuelle de la machine virtuelle en mémoire physique de la machine virtuelle, et ensuite de la mémoire physique de la machine virtuelle en mémoire physique de la machine physique, dans le but d’aider l’hyperviseur à accéder à la bonne zone mémoire pendant son introspection.
4. LibVMI
4.1 Qu’est-ce que LibVMI ?
LibVMI est une librairie en C permettant de mettre en place un système d’introspection des machines virtuelles sous Linux ou Windows. Elle permet d’accéder à la mémoire d’une machine virtuelle en cours d’exécution. Ceci tout en offrant une panoplie de fonctions permettant d’accéder à la mémoire grâce à l’adressage physique ou virtuel et des symboles Kernel. LibVMI offre aussi un accès à la mémoire à partir d’un Snapshot de mémoire physique, ce qui peut s’avérer intéressant lors de débogage ou d’investigation numérique.
En plus de l’accès mémoire, LibVMI supporte les événements mémoire. Ces événements déclenchent des notifications quand une région mémoire est accédée en lecture, écriture ou en exécution.
Plusieurs niveaux d’abstraction complexes existent quand on parle d’introspection. Ces niveaux sont heureusement gérés par LibVMI [1] et nous sont complètement transparents.
4.2 Les API LibVMI
LibVMI met à disposition une variété de fonctions et d’API intuitives permettant au développeur d’interagir plus facilement avec la mémoire. Il existe des API basiques et d’autres, plus avancées.
Toutes les API peuvent être retrouvées sur le site officiel de LibVMI.
4.3 Exemples d’utilisation
LibVMI fournit par défaut un ensemble d’exemples afin de tester ce concept, voire même reposer sur ces exemples comme base dans le but de créer son propre système d’introspection.
Tous les exemples listés ci-après sont à retrouver au complet sur le GitHub de LibVMI [2].
4.3.1 Listing de processus
Le code suivant permet de lister les processus lancés sur la machine virtuelle depuis l’hyperviseur.
On commence par initialiser les contextes de fonctionnement de LibVMI et donc l’instance LibVMI qui correspondra à la machine virtuelle sur laquelle nous lancerons notre programme.
...
vmi_init_complete(&vmi, name, VMI_INIT_DOMAINNAME, NULL, VMI_CONFIG_GLOBAL_FILE_ENTRY, NULL, NULL)
...
Ensuite, suivant le système d’exploitation sur lequel nous souhaitons lister les processus, l’initialisation des champs sera différente (dans l’exemple fourni, on considère trois systèmes d’exploitation différents : Windows, Linux et FreeBSD).
...
if (VMI_OS_LINUX == vmi_get_ostype(vmi)) {
if (VMI_FAILURE == vmi_get_offset(vmi, "linux_tasks", &tasks_offset))
goto error_exit;
if (VMI_FAILURE == vmi_get_offset(vmi, "linux_name", &name_offset))
goto error_exit;
if (VMI_FAILURE == vmi_get_offset(vmi, "linux_pid", &pid_offset))
goto error_exit;
} else if (VMI_OS_WINDOWS == vmi_get_ostype(vmi)) {
if (VMI_FAILURE == vmi_get_offset(vmi, "win_tasks", &tasks_offset))
goto error_exit;
if (VMI_FAILURE == vmi_get_offset(vmi, "win_pname", &name_offset))
goto error_exit;
if (VMI_FAILURE == vmi_get_offset(vmi, "win_pid", &pid_offset))
goto error_exit;
} else if (VMI_OS_FREEBSD == vmi_get_ostype(vmi)) {
tasks_offset = 0;
if (VMI_FAILURE == vmi_get_offset(vmi, "freebsd_name", &name_offset))
goto error_exit;
if (VMI_FAILURE == vmi_get_offset(vmi, "freebsd_pid", &pid_offset))
goto error_exit;
}
...
Après avoir récupéré les données nous intéressant, nous allons ensuite boucler sur la liste des processus et les afficher (PID, nom du processus et son adresse mémoire) un par un, jusqu’à la fin de la chaîne des processus.
...
while (1) {
current_process = cur_list_entry – tasks_offset;
vmi_read_32_va(vmi, current_process + pid_offset, 0, (uint32_t*)&pid);
procname = vmi_read_str_va(vmi, current_process + name_offset, 0);
if (!procname) {
printf("Failed to find procname\n");
goto error_exit;
}
printf("[%5d] %s (struct addr:%"PRIx64")\n", pid, procname, current_process);
if (procname) {
free(procname);
procname = NULL;
}
if (VMI_OS_FREEBSD == os && next_list_entry == list_head) {
break;
}
cur_list_entry = next_list_entry;
status = vmi_read_addr_va(vmi, cur_list_entry, 0, &next_list_entry);
if (status == VMI_FAILURE) {
printf("Failed to read next pointer in loop at %"PRIx64"\n", cur_list_entry);
goto error_exit;
}
if (VMI_OS_WINDOWS == os && next_list_entry == list_head) {
break;
} else if (VMI_OS_LINUX == os && cur_list_entry == list_head) {
break;
}
};
...
Et enfin, nous allons appeler la fonction qui va détruire l’instance LibVMI créée sur la machine virtuelle et libérer la mémoire concernée.
...
vmi_destroy(vmi);
...
4.3.2 Monitoring des événements
Mis à part son utilisation classique comme démontré dans l’exemple précédent, LibVMI permet aussi de monitorer des « événements » à la volée. Cela en notifiant l’hyperviseur de tout accès mémoire qui peut avoir lieu (lecture, écriture, exécution).
Ci-dessous, un exemple tiré aussi des exemples LibVMI permettant de créer des événements d’interruption.
Contrairement à la phase d’initialisation dans l’exemple du listing de processus, une petite modification est nécessaire. Le flag VMI_INIT_EVENTS doit être ajouté pour permettre à l’instance de monitorer les événements à la volée.
...
vmi_init(&vmi, VMI_XEN, (void*)name, VMI_INIT_DOMAINNAME | VMI_INIT_EVENTS, NULL, NULL)
...
Après avoir initialisé l’instance LibVMI, il est possible de créer un callback afin de traquer le type d’événement souhaité. Dans notre exemple, le choix a été fait sur l’événement d’interruption INT3 (breakpoint).
...
memset(&interrupt_event, 0, sizeof(vmi_event_t));
interrupt_event.version = VMI_EVENTS_VERSION;
interrupt_event.type = VMI_EVENT_INTERRUPT;
interrupt_event.interrupt_event.intr = INT3;
vmi_register_event(vmi, &interrupt_event);
...
On déclare ensuite une fonction de callback, qui sera exécutée à chaque fois qu’une interruption a lieu. Cette fonction de callback suppose que tous les événements INT3 sont causés par un débogueur par exemple, et va tout simplement les réinjecter sans rien faire.
event_response_t int3_cb(vmi_instance_t vmi, vmi_event_t *event)
{
event->interrupt_event.reinject = 1;
if (!event->interrupt_event.insn_length)
event->interrupt_event.insn_length = 1;
return 0;
}
5. Analyse de malware avec LibVMI
LibVMI permet de manipuler la mémoire, mais en aucun cas cela ne permet de faire de l’analyse de malware tel qu’il est. Afin de profiter du système d’introspection de LibVMI, il faudra développer son propre système pour analyser les malwares en reposant sur les API fournis par LibVMI.
Dans cette partie, nous allons nous focaliser sur le monitoring des API Kernel Windows, afin de lister les différents comportements du malware. Ci-dessous, quelques possibilités fournies par LibVMI pour monitorer la mémoire d’un exécutable.
5.1 Monitoring via des breakpoints
L’idée est donc de placer des breakpoints (points d’arrêt d’exécution, souvent utilisés dans le reverse engineering) sur les API Kernel qu’on souhaite monitorer sur la machine virtuelle lors de l’initialisation du système d’introspection. Ceci, tout en maintenant une table de correspondance des offsets des breakpoints placés et les octets modifiés. Après la mise en place et le lancement du système d’introspection, un malware sollicitant une API Kernel en passant par une API Usermode (l’appel à l’API Usermode CreateFile requiert l’appel à l’API Kernel NtCreateFile) sera interrompu si l’API Kernel figure parmi les API monitorées. Une notification est donc remontée au niveau de l’hyperviseur, nous permettant de retrouver quel exécutable dans l’espace utilisateur a atteint le breakpoint dans l’espace Kernel. Une fois que les informations souhaitées (la fonction, ses arguments…) sont récupérées, l’exécution du malware continue (surtout pour ne pas modifier son comportement) jusqu’au prochain breakpoint. Grâce à ces informations, il nous est possible de comprendre le comportement du malware analysé.
5.2 Monitoring via des pages mémoire
Avec cette technique, il n’y a nul besoin de placer des breakpoints et donc d’altérer la mémoire. L’idée consiste à placer une surveillance avec des événements mémoire sur chaque page mémoire exécutable et de calculer l’adresse des API qu’on souhaite monitorer. À chaque fois qu’une région de la page marquée en exécution est accédée, l’information est remontée et une comparaison est faite avec l’adresse de l’API monitorée.
5.3 Monitoring via des breakpoints + altp2m
Comme décrit précédemment, l’utilisation des breakpoints seule ne fonctionnera pas, c’est pourquoi les développeurs de Xen ont élaboré une technique intitulée altp2m. Cette technique a pour but de créer un shadow copy des pages mémoire souhaitées. L’idée est donc de créer une vue altérée (contenant les breakpoints sur les API Kernel qu’on souhaite monitorer) et garder une vue intacte qui sera retournée à chaque fois qu’il y a un accès en lecture ou écriture à ces pages mémoire.
Grâce à cette méthode, on résout les deux problèmes cités dans 5.1 Monitoring via des breakpoints.
6. Étude de cas
6.1 Préparation de l’environnement
Après avoir choisi le nom du domaine XEN de la machine, créé le fichier contenant le dictionnaire et déterminé le nom de fichier de malware à analyser, on lance depuis l’hyperviseur la commande suivante :
./monitor_api w6164-1 /tmp/w6164-1.json malware.exe
Notre script va prendre en argument plusieurs entrées :
- monitor_api : un script que nous avons développé et qui repose sur les fonctions de LibVMI ;
- w6164-1 : le nom du domaine XEN correspondant à de la machine virtuelle ;
- /tmp/w6164-1.json : le fichier contenant les fonctions/structures et les offsets correspondants ;
- malware.exe : le malware que nous souhaitons analyser.
Ci-dessous, le code permettant l’initialisation de notre système d’introspection :
...
vmi_init(&vmi, VMI_XEN | VMI_INIT_COMPLETE | VMI_INIT_EVENTS, argv[1]);
...
On crée ensuite la vue altp2m qui sera altérée :
...
xc_altp2m_set_domain_state(xch, domain_id, 1);
...
xc_altp2m_create_view(xch, domain_id, 0, &shadow_view);
...
6.2 Insertion des breakpoints
On définit dans un premier temps les API que nous souhaitons monitorer. Prenons par exemple les trois API suivantes :
- NtCreateFile : permet la création/ouverture de fichier ;
- NtSetValueKey: permet de créer ou remplacer la valeur d’une clé de registre ;
- NtDelayExecution : permet de retarder l’exécution (cette API est exécutée quand l’appel à l’API Sleep est effectué). Elle peut être utilisée à des fins d’évasion.
Pour plus d’informations sur ces API, vous pouvez vous référer à la documentation MSDN [3].
Ensuite, on insère nos breakpoints dans la vue altp2m pour chacune des API.
...
uint8_t trap = 0xcc;
vmi_write_8_pa(vmi, (shadow << 12) + shadow_offset, &trap)
...
6.3 Déclaration des callbacks
6.3.1 Callback des événements d’interruption
Ce callback est déclenché à chaque fois qu’un breakpoint est atteint. Il va permettre de récupérer les informations souhaitées grâce à la fonction process_event(). Cette fonction va nous permettre d’identifier le nom de l’API qui a atteint le breakpoint ainsi que ses arguments.
...
SETUP_INTERRUPT_EVENT(&trap_event, 0, interrupt_event_cb);
vmi_register_event(vmi, &trap_event);
...
event_response_t interrupt_event_cb(vmi_instance_t vmi, vmi_event_t *event)
{
...
process_event(...);
event->slat_id = 0;
event->interrupt_event.reinject = 0;
return VMI_EVENT_RESPONSE_TOGGLE_SINGLESTEP | VMI_EVENT_RESPONSE_VMM_PAGETABLE_ID;
}
6.3.2 Callback des événements de pas-à-pas
Ce callback est déclenché directement après l’exécution du callback d’interruption. Cela permet d’exécuter en pas-à-pas (comme dans un débugueur) l’instruction du breakpoint depuis la vue non altérée. Ensuite, on reprend l’exécution depuis la vue altérée. Dans le cas où plusieurs vcpus sont attribués à la machine virtuelle, il est nécessaire de boucler sur le nombre de vcpus et de créer un callback pour chaque vcpu.
...
int vcpus = vmi_get_num_vcpus(vmi);
for (i = 0; i < vcpus; i++)
{
SETUP_SINGLESTEP_EVENT(&singlestep_event[i], 1u << i, singlestep_event_cb, 0);
vmi_register_event(vmi, &singlestep_event[i]);
}
...
event_response_t singlestep_event_cb(vmi_instance_t vmi, vmi_event_t *event)
{
event->slat_id = shadow_view;
return VMI_EVENT_RESPONSE_TOGGLE_SINGLESTEP | VMI_EVENT_RESPONSE_VMM_PAGETABLE_ID;
}
6.3.3 Callback des événements lecture/écriture mémoire
Ce callback est déclenché quand une lecture ou écriture est faite sur l’une des pages mémoire du shadow copy. Si tel est le cas, un changement de vue est instantanément fait sur la vue non altérée. Ceci permet d’éviter un BSOD lorsque le système ou un malware effectue une vérification d’intégrité.
...
SETUP_MEM_EVENT(&mem_event, ~0ULL, VMI_MEMACCESS_RW, memory_event_cb, 1);
vmi_register_event(vmi, &mem_event);
...
event_response_t memory_event_cb(vmi_instance_t vmi, vmi_event_t *event)
{
event->slat_id = 0;
return VMI_EVENT_RESPONSE_TOGGLE_SINGLESTEP | VMI_EVENT_RESPONSE_VMM_PAGETABLE_ID;
}
Pour plus d’informations sur le fonctionnement de ces callbacks, veuillez consulter le site de LibVMI.
6.4 Récupération des informations
Quand un breakpoint est atteint, on le confronte au dictionnaire d’offsets et d’API, on récupère les informations souhaitées (arguments de l’API) et on poursuit l’exécution.
Prenons exemple d’un breakpoint sur NtCreateFile (ci-dessous son prototype).
NTSTATUS NtCreateFile(
OUT PHANDLE FileHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER AllocationSize OPTIONAL,
IN ULONG FileAttributes,
IN ULONG ShareAccess,
IN ULONG CreateDisposition,
IN ULONG CreateOptions,
IN PVOID EaBuffer OPTIONAL,
IN ULONG EaLength
);
Le nom du fichier et les droits d’accès sont principalement les informations intéressantes de cette API. Le nom de fichier se trouve dans la structure OBJECT_ATTRIBUTES. Afin de parser cette structure, on utilisera le dictionnaire généré précédemment et qui contient les offsets des différentes entrées de la structure :
"_OBJECT_ATTRIBUTES": [48, {
"Attributes": [24, ["unsigned long", {}]],
"Length": [0, ["unsigned long", {}]],
"ObjectName": [16, ["Pointer", { "target": "_UNICODE_STRING"}]]
On fera de même pour les autres structures.
Il n’y a nul besoin de modifier les arguments de l’API, même s’il est possible de le faire. Le but est de laisser exécuter le malware sans l’interrompre, récupérer les informations intéressantes à la volée, puis ensuite à la fin de son exécution, analyser les informations remontées et déterminer son comportement.
Ci-dessous un extrait de log des événements générés lors d’analyse de malware. Seules les informations faisant référence à un potentiel comportement malveillant sont gardées.
...
{"target":"C:\\Users\\Francis\\AppData\\Roaming\\Windows\\conhost.exe","process":"C:\\Users\\Francis\\Desktop\\sample.exe","pid":2616,"api":"NtCreateFile","access_mask":1074790528},
...
...
{"target":"\\REGISTRY\\USER\\S-1-5-21-336141597-709016518-532797093-1001\\SOFTWARE\\MICROSOFT\\WINDOWS\\CURRENTVERSION\\RUN\\Persistence","process" : "C:\\Users\\Francis\\Desktop\\sample.exe","pid":2616,"value":"C:\\Users\\Francis\\AppData\\Roaming\\Windows\\conhost.exe","api":"NtSetValueKey"},
...
...
{"value":3600,"process":"C:\\Users\\Francis\\Desktop\\sample.exe","pid":2616,"api":"NtDelayExecution"},
...
Conclusion
Les cyberattaques à base de malware ne cessent d’augmenter et sont de plus en plus sophistiquées, ce qui rend leur détection difficile. L’utilisation de l’introspection peut s’avérer très efficace. Comme démontré dans cet article, elle permet d’aller profondément dans l’analyse de malware et de tirer un maximum d’information sur son fonctionnement, sans avoir besoin d’interrompre son exécution normale.
Références
[2] https://github.com/libvmi/
[3] https://docs.microsoft.com/