标题 简介 类型 公开时间
关联规则 关联知识 关联工具 关联文档 关联抓包
参考1(官网)
参考2
参考3
详情
[SAFE-ID: JIWO-2024-2954]   作者: 大猪 发表于: [2021-11-09]

本文共 [325] 位读者顶过

深入分析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。在这里我们可以在进入内核之前看到调用堆栈。

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 个参数可以在下图中看到:

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 的地址中检索到。

从现在开始,我们就进入了 " 真正的 " 内核代码,首先要检查我们是否在 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。

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

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

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

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

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

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

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

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

KnownDll

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

HKEY_LOCAL_MACHINESYSTEMControlSet001ControlSession ManagerKnownDLLs

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

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

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

对于这种情况,我们将直接从函数 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 的哈希相匹配。

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

ObjectHeader - ObpInfoMaskToOffset - ObpInfoMaskToOffset [ InfoMask & 3 ]

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

如果检查函数接收的参数(特指此处)当结构体 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 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。

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

结论

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

[出自:jiwo.org]

评论

暂无
发表评论
 返回顶部 
热度(325)
 关注微信