RogueOneX

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

Tag: Servizi

  • 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

  • Windows Internals – Come scrivere un servizio – Parte 2

    Introduzione

    Proseguiamo la serie sui servizi di Windows arricchendo il servizio scritto nella parte 1 con istruzioni per la creazione di un file con una ACL personalizzata.

    Creazione di un file

    In Windows l’API per la creazione di un file, da user-mode, è CreateFile.
    La stessa funzione si utilizza anche per l’apertura di oggetti o device (vedremo cosa sono in post dedicati ai driver) e il valore restituito è sempre un handle.
    Nel caso di errori il valore restituito è INVALID_HANDLE_VALUE.
    Per ottenere il dettaglio dell’errore bisogna confrontare il valore restituito dalla funzione GetLastError con i codici errori di Windows: ad esempio il valore di ritorno pari a 0x5 corrisponde ad ERROR_ACCESS_DENIED.

    HANDLE CreateFileW(
      [in]           LPCWSTR               lpFileName,
      [in]           DWORD                 dwDesiredAccess,
      [in]           DWORD                 dwShareMode,
      [in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes,
      [in]           DWORD                 dwCreationDisposition,
      [in]           DWORD                 dwFlagsAndAttributes,
      [in, optional] HANDLE                hTemplateFile
    );

    Il primo parametro richiede il nome del file o del device da creare o aprire.

    Il secondo parametro, dwDesiredAccess, richiede i permessi necessari per effettuare operazioni sul file che possono essere di sola lettura, solo modifica, lettura e modifica con un certo grado di granularità.
    Infatti, è possibile specificare un valore generico come FILE_GENERIC_WRITE o un permesso specifico come FILE_APPEND_DATA, incluso in FILE_GENERIC_WRITE.

    Il terzo parametro, dwShareMode, richiede il tipo di condivisione del file/device mentre ci sono degli handle aperti.
    Se non si specifica un valore non sarà possibile aprire un altro handle fintanto che il primo rimane attivo.

    dwCreationDisposition specifica il comportamento da mantenere durante la creazione/apertura del file nel caso questo non esista o sia già presente.

    Con l‘handle ottenuto possiamo poi procedere a leggere il contenuto del file o a scriverne di nuovo.
    Esempio di utilizzo:

    g_FileHandle = CreateFile(
    	L"C:\\LoggerSvc.log", // nome del file
    	FILE_APPEND_DATA, // permesso per aggiungere dati alla fine del file
    	FILE_SHARE_READ, // altri processi possono aprire il file in sola lettura
    	&saFile,  // ACL
    	OPEN_ALWAYS, // Apri sempre il file, se non esiste crealo
    	FILE_ATTRIBUTE_NORMAL,
    	nullptr);

    Applicazione di ACL

    Al paragrafo precedente non abbiamo commentato lo scopo del parametro lpSecurityAttributes.
    Questo parametro accetta un puntatore a una struttura SECURITY_ATTRIBUTES:

    typedef struct _SECURITY_ATTRIBUTES {
      DWORD  nLength;
      LPVOID lpSecurityDescriptor;
      BOOL   bInheritHandle;
    } SECURITY_ATTRIBUTES, *PSECURITY_ATTRIBUTES, *LPSECURITY_ATTRIBUTES;

    Questa struttura riceve un parametro nLength che corrisponde alla dimensione della struttura stessa (sizeof(SECURITY_ATTRIBUTES)), lpSecurityDescriptor che riceve un puntatore a una struttura SECURITY_DESCRIPTOR e bInheritHandle che determina se gli attributi saranno ereditati da un processo figlio.

    Una struttura SECURITY_DESCRIPTOR contiene i valori che identificano le informazioni di sicurezza applicate a un oggetto, tra cui le ACL:

    typedef struct _SECURITY_DESCRIPTOR {
      BYTE                        Revision;
      BYTE                        Sbz1;
      SECURITY_DESCRIPTOR_CONTROL Control;
      PSID                        Owner;
      PSID                        Group;
      PACL                        Sacl;
      PACL                        Dacl;
    } SECURITY_DESCRIPTOR, *PISECURITY_DESCRIPTOR;

    L’inizializzazione del security descriptor è affidata alla funzione InitializeSecurityDescriptor che prepara la struttura a ricevere nuove informazioni come la DACL (Discretionary Access List) di seguito passata con la funzione SetSecurityDescriptorDacl.

    L’ACL è formata da una o più ACE (Access Control Entries) che sono inizializzate in strutture EXPLICIT_ACCESS.
    I campi di questa struttura definiscono se i permessi di lettura, scrittura o esecuzione sono concessi o revocati.

    typedef struct _EXPLICIT_ACCESS_W {
      DWORD       grfAccessPermissions; // permessi di lettura | scrittura | esecuzione
      ACCESS_MODE grfAccessMode;  // permessi concessi o negati
      DWORD       grfInheritance; // se e come l'ACL è ereditata da oggetti figlio
      TRUSTEE_W   Trustee; // utente, gruppo o servizio a cui si applica l'ACE
    } EXPLICIT_ACCESS_W, *PEXPLICIT_ACCESS_W, EXPLICIT_ACCESSW, *PEXPLICIT_ACCESSW;

    Una volta pronta la struttura viene aggiunta a una struttura ACL tramite la funzione SetEntriesInAcl.

    Nel codice che segue vengono create due ACE, una che concede il controllo completo sul file a NT\SYSTEM e una che concede solo il permesso di lettura al gruppo Everyone.

    PSID pEveryoneSid, pLocalSystemSid;
    PACL pAclFile;
    HANDLE hFile;
    
    // Inizializzazione del SID per il gruppo noto Everyone
    SID_IDENTIFIER_AUTHORITY sidAuthWorld = SECURITY_WORLD_SID_AUTHORITY;
    if (!AllocateAndInitializeSid(&sidAuthWorld, 1, SECURITY_WORLD_RID, 0, 0, 0, 0, 0, 0, 0, &pEveryoneSid))
    	SetStatus(SERVICE_STOPPED);
    
    SID_IDENTIFIER_AUTHORITY sidAuthNt = SECURITY_NT_AUTHORITY;
    if (!AllocateAndInitializeSid(&sidAuthNt, 1, SECURITY_LOCAL_SYSTEM_RID, 0, 0, 0, 0, 0, 0, 0, &pLocalSystemSid))
    	SetStatus(SERVICE_STOPPED);
    
    EXPLICIT_ACCESS eaFile[2];
    ZeroMemory(&eaFile, 2 * sizeof(EXPLICIT_ACCESS));
    
    
    // Creazione dell'ACE con permesso di sola lettura per il gruppo Everyone
    eaFile[0].grfAccessPermissions = FILE_GENERIC_READ;
    eaFile[0].grfAccessMode = SET_ACCESS;
    eaFile[0].grfInheritance = NO_INHERITANCE;
    eaFile[0].Trustee.pMultipleTrustee = nullptr;
    eaFile[0].Trustee.MultipleTrusteeOperation = NO_MULTIPLE_TRUSTEE;
    eaFile[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
    eaFile[0].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
    eaFile[0].Trustee.ptstrName = (LPTCH)pEveryoneSid;
    
    // Creazione dell'ACE con permessi di controllo completo per l'account LocalSystem
    eaFile[1].grfAccessPermissions = FILE_ALL_ACCESS;
    eaFile[1].grfAccessMode = SET_ACCESS;
    eaFile[1].grfInheritance = NO_INHERITANCE;
    eaFile[1].Trustee.pMultipleTrustee = nullptr;
    eaFile[1].Trustee.MultipleTrusteeOperation = NO_MULTIPLE_TRUSTEE;
    eaFile[1].Trustee.TrusteeForm = TRUSTEE_IS_SID;
    eaFile[1].Trustee.TrusteeType = TRUSTEE_IS_USER;
    eaFile[1].Trustee.ptstrName = (LPTCH)pLocalSystemSid;
    
    // Aggiunta delle ACE nell'ACL
    DWORD aclStatus = SetEntriesInAcl(2, eaFile, nullptr, &pAclFile);
    if (aclStatus != ERROR_SUCCESS) {
    	FreeSid(pLocalSystemSid);
    	FreeSid(pEveryoneSid);
    	SetStatus(SERVICE_STOPPED);
    }
    
    // Inizializzazione del Security Descriptor
    SECURITY_DESCRIPTOR sdFile;
    InitializeSecurityDescriptor(&sdFile, SECURITY_DESCRIPTOR_REVISION);
    if (!SetSecurityDescriptorDacl(&sdFile, true, pAclFile, false) {
    	FreeSid(pEveryoneSid);
    	FreeSid(pLocalSystemSid);
    	SetStatus(SERVICE_STOPPED);
    }
    
    // Inizializzazione della struttura SECURITY_ATTRIBUTES
    SECURITY_ATTRIBUTES saFile;
    saFile.nLength = sizeof(SECURITY_ATTRIBUTES);
    saFile.lpSecurityDescriptor = &sdFile;
    saFile.bInheritHandle = FALSE;
    
    hFile = CreateFile(
    	L"C:\\test.txt",
    	FILE_APPEND_DATA,
    	FILE_SHARE_READ,
    	&saFile,
    	OPEN_ALWAYS,
    	FILE_ATTRIBUTE_NORMAL,
    	nullptr);

    Circa i SID (Security Identifiers) dedicherò in futuro un post, per il momento è sufficiente sapere che sono identificatori univoci con i quali Windows gestisce utenti, gruppi e account nei contesti di sicurezza.

    Comparazione permessi con e senza ACL

    Giunti a questo punto il codice del servizio sarà così:

    #include <Windows.h>
    #include <iostream>
    #include <aclapi.h>
    
    // Variabili globali
    WCHAR g_ServiceName[] = L"LoggerSvc";
    SERVICE_STATUS_HANDLE g_ServiceStatusHandle;
    SERVICE_STATUS g_ServiceStatus;
    HANDLE g_FileHandle;
    BOOL g_Status;
    PSID g_pEveryoneSid, g_pLocalSystemSid;
    
    
    // Funzione wrapper attorno a SetServiceStatus per cambiare lo stato del servizio
    void SetStatus(DWORD state) {
    	g_ServiceStatus.dwCurrentState = state;
    	SetServiceStatus(g_ServiceStatusHandle, &g_ServiceStatus);
    }
    
    // Funzione per gestire le richieste di control provenienti dal Service Control Handler
    void WINAPI LoggerHandler(DWORD dwControl) {
    	switch (dwControl) {
    	case SERVICE_CONTROL_STOP:
    		CloseHandle(g_FileHandle);
    		FreeSid(g_pEveryoneSid);
    		FreeSid(g_pLocalSystemSid);
    		SetStatus(SERVICE_STOPPED);
    	}
    }
    
    
    // Funzione ServiceMain
    void WINAPI  LoggerSvcMain(DWORD dwNumServicesArgs, LPWSTR* lpServiceArgVectors) {
    	// Registrazione dell'handler al SCM
    	g_ServiceStatusHandle = RegisterServiceCtrlHandler(g_ServiceName, LoggerHandler);
    
    	SetStatus(SERVICE_START_PENDING);
    
    	// Inizializzazione dele risorse
    
    	PACL pAclFile;
    
    	// Inizializzazione del SID per il gruppo Everyone
    	SID_IDENTIFIER_AUTHORITY sidAuthWorld = SECURITY_WORLD_SID_AUTHORITY;
    	if (!AllocateAndInitializeSid(&sidAuthWorld, 1, SECURITY_WORLD_RID, 0, 0, 0, 0, 0, 0, 0, &g_pEveryoneSid))
    		SetStatus(SERVICE_STOPPED);
    
    	SID_IDENTIFIER_AUTHORITY sidAuthNt = SECURITY_NT_AUTHORITY;
    	if (!AllocateAndInitializeSid(&sidAuthNt, 1, SECURITY_LOCAL_SYSTEM_RID, 0, 0, 0, 0, 0, 0, 0, &g_pLocalSystemSid))
    		SetStatus(SERVICE_STOPPED);
    
    	EXPLICIT_ACCESS eaFile[2];
    	ZeroMemory(&eaFile, 2 * sizeof(EXPLICIT_ACCESS));
    
    	// Inizializzazione del primo elemento EXPLICIT_ACCESS per accesso in sola lettura per il gruppo Everyone
    	eaFile[0].grfAccessPermissions = FILE_GENERIC_READ;
    	eaFile[0].grfAccessMode = SET_ACCESS;
    	eaFile[0].grfInheritance = NO_INHERITANCE;
    	eaFile[0].Trustee.pMultipleTrustee = nullptr;
    	eaFile[0].Trustee.MultipleTrusteeOperation = NO_MULTIPLE_TRUSTEE;
    	eaFile[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
    	eaFile[0].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
    	eaFile[0].Trustee.ptstrName = (LPTCH)g_pEveryoneSid;
    
    	// Inizializzazione del primo elemento EXPLICIT_ACCESS per accesso in controllo completo per l'account LocalSystem
    	eaFile[1].grfAccessPermissions = FILE_ALL_ACCESS;
    	eaFile[1].grfAccessMode = SET_ACCESS;
    	eaFile[1].grfInheritance = NO_INHERITANCE;
    	eaFile[1].Trustee.pMultipleTrustee = nullptr;
    	eaFile[1].Trustee.MultipleTrusteeOperation = NO_MULTIPLE_TRUSTEE;
    	eaFile[1].Trustee.TrusteeForm = TRUSTEE_IS_SID;
    	eaFile[1].Trustee.TrusteeType = TRUSTEE_IS_USER;
    	eaFile[1].Trustee.ptstrName = (LPTCH)g_pLocalSystemSid;
    
    	// Creazione dell'ACL con le due ACE
    	DWORD aclStatus = SetEntriesInAcl(2, eaFile, nullptr, &pAclFile);
    	if (aclStatus != ERROR_SUCCESS) {
    		FreeSid(g_pLocalSystemSid);
    		FreeSid(g_pEveryoneSid);
    		SetStatus(SERVICE_STOPPED);
    	}
    
    	// Inizializzazione del Security Descriptor
    	SECURITY_DESCRIPTOR sdFile;
    	InitializeSecurityDescriptor(&sdFile, SECURITY_DESCRIPTOR_REVISION);
    	if (!SetSecurityDescriptorDacl(&sdFile, true, pAclFile, false)){
    		FreeSid(g_pEveryoneSid);
    		FreeSid(g_pLocalSystemSid);
    		SetStatus(SERVICE_STOPPED);
    	}
    
    	// Inizializzazione degli attributi di sicurezza
    	SECURITY_ATTRIBUTES saFile, saMailslot;
    	saFile.nLength = sizeof(SECURITY_ATTRIBUTES);
    	saFile.lpSecurityDescriptor = &sdFile;
    	saFile.bInheritHandle = FALSE;
    
    	// Creazione/apertura del file log
    	g_FileHandle = CreateFile(
    		L"C:\\LoggerSvc.log",
    		FILE_APPEND_DATA,
    		FILE_SHARE_READ,
    		&saFile,
    		OPEN_ALWAYS,
    		FILE_ATTRIBUTE_NORMAL,
    		nullptr);
    
    	if (g_FileHandle == INVALID_HANDLE_VALUE) {
    		FreeSid(g_pEveryoneSid);
    		FreeSid(g_pLocalSystemSid);
    		SetStatus(SERVICE_STOPPED);
    	}
    
    	// Set service status as running
    	SetStatus(SERVICE_RUNNING);
    }
    
    
    int main(void) {
    	
    	// Array of services
    	SERVICE_TABLE_ENTRY ServiceTableEntries[] = {
    	{ g_ServiceName, LoggerSvcMain },
    	{ nullptr }
    	};
    
    	// Determine if the service runs in its own process or the process is shared with other processes
    	g_ServiceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS;
    	g_ServiceStatus.dwControlsAccepted = SERVICE_ACCEPT_STOP;
    
    	// The main thread becomes the service control dispatcher and launch another thread with the service main function
    	if (!StartServiceCtrlDispatcher(ServiceTableEntries))
    		return 1;
    
    	return 0;
    
    }

    Procediamo quindi alla compilazione del file e installazione del servizio nella macchina di test.
    Appena avviamo il servizio possiamo verificare che il file è stato creato con successo.

    Il file è creato con i criteri di sicurezza specificati, ovvero sola lettura per tutti gli utenti e permessi di controllo completo per l’account LocalSystem.
    Se si prova a creare un file nella stessa directory, lasciando che erediti i criteri di sicurezza dalla cartella padre, i permessi sono diversi:

    In questo modo è possibile proteggere quanto prodotto dal servizio, che sia un file o un mailslot, da utenti standard.
    Da notare che un utente con poteri d’amministratore può effettuare l’override dei criteri di sicurezza degli oggetti e ottenerne l’accesso.

    Conclusioni

    I servizi sono un elemento importante in Windows e in ambito cybersecurity diventano cruciali per lo sviluppo di soluzioni EDR per il caricamento di driver e sensori.
    Come vedremo, infatti, questa serie sui servizi si inserisce in un quadro più ampio di approfondimento sul funzionamento degli EDR e del kernel di Windows, con esempi pratici di sviluppo di driver software e tecniche di evasione.

    Riferimenti

  • Windows Internals – Come scrivere un servizio – Parte 1

    Introduzione

    Un servizio è un programma (.exe o .sys) che viene eseguito in background e non richiede interazione da parte dell’utente.
    In Windows le informazioni relative ai servizi sono salvate al percorso del Registro di Sistema HKLM\SYSTEM\CurrentControlSet\Services, il quale contiene una chiave per ogni servizio.

    Nella prima parte di questa serie sui servizi vedremo come crearne uno il cui file binario sarà un eseguibile (.exe), partendo da una veloce infarinatura fino all’installazione.

    Struttura di un servizio

    L’applicazione che andremo a scrivere sarà di tipo console, ovvero priva di GUI, così come una qualsiasi altra applicazione eseguibile da riga di comando ma che, per essere considerata un servizio valido, deve rispettare i requisiti richiesti da Windows.

    Dall’immagine che segue si può da subito notare che il Processo del servizio utilizza due thread per funzionar correttamente: il main thread e il ervice thread.

    Struttura di un servizio

    Il main thread esegue il codice della funzione main (Service Entry Point) che si occupa di inizializzare una struttura SERVICE_TABLE_ENTRY:

    typedef struct _SERVICE_TABLE_ENTRYW {
      LPWSTR                   lpServiceName;
      LPSERVICE_MAIN_FUNCTIONW lpServiceProc;
    } SERVICE_TABLE_ENTRYW, *LPSERVICE_TABLE_ENTRYW;

    Questa struttura contiene il nome del servizio e la sua funzione principale (ServiceMain).
    La variabile di tipo SERVICE_TABLE deve essere un array che può contenere più di una coppia di valori, se lo stesso processo serve più servizi, ma la cui ultima voce deve corrispondere a un valore NULL, che sta ad indicare la fine dell’array.

    SERVICE_TABLE_ENTRY entries[]: {
        {lpServiceName1, lpServiceProc1},
        {nullptr}
    };

    Successivamente invoca la funzione StartServiceControlDispatcher che connette il main thread al Service Control Manager (SCM), rendendolo di fatto il service control dispatcher thread del processo.
    Il suo compito sarà, da quel momento in poi, la gestione delle richieste di modifica dello stato del servizio (avvio, arresto, sospensione…) provenienti dal Service Control Manager.

    if(!StartServiceCtrlDispatcher(entries){
        return 1;
    }

    Giunti a questo punto il main thread avvia il service thread che esegue il codice della funzione principale del servizio, la ServiceMain.
    La prima operazione che il service thread deve eseguire è la registrazione, tramite la funzione RegisterServiceCtrlHandler, di un handler che contiene il codice per gestire le richieste del SCM.
    Successivamente notifica al SCM che il servizio è in stato di avvio (SERVICE_START_PENDING) e infine che è attivo (SERVICE_RUNNING) tramite la funzione SetServiceStatus e la struttura SERVICE_STATUS.
    Il Service Control Manager è “impaziente” e deve essere notificato entro pochi secondi se il servizio è in avvio.
    Qualora l’inizializzazione delle risorse necessarie richieda più tempo, è necessario indicare il tempo rimanente o invocare con cadenza regolare che il servizio è in fase di avvio (SERVICE_START_PENDING).

    typedef struct _SERVICE_STATUS {
      DWORD dwServiceType;
      DWORD dwCurrentState;
      DWORD dwControlsAccepted;
      DWORD dwWin32ExitCode;
      DWORD dwServiceSpecificExitCode;
      DWORD dwCheckPoint;
      DWORD dwWaitHint;
    } SERVICE_STATUS, *LPSERVICE_STATUS;

    Scrittura del servizio

    Quanto esposto sopra è un breve accenno ai passaggi fondamentali che l’applicazione deve seguire affinché Windows lo possa registrare come servizio ed eseguirlo senza errori.

    Per avere una idea, il codice sarà così:

    // Headers necessari
    #include <Windows.h>
    
    // Variabili globali
    WCHAR g_ServiceName[] = L"NomeDelServizio";
    SERVICE_STATUS_HANDLE g_ServiceHandle;
    SERVICE_STATUS g_ServiceStatus;
    
    // Funzione wrapper attorno a SetServiceStatus
    void SetStatus(DWORD state) {
        g_ServiceStatus.dwCurrentState = state;
        SetServiceStatus(g_ServiceHandle, &g_ServiceStatus);
    }
    
    // Funzione handler che gestisce le richieste del SCM
    void WINAPI ServiceHandler(DWORD dwControl){
        switch (dwControl) {
        case SERVICE_CONTROL_STOP:
            SetStatus(SERVICE_STOPPED);
            break;
        }
    }
    
    // Funzione principale del service thread
    void WINAPI ServiceMain(DWORD dwNumServicesArgs, LPWSTR* lpServiceArgVectors) {
        
        // Registrazione della funzione handler
        g_ServiceHandle = RegisterServiceCtrlHandler(g_ServiceName, ServiceHandler);
    
        // Servizio in avvio
        SetStatus(SERVICE_START_PENDING);
    
        // Qualora fosse necessario inizializzare delle risorse il codice va inserito qui
    
        // Il servizio è avviato
        SetStatus(SERVICE_RUNNING);
    
    }
    
    // Funzione principale del processo eseguita nel main thread
    int main(){
    
        // Inizializzazione della variabile di tipo SERVICE_TABLE_ENTRY
        SERVICE_TABLE_ENTRY entries[] = {
            {g_ServiceName, ServiceMain},
            {nullptr}
        };
    
        // Il servizio viene eseguito nel suo processo e accetta le richieste di arresto
        g_ServiceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS;
        g_ServiceStatus.dwControlsAccepted = SERVICE_ACCEPT_STOP;
    
        // Avvia il service thread, in caso di errore chiude il processo
        if(!StartServiceCtrlDispatcher(entries))
            return 1;
    
        return 0;
    }

    Per compilare il codice è necessario avere a disposizione Visual Studio (anche edizione Community) con il SDK di Windows e le librerie per la compilazione (link alla guida dal sito Microsoft).
    La compilazione in modalità Debug non consente la registrazione del servizio ed è quindi necessario selezionare la modalità Release, come mostrato nel video che segue.

    A questo punto al percorso predefinito C:\Users\<nome utente>\sources\repos\<nome soluzione>\x64\Release si trovano il file eseguibile compilato e il file .pdb (utile per effettuare il debug del file).

    Distribuzione, installazione e verifica

    Il metodo più semplice per installare manualmente il servizio prevede di copiare il file eseguibile nella macchina test e utilizzare il comando sc da prompt dei comandi (cmd.exe) eseguito come amministratore.

    Il comando per l’installazione è sc create <nome del servizio> type= own binPath= <percorso del file eseguibile>.
    Da notare che gli spazi dopo type= e binPath= sono necessari.

    Se il comando ha successo significa che il servizio è stato creato ma, per impostazione predefinita, non è ancora in esecuzione.
    Per avviarlo è richiesto un input come sc start <nome del servizio>.

    Creazione, avvio e verifica del servizio

    Come si vede dallo screenshot, il servizio è in esecuzione.
    Essendo stato creato correttamente lo ritroviamo sia tra l’elenco dei servizi disponibili in Gestione dei servizi (services.msc) sia nel Registro di sistema.

    Servizio test compare nell’elenco di Gestione dei servizi
    Proprietà del servizio test viste dal Registro di sistema

    Non avendo specificato diversamente, Windows crea il servizio in avvio manuale (Start 3) e in esecuzione sotto l’utente LocalSystem.
    Per un maggiore controllo sulla creazione del servizio occorre scrivere una applicazione client che utilizzi l’API CreateService:

    SC_HANDLE CreateServiceW(
      [in]            SC_HANDLE hSCManager,
      [in]            LPCWSTR   lpServiceName,
      [in, optional]  LPCWSTR   lpDisplayName,
      [in]            DWORD     dwDesiredAccess,
      [in]            DWORD     dwServiceType,
      [in]            DWORD     dwStartType,
      [in]            DWORD     dwErrorControl,
      [in, optional]  LPCWSTR   lpBinaryPathName,
      [in, optional]  LPCWSTR   lpLoadOrderGroup,
      [out, optional] LPDWORD   lpdwTagId,
      [in, optional]  LPCWSTR   lpDependencies,
      [in, optional]  LPCWSTR   lpServiceStartName,
      [in, optional]  LPCWSTR   lpPassword
    );

    Come si vede dal prototipo è possibile specificare il tipo di avvio (dwStartType), sotto quale utente deve essere eseguito (lpServiceStartName), il tipo di servizio (dwServiceType) e l’ordine di avvio nel caso di dipendenze (lpLoadOrderGroup).

    Per fermare il servizio è sufficiente utilizzare il comando sc stop <nome del servizio> o il pulsante Arresta da Gestione dei servizi.

    Conclusione e anticipazioni

    In questa prima parte sono stati fissati i principali concetti riguardo ai servizi in Windows.
    Nelle parti che seguiranno il servizio sarà ampliato in un progetto più ampio che includerà la creazione di file, la creazione di mailsllot, la creazione di un client e l’utilizzo di ACL (Access Control List).

    Riferimenti