Inside the Windows I/O Manager : Deep dive into how read I/O requests are initialized.

How does initializing an I/O request look like internally ?

Introduction:

        If you are someone who periodically debug and reverse many different Windows programs, you may have noticed a common point shared among them despite they aren't related in any sense. This point is that the vast majority of them use at some stage a famous well documented Win32 API named ReadFile( ) exported by the module KERNELBASE.dll. For someone who doesn't do Windows native programming, he may think that ReadFile( ) is solely used to read data from a file as its name suggests (a file in its traditional meaning is a filesystem file). Despite this is not totally incorrect, it's not the full image. Before continuing, let me clarify what is the full definition of a file in the world of Windows. So, a file simply is an I/O endpoint to which end users can send input and from which they can receive output. Filesystem files are just one case, for example file system directories are also considered files since they are valid I/O endpoints; named pipes and mailslots are also files; even open instances of device objects are considered files (device objects and device stacks are beyond the scope of this article, if you want to understand these concepts checkout the official MSDN documentation). 

        Retrieving data from the majority of I/O endpoints is achieved through a documented API provided by the system which is as you might be guessing the same kernelbase!ReadFile( ) we mentioned before; there is also an async version named ReadFileEx( ) that relies on an APC (async procedure call) to notify the caller that the read operation has terminated. Each mentioned I/O endpoint is presented to user mode callers as file objects represented by the kernel by a predefined well documented data structure named nt!_FILE_OBJECT. So, it doesn't matter whether it's a device object, a named pipe or a mailslot; for user mode callers, it's a file object.

        Handling I/O requests is a task performed by the kernel executive layer which requires many steps and sub-tasks to be completed. Since the executive layer is divided into many managers; handling I/O operations is delegated to a separate manager to deal with it which is called the Input/Output Manager or for short the I/O manager. This manager knows exactly how to differentiate between sync/async operations, the target to which the request should be sent, what drivers are involved, whether it is possible to service request following the Fast I/O path and many other aspects that help it completes the request properly. So, as you see handling I/O operation is not a direct simple task as you might be guessing but it's the main task that any operating system should well implement to function properly.

        In this article. I am going to explain the different steps the I/O manager follows when initializing an I/O operation starting from determining the right target, choosing the right path and finally sending the request to be processed by the right drivers. To make it easier I choose to focus on read requests only since the major steps are common by all types of requests.

Determining the request Target:

        nt!NtReadFile( ) is the kernel counterpart of ntdll!NtReadFile( ) that is called internally by kernelbase!ReaFile( ) which is just a wrapper that internally forwards the rest of the work to the native undocumented layer. This routine takes the target of the read request as a HANDLE since its forbidden for user mode callers to access objects directly through their pointers. The first thing to do is getting the corresponding object to which the caller provided hFile parameter refers to by passing it to the object manager's defined routine nt!ObReferenceObjectByHandleWithTag( ) that performs the lookup operation in the current process handle table and get the corresponding nt!_HANDLE_TABLE_ENTRY that holds the starting memory address of the object main header nt!_OBJECT_HEADER which is sufficient to get the target object pointer which is of type nt!_FILE_OBJECT since nt!NtReadFile( ) operates on I/O endpoints. Only FILE_READ_ACCESS is requested in this step since the caller wants to read data from the provided target. For more information about how the handle table lookup is done checkout my previous article here.


        Getting the nt!_FILE_OBJECT pointer that corresponds to the hFile parameter provided by the caller is not enough to properly handle the read request. File objects aren't the real targets of I/O operations; they are just open instances to these real targets that are called device objects and represented by the kernel using the well documented data structure nt!_DEVICE_OBJECT. a single file object can represent an open instance of its related device object itself or to some sub item in its private namespace; however, any request sent to either the device object itself or to any of its sub items share the same target from the kernel perspective which is the device object. To get the device object to which the target file object retrieved in the previous is related the I/O Manager provides nt!IoGetRelatedDeviceObject( ). Determining the right device object to use is not straightforward as you might think because there is not only device object associated with a file object. As its declaration says, the nt!_FILE_OBJECT structure has a member named DeviceObject which is a direct pointer to a nt!_DEVICE_OBJECT structure; it has also a member named Vpb (Volume parameter block) which is pointer to a kernel data structure named nt!_VPB which is used only if the target file object is located in a mounted volume (it's filesystem file or directory). In such cases, the DeviceObject member of the file object points to the storage stack; to find the real target of its I/O requests which is the volume device object, you must get it from the DeviceObject member of the associated Vpb. The direct DeviceObject pointer found in the target file object corresponding nt!_FILE_OBJECT is used as the I/O target only if the following two conditions are true. First, the Vpb member or its DeviceObject member is NULL. Second, if the target file object corresponding nt!_FILE_OBJECT::Flags member has the flag FO_DIRECT_DEVICE_OPEN set but either the Vpb member or its DeviceObject member is NULL (in this case I am talking about the members of the DeviceObject member of the target file object corresponding nt!_FILE_OBJECT not the members of the file object itself). One last point to remember is that The Vpb associated with the file object has a priority over the one associated with the DeviceObject of the file object. 
        

        After determining which device object to use as the final target of the read request, we must walk its stack to get the top device object in order not to break the I/O Manager rule that says that I/O requests must be sent to the top of device stacks not to the middle of it. Each device object corresponding nt!_DEVICE_OBJECT structure has a member named AttachedDevice that represents the device object immediately above the current one in the stack. Using this member, we can easily get the top device object in the stack involved in handling I/O requests for the target file object. 


Validating User-Mode Parameters:

        As any other system service routine (kernel counterparts of syscall stubs implemented in NTDLL.dll), nt!NtReadFile( ) must validate the parameters the caller has provided if the request was issued by a user mode thread. To determine whether the request source is a user thread or not, nt!KeGetCurrentThread( ) is called to retrieve the nt!_ETHREAD that corresponds to the current thread (the caller that issued the current read request) then its PreviousMode member is checked; the value 0x1 is reserved for user mode. The IoStatusBlock parameter is the first one to validate; this parameter represents the output buffer where the final status of the read request is going to be written so this is why nt!NtReadFile( ) must ensure that both its two members Status and Information must be writeable.



        The next step is validating the memory range provided by the caller as the location where the read data must be written at the end. This range is delimited by two parameters provided by the caller too which are named Buffer and Length respectively. The entire must reside in the user address space and must be writable. To ensure writability, the system uses a smart trick; since page protection flags are set in a page-by-page basis and it's impossible to see a single page with more than one protection attribute; the system just checks whether the first byte of each page covered by the range is writable or not. One last point to explain is that the Length parameter cannot exceed certain limit in order not to cause an overflow in the result of the sum between it and the Buffer parameter to determine the last address in the range.



        After verifying that output parameters are valid user mode sub ranges and ensuring that all of them are writable, the next parameter that needs some special care is ApcRoutine that represents a user APC that is used as the completion mechanism when the read is to be done asynchronously. If the calling thread (the one that has issued the read request) is currently running in the context of a 32bit process (a WOW64 process) the ApcRoutine is encoded by setting its first bit to 1. Next, nt!NtReadFile( ) ensures that the target file object is not associated with an I/O completion port before moving forward; depending on its behavior when the caller passes a non-NULL ApcRoutine parameter and the target file object is associated with an I/O completion port, I can say that file I/O endpoints cannot rely on two asynchronous completion mechanisms at the same time. For more information about the internals of WOW64 checkout my previous article here. 



NOTE: the ApcRoutine parameter is only encoded if the target file object was opened for asynchronous I/O operations (the FO_SYNCHRONOUS_IO flag is not set in its corresponding nt!_FILE_OBJECT::Flags member). Otherwise, the ApcRoutine is not used by the I/O manager since the current thread is going to be put in a wait state until the read ends.

        Now, it's time to validate the alignment for various parameters. The first thing to ensure is that ByteOffset which is a pointer to a LARGE_INTEGER must be 4 bytes aligned; otherwise, a data misalignment exception is raised. Some device objects especially the ones related to the storage stack (disks and volumes) require some special alignment for the buffer used as the location where the read data should be written at the end, and also a defined I/O transfer ratio for callers to respect. These limitations are only necessary when the target file object doesn't support intermediate buffering (the FO_NO_INTERMEDIATE_BUFFERING flag in set in its corresponding nt!_FILE_OBJECT::Flags member) so the request cannot be satisfied from the cache, and it will reach the storage stack. There are three parameters that are affected by those limits; Buffer, Length and ByteOffset. Length and ByteOffset must respect the SectorSize member of the target device object determined previously so the caller can only read full sectors and cannot start the read at the middle of some sector. The Buffer member must be properly aligned as the Alignment member of the target device object says. Before moving to the next point, I need to clarify something related to the alignment of the ByteOffset parameter; as I said, this parameter is a pointer the 4 byte alignment mentioned before concerns the pointer itself not the offset value while the second SectorSize alignment concerns the offset value not the pointer itself. 



        Finally, at the end of this phase the Key parameter is checked to ensure that it's readable (this parameter is a pointer too). This parameter is not that important for handling read requests as it's not considered a main piece of data to complete this I/O operation.


Where should the read start ?:

        Before sending the request to the driver layer to be processed, it's necessary to determine the byte where the system should start reading from the target file object. This offset ranges from 0 (the first byte in the target file object) to the size of the target file object (the word size here can mean different things depending on the exact type of the file object). As I said in the previous section, nt!NtReadFile( ) takes a pointer to a LARGE_INTEGER named ByteOffset the caller can use it to force the I/O Manager to start the read at a specific hardcoded offset. Unfortunately, the caller provided starting offset parameter is not directly used as it is; the I/O Manager performs additional checks beyond the ones described in the previous section to determine whether to continue or fail the request at this stage. If the read request is meant to be processed asynchronously because the target file object was opened for asynchronous I/O (the FO_SYNCHRONOUS_IO is not set in its corresponding nt!_FILE_OBJECT::Flags member), the provided starting offset is allowed to be NULL only if the target is a named pipe or a mailslot; otherwise, the I/O Manager fails the request immediately returning STATUS_INVALID_PARAMETER. 


        For synchronous reads (the target file object has the FO_SYNCHRONOUS_IO flag set), the system will use either the current position preserved by the I/O Manager per file object (the nt!_FILE_OBJECT::CurrentByteOffset member) if and only if the caller provided ByteOffset parameter is NULL or its LowPart is set to the predefined value FILE_USE_FILE_POINTER_POSITION while its HighPart is set to 0xFFFFFFFF; or the ByteOffset as it is otherwise.


NOTE: the last mentioned cases regarding ByteOffset works the same way when the target file object was opened for asynchronous I/O and it's either a named pipe or a mailslot (Named pipes and mailslots are beyond the scope of this article).

Synchronizing requests (Sync I/O handling):

        As I said in the previous sections, the target file object may have been opened for either synchronous or asynchronous I/O depending on whether the FO_SYNCHRONOUS_IO flag is set in its corresponding nt!_FILE_OBJECT::Flags structure. For file objects opened for synchronous I/O, the system implements some sort of mutual exclusion between threads trying to perform an operation on the file object if it happened that two or more try it at the same exact time. To ensure that synchronization between requests, the I/O Manager has reserved 3 among the data members of the nt!_FILE_OBJECT structure. The first one is a ULONG named Busy; as its name suggests, it determines whether there is an I/O operation that was requested before and it still being processed. The I/O Manager reserves 2 distinct values for this member; the first one is 0x0 that indicates that the target file object is not currently busy because of an incomplete synchronous I/O request; the second one is obviously 0x1 that indicates the opposite of the previous one. As a consequence of this design pattern, the I/O Manager must check the value of the Busy member object before moving on because the file object might be busy at that moment. To prevent a race condition that might appear between the time of the check and the time of setting the Busy member to 0x1, the I/O Manager relies on nt!_InterlockedExchange( ) that atomically gets the current value of the Busy member and then sets it to 0x1 to defer other requests processing until the current one gets completed.


        The next member that is also involved in synchronizing I/O requests for file objects opened for synchronous I/O only is a ULONG too named Waiters. This member is used to track the real-time number of pending I/O requests that are temporarily blocked because the target file object is currently busy (the Busy member is set to 0x1). Whenever the I/O Manager decides to defer an I/O request targeted at a busy file object it increments the Waiters member by one for the new pended request. Again, a race condition might happen if two or more requests arrived at the same time which may lead to the Waiters member being incremented by one not by two as it should be. To prevent it, the I/O Manager relies on nt!_InterlockedAdd( ) that adds one to the Waiters member in an atomic safe manner.


        The last and the most important member involved in this task is an event object named Lock. It is the dispatcher object that is used to put threads that initiate synchronous I/O while the target file object is busy in a wait state. The wait is performed multi times until the Busy member becomes 0x0 indicating that the file object is not busy anymore; so, the wait is not considered satisfied just because the Lock event object becomes signaled; the reason for that is because there is a possibility that some other thread may get access to the file object because the Busy member was 0x0 before the current one gets a chance to set the file object to the busy state. 


        If the target file object was opened for alertable I/O (the FILE_SYNCHRONOUS_IO_ALERT flag was set when it's opened using nt!NtCreateFile( )), the wait may terminate early due to an alert or to service a user APC (nt!KeWaitForSingleObject( ) returned either STATUS_ALERT or STATUS_USER_APC) without truly giving ownership of the file object to the current thread which was waiting. Depending on my reversing, the next step if the wait was interrupted is checking whether the file object becomes free (its Busy member is switched to 0x0); if yes, the I/O Manager just sets the Lock event object to allow other waiters to proceed if there are any (the Waiters member of the target file object is greater than 0x0). Skipping this final step may lead to a deadlock leaving all the other threads waiting for the file object waiting forever.


        If otherwise, the wait was satisfied and the Lock event object becomes signaled and at the same time no other thread gets access before the current one, the ownership of the file object is given to it, the Waiters member is decremented by one indicating that one thread is removed from the wait state and gets exclusive use of the file object. 
 

NOTE: This whole synchronization mechanism is totally implemented in one I/O Manager internal routine named nt!IopAcquireFileObjectLock( ) that has the following signature (the output parameter named a4 is a pointer to a BOOLEAN that is set to TRUE only if the wait was interrupted by an alert or to deliver a user APC). Otherwise, it remains FALSE.


The Fast I/O path:

        For synchronous read requests only (the target file object was opened for synchronous I/O and the FO_SYNCHRONOUS_IO flag is set in its corresponding nt!_FILE_OBJECT::Flags), the I/O Manager has two options to handle the request. Each device object holds a pointer to the driver object that is responsible for handling all requests targeted to it in its corresponding nt!_DEVICE_OBJECT::DriverObject member. Some driver objects support a concept named Fast I/O which is a method defined by the I/O Manager used mainly by file system drivers (it may be used by other types of drivers) to either read or a write data to some target file while avoiding the overhead of sending the request down to the storage stack. Fast I/O is implemented by the kernel as a function table data structure named nt!_FAST_IO_DISPATCH; this structure holds pointers to various driver defined routines each one having its own purpose and use. To stay focusing on read requests only, I am not going to mention all the existing routines. For read requests, the I/O Manager reserves a Fast I/O routine from the target driver's Fast I/O dispatch table named FastIoRead( ). The implementation of the routine is left to the driver developer, though file system drivers rely on another executive manager named the Cache Manager for Fast I/O (the cache manager is beyond the scope of this article). One little point to mention about the Fast I/O path is that the I/O Manager determines whether to follow it or not by checking the driver associated with the target device object which is the highest one in the involved stack; if that one's associated driver doesn't support Fast I/O at all (it's corresponding nt!_DRIVER_OBJECT::FastIoDispatch member is NULL), the Fast I/O path is totally disabled for the entire stack. So, be careful when deciding to attach a filter driver at the top of an existing stack, you must ensure whether your new driver should support Fast I/O or not.  

        Even if the driver object responsible for handling the current read request for the target file object provided by the caller supports Fast I/O, the system doesn't immediately delegate the handling of the request to this driver's FastIoRead( ) routine. Despite this article is not meant to be a detailed explanation of the internals of the Cache Manager, I must mention some points related to it in this section since Fast I/O was added as a meant to speed up file system drivers work and these drivers rely on the Cache Manager for this path. Each file object corresponding nt!_FILE_OBJECT has two special members reserved for the Cache Manager; the first one is named SharedCacheMap and it's used by the cached manager to track the current memory locations where the file represented by the file object structure is currently cached (this is the simplest way to explain the role of this member); the second member is named PrivateCacheMap and it's used by the Cache Manager as helper for read ahead and write behind operations (these two are beyond the scope of this article). This PrivateCacheMap member has a second usefulness as it is checked by the I/O Manager to determine whether it is currently possible to follow the Fast I/O path or it should move directly to the traditional IRP path (explained in the next section). Having a NULL PrivateCacheMap member directs the I/O Manager to the slow IRP path directly. Otherwise, the corresponding driver's FastIoRead( ) is invoked immediately to try performing the read itself. If the FastIoRead( ) returned FALSE (it failed without even starting the read) or the read request was terminated but with an error status code different than STATUS_END_OF_FILE and STATUS_BUFFER_OVERFLOW, the I/O Manager doesn't return to the caller directly, it moves to the second option which is trying the read following the long way (building an IRP, filling it in, then send it to the target device stack for processing). Otherwise, the request is considered erroneous since those two status codes indicate that there is a problem regarding some parameters, either the Buffer provided by the caller was too small or the request number of bytes is larger than the target current size; in both cases, it is a huge waste of processor cycles and memory space to retry the read the other way without first adjusting the parameters.



NOTE: the Fast I/O is totally bypassed if the target file object was opened for asynchronous I/O (the FO_SYNCHRONOUS is not set in its corresponding nt!_FILE_OBJECT::Flags member) no matter whether its PrivateCacheMap member is NULL or not. 

Building the I/O request Packet:

        Before diving into how the I/O Manager allocates and fills in the request packet for the current read request, let me briefly describe what I/O request packets (IRPs for short) are and how they are filled in and used. an I/O request packet is data structure holding all the necessary pieces of information that let the I/O Manager and the underlying drivers identify and process the request correctly; it is represented by the kernel by a predefined well documented data structure named nt!_IRP. Each IRP is divided into two parts, the fixed header part which is the nt!_IRP structure followed by one or I/O stack locations. I/O stack locations are also kernel data structures defined as nt!_IO_STACK_LOCATION, they hold the request parameters except the output buffer (if it's an output request) which is preserved in the IRP header itself. The I/O Manager sets two strict rules regarding I/O stack locations to ensure the proper processing of I/O requests; the first one is that each driver in the target stack
should have an associated I/O stack location so it can access the request parameters; the second rule directs each driver in the stack except the last one to fill in the stack location of next driver below it the stack. below is the declaration of the nt!_IRP structure, the most important members are highlighted.

    
        Now, let's dive into how exactly the IRP is filled in. The first step is obviously allocating a kernel non- paged pool memory buffer large enough to hold the new IRP's fixed header part and all of its stack locations. To know the exact number of stack locations the I/O Manager should associate with new IRP, the target device object (the top in the stack) corresponding nt!_DEVICE_OBJECT::StackSize member is consulted. This member holds the number of device objects attached to the target stack. Since the header part has a fixed size, the only information needed before allocating the IRP is the number of stack locations which is then passed to nt!IoAllocateIrp( ) that allocates a new uninitialized IRP (both the header and the stack locations left empty). If the allocation was successful, the next step is filling in the new IRP's members starting by its nt!_IRP::Tail::Overlay union member that contains two major pieces of information; the first one is named OriginlaFileObject and it's set to the target file object corresponding nt!_FILE_OBJECT pointer; while the second preserves the initiating thread corresponding nt!_ETHREAD pointer (retrieved using nt!KeGetCurrentThread( )). The request origin that indicates whether it was initiated by a user thread or a kernel one is saved in the RequestorMode member of the nt!_IRP structure. the next member to fill in named UserEvent and it's set to the caller provided hEvent parameter corresponding nt!_KEVENT structure that is retrieved using nt!ObReferenceObjectByHandleWithTag( ). The IoStatusBlock output parameter is used as the UserIoSb member of the new IRP. The ApcRoutine and ApcContext parameters are used to fill the Overlay::AsynchronousParameters member. All of Those members are set the same way no matter what category the intended request fell into. The next step the I/O Manager jumps to is filling the stack location reserved for the target device object (the top one in the target stack); Since it's named a device stack, the system traverses it backward starting from the last added entry to the oldest one. Because of that, the stack location that corresponds to the first device in the stack is the last one (in terms of order relative to the start address of the allocated IRP structure); each IRP has a member named CurrentStackLocation that is used to track the start memory address of the stack location reserved for the current device object owning the IRP. This member is first set to the end of the allocated IRP buffer (the address after the end of the last stack location) and it gets decremented so it moves from location to another while IRP is moving to lower levels. After explaining those points about the CurrentStackLocation member and how it is used and what initial value it gets, it becomes obvious where this -1 comes from in the decompiled code. Initially only two members of the current stack location are filled in are FileObject which is the same as the IRP's OriginalFileObject member as both are set to the target file object corresponding nt!_FILE_OBEJCT pointer; the second member if the MajorFunction that identifies the request type and it's set to the predefined macro IRP_MJ_READ reserved for read requests.



        The next step is setting up the output buffer for the read request which depends on what transfer method the target device object supports. To determine which method to use, the I/O Manager checks the Flags member of the target device object corresponding nt!_DEVICE_OBJECT::Flags member; the first possible method is the buffered one in which the system allocates a non-paged system buffer equal in size to the caller provided output buffer (its size is in the Length parameter), then it saves this buffer in the new IRP's corresponding nt!_IRP::AssociatedIrp::SystemBuffer member which is used by the underlying drivers as the output buffer into which they should write data. The Buffer parameter provided by the caller is not ignored, the I/O Manager saves it in the IRP's corresponding  UserBuffer member (when the IRP gets completed, the I/O Manager will copy the data from the system buffer to the caller provided one in the context of the initiating thread). The last step is setting some flags in the IRP's Flags member; the first one is IRP_INPUT_OPERATION which is self-explanatory, IRP_DEALLOCATE_BUFFER is also set to instruct the I/O Manager to deallocate the system buffer that it previously allocated after copying the data from it to the caller provided buffer (letting this buffer will cause a non-paged memory leak that may degrade system performance), and finally IRP_BUFFERED_IO is set which is also self-explanatory (the target to which this IRP is going to be sent expects to find the output buffer at the corresponding nt!_IRP::AssociatedIrp::SystemBuffer). One final point to mention is that the previous steps (allocating a system buffer, saving it in the IRP and so on) are only required if the caller provided Length parameter is greater than 0; otherwise, there is no need to do any of them and the system only sets the IRP_INPUT_OPERATION and IRP_BUFFERED_IO flags in the IRP. 


         The next possible transfer method is the direct one in which the caller provided output range which is delimited by the Buffer and the Length parameters is locked in memory (it can no longer be paged out until unlocked) and the I/O Manager builds a memory descriptor list (nt!_MDL) that will represent the range physical memory layout (MDLs are beyond the scope of this article, for more information checkout the official MSDN documentation). To allocate a new MDL and initialize it nt!NtReadFile( ) calls an I/O Manager defined and well documented routine named nt!IoAllocateMdl( ); this routine takes the caller provided Buffer parameter (the range start address), the caller provided Length parameter (the range size in bytes) and an optional IRP parameter to associate the new MDL with (each IRP has a member named MdlChain that is used by the system to chain multi MDLs in a linked list and this is how the new one gets attached to the current IRP, it can either be inserted at the tail or the head of this list). nt!IoAllocateMdl( ) only initializes the members of the nt!_MDL structure, but it doesn't lock the provided range itself. To lock the range and construct the PFN (Page frame number) array that immediately follows the nt!_MDL structure in memory and that stores the physical page numbers that correspond to each virtual page included in the range that represents the output buffer, the system calls nt!MmProbeAndLockPages( ) passing the MDL itself and IoWriteAccess as the desired access modifier (IoWriteAccess is used because the locked range needs to be writable so the underlying drivers can write output data to it). One point to mention is that as the previous transfer method, if the Length parameter is 0 there is no need to allocate an MDL and lock it.


        If the target device object doesn't support none of the previous two methods, the caller provided Buffer parameter is used as it is; the system sets it as the new IRP's corresponding nt!_IRP::UserBuffer member. This method is not safe until the read request is guaranteed to be processed synchronously and no driver in the target stack pends it; otherwise, accessing the caller provided output buffer in the context of an arbitrary thread may lead to serious memory errors. 


        One final step before moving to filling-in the rest parameters in the first stack location, the system sets the IRP_NOCACHE flag in the IRP's Flags member if the target file object doesn't support intermediate buffering as indicated by its corresponding Flags member (the FO_NO_INTERMEDIATE_BUFFERING flags is set). IRP_INPUT_OPERATION is set also for the last two described transfer methods and depending on the pseudo code another flag IRP_DEFER_IO_COMPLETION is also set for all three methods.

        
        At the end, the Length, key and starting offset parameters are also saved in the stack location reserved for the first device object in the target stack, then the IRP is passed with other required parameters such as the target device object and file object to an internal routine named nt!IopSynchronousServiceTail( ) that eventually calls the documented nt!IoCallDriver( ) that passes the IRP to the DispatchRead( ) associated with the first driver in the stack.


Conclusion:

        That is all for now, I hope you have learned something. Despite being semi-documented, there are no example pseudo codes demonstrating how the initialization is done internally; I hope reading this article gave you an idea of how the I/O Manager treats I/O requests. 
Stay tuned, other posts about various topics will be published from time to time.


If you find my posts interesting, don't forget to follow me on LinkedIn and GitHub.

Comments