First steps in ring0

Written by aaSSfxxx -

Ceux qui me croisent sur IRC savent que je commence à me mettre (timidement) à la programmation ring0 sous Windows (the real life, je jouerai avec le kernel linux un peu plus tard peut-être, quand je serai réapprovisionné en chocapicz).
Cet article se veut être une initiation au monde merveilleux qu'est le noyau Windows et ses drivers, monde merveilleux où un BSOD arrive très vite cependant.

Devant l'enthousiasme général, je vous propose donc de me suivre dans cette longue quête du Graal.

Préparation de l'environnement: the h4x0r way

Pour explorer le monde du noyau, une petite préparation s'impose. En effet, la programmation de driver diffère de la programmation d'un programme Windows classique, puisque le driver est chargé par le noyau, et donc il est plus difficile d'y accéder pour le déboguer. De plus, nos chères APIs Windows ne sont d'aucune utilité ici, et il nous faudra passer par les fonctions du noyau pour arriver à nos fins (plus ou moins documentées). Pour le développement de drivers, je vous recommande fortement de le faire depuis une machine virtuelle (ou depuis un PC "sacrifié"), un BSOD étant très vite arrivé.

Il nous faudra dans un premier temps installer un compilateur C (si ceci n'a pas déjà été fait). Que les libristes ou les réfractaires de Visual Studio se rassurent, il est tout à fait possible de compiler un driver avec MinGW, qui installe nativement les en-têtes nécessaires (et Code::Blocks, ce que je montrerai dans l'article). Si vous voulez malgré tout utiliser le compilateur MSVC, je vous laisse stfw pour la procédure d'installation du DDK.

Code::Blocks propose par ailleurs un modèle "Windows Driver" dont il serait bête de se priver. Il faudra juste veiller à rajouter le dossier "include\ddk" du répertoire d'installation de MinGW aux répertoires d'include pour que la compilation réussisse sans problème.

Avant de continuer, il nous faut aussi deux éléments indispensable: DebugView, qui permet d'afficher les messages de débogage du noyau (via la fonction DbgPrint), qui sera notre seul moyen de vérifier que tout fonctionne bien pour le moment ; ainsi que InstDrv, qui va permettre de charger notre driver. InstDrv est téléchargeable ici, et DebugView est disponible ici. Il faudra évidemment veiller à lancer DebugView avant de charger le driver avec InstDrv pour voir les messages (et afficher les messages du noyau, en cochant le menu "Capture -> Capture kernel", ainsi que "Catpure -> Enable Verbose Kernel Output").

Une fois ceci fait, nous voilà prêt pour créer notre premier driver !

Your first driver !

Il ne vous reste plus qu'à créer un nouveau projet du type "Kernel mode driver" sous Code::Blocks (et stfw si vous utilisez autre chose :þ). Cela nous crée un fichier driver.c qui contient:

NTSTATUS
STDCALL
DriverDispatch(IN PDEVICE_OBJECT DeviceObject,
               IN PIRP Irp)
{
    return STATUS_SUCCESS;
}

VOID
STDCALL
DriverUnload(IN PDRIVER_OBJECT DriverObject)
{
    DbgPrint("DriverUnload() !\n");
    return;
}

NTSTATUS
STDCALL
DriverEntry(IN PDRIVER_OBJECT DriverObject,
            IN PUNICODE_STRING RegistryPath)
{
    DbgPrint("DriverEntry() !\n");

    DriverObject->DriverUnload = DriverUnload;

    return STATUS_SUCCESS;
}

La fonction principale est DriverEntry, comme on peut s'y attendre ; et je vous laisse demander à votre petite soeur ce que fait DriverUnload. La fonction DriverDispatch semble, elle plus bizarre. Cette fonction servira à gérer les IRP (je reviendrai sur ce point), mais n'est pas d'une grande utilité pour le moment.

Pour le moment contentons-nous de charger le driver avec InstDrv (normalement votre grand-mère devrait pouvoir le faire), et admirer le "DriverEntry() !" qui s'affiche dans DebugView (qu'il faut lancer avant de démarrer le driver). En cliquant sur le bouton "Stop" de InstDrv, on doit voir apparaître "DriverUnload() !" apparaître (si c'est pas le cas, vous pouvez toujours vous reconvertir dans l'élevage de chèvres).

On ne va pas s'arrêter en si bon chemin, et on va découvrir comment sortir notre driver de son autisme, et comment lui permettre de communiquer avec le monde utilisateur que nous connaissons bien, nous, humains.

Une histoire de devices et d'IRP

Notre driver est pour le moment un pauvre autiste, incapable de communiquer avec le monde extérieur. Un moyen assez simple de lui permettre de communiquer vers l'extérieur est de créer un "device" (du point de vue du kernel, ne rêvez pas, une imprimante 3D ne va pas se matérialiser subitement) qui sera accessible depuis l'userland (chez Mme Michu) via la fonction CreateFile. Une fois le "fichier" créé (point commun avec les systèmes UNIX-like, un device est un fichier), on peut soit lui envoyer des IOCTL, soit utiliser les fonctions ReadFile et WriteFile ; ces deux méthodes envoyant des IRP à notre driver, qu'il devra rattraper et traiter.

Une IRP est en réalité une structure qui "représente" une opération d'entrée/sortie sur le driver. Ainsi toutes les opérations de lecture ou d'écriture sont des IRP du point de vue du driver. Heureusement pour nous, Windows propose un moyen de filtrer les IRP, et de définir des fonctions "handler" permettant de traiter ces IRP (du même prototype que DriverDispatch).

Du point de vue programmation, on crée un device via la fonction IoCreateDevice, avec le code suivant qu'on placera dans DriverEntry, puis on crée un lien symbolique vers DosDevices pour rendre notre device accessible depuis l'userland (via \.\NomDuDevice):

NTSTATUS ntStatus;
const WCHAR *driverName = L"\\Device\\T4pZ";
const WCHAR *symlinkName = L"\\DosDevices\\T4pZ";

UNICODE_STRING unicodeDriverName;
UNICODE_STRING unicodeSymlink;

RtlInitUnicodeString(&unicodeDriverName, driverName);
RtlInitUnicodeString(&unicodeSymlink, symlinkName);

ntStatus = IoCreateDevice (
    DriverObject,
    0,
    &unicodeDriverName,
    FILE_DEVICE_UNKNOWN,
    0,
    TRUE,
    &mydevice);
mydevice->Flags |= DO_BUFFERED_IO;

if(NT_SUCCESS(ntStatus))
{
    DbgPrint("THE GAME BITCHZ\n");
    IoCreateSymbolicLink(&unicodeSymlink, &unicodeDriverName);
}

Il ne faudra pas oublier de rajouter PDEVICE_OBJECT mydevice; en variable globale, afin de pouvoir utiliser IoDeleteDevice dans la fonction DriverUnload (sous peine de devoir rebooter la machine ou de changer le nom du device ensuite). On a donc pour le moment ce code de driver, qui crée un device, mais qui ne fait rien pour le moment:

#include <ntddk.h>

PDEVICE_OBJECT mydevice;

NTSTATUS
STDCALL
DriverDispatch(IN PDEVICE_OBJECT DeviceObject,
               IN PIRP Irp)
{
    return STATUS_SUCCESS;
}

VOID
STDCALL
DriverUnload(IN PDRIVER_OBJECT DriverObject)
{
    DbgPrint("DriverUnload() !\n");
    IoDeleteDevice(mydevice); // destruction du device sinon on est bon pour rebooter
    return;
}

NTSTATUS
STDCALL
DriverEntry(IN PDRIVER_OBJECT DriverObject,
            IN PUNICODE_STRING RegistryPath)
{
    NTSTATUS ntStatus;
    const WCHAR *driverName = L"\\Device\\T4pZ";
    const WCHAR *symlinkName = L"\\DosDevices\\T4pZ";

    UNICODE_STRING unicodeDriverName;
    UNICODE_STRING unicodeSymlink;

    DbgPrint("DriverEntry() !\n");

    DriverObject->DriverUnload = DriverUnload;

    RtlInitUnicodeString(&unicodeDriverName, driverName);
    RtlInitUnicodeString(&unicodeSymlink, symlinkName);

    ntStatus = IoCreateDevice (
        DriverObject,
        0,
        &unicodeDriverName,
        FILE_DEVICE_UNKNOWN,
        0,
        TRUE,
        &mydevice);
    mydevice->Flags |= DO_BUFFERED_IO;

    if(NT_SUCCESS(ntStatus))
    {
        IoCreateSymbolicLink(&unicodeSymlink, &unicodeDriverName);
    }
    return STATUS_SUCCESS;
}

Gestion des IRP

Une fois notre device créé et un handle ouvert par un programme userland quelconque, notre driver se fait bombarder d'IRPs qu'il ne sait pas traiter. Pour simplifier les choses, on ne traitera ici que les requêtes envoyées par ReadFile et WriteFile, et faire en sorte qu'un appel de WriteFile sur le handle de notre device affiche ce qui a été écrit avec DbgPrint, et qu'un appel à ReadFile renvoie "Salut les tapz !" (l'exemple est inutile, mais assez instructif).

Avant de passer du côté codingz de la Force, il faut savoir qu'il existe 3 modes différents de lecture/écriture des données passées dans un IRP,définis par le champs Flags de la structure PDEVICE_OBJECT (expliqués en mieux ici):

  • Le Direct Mode: dans ce mode, le kernel remplit la structure MdlAddress, qui est un pointeur vers une structure qui décrit le tampon de donnée (et il faudra utiliser les fonctions commençant Mm pour y accéder)
  • Le "Buffered I/O": ici le buffer est stocké dans le champ AssociatedIrp.SystemBuffer de la structure IRP, ce buffer étant une "copie" faite par le kernel du tampon fourni par l'utilisateur.
  • Le Neither Mode: ce mode donne un accès direct au tampon utilisateur, via le champ UserBuffer de la structure IRP.

Comme vous l'avez remarqué, j'ai utilisé le mode Buffered I/O dans le code, le direct mode étant assez "fastidieux" à utiliser. Grâce à ces précieuses informations, on va enfin pouvoir implémenter l'IRP associé à WriteFile (le plus facile), dont le doux nom est IRP_MJ_WRITE. Voici donc le code de notre handler de IRP_MJ_WRITE:

NTSTATUS
STDCALL
WriteFunction(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
{
  DbgPrint("Write Function called");
  if(Irp->AssociatedIrp.SystemBuffer != 0) {
    DbgPrint("Got data from SystemBuffer: %s ", Irp->AssociatedIrp.SystemBuffer);
  }
  else {
    //on rattrape l'erreur pour pas se prendre un BSOD dans la face
    DbgPrint("OMGWTFBBQ is that shit ?");
  }
  return STATUS_SUCCESS;
}

Cependant, le kernel n'est pas au courant de l'existence de cette fonction, et il faut donc lui déclarer son existence grâce à la ligne DriverObject->MajorFunction[IRP_MJ_WRITE] = WriteFunction; dans notre fonction DriverEntry. Ainsi, un appel à WriteFile provoquera l'affichage de ce qui a été écrit dans DebugView.

Pour l'IRP Read, c'est un peu plus compliqué, puisque le buffer fourni par le programme userland a une taille fixée, et il se peut que les données à envoyer soient trop grosses pour le pauvre buffer. Pour la fonction de lecture, il faut donc récupérer la taille du buffer, s'assurer que les données à envoyer ne dépassent pas et indiquer le nombre d'octets écrits une fois la transmission terminée. Pour obtenir ces informations, il nous faut obtenir la structure IO_STACK_LOCATION, ce qui est possible grâce à la fonction IoGetCurrentIrpStackLocation. Je vous laisse lire la documentation de IRP_MJ_READ, pour comprendre le fonctionnement du code (qui devrait être assez simple à comprendre).

ReadFunction(IN PDEVICE_OBJECT DeviceObject,
                      IN PIRP Irp)
{
  PIO_STACK_LOCATION IrpSp = IoGetCurrentIrpStackLocation(Irp);
  DbgPrint("Read Function called");
  DbgPrint("Asking reading %d bytes", IrpSp->Parameters.Read.Length);
  if(Irp->AssociatedIrp.SystemBuffer != 0)
  {
      DbgPrint("Sending \"Salut les t4pz !\" to reader");
      char *data = "Salut les t4pz !";
      int mini = min(IrpSp->Parameters.Read.Length, 17); // 17 = strlen(data)
      memcpy(Irp->AssociatedIrp.SystemBuffer, data, mini);
      Irp->IoStatus.Status = STATUS_SUCCESS;
      Irp->IoStatus.Information = mini;
  }
  else {
    //on rattrape l'erreur pour pas se prendre un BSOD dans la face
    DbgPrint("OMGWTFBBQ is that shit ?");
  }
  return STATUS_SUCCESS;
}

, et on enregistre notre ReadFunction comme ci-dessus.

Le code au complet nous donne finalement ceci:

#include <ntddk.h>

PDEVICE_OBJECT mydevice;

NTSTATUS
STDCALL
DriverDispatch(IN PDEVICE_OBJECT DeviceObject,
               IN PIRP Irp)
{
    return STATUS_SUCCESS;
}

NTSTATUS
STDCALL
ReadFunction(IN PDEVICE_OBJECT DeviceObject,
                      IN PIRP Irp)
{
  PIO_STACK_LOCATION IrpSp = IoGetCurrentIrpStackLocation(Irp);
  DbgPrint("Read Function called");
  DbgPrint("Asking reading %d bytes", IrpSp->Parameters.Read.Length);
  if(Irp->AssociatedIrp.SystemBuffer != 0)
  {
      DbgPrint("Sending \"Salut les t4pz !\" to reader");
      char *data = "Salut les t4pz !";
      int mini = min(IrpSp->Parameters.Read.Length, 17); // 17 = strlen(data)
      memcpy(Irp->AssociatedIrp.SystemBuffer, data, mini);
      Irp->IoStatus.Status = STATUS_SUCCESS;
      Irp->IoStatus.Information = mini;
  }
  else {
    //on rattrape l'erreur pour pas se prendre un BSOD dans la face
    DbgPrint("OMGWTFBBQ is that shit ?");
  }
  return STATUS_SUCCESS;
}

NTSTATUS
STDCALL
WriteFunction(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
{
  DbgPrint("Write Function called");
  if(Irp->AssociatedIrp.SystemBuffer != 0) {
    DbgPrint("Got data from SystemBuffer: %s ", Irp->AssociatedIrp.SystemBuffer);
  }
  else {
    //on rattrape l'erreur pour pas se prendre un BSOD dans la face
    DbgPrint("OMGWTFBBQ is that shit ?");
  }
  return STATUS_SUCCESS;
}

VOID
STDCALL
DriverUnload(IN PDRIVER_OBJECT DriverObject)
{
    DbgPrint("DriverUnload() !\n");
    IoDeleteDevice(mydevice); // destruction du device sinon on est bon pour rebooter
    return;
}

NTSTATUS
STDCALL
DriverEntry(IN PDRIVER_OBJECT DriverObject,
            IN PUNICODE_STRING RegistryPath)
{
    NTSTATUS ntStatus;
    const WCHAR *driverName = L"\\Device\\T4pZ";
    const WCHAR *symlinkName = L"\\DosDevices\\T4pZ";

    UNICODE_STRING unicodeDriverName;
    UNICODE_STRING unicodeSymlink;

    DbgPrint("DriverEntry() !\n");

    DriverObject->MajorFunction[IRP_MJ_CREATE] = DriverDispatch; //on fait rien à l'ouverture et à la fermeture du "fichier"
    DriverObject->MajorFunction[IRP_MJ_CLOSE] = DriverDispatch;
    DriverObject->MajorFunction[IRP_MJ_READ] = ReadFunction;
    DriverObject->MajorFunction[IRP_MJ_WRITE] = WriteFunction;
    DriverObject->DriverUnload = DriverUnload;

    RtlInitUnicodeString(&unicodeDriverName, driverName);
    RtlInitUnicodeString(&unicodeSymlink, symlinkName);

    ntStatus = IoCreateDevice (
        DriverObject,
        0,
        &unicodeDriverName,
        FILE_DEVICE_UNKNOWN,
        0,
        TRUE,
        &mydevice);
    mydevice->Flags |= DO_BUFFERED_IO;

    if(NT_SUCCESS(ntStatus))
    {
        IoCreateSymbolicLink(&unicodeSymlink, &unicodeDriverName);
    }
    return STATUS_SUCCESS;
}

The userland code

Maintenant, compilez et lancez le driver, puis créez un exécutable classique contenant ce code:

#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
int main()
{
    HANDLE hDevice = CreateFile("\\\\.\\T4pZ",GENERIC_WRITE|
        GENERIC_READ,0,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,NULL);
    DWORD written, read;
    char buffer[255];
    WriteFile (hDevice, "THE GAME", 9, &written, NULL);
    ReadFile (hDevice, buffer, 255, &read, NULL);
    printf ("Got \"%s\" from the kernel - %d bytes read\n", buffer, read);
    printf("hDevice handle: %x", hDevice);
    return 0;
}

puis lancez l'exécutable.

Vous devriez voir apparaître "Got data from SystemBuffer: THE GAME" dans DebugView, puis "Salut les tapz !" dans la console du programme lancé.

Voilà pour cette introduction à la programmation système sous Windows :þ