翻译文章,原文链接:https://n4r1b.netlify.com/en/posts/2019/03/part-1-digging-deep-into-loadlibrary/
原文作者:@n4r1b
前言
在这里,我们再次来到内核,今天我们将讨论Windows API中最重要的功能之一。做这项研究的动机来自几周前我正在研究的一个项目,当时我正在编写一个DLL的ReflectiverLoader,但我无法使其工作(最后它与一些reloc的东西有关),因此我认为发现我的错误的最好方法是看看Windows是如何处理加载库过程的。。
我将集中讨论调用LoadLibrary时执行的内核代码,因此Userland上的所有内容我都会浏览它。另一方面,我不会深入内核中的每个调用/指令,相信那里有很多代码。我将重点关注我认为最重要的功能和结构。
LoadLibrary
在分析中,我将使用这个代码段:
int WinMain(...) {
HMODULE hHandle = LoadLibraryW(L"kerberos.dll");
return 0;
}
我使用Unicode函数,因为内核只处理这类字符串,所以我节省了一些时间来做研究。
当LoadLibraryW被执行时,首先发生的事情是执行被重定向到KernelBase.dll(这与Windows自Windows 7采用的新MinWin内核有关,更多信息),在KernelBase中,函数RtlInitUnicodeStringEx第一个被调用,接下来它获取UNICODE_STRING并将其参数传递给LoadLibrary(这是一个结构而不是字符串)。接下来,我们进入函数LdrLoadDLL(Prefix Ldr == Loader),其中 r9
中的参数是一个out参数,它具有加载模块的句柄。在此之后我们进入函数LdrpLoadDll的私有版本,这两个函数是Userland的所有代码都将被执行的地方。经过一些完整性检查并进入更多函数后,我们最终进入了内核代码的第一步。要执行的内核函数是NtOpenSection,这也是我将在这篇文章中重点讨论的。在这里,我们可以在进入内核之前看到调用堆栈。
NtOpenSection函数
我们首先需要知道的是“Section”代表什么,在Windows驱动程序文档的内存管理章节中有一个名为 “Section Objects and Views”的部分,其中“Section Object”表示可以共享的存储区域,并且该对象为进程提供了将文件映射到其存储地址空间的机制(这几乎引用了doc)。
请记住,尽管Windows Kernel被认为几乎完全是用C编写的,但它有点面向对象(它不是100%面向对象,没有严格遵循继承原则),这就是为什么我们通常在内核中讨论Object whitin。在这种情况下是“Section Object”。
因此,考虑到一个部分的定义,完全可以理解NtOpenSection是第一个在加载库时被执行的内核函数。
让我们开始,首先让我们看看这个函数将收到的参数。如你所见,将有3个参数(我们在x64上,所以按照__fastcall调用约定,前4个参数进入寄存器)。
-
rcx
-> PHANDLE pointer that receives the handle to the Object -
rdx
-> ACCESS_MASK requested access to the Object -
r8
-> POBJECT_ATTRIBUTES pointer to the OBJECT_ATTRIBUTES of the DLL
这三个参数可以在下图中看到:
ACCESS_MASK是下列值的组合,可以在winnt.h头中获得:
#define SECTION_QUERY 0x0001
#define SECTION_MAP_WRITE 0x0002
#define SECTION_MAP_READ 0x0004
#define SECTION_MAP_EXECUTE 0x0008
几乎和其他的内核执行函数一样,这个函数首先会获取PreviousMode,然后将会有另一个检查,在内核函数中也很常见,它将检查PHANDLE值是否超过MmUserProbeAddress,如果第二次检查出错,将弹出错误998(“无效访问内存位置”)。
前些日子, Project Zero的@benhawkes披露了一个与预览模式检查有关的Windows 内核漏洞,请务必阅读他的文章,这很棒(总是使用Project Zero文章)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获得一个池(KTHREAD-> PPLookAsideList) [8] .P),我不会深入研究LookAside列表的内容,而是将它们视为某种缓存。(你可以在这里阅读更多内容)接下来将调用函数ObpCaptureObjectCreateInformation,经过一些完整性检查后,代码将存储一个OBJECT_CREATE_INFORMATION结构,其中包含来自之前的池中OBJECT_ATTRIBUTES的数据。
如果Object属性具有对象名( UNICODE_STRING ),该名称将被复制到 r9
参数中指向的地址中,但稍加修改,最大长度将更改为 F8h
。
从那个函数返回后,开始变得有趣。首先我们从这里获得一个指向KTHREAD(gs:188h
)的指针,我们获得了一个指向KPROCESS(KTHREAD + 98h
- > ApcState + 20h
- > Process)的指针,如你所知,KPROCESS是EPROCESS的第一个元素(有点像内核进程中的PEB)。因此,如果你得到一个指向KPROCESS的指针,你也有一个指向EPROCESS的指针。
这样,内核获取UniqueProcessId(EPROCESS + 2E0h
),这些代码也会获得指向成员GenericMapping的指针,该成员是0xc
结构OBJ_TYPE_INITIALIZER内部的偏移量,它位于偏移量40h
中的结构OBJECT_TYPE内。在此之后,将调用函数SepCreateAccessStateFromSubjectContext,顾名思义,我们在调用此函数后会接收到ACCESS_STATE对象(指针作为rdx
中的参数传递),此函数属于组件“安全参考监视器”,此组件主要提供检查访问和权限的函数,你可以通过前缀Se识别这些函数。
下一步,可能是此过程中最重要的一步,是执行函数ObpLookupObjectName。同样,这个名字给了我们一点关于这个方法功能的信息,在这里代码将基于一个名字(在本例中为DLL名称)来寻找一个对象。通过查看函数Graph,我们可以看出它是一个重要的函数。
理解这些函数的一个非常有价值的方面是知道函数期望的参数是什么,很多内核函数没有在WDK上记录下来,所以我们有两个选择, 第一种方法是内核逆向,尝试理解哪些参数被传递给函数,第二种方法更快,就是在Google上搜索函数,你可能会进入ReactOS,这是一个超级棒的项目(有点像开源Windows),这个项目中有很多函数几乎完全匹配Windows内核,这是理解内核中很多东西的好方法,所以一定要访问该项目。
关于这个函数参数,请看下图:
在这个函数中,首先要初始化结构OBP_LOOKUP_CONTEXT,接下来我们通过调用ObReferenceObjectByHandle获得对“KnownDlls”目录对象的引用,该对象包含已经加载到内存中的Section Objects列表,并且每个对应于来自“KnownDlls”注册表项的一个DLL。
Spoiler: 正如你在Userland调用堆栈中所看到的,NtOpenSection之前的函数称为LdrpFindKnownDll,这意味着如果我们尝试加载的DLL不在“KnownDlls”列表中,我们将收到错误。
接下来代码将使用DLL的名称计算Hash值,它将检查此Hash值是否与“KnownDlls”中的一个Hash值匹配,如果没有匹配那么函数将返回错误“c0000034:“找不到对象名“。 从这里开始,流程主要是在返回Userland之前清理所有内容。
Another Spoiler:在第2部分,我们将看到Userland在收到错误“c0000034”时如何反应。快速预览将会找到DLL,并调用函数NtOpenFile。
KnownDll
现在让我们假设我们正在寻找的DLL在已知Dll列表中,因为我懒得再次编译代码,我们将“kerberos.dll”添加到此列表中。我们可以在以下注册表中找到此列表:
HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Session Manager\KnownDLLs
注意!我们需要提升权限来执行此操作,在我的例子中,我只是将自己设置为该密钥的所有者并添加了DLL。
在下图中,你可以看到Kerberos DLL如何作为已知DLL的一部分加载(没有检查太多,但我相信名称必须是大写,因为hash是使用DLL的大写名称计算的,,但是有些情况下像“kernel32.dll”是小写的,所以我需要对此进行更多的研究)。
我们可以看到函数ObpLookupObjectName这次作为NTSTATUS是如何返回0而非“c0000034”。
对于这种情况,我们将直接从函数ObpLookupObjectName开始,特别是从计算散列的点开始(在这两种情况,代码流都是相同的)。这次我们将通过查看以下伪代码来查看hash的计算方法:
注意!此函数未记录,因此很可能实现从一个版本的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”是作为已知DLL的一部分加载,并在hash列中进行检查,你可以看到值是32。接下来,该函数检查写入OBP LOOKUP CONTEXT结构的计算散列是否与该部分的散列以及计算索引相匹配:
如果第一次检查顺利,则代码OBJECT_HEADER_NAME_INFO
将使用公式获取ObjectHeader - ObpInfoMaskToOffset - ObpInfoMaskToOffset[InfoMask & 3]
,并且根据我们作为参数传递给函数LoadLibrary的名称再次检查对象的名称。如果进展顺利,那么OBP_LOOKUP_CONTEX
的成员对象和EntryLink将被填充,经过多次检查,这个结构将被复制到外部参数指针中,我们将从这个函数返回。该函数有两个out参数,返回后,第一个参数将有指向对象的指针,第二个参数将有指向填充的OBP_LOOKUP_CONTEX
结构的指针。
如果检查函数接收的参数(此处),FoundObject
值将为rsp + 68h
,而OBP_LOOKUP_CONTEX
结构将为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,它负责从我们试图获取句柄的节对象和一般对象类型计数中递增句柄计数(这个函数只增加计数器,但这个并不意味着句柄是打开的。这可以通过运行!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
。从现在开始直到返回用户域Userland,一切都与清理机器状态(结构,单个列表,访问状态等等)有关,当我们最终到达Userland(LdrpFindKnowDll)时,我们将拥有句柄,STATUS将为0。
这个句柄与LoadLibrary在完成所有操作时将返回的模块句柄无关,这只是一个将在“内部”使用的Section对象的句柄。更重要的是,在这一点上,DLL甚至没有被加载到进程的地址空间中,我们将在第2部分中看到这是如何发生的。
结论
如你所见,内核中有很多代码,并非一切都是简单直接的,我敢肯定事情非常复杂。请记住我们将进入更复杂的东西。另一方面,我留下了大量没有评论也没有提及的代码、结构、列表等,我只是试着总结我所认为最重要的东西。当然,如果你有任何疑惑和问题,请毫不犹豫与我联系。我希望你喜欢它并在第2部分见到你。
没有评论