深入分析Windows API - LoadLibrary 的内部实现Part.1

丝绸之路 二进制安全 2019年3月27日发布
Favorite收藏

导语:在本文中,我们将讨论Windows系统中最重要的一个 API——LoadLibrary。

在本文中,我们将讨论Windows系统中最重要的(如果不是最重要的,那么也是最众所周知的)一个 API——LoadLibrary。进行这项研究的动机是来自几周前我正在研究的一个项目,我正在编写一个DLL的反射加载器而我无法使其正常工作(最后发现它和reloc的一些东西有关 ),所以,我认为找到我的错误的最好的方法是搞清楚Windows处理加载库的过程。

免责声明!

我将重点关注调用LoadLibrary时执行的内核代码。用户层的所有内容我都会进行阐述。另一方面,我不会进入内核中的每个调用/指令,你要明白,内核里有很多很多的代码。我将重点关注我认为最重要的函数和结构。

LoadLibrary!

为了便于研究,我将使用下面这个代码段作为开始:

int WinMain(...) {
    HMODULE hHandle = LoadLibraryW(L"kerberos.dll");
    return 0;
}

我使用了Unicode函数,因为内核只适用于这些类型的字符串,并且这为我做研究节省了一些时间😁。

LoadLibraryW执行时发生的第一件事是执行被重定向到了KernelBase.dll这个DLL中(这与Windows自Windows 7以来所采用的新MinWin内核有关。点击这里查看更多信息),在KernelBase内部将被调用的第一个函数是RtlInitUnicodeStringEx,用来获取UNICODE_STRING(这是一个结构体而不是字符串!!),这个是我们传递给LoadLibrary的参数。接下来,我们进入函数LdrLoadDLL( Ldr前缀 == Loader)中,其中参数r9是一个out参数,它将具有加载模块的句柄。之后,我们进入LdrpLoadDll这个函数的私有版本,这两个函数是用户层代码将被执行的地方。经过一些完整性检查并跳进更多的函数后,我们终于看到了第一次内核代码的跳转。要执行的内核函数是NtOpenSection。在这里我们可以在进入内核之前看到调用堆栈。

alt img

NtOpenSection

我们需要知道的第一件事是“Section”代表了什么,翻翻Windows驱动程序文档中的内存管理章节,会发现有一个名为 “Section Objects and Views” 的部分,其中可以读取“Section Object”代表的可以共享的内存区域,并且该对象提供了一个进程,该进程可以将文件映射到其内存地址空间的机制(这段话几乎全部引用自上述文档)。

请记住,Windows内核可以认为几乎完全使用了C语言编写而成,它有点面向对象的性质(它不是100%的面向对象,虽然严格遵循了继承原则),这就是为什么我们通常要在内核中讨论对象。我们现在要说的是“Section Object” 。

因此,在了解了section的定义后,就完全可以理解为什么在加载库时NtOpenSection是第一个被调用的内核函数。

让我们继续进一步研究一下,首先让我们看看这个函数接收到的参数。正如你所看到的,它有3个参数(由于我在x64上进行研究,所以在__fastcall调用约定后,前4个参数进入了寄存器)

· rcx – > PHANDLE指针,用于接收Object的句柄

· rdx – > ACCESS_MASK请求访问Object

· r8 – > POBJECT_ATTRIBUTES指向DLL的OBJECT_ATTRIBUTES的指针

这3个参数可以在下图中看到:

alt img

ACCESS_MASK是以下值的组合,可以在winnt.h头中获取到。

#define SECTION_QUERY                0x0001
#define SECTION_MAP_WRITE            0x0002
#define SECTION_MAP_READ             0x0004
#define SECTION_MAP_EXECUTE          0x0008

这个函数所做的第一件事,就像所有其他的Executive Kernel函数一样,都会先获取PreviousMode,然后再做另一个检查,这种情况在内核函数中也很常见,该函数会检查PHANDLE的值是否超过了MmUserProbeAddress,如果第二次检查出错,将弹出错误998(“无效的访问内存位置”)。

前些日子@benhawkes从Project Zero中透露了一个Windows内核漏洞,这个漏洞与PreviousMode检查有关,请务必阅读他的文章(https://googleprojectzero.blogspot.com /2019/03/windows-kernel-logic-bug-class-access.html)。

如果两个检查都通过了,代码将进入“ObOpenObjectByName”函数,该函数将接收存储在 rdx 参数中的类型为Section 的对象,该对象可以从MmSectionObjectType的地址中检索到。

alt img

从现在开始,我们就进入了“真正的”内核代码😆😆,首先要检查我们是否在rcx参数中接收到了OBJECT_ATTRIBUTES并在参数rdx中接收到了OBJECT_TYPE ,如果一切顺利,内核将从LookAside List 8获得一个Pool(KTHREAD-> PPLookAsideList [8] .P),我不会深入研究LookAside列表的内容,但我们可以将它们视为某种缓存,(你可以在这里阅读到更多有关内容)。接下来将调用函数ObpCaptureObjectCreateInformation,经过一些完整性检查后,代码将存储一个OBJECT_CREATE_INFORMATION结构体,该结构体中包含了来自之前接收到的Pool中的OBJECT_ATTRIBUTES数据。如果对象属性中包含有ObjectName(UNICODE_STRING),则该名称将被复制到r9参数指向的地址中,不过稍加修改后,MaximumLength可以被更改为 F8h。

alt img

从该函数返回后,就进入到了结构体中非常有趣的部分!🤣🤣。首先我们从这里获得一个指向KTHREAD(gs:188h)的指针。然后我们又获得了一个指向KPROCESS的指针(KTHREAD + 98h- > ApcState + 20h- > Process),如你所知,KPROCESS是EPROCESS的第一个元素(有点像内核中的PEB进程)。所以基本上,如果你得到了一个指向KPROCESS的指针,那么同时你也就得到了一个指向EPROCESS的指针

alt img

通过这样的方式,内核获取到了UniqueProcessId(EPROCESS + 2E0h),这些代码也会获得指向GenericMapping成员的指针,这些成员在OBJ_TYPE_INITIALIZER结构体内部的偏移量是0xc,它位于偏移量40h中的OBJECT_TYPE结构体内。在此之后,系统将调用函数SepCreateAccessStateFromSubjectContext,该函数的名称暗示了我们在调用完此函数后我们可以接收到一个ACCESS_STATE对象(作为rdx参数传递了该对象的指针),此函数属于“Security Reference Monitor”组件。该组件主要提供检查访问的功能和权限,你可以通过前缀Se识别这些函数。

下一步,可能是此过程中最重要的一步,那就是执行函数ObpLookupObjectName。这个函数名称再次提供了关于功能方法的一些信息,在这里,代码将根据名称查找对象(在本例中为DLL的名称)。通过查看函数的Graph,我们可以看出它是一个非常重要的函数🤣。

ObpLookupObjectName Graph

理解这些函数的一个非常有价值的方面是知道函数期望接收哪些参数,WDK上没有记录很多内核函数,所以我们有两个方法,第一个是逆向内核并尝试理解哪个参数将被传递给函数。第二个方法更快一些,就是在Google上搜索函数,你可能会搜索到ReactOS,这是一个Super Awesome项目(有点像开源的Windows),并且这个项目有很多函数几乎完全匹配于Windows内核,这是理解Windows内核的一个好方法,所以一定要了解一下这个项目!要了解该函数的参数,请查看下图:

Params ObpLookupObjectName

在这个函数中,首先是初始化结构体OBP_LOOKUP_CONTEXT,接下来我们通过调用ObReferenceObjectByHandle获得了对“KnownDlls”目录对象的引用,该对象包含了已经加载到内存中的Section Objects列表,并且列表中的每一项均对应于“KnownDlls”注册表项中的每一个DLL。

剧透:正如你在用户层调用堆栈中看到的那样,NtOpenSection之前的函数叫作LdrpFindKnownDll,这意味着如果我们尝试加载的DLL不在“KnownDlls”列表中,我们将得到一个错误。

alt img

接下来,代码将使用DLL的名称计算一个哈希,它将检查此哈希是否与“KnownDlls”中的某个哈希相匹配,如果没有匹配到则函数将返回错误“c0000034:对象名称未找到。” 从这里开始,后面的工作流程主要是在返回到用户层之前清理掉所有内容。

alt img

另一个剧透:在本系列文章的第2部分中,我们将看到用户层在收到错误“c0000034”时会作何反应。快速预览相关内容,我们会发现系统将搜索DLL并调用函数NtOpenFile。

KnownDll

现在让我们假设我们正在寻找的DLL已经存在于KnownDlls列表中,为此,'因为我懒得再次编译代码,我们将“kerberos.dll”添加到此列表中。我们可以在以下注册表中找到这个列表:

HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Session Manager\KnownDLLs

注意!我们需要提升权限来执行此操作,在我的演示中,我只是将自己设置为该注册表键的所有者并添加了DLL。

在下图中,你可以看到Kerberos DLL是如何作为KnownDlls的一部分而被加载(没有仔细检查太多细节,但我相信名称必须是大写的,因为哈希是使用DLL的大写名称计算的,但是像“kernel32.dll”这样的情况却是小写的,所以我要对此进行更多的细致调查)。

alt img

快进一下,我们可以看到ObpLookupObjectName函数如何这次返回了0而不是“c0000034”来作为NTSTATUS

alt img

对于这种情况,我们将直接从函数ObpLookupObjectName开始,特别是从计算哈希开始(对于这两种情况,代码流都是相同的)。这次我们将通过查看以下伪代码来分析哈希的计算方法:

注意!此函数未在文档中记录,因此很可能实现细节会从某个版本的Windows更改为另一个版本,甚至从某个SP更改为下一个版本。特别是我正在研究的内核版本:Windows 8.1 Kernel Version 9600 MP (2 procs) Free x64

// Credit to Hex-Ray xD
QWORD res = 0;
DWORD hash = 0;
DWORD size = Dll.Length >> 1;
PWSTR dll_buffer = unicode_string_dll.Buffer;
if (size > 4) {
    do {
        QWORD acc = dll_buffer;
        if (!(Dll_Buffer & ff80ff80ff80ff80h))
            acc = (QWORD *) Dll_Buffer & ffdfffdfffdfffdfh;
        }
        /* This code is really executed in the else statement, the if
        statement is a while that goes element by element substracting 
        20h from every element between 61h and 7Ah, of course that's 
        much slower than this */
        size -= 4;
        dll_buffer += 4;
        res = acc + (res >> 1) + 3 * res;
    } while (size >= 4)
    hash = (DWORD) res + (res >> 20h)
    /* If size is not a multiple of 4 the last iteration
    would be done using the while explained before */
}
obpLookupCtx.HashValue = hash;
obpLookupCtx.HashIndex = hash % 25;

如果你使用DLL名称“kerberos.dll”执行此操作,你将获得20h对应于十进制值32的HashIndex ,如果你仔细查看了我在解释“kerberos.dll”是如何作为KnownDlls的一部分而被加载并检查哈希时贴的图,那么你可以看到散列值为32。接下来,该函数检查写入OBP_LOOKUP_CONTEXT结构体的计算哈希是否与该section的哈希相匹配。

alt img

如果第一次检查通过,则代码会使用公式。

ObjectHeader - ObpInfoMaskToOffset - ObpInfoMaskToOffset[InfoMask & 3]

获取OBJECT_HEADER_NAME_INFO,并且再次将我们作为参数传递给LoadLibrary函数的名称与求和后的对象的名称做检查。如果这次也通过了检查,那么OBP_LOOKUP_CONTEX的成员对象和EntryLink将被填充。经过几次检查后,这个结构体将被复制到out参数指针中,之后我们将从这个函数返回。这个函数有两个out参数,返回时第一个将有指向对象的指针,第二个将有指向填充OBP_LOOKUP_CONTEX结构体的指针。

alt img

如果检查函数接收的参数(特指此处)当结构体OBP_LOOKUP_CONTEX存储在rsp+48h时,FoundObject将存储在rsp+48h中。另外看看对象为何没有打开任何句柄,这个原因将发生在我们今天要学习的最后一个函数ObpCreateHandle中,这个函数会从对象获取句柄的过程中被调用。

这个函数也有很多代码,因为代码太长了,所以我不会在本文中详细介绍(也许在其他文章中我可以详细介绍,因为它是一个非常有趣的函数)

ObpCreateHandle所接收的最重要的参数是rcx,它将从OB_OPEN_REASON枚举中接收下面五个中的某个值:

ObCreateHandle      =   0
ObOpenHandle        =   1
ObDuplicateHandle   =   2
ObInheritHandle     =   3
ObMaxOpenReason     =   4

然后在rdx中函数期望引用对象(DLL Section Object),并在r9参数中函数接收到ACCESS_STATE结构体,以及ACCESS_MASK等其他有趣的东西。

知道了这一点,并且我们已知 OB_OPEN_REASON 枚举的值将是ObOpenHandle,因此,我们可以继续深入研究。该函数将做的第一件事是检查我们试图获取的处理程序是否用于内核对象(换句话说,我们正在尝试获取内核句柄)。如果不是这种情况,那么函数将检索KTHREAD->ApcState->Process->(EPROCESS) ObjectTable对应于HANDLE_TABLE结构体的ObjectTable,在经过一些检查之后,系统将调用函数ExAcquireResourceSharedLite来获取PrimaryToken的资源(我在这里谈论的资源是ERESOURCES结构体的某种互斥体,你可以在这里阅读更多有关资源的信息

如果已获取到资源,则将调用函数SeAccessCheck,这些函数会检查是否可以授予对特定对象的请求访问权限。如果授予了这些权限,我们就进入到了函数ObpIncrementHandleCountEx中,它负责从我们试图获取句柄的Section Section对象和一般Section对象类型计数中递增Handle计数。(这个函数只增加计数器,但这并不意味着句柄是打开的。这可以通过运行!object [object]来检查,你会注意到HandleCount已经递增,但是运行!handle检查进程的句柄你将看不到对这个句柄的任何引用)。

最后,句柄将被打开。为了节省时间,我将展示完成这个过程的伪代码,我将在伪代码中添加一些注释便于你的理解。(再次感谢由Hex-Rays提供的伪代码🤣)

// I'm goint to simplify, there will be no check nor casts
HANDLE_TABLE * HandleTable = {};
HANDLE_TABLE_ENTRY * NewHandle = {};
HANDLE_TABLE_FREE_LIST * HandlesFreeList = {};
// Get reference to the Object and his attributes (rsp+28h), to get
// the object we use the Object Header (OBJECT_HEADER) which is 
// obtained from the Object-30h (OBJECT_HEADER+30h->Body) 
QWORD LowValue = 
    (((DWORD) Attributes & 7 << 11) | (Dll_object - 30h << 10) | 1)
// Get the type, Object-18h (OBJECT_HEADER+18h->TypeIndex)
HIDWORD(HighValue) = Dll_Object - 18h
// Get the requested access 
LODWORD(HighValue) = ptrAccessState.PrevGrantedAccess & 0xFDFFFFFF;
// Get the HANDLE_TABLE from the process
HandleTable = KeGetCurrentThread()->ApcState.Process->ObjectTable;
// Calculate index based on Processor number 
indexTable = Pcrb.Number % nt!ExpUuidSequenceNumberValid+0x1;
// Get the List of Free Handles
HandlesFreeList = HandleTable->FreeLists[indexTable];
if(HandlesFreeList) {
    Lock(HandlesFreeList); // This is more complex than this
    // Get the First Free Handle
    NewHandle = HandlesFreeList->FirstFreeHandleEntry;
    if (NewHandle) {
        // Make the Free handles list point to the next free handle
        tmp = NewHandle->NextFreeHandleEntry;
        HandlesFreeList->FirstFreeHandleEntry = tmp;
        // Increment Handle count
        ++HandlesFreeList->HandleCount;
    }
    UnLock(HandlesFreeList);
}
if (NewHandle) {
    // Obtain the HandleValue, just to return it
    tmp = *((NewHandle & 0xFFFFFFFFFFFFF000) + 8)
    tmp1 = NewHandle - (NewHandle & 0xFFFFFFFFFFFFF000) >> 4;
    HandleValue = tmp + tmp1*4;
    // Assign pre-computed values to the handle so it
    // knows to which object points, whick type of object it
    // is and which permissions where granted
    NewHandle->LowValue = LowValue;
    NewHandle->HighValue = HighValue;
}

最后,该函数将返回存储在rsp+48的句柄值。从现在开始直到返回到用户层,一切都与清理机器状态(结构体,单个列表,访问状态等等)有关,当我们最终到达用户层(LdrpFindKnowDll)时,我们将得到一个句柄,并且 STATUS 将为0。

alt img

这个句柄与LoadLibrary在完成所有操作时返回的模块的句柄无关,这只是一个将在“内部”使用的Section对象的句柄。更重要的是,在这一点上,DLL甚至没有被加载到进程的地址空间中,讨论为何发生了这种情况就是我们将在第2部分中所要提到的内容。

结论

正如你所看到的,内核中有很多代码,并不是一切都是直截了当的,我敢说事情非常复杂。但请记住,这已经算是简单的东西了,因为我们将进入更为复杂的东西😀😀之中。另一方面,我在文中留下了大量的代码,结构体,列表等……没有作评论也没有提及所以请不要因此而怼我,我只是试着总结了一下我认为最重要的东西。当然,如果你有任何疑问或问题,或者我写的有什么不对的地方,你想要怼我,请不要犹豫,直接与我取得联系吧。

本文翻译自:https://n4r1b.netlify.com/en/posts/2019/03/part-1-digging-deep-into-loadlibrary/如若转载,请注明原文地址: https://www.4hou.com/binary/16955.html
点赞 0
  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论