Windows x86 Shellcode开发:寻找Kernel32.dll地址

针对一个已经学习了Linux Shellcode开发,并开始在Windows上尝试的研究人员来说,这一过程可能要比想象的更加艰难。Windows内核与Linux完全不同。尽管如此,但Linux内核要比Windows更容易理解,原因在于其开源的特性,并且与Windows相比,Linux只具有相当少的功能。另一方面,Windows在过去几年中进行了重大的改进,由于这一改进,新版本与老版本相比已经发生了许多变化。在本文中,我们将专注于对Windows 10 x86进行分析,但其他旧版本可能与之相比没有太多的不同。目前,已经有很多关于PEB LDR的博客文章,但我们还没有看到有任何文章中展现了完整的逻辑,并阐述其本质原因的。大多数研究人员只是通过WinDBG进行分析,并要求读者具备一定程度的后端基础。我撰写这篇文章的主要原因,是希望从C语言转到ASM,并希望我们能共同了解在ASM x86中进行Shellcode开发时,后端的工作原理。
.data段
在我们开始处理Shellcode部分之前,我建议首先应该理解内存是如何工作的,因为我们即将做的一切操作都是在内存中。如果我们已经了解像LPWSTR、LPSTR这样的Windows数据类型,那么无疑是一个好消息,因为我们必须要知道:
标准的C语言并不等同于Windows C编程
接下来,唯一需要重点掌握的,就是基本的Assembly x86。默认情况下,除了系统调用或API调用之外,ASM在Linux或Windows中是相同的。因此,了解寄存器的工作原理就显得非常重要。
最重要的是,我们应该了解如何对二进制文件进行反汇编。我主要使用x32dbg和WinDBG x86。我会同时使用这两个工具进行调试,因为有一些我们不能在x32dbg中完成的事情,在WinDBG x86中是可以的,反之亦然。因此,我们将不断切换使用这两个工具。
.text段
在我们开始使用Shellcode之前,理解其在较低级别的工作方式,这一点非常重要。我们首先将从一个非常简单的例子开始,找到系统的当前主机名。我们来看看下面是使用C语言编写的Windows API示例:

在上图中,我创建了两个变量,分别是compName和compNameSize。这些将是提供给函数GetComputerNameA的参数。请记住,GetComputerNameA和GetComputerNameW有两个相似的函数。 W代表宽Unicode字符,而A代表ANSI CHAR字符串。我们将在整个博客系列中使用ANSI。下面是MSDN对GetComputerNameA函数的说明:
BOOL GetComputerNameA(LPSTR lpBuffer, LPDWORD nSize);
上面的代码表示,GetComputerNameA接受LPSTR,表示长指针字符串,而LPDWORD则表示长指针双字。一个字的大小是16位,因此DWORD在所有平台上都是32位。现在,如果使用g++编译上述程序,我们将看到如下内容:

现在在这里,在程序的最开始,有#include ,这也就意味着Windows库将被引入到代码中,它应该在这里动态链接默认依赖项。但是,我们不能对ASM进行相同的操作。在ASM的场景中,我们需要动态地找到函数GetComputerNameA所在的地址,在堆栈上加载参数,并调用具有函数指针的寄存器。我们要知道的一件重要事情是,Windows的大多数功能,都是通过三个主要DLL访问的:NTDLL.DLL、Kernel32.DLL和Kernelbase.DLL。因此,无论任何时间执行任何二进制文件,这些都是始终要加载的必要DLL。为了加载函数GetComputerNameA,我们必须找到这个函数所在的DLL,并在那里找到它的基址。接下来,我们在x32dbg上尝试加载任何x86二进制文件,看看能得到什么。我将加载我们编译的上述exe文件,但实际上,我们可以加载任何随机的32位可执行文件,因为我们只会浏览上面提到的那些DLL。使用x32dbg打开exe文件,并导航到Log部分,可以看到加载了这三个DLL,以及其特定的地址:

接下来,我们将导航到突出显示的Symbols部分,可以看到加载的不同DLL的名称。在这里,我们可以浏览DLL,并查看它们提供的所有功能。

现在,如果我们在搜索框中搜索函数GetComputerNameA,它将显示Kernel32.DLL加载该函数。此外,还将打印出函数所在的地址0x74F69AC0。在理论和实际测试中,这一点都能够很好地展现。接下来,让我们通过C编程然后通过ASM来完成,我们要执行的步骤如下:
1. 使用函数LoadLibraryA WinAPI在内存中加载Kernel32.dll;
2. 使用GetProcAddress在Kernel32.dll中找到函数GetComputerNameA的地址;
3. 将GetProcAddress返回值类型转换为接受2个参数的WinAPI函数(因为GetComputerNameA接受2个参数);
4. 为ComputerName及其Length创建缓冲区。
将Address作为函数指针来执行。

访问LoadLibraryA的MSDN页面,可以发现它返回一个HMODULE,这意味着它将一个句柄返回到一个被加载的模块。因此,我们创建了一个变量hmod_libname。类似地,GetProcAddress返回从DLL加载的函数的地址。我们需要将GetProcAddress返回的地址类型转换为GetComputerNameA函数,以使其能够正常工作。为此,我们创建了一个typedef,它基本上复制了函数GetComputerNameA的结构。在上图中,我们加载库Kernel32.dll,并使用GetProcAddress查找函数GetComputerNameA的基址,将地址存储在GetComputerNameProc中。最后,我们创建两个变量CompName和CompNameSize,并使用(*GetComputerNameProc)作为函数指针,执行存储在GetComputerNameProc中的地址,并为其提供所需的变量。上面的代码中,还打印了函数GetComputerNameA的地址。我们尝试对其进行编译,看看结果如何:


不错!地址0x74F69AC0与上面用x32dbg调试时发现的地址一致。
_start
我们接下来进入到有趣的部分。所有DLL及其函数的地址在重新启动时都会发生变化,并且在每个其他系统中都会有所不同。这就是我们无法对ASM代码中的任何地址进行硬编码的原因。但是,主要问题仍然存在,那就是我们如何找到kernel32.dll自身的地址?
我在一开始说过,每个exe都加载了Kernel32.dll、NTDLL.DLL和Kernelbase.dll。事实上,这些DLL是操作系统中非常重要的一部分,每次在执行任何操作时,都会加载这些DLL。因此,这些DLL到内存中的加载顺序总是相同的。然而,这可能因操作系统而异。这就意味着,在Windows XP与Windows 10之间可能有所不同,但所有Windows 10中的加载顺序将保持不变。
所以,我们在继续下一步之前,需要完成下面的工作:
1. 找到Kernel32.dll的加载顺序;
2. 找到Kernel32.dll的地址;
3. 找到GetComputerNameA的地址;
4. 在栈上加载GetComputerNameA的参数;
5. 调用GetComputerNameA函数指针。
可能听起来很容易?我们来实际尝试一下。
查找kernel32.dll的地址并不简单。当我们执行任何exe时,在操作系统中首先创建的就是TEB(线程环境块)和PEB(进程环境块)。
我们的主要关注点在于PEB结构(称为LDR),因为这是与进程相关的所有信息都被加载的地方。从流程参数到流程ID的所有内容都存储在这个位置。在PEB中,有一个名为PEB_LDR_DATA的结构,它包含三个关键部分。这些被称为链接列表(Linked Lists)。
1. InLoadOrderModuleList – 加载模块(exe或dll)的顺序;
2. InMemoryOrderModuleList – 模块(exe或dll)存储在内存中的顺序;
3. InInitializationOrderModuleList – 在进程环境块中初始化模块(exe或dll)的顺序。
在链表中加载模块的顺序是固定的。这意味着,我们如果能够在上面的列表中找到kernel32.dll的顺序,就可以搜索kernel32.dll的地址,并继续进行。现在,我们启动WinDBG x86。如果各位还没有安装WinDBG及其依赖项,你可以在SLAER上找到一篇关于WinDBG的文章。一旦安装WinDBG之后,就可以像我们之前那样打开任意的exe文件。
在WinDBG中加载exe文件后,会显示一些输出。限制,我们将忽略输出内容,并在下面的命令提示符中输入.cls以清除屏幕并重新开始。现在,我们在命令提示符下输入!peb,看看在这里能够得到什么:

如大家所见,我们得到了LDR(PEB结构)的地址,即779E0C40。这非常重要,因为我们要使用该地址来计算前进的地址。接下来,我们输入命令dt nt!_TEB,以查找PEB结构的偏移量。

如我们所见,_PEB位于偏移量0x030的位置。以类似的方式,我们可以使用dt nt!_PEB查看_PEB结构的内容。

_PEB_LDR_DATA的偏移量为0x00c。接下来,我们尝试查找_PEB_LDR_DATA结构中的内容。我们可以用类似的方式实现这一点:
dt nt!_PEB_LDR_DATA

在这里,我们可以看到InLoadOrderModuleList位于偏移量0x00c处,InMemoryOrderModuleList位于偏移量0x014处,InInitializationOrderModuleList位于偏移量0x01c处。此外,如果要查看每个列表所在的地址,可以使用我们此前找到的地址779E0C40(LDR的地址)以及命令dt nt!_PEB_LDR_DATA 779E0C40。这将向我们显示链接列表的相应起始地址和结束地址,如下所示:

有一个地方,可能会被一些人误解,就是上图中展示出InMemoryOrderModuleList的类型为_LIST_ENTRY,但在MSDN上已经另有说明:

因此,MSDN声明它是LDR_DATA_TABLE_ENTRY类型而不是_LIST_ENTRY类型。我们尝试查看结构中加载的模块,并指定该结构的起始地址为0x7041e8,以便可以看到加载的模块的基址。需要注意的是,0x7041e8是此结构的地址,因此第一个条目将比此地址少8个字节。因此,我们的命令是:
dt nt!_LDR_DATA_TABLE_ENTRY 0x7041e8-8

第一个出现的BaseDllName是gethost.exe。这就是我之前执行的exe文件。此外,我们可以看到现在InMemoryOrderLinks的地址是0x7040e0。偏移量0x018处的DllBase中包含BaseDllName的基址。现在,我们下一个加载的模块必须距离0x7040e0有8个字节,也就是0x7040e0-8。

dt nt!_LDR_DATA_TABLE_ENTRY 0x7040e0-8

所以,我们的第二个模块是ntdll.dll,它的地址是0x778c000,下一个模块位于0x704690之后的8个字节。所以,我们的下一个命令是:
dt nt!_LDR_DATA_TABLE_ENTRY 0x704690-8

由此,就得到了第三个模块Kernel32.dll,其地址是0x74f50000,其偏移量时0x018。模块加载的顺序总是固定的,至少这适用于Windows 10、Windows 7、Windows 8(包括8.1)。因此,当我们编写ASM时,我们可以遍历整个PEB LDR结构体,并找到Kernel32.dll的地址,并将其加载到我们的Shellcode中。以类似的方式,我们还可以找到Kernelbase.dll的地址,这是第四个模块。

现在,我们总结一下需要进行的工作:
1. PEB位于距离文件段寄存器偏移量为0x030的位置;
2. LDR位于偏移量为PEB + 0x00C的位置;
3. InMemoryOrderModuleList位于偏移量LDR + 0x014的位置;
4. 第一个模块入口是exe本身;
5. 第二个模块入口是ntdll.dll;
6. 第三个模块入口是kernel32.dll;
7. 第四个模块入口是Kernelbase.dll。
我们现在最感兴趣的,就是Kernel32.dll。每次加载DLL时,地址都将存储在DllBase的偏移量0x018的位置。我们链接列表的起始地址将存储在InMemoryOrderLinks的偏移量中,即0x008。因此,偏移量之间的关系将是 DllBase – InMemoryOrderLinks = 0x018 – 0x008 = 0x10。因此,Kernel32.dll的偏移量将是LDR + 0x10。更详细的理解,可以在下图中看到,这张图是我从这里偷过来的。

现在,如果我们在ASM中做同样的工作,将会是如下所示:

我们使用NASM来编译,并在x32dbg中加载它。大家可以从这里下载NASM。

实际上,一旦我们的最后一条指令被运行,就应该在EAX寄存器中加载Kernel32.dll的地址。我们来看看它在x32dbg中看起来是否相同。

如我们所见,在最后一条指令之后,加载到EAX中的地址与我们在下面使用lm命令在WinDBG中看到的地址相同,都是74F50000,这是Kernel32.dll的地址。
现在,我们已经有了Kernel32.dll的地址,下一步就是使用LoadLibraryA找到GetComputerNameA的地址,并调用该函数。我们将在下一篇文章中重点讨论这一问题,完善ASM代码,实现获取计算机名称并将其打印在屏幕上,然后打印到Shellcode部分。