RogueOneX

Cyberspace. A graphic representation of data abstracted from the banks of every computer in the human system.

Tag: WDK

  • Windows Internals – Come scrivere un driver

    Introduzione

    I driver sono elementi fondamentali nel funzionamento di un sistema operativo e sono ampiamente sfruttati nelle soluzioni EDR moderne.

    Quando un driver è caricato nel sistema operativo e opera al livello del kernel, il suo codice viene eseguito con il massimo dei privilegi disponibili.
    A tal punto, oltre a raccogliere dati di telemetria fondamentali system-wide, un driver può facilmente intraprendere azioni di protezione dell’endpoint e bloccare azioni sospette o malevole sul nascere.

    Questo post continua la serie di approfondimento sugli internals di Windows e sul mondo EDR, fornendo una panoramica introduttiva ai driver di Windows, a come distribuirli e analizzarli in un ambiente di test.

    Senza indugiare oltre iniziamo a esplorare lo scheletro del nostro driver.

    Struttura di un driver

    Quanto segue è il codice del driver che scriveremo e analizzeremo in questo post.
    Come si può intuire già da una prima, rapida, lettura, il driver non compie grandi operazioni se non lasciare dei messaggi per il debugger.

    Le macro e le funzioni come KdPrint, KdPrintEx, DbgPrint e DbgPrintEx vengono compilate solo in modalità Debug.

    // Header principale di WDK, paragonabile a Windows.h per le applicazioni user-mode
    #include <ntddk.h>
    
    // Prefisso che sarà anteposto alle stringhe di debug
    #define DRIVER_PREFIX "Test: "
    
    // Dichiarazione delle funzioni di Unload del driver e di risposta alle richieste di apertura di handle all'device object
    void UnloadRoutine(PDRIVER_OBJECT DriverObject);
    NTSTATUS CreateClose(PDEVICE_OBJECT, PIRP Irp);
    
    // Funzione principale del driver
    extern "C" NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, UNICODE_STRING RegistryPath) {
        
        // Parametro non utilizzato nel codice che segue e quindi esplicitamente dichiarato come non referenziato
        UNREFERENCED_PARAMETER(RegistryPath);
    
        KdPrint((DRIVER_PREFIX "saluti dal driver! Siamo ora nella DriverEntry Routine e la chiave di Registro del driver è %wZ\n", RegistryPath));
        
        // Popolamento della struttura del DriverObject
        DriverObject->DriverUnload = UnloadRoutine;
        DriverObject->MajorFunction[IRP_MJ_CREATE] = DriverObject->MajorFunction[IRP_MJ_CLOSE] = CreateClose;
        
        // Creazione del DeviceObject
        PDEVICE_OBJECT DeviceObject;
        UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\Neuromancer");
        NTSTATUS status = IoCreateDevice(DriverObject,0, &deviceName, FILE_DEVICE_UNKNOWN, NULL, FALSE, &DeviceObject);
        if (!NT_SUCCESS(status)) {
            KdPrint((DRIVER_PREFIX "errore durante la creazione del Device"));
                return status;
        }
    
        KdPrint((DRIVER_PREFIX "DeviceObject creato con successo\n"));
    
        // Creazione del link simbolico per l'utilizzo dalla user-mode
        UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\Neuromancer");
        status = IoCreateSymbolicLink(&symLink, &deviceName);
        if (!NT_SUCCESS(status)) {
            KdPrint((DRIVER_PREFIX "errore durante la creazione del link simbolico\n"));
            IoDeleteDevice(DeviceObject);
            return status;
        }
    
        KdPrint((DRIVER_PREFIX "SymbolicLink creato con successo\n"));
    
        return STATUS_SUCCESS;
    }
    
    // Funzione per l'unload del driver
    void UnloadRoutine(PDRIVER_OBJECT DriverObject) {
        KdPrint((DRIVER_PREFIX "Inizia la routine di Unload\n"));
        UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\Neuromancer");
        IoDeleteSymbolicLink(&symLink);
        IoDeleteDevice(DriverObject->DeviceObject);
    }
    
    // Funzione per l'apertura e chiusura di handle al DeviceObject
    NTSTATUS CreateClose(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
        UNREFERENCED_PARAMETER(DeviceObject);
        Irp->IoStatus.Status = STATUS_SUCCESS;
        Irp->IoStatus.Information = 0;
        IoCompleteRequest(Irp, IO_NO_INCREMENT);
    
        return STATUS_SUCCESS;
    }

    Gli elementi fondamentali di un driver sono pochi:

    • la DriverEntry routine
    • la creazione di un DeviceObject
    • la creazione di un Link simbolico
    • la Unload routine

    Nel driver è presente anche la routine CreateClose, non necessaria in questo esempio ma che dimostra come le funzioni siano poi associate a specifiche operazioni che il driver può compiere.

    Nello specifico, questa funzione serve a completare con lo stato STATUS_SUCCESS le operazioni CreateFile e CloseHandle (e simili) invocate da applicazioni user-mode.

    La DriverEntry routine corrisponde, per semplificazione, alla funzione main delle applicazioni user-mode e contiene il codice d’inizializzazione del driver.
    I suoi parametri sono una struttura DRIVER_OBJECT, che rappresenta il driver, e una stringa UNICODE_STRING che punta al percorso di Registro di Sistema con le informazioni sul driver.

    La struttura DRIVER_OBJECT contiene numerosi campi, due dei quali è necessario popolare non appena possibile: DriverUnload e MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1] .

    typedef struct _DRIVER_OBJECT {
      CSHORT             Type;
      CSHORT             Size;
      PDEVICE_OBJECT     DeviceObject;
      ULONG              Flags;
      PVOID              DriverStart;
      ULONG              DriverSize;
      PVOID              DriverSection;
      PDRIVER_EXTENSION  DriverExtension;
      UNICODE_STRING     DriverName;
      PUNICODE_STRING    HardwareDatabase;
      PFAST_IO_DISPATCH  FastIoDispatch;
      PDRIVER_INITIALIZE DriverInit;
      PDRIVER_STARTIO    DriverStartIo;
      PDRIVER_UNLOAD     DriverUnload;
      PDRIVER_DISPATCH   MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
    } DRIVER_OBJECT, *PDRIVER_OBJECT;

    DriverUnload accetta un puntatore alla funzione che verrà invocata al momento dell’unload del driver dal sistema mentre la seconda accetta i puntatori alle funzioni che verranno invocate per l’apertura e chiusura di handle al driver, lettura di dati dal driver, scrittura di dati al driver, controllo delle operazioni del driver…
    Non è necessario compilare tutte le MajorFunctions ma solo quelle per cui s’intende fornire delle routine.

    Successivamente, occorre creare un DeviceObject ovvero una struttura che rappresenta un driver fisico o logico e che consente l’interazione da e con altri componenti di sistema.

    Affinché un’applicazione user-mode possa accedere al DeviceObject, è necessario creare un Link simbolico, il quale viene posizionato nella directory \?? dell’Object Manager.
    Questo link, accessibile dallo user-space, consente appunto l’accesso all’oggetto kernel tramite handle (è importante ricordare che solo il kernel può operare direttamente con gli oggetti di sistema).

    Il tool WinObj di Sysinternals consente di esplorare gli oggetti di sistema, tra cui device object e symbolic link del nostro driver.

    typedef struct _DEVICE_OBJECT {
      CSHORT                   Type;
      USHORT                   Size;
      LONG                     ReferenceCount;
      struct _DRIVER_OBJECT    *DriverObject;
      struct _DEVICE_OBJECT    *NextDevice;
      struct _DEVICE_OBJECT    *AttachedDevice;
      struct _IRP              *CurrentIrp;
      PIO_TIMER                Timer;
      ULONG                    Flags;  // Vedremo più avanti che questo campo è importante per l'accesso ai buffer tra user-mode e kernel-mode
      ULONG                    Characteristics;
      __volatile PVPB          Vpb;
      PVOID                    DeviceExtension;
      DEVICE_TYPE              DeviceType;
      CCHAR                    StackSize;
      union {
        LIST_ENTRY         ListEntry;
        WAIT_CONTEXT_BLOCK Wcb;
      } Queue;
      ULONG                    AlignmentRequirement;
      KDEVICE_QUEUE            DeviceQueue;
      KDPC                     Dpc;
      ULONG                    ActiveThreadCount;
      PSECURITY_DESCRIPTOR     SecurityDescriptor;
      KEVENT                   DeviceLock;
      USHORT                   SectorSize;
      USHORT                   Spare1;
      struct _DEVOBJ_EXTENSION *DeviceObjectExtension;
      PVOID                    Reserved;
    } DEVICE_OBJECT, *PDEVICE_OBJECT;

    Vediamo ora come compilare il driver in Visual Studio.

    Scrittura e compilazione

    Per lo sviluppo dei driver è necessario utilizzare Windows e gli strumenti che Microsoft mette a disposizione: Visual Studio (l’edizione Community è sufficiente), il kit SDK e il kit WDK.
    Le istruzioni sono comodamente disponibili al seguente link che lascio anche nei riferimenti a fondo pagina.

    Al momento la versione 2026 di Visual Studio non è pronta per lo sviluppo di driver.

    Quando pronti, in Visual Studio creiamo un nuovo progetto Driver WDM vuoto (Empty WDM driver).
    In Esplora soluzioni troveremo delle cartelle predefinite così come alcuni file.
    Siccome il nostro driver non avrà una controparte fisica (stiamo quindi scrivendo un software driver), possiamo eliminare il file .inf dalla cartella Driver Files..

    Per compilare il driver clicchiamo sul nome del progetto e poi scegliamo Compila.
    In assenza di errori, il driver viene salvato al percorso predefinito C:\<nome utente>\source\repos\<nome soluzione>\x64\Debug\<nome driver>.sys.

    Se non si specifica diversamente, VisualStudio crea anche il file .pdb che contiene i simboli di debug del driver.
    Questo file, nel nostro caso è utile perché, se trasferito nella macchina di test insieme al file .sys, viene caricato da WinDbg quando si effettua il debug.

    Risulta utile anche durante sessioni di reverse engineering perché consente ai programmi dedicati, come IDA, di interpretare il contenuto del driver (nel nostro caso) e di rinominarne le funzioni e le variabili, di fatto rendendo l’output molto più leggibile.

    Distribuzione, installazione, verifica

    Per testare il driver consiglio l’utilizzo di una macchina ad-hoc, sia questa fisica o virtuale.

    Essendo Windows la piattaforma di sviluppo, personalmente utilizzo Hyper-V come hypervisor e una macchina virtuale con Windows 11 come macchina di test.
    Affinché il driver funzioni è necessario attivare la modalità di test nella macchina virtuale con il comando bcdedit /set testsigning on che consente di caricare driver firmati con qualsiasi certificato.

    Al riavvio successivo, comparirà la scritta Modalità test (o Test mode) nell’angolo in basso a destra del Desktop, oltre all’edizione di Windows e alla build.
    Questa configurazione è sufficiente per caricare il driver ed eseguirlo ma possiamo compiere un ulteriore passettino in avanti e configurare anche la modalità di debug.

    È possibile effettuare il provisioning della macchina di test direttamente da VisualStudio ma per comodità, manterremo un approccio manuale.

    Per chi fosse interessato tutti i passaggi necessari sono disponibili consultando la documentazione Microsoft.
    Generalmente gli step richiesti sono la verifica che le due macchine siano reciprocamente raggiungibili tramite ping e che VisualStudio possa raggiungere la macchina di test tramite indirizzo IP o nome host.

    Il provisioning crea un nuovo utente e installa i requisiti necessari per il debug.
    Al momento della compilazione poi, è possibile distribuire direttamente il driver nella macchina di test.

    A questo link Microsoft pubblica gli scenari e le configurazioni disponibili per il debug di Windows.

    Nel mio laboratorio utilizzo il nuovo WinDbg (Preview) e una connessione di rete Ethernet per la comunicazione tra host e guest (quindi in Hyper-V è necessario creare un nuovo commutatore virtuale come rete interna e assegnare manualmente gli indirizzi IP).

    Affinché le macchine possano comunicare potrebbe essere necessario cambiare manualmente il profilo del firewall di Windows, sia su host sia su guest, da Pubblico a Privato, o attivare la regola Richiesta echo – ICMPv4-In.
    In ogni caso, il comando bcdedit /set debug on attiva la modalità di debug mentre il comando bcdedit /dbgsettings net hostip:xxx.xxx.xxx.xxx. port:xxxxx busparams b.d.f imposta la configurazione di rete che il client utilizzerà per il collegamento al debugger.

    Se hostip identifica l’indirizzo IP dell’host dove viene eseguito il debugger e port identifica la porta di ascolto dell’host, busparams identifica i dettagli della scheda di rete da utilizzare.
    Nello specifico b corrisponde al numero di bus, d corrisponde al numero del dispositivo ed f corrisponde al numero di funzione dell’adattatore.

    L’ultimo comando genera una chiave (se non specificata manualmente) che deve essere utilizzata in WinDbg e che agisce come segreto condiviso tra le due parti.
    Nel debugger, avviato come amministratore, scegliere Attach to kernel e inserire il numero della porta di ascolto e la chiave.
    Dopodiché si può avviare la sessione di debug e se tutto funziona correttamente il client si collegherà al debugger.

    Ricorda: il client si collega al debugger.
    Se qualcosa non funziona verifica la configurazione del firewall nel computer host.

    A questo punto siamo pronti a trasferire il driver nella macchina di test (personalmente utilizzo scp con autenticazione basata su certificati per evitare di inserire ripetutamente la password).

    Per installare il driver dobbiamo creare un servizio (per un approfondimento sui servizi lascio il link al primo post di una serie dedicata proprio ai servizi) il cui tipo sarà kernel e il cui eseguibile punterà al nostro driver: sc create Test type= kernel binPath= C:\drivers\Test.sys.

    Prima di avviare il servizio, però, dobbiamo attivare in WinDbg la lettura delle comunicazioni indirizzate al debugger a un livello informativo, in caso contrario non vedremmo i messaggi generati dal nostro driver.
    Passiamo quindi in WinDbg e interrompiamo (Break) l’esecuzione della macchina di test.
    A questo punto, con la linea di comando sbloccata inseriamo ed Kd_DEFAULT_MASK 8.

    Questo comando abilita il bit corrispondente al livello DPFLTR_INFO_LEVEL per il componente DPFLTR_DEFAULT_ID usato da DbgPrint (KdPrint è una macro che dietro le quinte fa uso di DbgPrint).
    Qui il link di approfondimento.

    Rilasciamo la macchina test cliccando Go oppure usando il comando g e avviamo il servizio.
    Se tutto funziona correttamente vedremo i messaggi di debug apparire nel debugger.

    I più attenti avranno notato come il percorso del Registro di Sistema visto dal kernel sia diverso da quanto rappresentato in user-space.

    Debug

    Ora che il driver è caricato e funzionante, vediamo come analizzarlo.

    Come prima step possiamo verificare che il driver (module) sia effettivamente caricato e trovare il suo indirizzo di memoria, a tal scopo usiamo il comando lmDvm Test*.

    Per aprire l’helper e la documentazione dei comandi di WinDbg è sufficiente usare il comando .hh.
    Per ottenere la documentazione specifica di un comando, utilizzare .hh seguito dal nome del comando, ad esempio: .hh lm.

    Dall’output possiamo vedere lo spazio di memoria occupato dal driver, il nome del modulo e il nome dell’immagine caricata.
    Le informazioni sono rese cliccabili e navigabili grazie al linguaggio DML (Debugger Markup Language) che offre un’esperienza semplificata di utilizzo del debugger.

    Nulla ci impedisce di utilizzare la riga di comando per conoscere meglio WinDbg: ad esempio, se volessimo elencare le funzioni presenti nel modulo potremmo usare il comando x /D /t /f Test!* e otterremmo il seguente output.

    Come abbiamo visto poco sopra, le due strutture più importanti da cui possiamo iniziare l’analisi sono DRIVER_OBJECT e DEVICE_OBJECT.
    Per ricavarne i rispettivi indirizzi di memoria usiamo il comando !drvobj Test (se non specificato diversamente, WinDbg antepone al nome del driver il percorso \Driver\) che restituisce l’output che segue.

    Un metodo alternativo per individuare l’indirizzo del DRIVER_OJECT consiste nel creare un breakpoint per la funzione DriverEntry: bp Test!DriverEntry.

    Secondo la calling convention per i sistemi x64, i primi 4 argomenti della funzione sono passati secondo l’ordine left-to-right e salvati nei registri RCX, RDX, R8, e R9.

    Al momento del breakpoint l’indirizzo è salvato nel registro RCX che possiamo ispezionare con il comando r rcx (o attivando il pannello Registers in WinDbg).

    Se passiamo l’indirizzo ottenuto a !drvobj otterremo solo la prima parte dell’output, non essendo il DEVICE_OBJECT ancora stato creato.

    A questo punto possiamo vedere come sia popolata la struttura in memoria usando il comando dt (Display Type) formattando il contenuto all’indirizzo di memoria secondo esigenza: dt nt!_DRIVER_OBJECT ffffc38e5eae6950.

    Questa rappresentazione è molto utile perché ci permette di vedere effettivamente come viene utilizzata la struttura DRIVER_OBJECT dal kernel.

    typedef struct _DRIVER_OBJECT {
      CSHORT             Type;
      CSHORT             Size;
      PDEVICE_OBJECT     DeviceObject;
      ULONG              Flags;
      PVOID              DriverStart;
      ULONG              DriverSize;
      PVOID              DriverSection;
      PDRIVER_EXTENSION  DriverExtension;
      UNICODE_STRING     DriverName;
      PUNICODE_STRING    HardwareDatabase;
      PFAST_IO_DISPATCH  FastIoDispatch;
      PDRIVER_INITIALIZE DriverInit;
      PDRIVER_STARTIO    DriverStartIo;
      PDRIVER_UNLOAD     DriverUnload;
      PDRIVER_DISPATCH   MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
    } DRIVER_OBJECT, *PDRIVER_OBJECT;

    Possiamo poi procedere con l’analisi del DEVICE_OBJECT, utilizzando la stessa sintassi, oppure disassemblare il codice della routine di Unload o ricavare gli indirizzi delle routine associate alle MajorFunctions.

    Se analizziamo quest’ultime, otteniamo l’elenco delle funzioni e i rispettivi indirizzi di memoria da cui possiamo disassemblarle.

    Per le MajorFunctions senza una routine, viene assegnata una funzione che si limita a completare la richiesta senza condurre alcuna operazione specifica.

    Per concludere, analizziamo il DEVICE_OBJECT e anche in questo caso vediamo il confronto con la dichiarazione della struttura.

    typedef struct _DEVICE_OBJECT {
      CSHORT                   Type;
      USHORT                   Size;
      LONG                     ReferenceCount;
      struct _DRIVER_OBJECT    *DriverObject;
      struct _DEVICE_OBJECT    *NextDevice;
      struct _DEVICE_OBJECT    *AttachedDevice;
      struct _IRP              *CurrentIrp;
      PIO_TIMER                Timer;
      ULONG                    Flags;
      ULONG                    Characteristics;
      __volatile PVPB          Vpb;
      PVOID                    DeviceExtension;
      DEVICE_TYPE              DeviceType;
      CCHAR                    StackSize;
      union {
        LIST_ENTRY         ListEntry;
        WAIT_CONTEXT_BLOCK Wcb;
      } Queue;
      ULONG                    AlignmentRequirement;
      KDEVICE_QUEUE            DeviceQueue;
      KDPC                     Dpc;
      ULONG                    ActiveThreadCount;
      PSECURITY_DESCRIPTOR     SecurityDescriptor;
      KEVENT                   DeviceLock;
      USHORT                   SectorSize;
      USHORT                   Spare1;
      struct _DEVOBJ_EXTENSION *DeviceObjectExtension;
      PVOID                    Reserved;
    } DEVICE_OBJECT, *PDEVICE_OBJECT;

    Molto interessante è il contenuto del Security Descriptor associato al device che determina i permessi di accesso di utenti e gruppi (vedremo in un prossimo post come restringere i permessi e limitare l’accesso al device).

    Il comando !sd interpreta il contenuto del Security Descriptor.
    Per rendere l’output più leggibile consiglio l’utilizzo del flag 0x1 affinché vengano tradotti i SID.

    Conclusioni e anticipazioni

    In questo post ho cercato di fornire una panoramica sui driver di Windows cercando di mantenere le informazioni brevi e puntuali.

    Da quando ho avuto tra le mani Microsoft Defender for Endpoint e ho trovato noioso scrivere regole in KQL, ho deciso di muovermi verso il basso e vedere come un EDR funziona nel concreto.
    Come la moda impone, sono partito dal lato offensive studiando Evading EDR salvo fermarmi dopo pochi capitoli cercando di replicare le tecniche illustrate.

    Resomi conto di quanto poco sapessi del funzionamento di WIndows sotto il cofano ho messo da parte il manuale per prenderne in mano altri 2, anzi 3 per la precisione: Windows Internals parte 1, Windows Internals parte 2 e Windows Kernel Programming Second Edition.

    Un’altra fonte di informazione estremamente utile, ma che ripercorre a grandi linee il contenuto di Windows Kernel Programming, è il corso EDR Internals – Research and Development di Trainsec.

    Come avrete intuito non sono certamente un super esperto e i post che pubblicherò non sono altro che note di quanto apprendo, solo riorganizzate meglio di quanto lo siano nella mia vault di Obsidian.

    Nei prossimi post mi piacerebbe entrare nel vivo del tema e iniziare ad affrontare le callback routines, ovvero come un EDR monitora la creazione dei processi.

    Stay tuned!

    Riferimenti