Sbirciando dopo quasi 5 anni nel codice sorgente di BugChecker (un kernel debugger "alla SoftICE" che ho sviluppato all'epoca con l'intento primario di approfondire le mie conoscenze del sistema ai più bassi livelli possibili e per collaudare tecniche di reverse engineering non frequentemente utilizzabili in scenari quotidiani) mi sono appassionato non poco a ripercorrere l'iter di attivazione del debugger nel caso di sistemi multiprocessore. Questo è uno screenshot del debugger al lavoro sul codice del kernel di Windows 2000 --- con supporto per tastiera e mouse PS/2 (il sistema, un Pentium 3 bi-processore, è bloccato in ogni sua funzione in attesa di un input alla console di BugChecker):



Per chi non lo sapesse, un kernel debugger, a differenza di Visual Studio, che è uno "user mode debugger", per così dire, o del CLR Debugger, permette di "debuggare" (o più comunemente "analizzare") il codice assembler del sistema a tutti i livelli, sia quello kernel (dove le componenti core del sistema e parte dei driver risiedono) che quello user (dove le applicazioni di tutti i giorni vengono caricate ed eseguite). Il supporto tipico a questa attività sono i file di simboli dei principali moduli kernel e user del sistema, facilmente scaricabili da una sorta di Web Service di Microsoft (i dettagli del protocollo web utilizzato dal Symbol Server di Microsoft sono discussi in questo mio articolo). Senza i simboli, che danno un nome alle procedure e alle aree di memoria, l'interpretazione del codice assembler sarebbe estremamente più impegnativa. Come è risaputo i codici sorgente di Windows sono preclusi ai più, e libri essenziali come Windows Internals di Russinovich possono soddisfare la curiosità dei più arditi fino ad un certo punto: poi devono intervenire le capacità personali di analisi e deduzione dell'individuo, attraverso quella attività che è detta di "reverse engineering". Sviluppare BugChecker è stata una delle esperienze professionali più divertenti che abbia mai condotto, poichè l'intera base di conoscenze che sono state presupposto per il suo sviluppo derivano da una attività sistematica e metodica di reverse engineering di parte del kernel di Windows 2000 (attraverso SoftICE stesso, IDA, un decompilatore, e la bibbia già citata di Russinovich).

Per questa sua caratteristica peculiaria, questo tipo di software viene spesso utilizzato per "sbirciare" nel codice di applicazioni (per capirne il funzionamento, a fini più o meno leciti) e nel codice del sistema e delle sue componenti chiave (comunemente per scoprirne vulnerabilità o simili). Tipicamente, malware come i rootkit nascono da analisi condotte attraverso strumenti di questo tipo.

La base di funzionamento di BugChecker (e di SoftICE) è un kernel driver destinato ad intercettare l'indirizzo di memoria virtuale del framebuffer video e il suo formato, in modo da poter disegnare la propria interfaccia senza passare dai servizi di sistema (DDI/GDI). Un driver secondario è richiesto (rispetto al modulo del debugger vero e proprio) poichè tale driver viene caricato al boot del sistema, prima che avvenga l'inizializzazione di DirectDraw (e dando la possibilità di intercettare gli entry point primari in fase di inizializzazione del sottosistema video al fine di intercettare certe strutture contenenti le informazioni di cui parlavo prima). Un articolo sul mio sito (qui) spiega in dettaglio il funzionamento di questa componente, con codici sorgente e binari inclusi. Lo sviluppo di questa parte ha richiesto uno studio approfondito di DirectDraw lato kernel (via MSDN) e lunghe sessioni di "debugging" kernel per capire come in effetti il sistema funzionava.

La prima cosa che il debugger fà è allocare memoria. La funzione utilizzata è ExAllocatePool, col parametro NonPagedPool, che indica al sistema di riservare della memoria virtuale non paginabile su disco (quindi perennemente associata a della memoria fisica). Questo è particolarmente importante, poichè il debugger potrebbe trovarsi a fare trace in codice di sistema ad un IRQL maggiore o uguale a DISPATCH_LEVEL. In parole povere, l'IRQL (Interrupt Request Level) è un intero assegnato per processore (con costanti quali DISPATCH_LEVEL) che regola la prioritizzazione degli interrupt per quella CPU. In sostanza, gli interrupt (ossia eventi software o hardware) che accadono ad un IRQL minore o uguale a quello attualmente impostato per un processore vengono bloccati e messi in attesa, mentre interrupt che accadono ad un IRQL maggiore interrompono l'attività corrente (poichè più prioritari) vengono eseguiti ed una volta completati viene ripresa l'attività precedente, all'IRQL precedente. Le routine di sistema che gestiscono lo swap di pagine di memoria nel paging file verso memoria fisica vengono eseguite ad un IRQL di livello DISPATCH_LEVEL: questo significa che se ci trovassimo ad un IRQL maggiore e provassimo a leggere/scrivere/eseguire della memoria "non presente", il sistema non potrebbe leggerla dal disco e si andrebbe direttamente in Blue Screen (con errore IRQL_NOT_LESS_OR_EQUAL).

post_kernel_1_bsod.jpg

Una delle cose successive che vengono fatte è determinare l'indirizzo virtuale della memoria video testo (indirizzo fisico: 0xB8000). Il debugger infatti viene caricato subito dopo il boot ed è possibile fare trace di codice kernel durante l'avvio del sistema, quando ancora non si è passati ad una modalità video grafica. Questo può essere fatto navigando la Page Directory (che tiene traccia del rapporto tra memoria fisica e virtuale) oppure attraverso l'API di sistema MmMapIoSpace:

#ifdef USE_PAGETABLE_TO_OBTAIN_TEXTMODE_VIDEOADDRESS

      PhysAddressToLinearAddresses( & extension->pvTextVideoBuffer, 1, NULL, 0xB8000 );

#else

      {

            PHYSICAL_ADDRESS        paPhysAddr;

            paPhysAddr.QuadPart = 0xB8000;

            extension->dglLayouts.pvTextVideoBuffer = MmMapIoSpace( paPhysAddr, 1, MmNonCached );

      }

#endif


La procedura PhysAddressToLinearAddresses, riportata di seguito, è piuttosto complessa. Conto in un post successivo di discutere circa i meccanismi di gestione della memoria virtuale in Windows NT-Vista Kernel: Page Tables, Page Directories, Translation lookaside buffers, differenze tra x86/PAE/x64/Itanium e quant'altro.

NTSTATUS PhysAddressToLinearAddresses( OUT PVOID* ppvOutputVector, IN ULONG ulOutputVectorSize, OUT ULONG* pulOutputVectorRetItemsNum, IN DWORD dwPhysAddress )

{

      NTSTATUS                nsRetVal = STATUS_SUCCESS;

      DWORD*                       pdwPageDir = (DWORD*) 0xC0300000;

      ULONG                   i, j;

      DWORD                   dwPageDirEntry, dwPageTblEntry;

      DWORD*                       pdwPageTable;

      DWORD                   dwPageTblEntryPhysAddress[ 2 ];

      ULONG                   ulOutputVectorPos = 0;

 

      if ( pulOutputVectorRetItemsNum )

            * pulOutputVectorRetItemsNum = 0;

 

      // Search in the Page Directory for the Specified Address.

 

      for ( i=0; i<1024; i++ )

      {

            dwPageDirEntry = pdwPageDir[ i ];

 

            // Check if this Page Table has an Address and if its Present bit is set to 1.

 

            if ( ( dwPageDirEntry >> 12 ) &&

                  ( dwPageDirEntry & 0x1 ) )

            {

                  pdwPageTable = (DWORD*) ( (BYTE*) 0xC0000000 + i * 0x1000 );

 

                  for ( j=0; j<1024; j++ )

                  {

                        dwPageTblEntry = pdwPageTable[ j ];

 

                        // Check if this Page Table Entry has an associated Physical Address.

 

                        if ( dwPageTblEntry >> 12 )

                        {

                             // Calculate the MIN and MAX Phys Address of the Page Table Entry.

 

                             dwPageTblEntryPhysAddress[ 0 ] = dwPageTblEntry & 0xFFFFF000;

                             dwPageTblEntryPhysAddress[ 1 ] = dwPageTblEntryPhysAddress[ 0 ] + 0x1000 - 1;

 

                             // Check if our Address is between the Interval.

 

                             if ( dwPhysAddress >= dwPageTblEntryPhysAddress[ 0 ] &&

                                   dwPhysAddress <= dwPageTblEntryPhysAddress[ 1 ] )

                             {

                                   // Add this Linear Address.

 

                                   if ( ulOutputVectorPos < ulOutputVectorSize )

                                   {

                                         ppvOutputVector[ ulOutputVectorPos ++ ] = (PVOID)

                                               ( i * 0x400000 + j * 0x1000 +

                                               ( dwPhysAddress - dwPageTblEntryPhysAddress[ 0 ] ) );

                                   }

                                   else

                                   {

                                         if ( pulOutputVectorRetItemsNum )

                                                * pulOutputVectorRetItemsNum = ulOutputVectorPos;

 

                                         return STATUS_SUCCESS;

                                   }

                             }

                        }

                  }

            }

      }

 

      // Return to the Caller.

 

      if ( pulOutputVectorRetItemsNum )

            * pulOutputVectorRetItemsNum = ulOutputVectorPos;

 

      return nsRetVal;

}


Passo successivo è il caricamento del file di configurazione del debugger. Questo viene fatto attraverso la funzione LoadFile, riportata qui di seguito:

PVOID LoadFile( IN PCWSTR pszFileName, IN POOL_TYPE ptPoolType, OUT ULONG* pulSize )

{

      PVOID                              retval = NULL;

      NTSTATUS                           ntStatus;

      HANDLE                                   handle = NULL;

      OBJECT_ATTRIBUTES            attrs;

      UNICODE_STRING                     unicode_fn;

      IO_STATUS_BLOCK                    iosb;

      FILE_STANDARD_INFORMATION    info;

      ULONG                              size = 0;

      PVOID                              mem;

      LARGE_INTEGER                      zeropos;

 

      memset( & zeropos, 0, sizeof( zeropos ) );

 

      // Load the File.

 

      RtlInitUnicodeString( & unicode_fn, pszFileName );

 

      InitializeObjectAttributes( & attrs,

            & unicode_fn,

            OBJ_CASE_INSENSITIVE,

            NULL,

            NULL );

 

      ntStatus = ZwCreateFile( & handle,

            FILE_READ_DATA | GENERIC_READ | SYNCHRONIZE,

            & attrs,

            & iosb,

            0,

            FILE_ATTRIBUTE_NORMAL,

            0,

            FILE_OPEN,

            FILE_NON_DIRECTORY_FILE | FILE_RANDOM_ACCESS | FILE_SYNCHRONOUS_IO_NONALERT,

            NULL,

            0 );

 

      if ( ntStatus == STATUS_SUCCESS && handle )

      {

            ntStatus = ZwQueryInformationFile(

                  handle,

                  & iosb,

                  & info,

                  sizeof( info ),

                  FileStandardInformation );

 

            if ( ntStatus == STATUS_SUCCESS )

            {

                  size = info.EndOfFile.LowPart;

 

                  mem = ExAllocatePool( ptPoolType, size );

 

                  if ( mem )

                  {

                        ntStatus = ZwReadFile(

                             handle,

                             NULL,

                             NULL,

                             NULL,

                             & iosb,

                             mem,

                             size,

                             & zeropos,

                             NULL );

 

                        if ( ntStatus != STATUS_SUCCESS || iosb.Information != size )

                        {

                             ExFreePool( mem );

                        }

                        else

                        {

                             retval = mem;

                        }

                  }

            }

 

            ZwClose( handle );

      }

 

      // Return.

 

      if ( pulSize && retval )

            * pulSize = size;

 

      return retval;

}


Successivamente vengono letti e salvati alcuni export del kernel come *MmUserProbeAddress (indirizzo virtuale che determina l'inizio della memoria riservata al kernel) e *KeNumberProcessors, che si commenta da solo.

Il passo successivo, ben più importante, è di scoprire l'indirizzo virtuale della DriverSection del kernel, ossia del file NtOsKrnl.exe. Per inciso il nome del modulo del kernel nel CD di installazione del sistema dipende dall'architettura del processore:

  • NTOSKRNL.EXE, single-processor without PAE
  • NTKRNLMP.EXE, multi-processor without PAE
  • NTKRNLPA.EXE, single-processor with PAE
  • NTKRPAMP.EXE, multi-processor with PAE

Nota: Le Physical Address Extension (PAE) (architettura Intel IA-32) è un meccanismo che permette a processori IA-32 di indirizzare fino a 64 GB di memoria RAM.

La DriverSection di un driver kernel è una struttura di tipo _LDR_DATA_TABLE_ENTRY. Ciascun driver caricato attraverso l'SCM, ha un entry point del tipo:

NTSTATUS DriverEntry( IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath )

dove DriverObject->DriverSection punta alla struttura _LDR_DATA_TABLE_ENTRY del driver sys di BugChecker. La DriverSection, grazie al comando dt di WinDbg, rivela questi field:

lkd> dt _LDR_DATA_TABLE_ENTRY

+0×000 InLoadOrderLinks : _LIST_ENTRY

+0×008 InMemoryOrderLinks : _LIST_ENTRY

+0×010 InInitializationOrderLinks : _LIST_ENTRY

+0×018 DllBase : Ptr32 Void

+0×01c EntryPoint : Ptr32 Void

+0×020 SizeOfImage : Uint4B

+0×024 FullDllName : _UNICODE_STRING

+0×02c BaseDllName : _UNICODE_STRING

+0×034 Flags : Uint4B

+0×038 LoadCount : Uint2B

+0×03a TlsIndex : Uint2B

.. .. .. .. .. .. .. .. .. ..

Il field che ci interessa è InLoadOrderLinks, che è un nodo alla linked list (di tipo _LIST_ENTRY) di tutti i moduli mappati nel kernel. Il primo modulo caricato nel kernel è NtOsKrnl.exe; quindi è ragionevole cercare per la DriverSection di NTOSKRNL in modo da individuare la "testa" della linked list, e quindi avere una maniera semplice e veloce per iterare tra tutti i moduli kernel caricati in un dato momento (navigando la linked list dalla testa verso la coda, per così dire). Questo è fondamentale per BugChecker e per il suo motore dei simboli: partendo dall'informazione di dove una specifica posizione di memoria si trova (sia codice che dati), in termini di modulo!section+displacement, è possibile estrarre dalle informazioni dei simboli di un dato modulo il nome di una funzione o di una variabile.

post_kernel_1_symloc.gif

La funzione DiscoverNtoskrnlDriverSection è piuttosto semplice, e riceve come parametro la DriverSection di BugChecker (o di qualsivoglia driver caricato dall'SCM), ossia DriverObject->DriverSection.

static CHAR             g_szDiscoverNtoskrnlDriverSectionTempBuffer[ 2 * 1024 ]; // NOTE: sizeof < sizeof( System Page Size )

 

VOID* DiscoverNtoskrnlDriverSection( IN VOID* pvDriverSection )

{

      LIST_ENTRY*             pleListNodePtr;

      WORD*                   pwImageNameLengthPtr;

      WORD                    wImageNameLength;

      WORD*                   pwImageNameUnicodePtr;

      DWORD*                       pdwImageNameUnicodePtrPtr;

      WORD*                   pwWordPtr;

      CHAR*                   pcCharPtr;

      ULONG                   ulI;

 

      // Do the Requested Operation.

 

      pleListNodePtr = (LIST_ENTRY*) pvDriverSection;

 

      while( TRUE )

      {

            // Get the Pointer to the Previous Node.

 

            if ( pleListNodePtr == NULL ||

                  IsPagePresent_DWORD( (DWORD*) ( ( (BYTE*) pleListNodePtr ) + FIELD_OFFSET( LIST_ENTRY, Blink ) ) ) == FALSE )

                        return NULL;

 

            pleListNodePtr = pleListNodePtr->Blink;

 

            if ( pleListNodePtr == NULL ||

                  pleListNodePtr == (LIST_ENTRY*) pvDriverSection ||

                  IsPagePresent( pleListNodePtr ) == FALSE )

                        return NULL;

 

            // Get the Name of the Module.

 

            pwImageNameLengthPtr = (WORD*) ( ( (BYTE*) pleListNodePtr ) + MACRO_IMAGENAME_FIELDOFFSET_IN_DRVSEC );

 

            if ( IsPagePresent_WORD( pwImageNameLengthPtr ) == FALSE )

                        return NULL;

 

            wImageNameLength = * pwImageNameLengthPtr / sizeof( WORD );

 

            if ( wImageNameLength == 0 ||

                  wImageNameLength > sizeof( g_szDiscoverNtoskrnlDriverSectionTempBuffer ) - 1 )

                        return NULL;

 

            pdwImageNameUnicodePtrPtr = (DWORD*) ( ( (BYTE*) pleListNodePtr ) +

                  MACRO_IMAGENAME_FIELDOFFSET_IN_DRVSEC + FIELD_OFFSET( UNICODE_STRING, Buffer ) );

 

            if ( IsPagePresent_DWORD( pdwImageNameUnicodePtrPtr ) == FALSE )

                        return NULL;

 

            pwImageNameUnicodePtr = (WORD*) * pdwImageNameUnicodePtrPtr;

 

            if ( pwImageNameUnicodePtr == NULL ||

                  IsPagePresent( pwImageNameUnicodePtr ) == FALSE )

                  return NULL;

 

            if ( IsPagePresent_WORD( pwImageNameUnicodePtr + wImageNameLength - 1 ) == FALSE )

                  return NULL;

 

            pwWordPtr = pwImageNameUnicodePtr;

            pcCharPtr = g_szDiscoverNtoskrnlDriverSectionTempBuffer;

 

            for ( ulI = 0; ulI < wImageNameLength; ulI ++ )

                  * pcCharPtr ++ = (CHAR) ( ( * pwWordPtr ++ ) & 0xFF );

 

            * pcCharPtr = '\0';

 

            _strupr( g_szDiscoverNtoskrnlDriverSectionTempBuffer );

 

            // Check for the Presence of the NTOSKRNL Module Name.

 

            if ( strstr( g_szDiscoverNtoskrnlDriverSectionTempBuffer, MACRO_NTOSKRNL_MODULENAME_UPPERCASE ) )

                  return pleListNodePtr;

      }

}


La funzione DiscoverBytePointerPosInModules è utilizzata appunto per risalire all'informazione modulo!section+displacement partendo da un indirizzo virtuale (che può essere il primo byte disassemblato della finestra di debug, oppure l'Instruction Pointer attuale). In base alla "posizione" del byte di memoria richiesto (se kernel o user, in base all'export *MmUserProbeAddress) vengono adottati due approcci diversi. Nel caso della memoria kernel, viene utilizzata la linked list della DriverSection dell'NTOSKRNL.EXE. In sostanza, percorrendo la linked list da sinistra verso destra (partendo dalla driver section di NtOsKrnl), seguendo i vari puntatori forward della struttura LIST_ENTRY, è possibile avere un riferimento a tutti i moduli caricati nel kernel. La struttura IMAGE_DOS_HEADER di ciascun modulo caricato nel kernel (che sia .sys, .exe o .dll) si trova ad uno specifico offset di byte rispetto alla stessa struttura LIST_ENTRY. Questo permette facilmente di avere accesso al modulo, alla relativa sezione con la lista delle "section" (tipo .text o .data) e quindi di determinare se una posizione di memoria virtuale "cade" all'interno del modulo, in quale section, e con quale displacement rispetto alla base della section stessa. Questa informazione (modulo!section+displacement) è la chiave di lookup nei file di simboli per ottenere in fase di debugging nomi di funzioni, variabili e quant'altro (questo vale anche per le applicazioni user e relativi PDB).

Se il byte di memoria "cade" nello spazio user, il discorso è un poco diverso. In questo caso, viene analizzato il VAS (Virtual Address Space) associato al processo attualmente attivo nel sistema, per risalire alle stesse informazioni di modulo!section+displacement necessarie per ottenere le info di debug. Il VAS è una risorsa di sistema che rappresenta la memoria allocata da un dato processo attraverso le API VirtualAlloc* e VirtualFree*, che stanno alla base di ogni operazione su memoria virtuale user in Windows (dal malloc/free di C ai memory mapped files). Il VAS si basa su una struttura ad albero di descrittori VAD (Virtual Address Descriptor) che rappresentano le caratteristiche di una determinata area di memoria virtuale in quello specifico processo (per inciso, ad ogni processo Windows corrisponde un unico specifico VAS, che a sua volta gestisce un albero dettagliato di descrittori VAD per rappresentare lo stato della memoria virtuale in quel dato processo). Un descrittore VAD ha questo formato:

#pragma pack(push, 1)

 

      typedef struct _VAD

      {

            VOID*             pvStartingAddress;

            VOID*             pvEndingAddress;

            struct _VAD*      pvadParentLink;

            struct _VAD*      pvadLeftLink;

            struct _VAD*      pvadRightLink;

            DWORD             dwFlags;

            DWORD             dwUndocumented_DWORD;

 

      } VAD, *PVAD;

 

#pragma pack(pop)


La struttura ad albero risultante è facilmente intuibile. Il codice sorgente della funzione DiscoverBytePointerPosInModules è visionabile qui:

post_kernel_1_dbpim.gif

La funzione non è particolarmente complessa: prima viene determinata al "zona" del puntatore in input, se kernel o user. Nel caso di puntatore user, viene chiamata la funzione VadTreeWalk che in maniera ricorsiva determina, se presente, un descrittore VAD facente riferimento alla memoria che stiamo cercando. La seconda parte della procedura, quella relativa alla ricerca nello spazio kernel, fa ciò che ho illustrato precedentemente, in buona sostanza (ricerca del modulo partendo da NtOsKrnl). Particolare interessante è l'utilizzo estensivo della funzione IsPagePresent:

//====================================

// IsPagePresent Function Definition.

//====================================

 

BOOLEAN IsPagePresent( IN PVOID pvVirtAddress )

{

      DWORD*                        pdwPageDir = (DWORD*) 0xC0300000;

      DWORD                   dwPageDirEntry;

      DWORD*                        pdwPageTables = (DWORD*) 0xC0000000;

      DWORD                   dwPageTableEntry;

 

      // Check the Page Tables.

 

      dwPageDirEntry = pdwPageDir[ ( (DWORD) pvVirtAddress ) / 0x400000 ];

 

      if ( ( dwPageDirEntry >> 12 ) &&

            ( dwPageDirEntry & 0x1 ) )

      {

            if ( dwPageDirEntry & (1<<7) )

                  return TRUE;

 

            dwPageTableEntry = pdwPageTables[ ( (DWORD) pvVirtAddress ) / 0x1000 ];

 

            if ( ( dwPageTableEntry >> 12 ) &&

                  ( dwPageTableEntry & 0x1 ) )

                        return TRUE;

      }

 

      // Return to the Caller.

 

      return FALSE;

}


Lo scopo di questa funzione e delle sue varianti è di verificare (nella Page Directory) se una zona di memoria è "presente", cioè se è fisicamente accessibile in lettura. Considerando che il codice del debugger deve funzionare a qualsiasi IRQL (vedi discorso di prima su NonPagedPool) ogni lettura esterna alle strutture del debugger deve essere prima verificata per evitare STOP di tipo IRQL_NOT_LESS_OR_EQUAL.

- fine prima parte -