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).
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.
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:
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 -