Chip Security Testing 
Binary Security Analysis 
Resources 
Blog
Contact us
Back to all articles
Vulnerability Research

Retrieve the drive letter from a device name on Windows

17 min read
Edit by Quentin Buathier • Sep 13, 2022
Share

A dive into REVEN® Analysis API.

 

In REVEN 2.11 we introduced multiple new changes to the Python API, mainly:

  • An extension of the Type API to read data structures in the trace of a Windows scenario, directly after their description in the PDBs
  • Access to data symbols and the kernel mapping, allowing to easily read kernel global variables
  • A preview API to access Windows (10 & 11) specific information, like the handles and the parsing of objects

Using the new preview API, we are able to retrieve the path of a file corresponding to a handle, e.g when a process is reading a file with the handle 0xa4:

>> # Connection to a server is already established and a `ctx` variable contains a Context object >> from reven2.preview.windows import Context as WindowsContext >> ctx = WindowsContext(ctx) >> print(ctx.handle(0xa4).object().filename_with_device) \Device\HarddiskVolume2\reven\foo.exe

As you can see, the retrieved path is prefixed by the device name (\Device\HarddiskVolume2\) rather than the drive letter that is typical of Windows paths (e.g. C:\reven\foo.exe). The goal of this article is to demonstrate how you can advantageously use the newly introduced API features to retrieve the path containing the drive letter!

The final script we'll be working toward in the article is downloadable here.

 

How do we retrieve the complete path from a handle?

The starting point will be a C++ example provided by Microsoft in its documentation, where we can see the implementation of a function taking a handle as a parameter and printing the full path with the drive letter, using multiple syscalls to the kernel.

Here is what the C++ code is doing:

  • Retrieving the full path containing the device by creating a mapping for the file (we already have the full path containing the device thanks to the property FileObject.filename_with_device so we can skip this part)
  • Listing all the drive letters with GetLogicalDriveStrings
  • For each drive letter, call QueryDosDevice to retrieve the device associated with the drive
  • For each device, check if it's the one used in the path
    • If yes, replace the device in the path by the drive letter and stop there
    • If no, test the next drive letter

We will implement each of these steps using the Python API, and just by looking into the kernel memory as we no longer have the ability to make syscalls at the point where we're analyzing a REVEN trace.

 

Step 0: Have a proper base for our script

The first step is to parse the arguments for our script, connect to a REVEN server, retrieve a context where a file handle is valid, retrieve the file handle and the path with device in it. For that we will start by adding a main and arguments parsing.

import argparse from typing import Iterator, Optional, Tuple import reven2 from reven2.preview.windows import Context as WindowsContext, FileObject, Object from reven2.preview.windows.utils import read_unicode_string def main() -> None: # Parse the arguments given to our script parser = argparse.ArgumentParser() parser.add_argument( "--host", type=str, default="localhost", help='Reven host, as a string (default: "localhost")', ) parser.add_argument( "-p", "--port", type=int, default=13370, help="Reven port, as an int (default: 13370)", ) parser.add_argument( "--transition", type=int, required=True, help="The transition id, as an int, used to retrieve the file handle", ) parser.add_argument( "--handle", type=int, required=True, help="The file handle, as an int" ) args = parser.parse_args() # TODO: # - Connect to the REVEN server # - Retrieve the context from the arguments # - Retrieve the path of the file object from the arguments raise NotImplementedError("TODO") if __name__ == "__main__": main()

With the script above we have a main function capable of parsing some arguments:

  • The host and the port to be able to connect to a REVEN server
  • The handle to the file object that we want the full path containing the drive letter
  • The transition where this handle is valid

Inside the main function we can then add the connection to the REVEN server, it's quite simple:

def main() -> None: # Previous code... # Connect to the REVEN server server = reven2.RevenServer(args.host, args.port)

Now, to retrieve the handle we need to wrap a REVEN context in an enhanced windows.Context from the new preview API that exposes Windows 10 information. For the base REVEN context, we will use the context before the transition that was passed as argument to the script: the handle should be valid at this context given it is valid during the passed transition.

def main() -> None: # Previous code... # Retrieve the context from the argument # Use the new API to get an enhanced context containing Windows 10 specific information ctx = WindowsContext(server.trace.context_before(args.transition))

The next step is to retrieve the file object and its path. To do so the WindowsContext class is exposing a handle method, which takes the handle value as a parameter and returns a Handle object. This Handle object can be used to retrieve the Object associated with it, and we want to make sure it is a FileObject:

def main() -> None: # Previous code... # Retrieve the handle from the argument at the given context file_object_handle = ctx.handle(args.handle) if file_object_handle is None: print(f"Couldn't find the handle 0x{args.handle:x}") return # Retrieve the object associated with the handle and check if it is an object of type 'File' file_object = file_object_handle.object() if not isinstance(file_object, FileObject): print(f"Object is of type '{file_object.type_name}' instead of 'File'") return print( f"File object found with path: {file_object.filename_with_device}, trying to find the path with the drive letter..." )

To better prepare for the next step, we will add a function get_path_with_drive_letter that will be completed in the next steps so that we don't have to change the main later.

def main() -> None: # Previous code... # Try to retrieve the path with the drive letter instead of the device name path_with_drive_letter = get_path_with_drive_letter(ctx, file_object) if path_with_drive_letter is None: print("Couldn't find the path with the drive letter") else: print(f"Path with the drive letter: {path_with_drive_letter}") def get_path_with_drive_letter( ctx: WindowsContext, file_object: FileObject ) -> Optional[str]: # Sort of implement the algorithm from the C++ code # - Retrieve the drive letters # - For each drive find the device associated with it # - If the device match the one in the path, we should use this drive letter in the path # TODO

 

Step 1: Retrieve the drive letters

The first step to implement from the C++ code in our get_path_with_drive_letter function is to retrieve the drive letters. For that, we will build a new function named get_drive_letters taking as an argument the context and returning an iterator of str (each element being a drive letter).

To do that we need to understand how GetLogicalDriveStrings is working. Here is some pseudo-Python code to describe its implementation:

def GetLogicalDriveStrings(): drive_map = retrieve_drive_map() for i in range(26): if drive_map & (1 << i): # The drive corresponding to the bit i is present # - Bit 0 present: A drive is present # - Bit 1 present: B drive is present # - ... pass # ...

As you can see, Windows is using what it is calling a "drive map", which is a 32-bit integer corresponding to a kind of bitfield where each bit corresponds to a drive. If the bit is set, the drive exists, if not the drive doesn't exist.

The question is how to retrieve the drive map from the memory. I will not go into the details, but if you reverse the code you can find a path that is easily accessible from the global variable PspHostSiloGlobals of type _ESERVERSILO_GLOBALS, PspHostSiloGlobals.ObSiloState.SystemDeviceMap->DriveMap.

So, let's start our function get_path_with_drive_letter by reading the global variable PspHostSiloGlobals. For that, we will need to build its address from the RVA of the data symbol PspHostSiloGlobals and the base address of the kernel in memory, which we can get using the kernel mapping. We will also use the newly introduced API Binary.get_exact_type to retrieve the type _ESERVERSILO_GLOBALS.

def get_drive_letters(ctx: WindowsContext) -> Iterator[str]: # Retrieve the kernel mapping and the types that will be used in this function ntoskrnl_mapping = ctx.kernel_mapping() EServerSiloGlobalsType = ntoskrnl_mapping.binary.exact_type("_ESERVERSILO_GLOBALS") # Retrieve the symbol `PspHostSiloGlobals` try: PspHostSiloGlobals = next( ntoskrnl_mapping.binary.data_symbols("^PspHostSiloGlobals$") ) except StopIteration: print("Symbol 'PspHostSiloGlobals' not found in the kernel") return # Dereference `PspHostSiloGlobals` as a `_ESERVERSILO_GLOBALS` psp_host_silo_globals: reven2.types.StructInstance = ctx.read( ntoskrnl_mapping.base_address + PspHostSiloGlobals.rva, EServerSiloGlobalsType, ) # TODO

From the global variable we will follow the path that I wrote above to access the drive map:

def get_drive_letters(ctx: WindowsContext) -> Iterator[str]: # Previous code... # Retrieve the drive map from `PspHostSiloGlobals.ObSiloState.SystemDeviceMap->DriveMap` drive_map = ( psp_host_silo_globals.field("ObSiloState") .read_struct() .field("SystemDeviceMap") .deref_struct() .field("DriveMap") .read_int() )

As you can see, we are capable of acceding fields of the structures, dereferencing pointers, etc., using this new API feature.

Now that we have the drive map, we can just mimick the pseudo-Python code of GetLogicalDriveStrings:

def get_drive_letters(ctx: WindowsContext) -> Iterator[str]: # Previous code... # Yield the drive letters accordingly to the bits inside the drive map # Each bit corresponding to a drive letter (bit 0 set = drive A present, etc) for i in range(26): if drive_map & (1 << i): yield chr (ord('A') + i)

Running this function will give you the list of Windows drives available at the specified context!

 

Step 2: Retrieve the device associated with a drive letter

Now that we have the drive letters, we need to find the device associated with each of them. The C++ code is using QueryDosDevice for that.

If we record a call of QueryDosDevice inside REVEN, we are able to see the calltree:

QueryDosDevice-calltree.png

If we look at the arguments of these functions we can see what is happening:

  • Using NtOpenDirectoryObject to open the directory object \??
  • Using NtOpenSymbolicLinkObject to open the symbolic link object C: (if the drive given to QueryDosDevice is C:) inside the directory \?? previously opened
  • Using NtQuerySymbolicLinkObject to retrieve the target of the symbolic link object C: (the target is the device, here \Device\HarddiskVolume2)
  • Closing the handles to the symbolic link and the directory

The \?? directory is a virtual directory that will point to an overlay between the local DOS devices directory and the global DOS devices directory of the current thread inside the namespace of the object manager. For this article we will assume that the drive letter we are looking for isn't opened only for the current thread, user, etc., but system wide, so we will only use the global DOS devices directory.

After retrieving the global DOS devices directory, we will need to list its child objects to find the symbolic link object C: (or another name if the drive isn't C) and when found, retrieve its target link.

 

Step 2.1: Retrieve the global DOS devices directory object

A simple path to retrieve the global DOS devices directory is to start from the current structure _EPROCESS that contains a field DeviceMap pointing to a structure of type _DEVICE_MAP, itself containing a pointer to the local and global DOS devices directories of the current process.

So let's start an implementation of a function named get_device_from_drive_letter taking in arguments a context and the driver letter and returning optionally the device associated with the drive letter.

def get_device_from_drive_letter( ctx: WindowsContext, drive_letter: str ) -> Optional[str]: # Retrieve the kernel mapping and the types that will be used in this function ntoskrnl_mapping = ctx.kernel_mapping() ObjectSymbolicLinkType = ntoskrnl_mapping.binary.exact_type("_OBJECT_SYMBOLIC_LINK") DeviceMapType = ntoskrnl_mapping.binary.exact_type("_DEVICE_MAP") # Retrieve the global DOS devices directory from `_EPROCESS.DeviceMap->GlobalDosDevicesDirectory` dos_devices_directory = ( ctx.get_eprocess() .field("DeviceMap") .read_ptr() .cast_inner(DeviceMapType) .assert_struct() .deref() .field("GlobalDosDevicesDirectory") .deref_struct() ) # TODO

As in the previous step, we are retrieving types from the kernel binary (the type _OBJECT_SYMBOLIC_LINK will be used in a later step), and following a path inside structures to find our data. A subtlety is that the field DeviceMap inside the structure _EPROCESS is typed void*, but as we know that this field is pointing to a _DEVICE_MAP we can use the method cast_inner to indicate it.

The type pointed by GlobalDosDevicesDirectory is an _OBJECT_DIRECTORY.

 

Step 2.2: Retrieve the child objects of a Directory object

Now that we have our global DOS devices directory, we need to iterate on its children to find the one we are interested in. For that we will provide a utility function named list_directory_object_entries taking in arguments a context and an instance of a structure _OBJECT_DIRECTORY and returning an iterator of tuples for each child object accompanied by its address.

If we look into the _OBJECT_DIRECTORY structure, listing its child objects is quite easy as it contains a hash map.

The field HashBuckets is an array of linked lists of type _OBJECT_DIRECTORY_ENTRY. Each _OBJECT_DIRECTORY_ENTRY has a pointer to a child object.

A possible implementation of list_directory_object_entries is:

def list_directory_object_entries( ctx: WindowsContext, directory: reven2.types.StructInstance ) -> Iterator[Tuple[reven2.address._AbstractAddress, Object]]: assert directory.type.name == "_OBJECT_DIRECTORY" # Read the hash map of entries inside the directory for bucket in directory.field("HashBuckets").read_array().assert_ptr(): if bucket.address == 0: continue # Take the first _OBJECT_DIRECTORY_ENTRY and go trough the entire linked list of entries # Using an `Optional` here as this variable will be used to store `None` when we are at the end of the linked list object_directory_entry: Optional[ reven2.types.StructInstance ] = bucket.assert_struct().deref() while object_directory_entry is not None: # For each entry, retrieve the object by computing its header address and giving it to the API obj_address = object_directory_entry.field("Object").read_ptr().address obj = Object.from_header( ctx, Object.header_address_from_object(ctx, obj_address) ) yield (obj_address, obj) # Follow the linked list by fetching the next entry inside the field `ChainLink` next_object_directory_entry_ptr = object_directory_entry.field( "ChainLink" ).read_ptr() if next_object_directory_entry_ptr.address == 0: object_directory_entry = None else: object_directory_entry = ( next_object_directory_entry_ptr.assert_struct().deref() )

As you can see in the code below, we are:

  • Iterating over each "bucket", checking if it is NULL or not
  • For each "bucket" we are following the linked list of _OBJECT_DIRECTORY_ENTRY using the field ChainLink
  • For each _OBJECT_DIRECTORY_ENTRY we are parsing the associated object using the newly introduced API class Object

Parsing the associated object will give us meaningful information (e.g the type of the object, and if it's a known object of the API, other information like the filename of a file object). This will give us the same type of object returned by the method Handle.object.

 

Step 2.3: Retrieve the device associated with a drive letter

Now that we are capable of iterating on our child objects, let's find our object of interest.

def get_device_from_drive_letter( ctx: WindowsContext, drive_letter: str ) -> Optional[str]: # Previous code... for obj_address, obj in list_directory_object_entries(ctx, dos_devices_directory): # We know that drives will be symbolic links inside the dos devices directory if obj.type_name != "SymbolicLink": continue # Retrieve the name of the object from the optional header (`_OBJECT_NAME_INFORMATION`) of the object obj_name_info_header = obj.raw_name_info_header if obj_name_info_header is None: continue obj_name = read_unicode_string(obj_name_info_header.field("Name").read_struct()) # If the name is matching our drive letter we found the symbolic link of the drive pointing to the target device # We just need to retrieve the target of the symbolic link and it's our device if obj_name != f"{drive_letter}:": continue # The object is the right one # TODO: Find the target device of this symbolic link

We know that our object of interest will:

  • Be a symbolic link to our device
  • Have the name C: for the drive C (the name of an object is given by an optional header _OBJECT_NAME_INFORMATION of the object retrievable using the property Object.raw_name_info_header)

If an object matches all the above conditions, we found our symbolic link corresponding to the drive and we just need to retrieve its target.

For that, we also know that an object of type SymbolicLink will have a body of type _OBJECT_SYMBOLIC_LINK. To make it simpler in this article, we will assume that the field LinkTarget is used and that the Callback isn't.

Here is the final code of get_device_from_drive_letter:

def get_device_from_drive_letter( ctx: WindowsContext, drive_letter: str ) -> Optional[str]: # Retrieve the kernel mapping and the types that will be used in this function ntoskrnl_mapping = ctx.kernel_mapping() ObjectSymbolicLinkType = ntoskrnl_mapping.binary.exact_type("_OBJECT_SYMBOLIC_LINK") DeviceMapType = ntoskrnl_mapping.binary.exact_type("_DEVICE_MAP") # Retrieve the global dos devices directory from `_EPROCESS.DeviceMap->GlobalDosDevicesDirectory` dos_devices_directory = ( ctx.get_eprocess() .field("DeviceMap") .read_ptr() .cast_inner(DeviceMapType) .assert_struct() .deref() .field("GlobalDosDevicesDirectory") .deref_struct() ) for obj_address, obj in list_directory_object_entries(ctx, dos_devices_directory): # We know that drives will be symbolic links inside the dos devices directory if obj.type_name != "SymbolicLink": continue # Retrieve the name of the object from the optional header (`_OBJECT_NAME_INFORMATION`) of the object obj_name_info_header = obj.raw_name_info_header if obj_name_info_header is None: continue obj_name = read_unicode_string(obj_name_info_header.field("Name").read_struct()) # If the name is matching our drive letter we found the symbolic link of the drive pointing to the target device # We just need to retrieve the target of the symbolic link and it's our device if obj_name != f"{drive_letter}:": continue # Read manually the body of the object as a `_OBJECT_SYMBOLIC_LINK` body: reven2.types.StructInstance = ctx.read( obj_address, ObjectSymbolicLinkType ) return read_unicode_string(body.field("LinkTarget").read_struct()) return None

read_unicode_string is a utility function provided by the new API taking as parameter an instance of a structure _UNICODE_STRING and returning a Python string.

 

Step 3: Integrate everything in get_path_with_drive_letter

Now that we have all the blocks we need, we can just assemble them into get_path_with_drive_letter:

def get_path_with_drive_letter( ctx: WindowsContext, file_object: FileObject ) -> Optional[str]: path = file_object.filename_with_device # Sort of implement the algorithm from the C++ code # - Retrieve the drive letters # - For each drive find the device associated with it # - If the device match the one in the path, we should use this drive letter in the path for drive_letter in get_drive_letters(ctx): device = get_device_from_drive_letter(ctx, drive_letter) if device is None: continue if path.startswith(device): return f"{drive_letter}:{path[len(device):]}" return None

When launching this script with the file object displayed at the very beginning of this article, we have:

File object found with path: \Device\HarddiskVolume2\reven\foo.exe, trying to find the path with the drive letter... Path with the drive letter: C:\reven\foo.exe

We successfuly retrieved the path with the drive letter!

 

Conclusion

The full script is downloadable here.

Currently the algorithm isn't optimal as we tried to follow closely what the C++ code was doing. For example, we are parsing multiple times the global DOS devices directory (once per drive). A better solution would have been to iterate on it once and cache it or build a hash map drive letter => device name.

In the future, following this article, we can see multiple possible improvements to the API:

  • Allow to directly parse SymbolicLink and Directory objects so that the iteration on the child objects or the retrieval of the target is done by the API and not by the user
  • Expose the address of the object inside the Object class and a way to parse its body directly with a user known type (as you have seen in this article, we needed to return the address of the object and not just the object to be able to parse its body manually using a Context.read)
  • Expose a new API to list the drives and their types (if you want to know the type of a drive you can look into the field DriveType in the same structure as the field DriveMap)
  • Expose a new API to allow the opening of objects by name (e.g to do: Object.open_by_name("\??\C:").target)
  • And more

You can try to optimize or improve this script if you want! If you are encountering any issue doing so, we are available to help you in our community if you are a free / professional / enterprise edition user, or also directly by email if you are an enterprise edition user.

Share

Categories

All articles
(99)
Case Studies
(2)
Chip Security
(29)
Corporate News
(11)
Expert Review
(3)
Mobile App & Software
(27)
Vulnerability Research
(35)

you might also be interested in

Vulnerability Research
Corporate News

Introducing esReverse 2024.01 — for Binary Security Analysis

4 min read
Edit by Hugues Thiebeauld • Mar 13, 2024
CopyRights eShard 2024.
All rights reserved
Privacy policy | Legal Notice