Duplicating Handles - Step by Step guide
What happens behind the scenes when you call DuplicateHandle( ) ?
Caution:
This article is based on my own reverse engineering and knowledge about the internals of the object manager. Since it's done solely based on static analysis, it may be prone to gaps or errors. If you find any mistakes or gaps, please don't hesitate to leave me a comment.
Introduction:
As you might know, user-mode programs cannot access an object through its pointer and directly manipulate it this way, instead the operating system or more precisely the object manager which is one of the most important kernel executive managers implements a safe mechanism for user-mode callers to access objects that are managed by the kernel to prevent them from directly manipulating their internal data structures and also to add a security boundary so an object opened by process A cannot be used by process B until this same process opens it.
To achieve its goal, the Object Manager relies on an opaque entity called HANDLE which is a normal number that is enforced to be a multiple of 4. Each HANDLE has an associated data structure declared by the system as nt!_HANDLE_TABLE_ENTRY that mainly holds the start address where the target object's main header is located, whether the object is inheritable or not, a bitmask including all rights that are granted to the corresponding handle and finally the value of the HANDLE that corresponds to the next entry which is only used internally to track the state of another data structure used by the Object Manager which is the handle table (in the kernel terminology its sometimes called the Object Table) which is declared as nt!_HANDLE_TABLE. I won't go deeper in the concept of handle tables as they are deeply explained in one of my previous articles here, but for the purpose of this article I will briefly explain what handle tables are and for what they are used.
Handle tables are kernel only multi-level containers that store entries of type nt!_HANDLE_TABLE_ENTRY. Each active process in the system has its own separate table and this is how isolation is ensured. The number of entries each table can hold is not infinite but at the same time it's too large to be exceeded. HANDLEs are divided into user and kernel handles and to prevent kernel ones from being accessible to user-mode programs, the system encodes kernel handles using a predefined mask 0xFFFFFFFF80000000 and stores their corresponding entries in a global handle table known as the kernel handle table and it's declared as nt!ObpKernelHandleTable.
To enhance the system performance, the object manager supports also a special type of HANDLEs called pseudo-handles. Those handles are not real HANDLEs like the ones described before and do not have corresponding entries stored in some handle table, they are just place holders that the system must handle them differently. The value returned when you call GetCurrentProcess( ) is always (HANDLE)-1 which a pseudo-handle that if passed to a function that needs a handle to a process, directs the system to operate on the current process. The same logic applies for the value returned when you call GetCurrentThread( ). Standard output, input and error handles can also be considered as pseudo-handles but the only difference is that these ones get replaced with their real counterparts before reaching the kernel, the system gets them from the current process environment block nt!_PEB.
In this article, I will try to deeply dive into how the system duplicates a handle and what steps it must follow to make the new handle usable in the context of the target process. I will demonstrate the entire call-flow and the details of each step.
Deep Dive:
Any user mode caller that wants to create a new handle that is considered a duplicated copy of the first that points to the same object will obviously call a predefined and a well-documented WIN32 Api which is DuplicateHandle( ) which is imported from the famous kernelbase.dll module. This Api is considered the entry point to the HANDLE duplication path; it takes many parameters starting from both the source and the target process handles representing in which context the handle the caller wants to duplicate is valid and in which context should the new handle be valid respectively. It takes also a BOOLEAN that determines whether the duplicate HANDLE should be inheritable or not and a last parameter that directs the system to some special behaviors during the duplication like whether the new HANDLE should inherit the attributes of the original HANDLE, whether the granted rights to new HANDLE should be the same as the original one and finally whether the system must close the original HANDLE so after the duplication is done successfully the source process can no longer use this HANDLE to access the corresponding object. Despite DuplicateHandle( ) takes many parameters it doesn't do anything other than mapping pseudo standard handles to their corresponding real HANDLEs extracted from the current process environment block even if the source process HANDLE provided by the caller doesn't refer to the current process, then it delegates the rest to the native undocumented layer calling ntdll!NtDuplicateObject( ) passing the same parameters as they are, only bInheritHandle is changed to OBJ_INHERIT if it is TRUE, otherwise it is kept as 0x0.
Since ntdll!NtDuplicateObject( ) is just a traditional syscall stub I will directly jump to its kernel counterpart which is nt!NtDuplicateObject( ) that does the actual work. At the beginning and as any other system service, nt!NtDuplicateObject( ) can handle requests originated from either user or kernel callers; in the case of user mode requests, it must ensure that output parameters are valid memory addresses that will not cause an access violation and also ensure that they are writeable. The next step is retrieving the corresponding objects to which both the source and the target process HANDLEs refer using an Object Manager documented routine which is nt!ObReferenceObjectByHandleWithTag( ) setting the desired access to PROCESS_DUP_HANDLE as this is the only required right to achieve the duplication. The final major step is delegating the rest of the work to the Object Manager calling an undocumented routine named nt!ObDuplicateObject( ) passing the previously retrieved source and target process corresponding nt!_EPROCESS structures and the same parameters passed to nt!NtDuplicateObject( ) adding only the current thread's Previous Mode that represents the request origin. Of course, the system needs to prevent reference leaks so it must dereference the source and the target process objects using nt!ObDereferenceObject( ) in the cleanup phase.
nt!ObDuplicateObject( ) is considered the main kernel routine involved when someone tries to duplicate a HANDLE. Of course, before starting this it needs to ensure that the source process is not currently terminating or already terminated; each process has an associated rundown protection nt!_EPROCESS::RundownProtect that prevent the system from freeing its resources while it's in use by some entity. I will not deeply explain what rundown protection is and how it is used because it is well documented here, but as a summary these are special references that gets incremented before accessing the object then decremented after finishing using it, the owner of the object must wait until the rundown reference count reaches 0 to free it. The owner can also rundown the object so no new users can acquire it rundown reference anymore, but old references aren't released automatically. nt!ObDuplicateObject( ) tries to acquire the rundown protection associated with the source process; if the acquisition failed, STATUS_PROCESS_IS_TERMINATING is returned immediately to the caller indicating that the source process is currently terminating and it may be unsafe to access its handle table. After acquiring the rundown protection of the source process it's now safe to access its handle table, but before accessing it we must ensure that it's not NULL, otherwise STATUS_PROCESS_IS_TERMINATING is returned immediately to the caller.
The next step after validating the handle table is retrieving the corresponding object that the caller provided source handle (the handle to duplicate) refers to, the granted rights assigned to it and finally the Attributes (nt!_OBJ_* flags) used when it is opened. This task is delegated to another kernel routine named nt!ObpReferenceProcessObjectByHandleWithTag( ) that takes the source process corresponding nt!_EPROCESS, the caller provided source HANDLE and the source process associated handle table retrieved and validated previously. If the HANDLE is a pseudo-handle that refers to the current process (source handle = (HANDLE)-1), the returned object is the source process corresponding nt!_EPROCESS as it is and the granted rights mask is hardcoded to PROCESS_ALL_ACCESS. The next case is obviously when the source handle is set to (HANDLE)-2 which is also a pseudo-handle referring to the current thread; in this case the system hardcodes also the granted rights mask as THREAD_ALL_ACCESS and the returned object pointer is the nt!_ETHREAD the represents the current thread retrieved through nt!KeGetCurrentThread( ). Before jumping to the next case, let me clarify something that might be a little bit confusing; even if the current thread is not one of the source process threads, it is returned to the caller when the source handle is a pseudo one representing the current thread since this information is not preserved per process but globally through the current processor control block nt!_KPRCB. The last case that this routine takes care of is when the source handle provided by the caller is not a pseudo one. Before searching for its corresponding nt!_HANDLE_TABLE_ENTRY in the previously retrieved source process associated handle table, the system checks whether the source handle represents a kernel one using the predefined mask 0xffffffff80000000 then using the same mask, it applies a XOR between it and the source handle to convert the handle value to a valid one that it can use in the lookup phase. nt!ObpReferenceProcessObjectByHandleWithTag( ) is also directed to use the global kernel handle table nt!ObpKernelHandleTable if the previous check indicates that the source handle is a kernel one. After that, the handle value is validated by checking whether it is a multiple of 0x400 (all handle values that are multiples of 0x400 are considered invalid by the system for some reason I am not aware of); if the value is valid, nt!ExpLookupHandleTableEntry( ) is called passing both the target handle table and the HANDLE to lookup its corresponding nt!_HANDLE_TABLE_ENTRY.
As I said at the beginning, I will not dive into the details of how handle tables are structured internally so I will just say that this routine firstly ensures that the source handle value already has an associated entry by comparing it with the target handle table's corresponding nt!_HANDLE_TABLE::NextHandleNeedingPool member that represents the value of the first handle that doesn't currently have an associated entry allocated for it. After validating the HANDLE, it's used as an index depending on the current table level to get the correct nt!_HANDLE_TABLE_ENTRY that corresponds to it and finally return it to the caller. From this entry, the system gets the start address of the object body, the mask representing the granted rights to the source handle and finally the attributes used to open it. For more information about the details of handle tables, read my previous article here.
After extracting the required data we need from the from the source handle's corresponding nt!_HANDLE_TABLE_ENTRY, nt!ObDuplicateObject( ) checks whether the target process is NULL; it ends up equal to NULL either because the hTargetProcess parameter was set to NULL or to a garbage HANDLE that doesn't point to anything or does point to an object of another type. If it is NULL, no duplication happens, but if the dwOptions parameter includes DUPLICATE_CLOSE_SOURCE the system attaches to the source process to allow the current thread to access its handle table, calls nt!NtClose( ) to close the source handle and makes it unusable in the context of the source process, then finally detach the current thread from the source so it is attached back to its original process and returns.
If the target process is not NULL, the system tries first to acquire its associated rundown protection to ensure that it's not terminating or already terminated. If the acquisition failed, the system immediately considers the target process as a terminated one and returns STATUS_PROCESS_IS_TERMINATING to the caller. As it did with the source process, the handle table of the target process is also checked whether it's NULL or not to decide the next step. If it is NULL, the target process is considered terminating and STATUS_PROCESS_IS_TERMINATING is returned immediately. Before jumping to the next phase where the source HANDLE gets actually duplicated, I see it a mandatory to clarify an important point; whenever nt!ObDuplicateObject( ) reaches a failure case where it must return immediately to its caller, it checks whether DUPLICATE_CLOSE_SOURCE is included in the dwOptions parameter to decide whether to close the source or not; for example, if the rundown protection acquisition failed, the current thread is attached to the source process, nt!NtClose( ) is called to close the source HANDLE then finally the current thread is detached back to its original process.
After ensuring that the target process is still running and that is associated handle table is still valid, the system checks for different flags in the dwOptions parameter and depending on the presence of each one it determines what to do next. It starts first by checking DUPLICATE_SAME_ACCESS; if it is present, the same mask extracted from the nt!_HANDLE_TABLE_ENTRY that corresponds to the source HANDLE that we have got previously using nt!ObpReferenceProcessObjectByHandleWithTag( ) is used next in the access ignoring the desired access mask requested by the caller. The next flag that is checked is DUPLICATE_SAME_ATTRIBUTES that directs the system to either take the bitmask that represents the attributes that are used to open/create the source HANDLE only, or use an OR combination between them and the attributes requested by the caller for the duplicate HANDLE. It is interesting that the system combines both the requested and the old attributes if DUPLICATE_SAME_ATTRIBUTES is not present in the dwOptions parameter and doesn't only use the requested ones which seems more logical.
The next step is validating the access mask before passing to the access check phase. nt!ObDuplicateObject( ) starts first by checking whether the final mask representing the rights to check contains any generic rights (GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE or GENERIC_ALL) then mapping them to object specific ones using nt!RtlMapGenericMask( ) and the corresponding nt!_OBJECT_TYPE of the object that the source HANDLE refers to (we have got the start address of the object body previously using nt!ObpReferenceProcessObjectByHandleWithTag( )). Next, all of the bits that correspond to invalid rights that are not compatible with the target object are masked off; to achieve this, any bit not set in the corresponding nt!_OBJECT_TYPE::ValidAccessMask member are unset in the access mask. Finally, the system compares the final access mask we have got after all the previous modifications with the one found in the source HANDLE's corresponding nt!_HANDLE_TABLE_ENTRY to verify whether it is possible to skip the access check phase and pass directly to the next step. If all rights in the final mask are included in the granted access mask associated with the source HANDLE, there is no need to check them again. Otherwise, if there are some rights that are not already granted, the system moves to the access check phase starting first by creating an access state which is a required input to this phase then delegates the rest to nt!ObGrantAccess( ) which is just an object manager wrapper that internally calls nt!SeAccessCheck( ) which is the security reference monitor entry point to the access check. For more details about how the system implements the access check and what steps and routines are involved during it, my previous article here is a good resource.
If all rights in the final bitmask are granted to the caller, the next step is updating the target object's optional headers especially the nt!_OBJECT_HEADER_HANDLE_INFO that tracks how many handles referring to the object each process is currently having through a data structure called a handle count database. You can find more details about optional headers here and here. Finally after applying the required modifications that ensure that the new duplicate HANDLE remains correctly tracked, the next step is closing the source HANDLE if of course DUPLICATE_CLOSE_SOURCE is included in the dwOptions parameter by calling nt!NtClose( ) after attaching the current thread to the source process so its handle table becomes accessible in the current context. The last step before creating/reserving a new handle table entry for the duplicate HANDLE is calling nt!ObpPreInterceptHandleDuplicate( ) that sequentially invokes all pre-operation callbacks currently pushed to the target object's corresponding type (nt!_OBJECT_TYPE) callback list if it supports operation callbacks.
To reserve a new nt!_HANDLE_TABLE_ENTRY for the duplicate HANDLE from the target process' associated handle table, the system calls nt!ExpAllocateHandleTableEntry( ) that starts by acquiring the handle table's push lock for exclusive to prevent other threads in the system from modifying the table at the same time. Next, this routine checks whether the FirstFreeHandle member in the corresponding nt!_HANDLE_TABLE that represents the target handle table is 0x0 or not to determine if there are some unused allocated entries so it can escape the overhead of allocating new ones. If the previous check has indicated that the system must allocate new entries because the table is currently full, it calls nt!ExpAllocateHandleTableEntrySlow( ) that performs the desired task and finally updates the FirstFreeHandle member setting it to the value of the HANDLE that corresponds to the first unused allocated entry in the table. Otherwise, if the table is not full, the FirstFreeHandle member is used as it is as the value of the duplicate HANDLE that is going to be returned to the caller at the end. Before returning to its caller, nt!ExpAllocateHandleTableEntry( ) calls nt!ExpLookupHandleTableEntry( ) to gets the associated nt!_HANDLE_TABLE_ENTRY which is going to be filled next by nt!ObDuplicateObject( ) and increments the total number of HANDLEs in the table then releases its push lock so other threads can access it. For more details about the layout of handle tables checkout my previous article here.
As I said before, handle table entries declared as nt!_HANDLE_TABLE_ENTRY hold two main pieces of information. The first 8 bytes hold a combination of the start address of the object main header nt!_OBJECT_HEADER; since object headers are 8 bytes aligned, the lower 3 bits are free to be used to store some information, and the system uses them to store some of the attributes (OBJ_* macros) we previously mentioned (depending on the documented macros 3 bits can only hold OBJ_INHERIT that indicates whether child processes created by the target process can inherit the HANDLE or not). The next 8 bytes are set the access mask that represents which rights are granted through the corresponding HANDLE. At this step, the duplicate HANDLE becomes ready to be used by any thread running in the context of the target process; before returning some cleanup is performed mainly the previously created access state is deleted to prevent a memory leak. The last step before returning the HANDLE to the caller is encoding it as a kernel one by ORing it with the predefined mask 0xFFFFFFFF80000000 if the caller has requested a kernel HANDLE which is indicated by setting OBJ_KERNEL in the requested attributes while the request originated from kernel mode. The following flow chart represents the most important involved routines in the HANDLE duplication starting from user mode to the undocumented part of the object manager.
Conclusion:
That is all for now, It was a really good experience to reverse the entire flow from A to Z, I hope you have learned something from this article. Stay tuned, other posts about various topics will be published from time to time.
.jpg)
Comments
Post a Comment