WINDOWS+PE权威指南读书笔记(17)

目录

动态加载技术

Windows 虚拟地址空间分配:

用户态低 2GB 空间分配:

核心态高 2GB 空间分配:

HelloWorld 进程空间分析:

Windows 动态库技术:

DLL静态调用:

DLL动态调用:

导出函数起始地址实例:

在编程中使用动态加载技术:

获取 kernel32.dll 基地址:

获取 GetProcAddress 地址:

在代码中使用获取的函数地址编程:

动态API技术编程实例:

小结:


动态加载技术

该技术可以让程序设计者脱离复杂的导入表结构,在程序空间中构造类似导入表的调用引入函数机制。动态加载技术的核心是对被调用函数的地址的获取,调用函数位于动态链接库中。

动态链接库在程序运行时会被加载到进程的虚拟地址空间,因此,首先来看 Windows 操作系统对进程虚拟地址空间的管理。通过了解进程虚拟地址空间进一步理解操作系统、进程、动态链接库和被调用函数之间的关系。

Windows 虚拟地址空间分配:

在 32 位的机器上,地址空间从 0x00000000 ~ 0xFFFFFFFF,总大小为4GB。一般而言,低地址空间,即从 0x00000000 ~ 0x7FFFFFFF 的 2GB 是用户空间,高地址空间则被分配给了系统。内存的管理是分层次的,Windows 使用一个以页为基础的虚拟地址空间,充分利用了80X86 处理器保护模式下的线性寻址机制和分页机制。

(分页机制在第一个目录的PE相关的基本概念中)

由于 2GB 的用户地址空间对于很多程序并不够用(特别是一些大型的数据库系统),于是微软就想了一些变通的办法。比如,在 Windows 2000 的启动文件 boot.ini 中,设置 /3GB 和 /USERVA 选项,用户空间就变成3GB,相应的系统空间就减少到 1GB。

程序编写者在链接可执行文件的时候,指定链接参数 -LARGEADDRESSAWARE 即可设置 PE 映像头部标志为常量符号 IMAGE_FILE_ LARGE_ADDRESS_AWARE。该标志定义在字段IMAGE_FILE_HEADER.Characteristics 中,处在单字的第 5 位上(详细见第 3 章)。如果开了 3GB 选项,并且 PE 头不加这个设置,那么用户空间是 2GB, 系统空间是 1GB。如果开了 3GB 选项,PE 头也加了这个设置,那么用户空间是 3GB,系统空间是 1GB。

一般情况下,高端的 2GB 内存是供系统内核使用的。在这高端的 2GB 空间中安排了操作系统的系统代码和数据,用户一般无法访问到这个地址空间。用户地址空间使用低端 2GB 内存,其中包含了用户应用程序、调用的动态链接库、用户栈、用户可使用的空闲内存空间等。

从整体上看,Windows 虚拟内存地址空间的分配见表 11-1:

用户态低 2GB 空间分配:

不同的操作系统对用户态的 2GB 的虚拟地址空间分配策略是不同的:

在 Windows 2000/XP 中,大致分配见表 11-2:(就是方便请求内核中常规的数据。)

核心态高 2GB 空间分配:

核心态高 2GB 空间供 Windows 操作系统使用,主要用来存放内核管理所需要用到的代码和数据:

大致分配情况见表 11-3。

HelloWorld 进程空间分析:

本小节以 HelloWorld 程序为例,对其进程空间进行分析:

使用 OD 将 HelloWorld.exe 加载到内存中,查看虚拟地址空间的布局:

如果没有特殊情况(装载基地址处没有其他程序数据),程序代码所在的地址空间的位置由 PE 中的字段 IMAGE_ OPTIONAL_HEADER32.ImageBase 来确定。从图中可以看出,进程调用的动态链接库已经全部装载或映射到了地址空间里。所以,在 HelloWorld 中引入的所有函数都可以在地址空间中找到,一些重要的动态链接库比如 kernel32.dll,即使应用程序并没有调用其中的函数,操作系统也会早早地把它们加载到系统进程空间中,因为装载进程的函数就在这个库里。没有这个函数,任何进程就不会被创建,本章要介绍的动态加载技术就是利用了操作系统的这个特点。kernel32.dll 中有不少动态加载时需要用到的重要函数,这些函数在程序进程地址空间中随手可得。通过这些函数,用户可以自己完成类似于 Windows 加载器的某些行为,例如,可以自己填充 IAT、自己加载 DLL 到虚拟地址空间等操作。

Windows 动态库技术:

Windows 系统平台提供了一种有效的编程和运行环境,一些具有通用功能的模块会被单独编译为独立的文件,这些文件通常以 DLL 为扩展名,程序开发者可以单独对这些文件实现的功能进行独立测试。当多个应用程序同时用到这一个文件时,操作系统只需要将内存中的这段代码共享即可。这种共享代码的方式不仅可以有效地减少 EXE 文件大小,节约系统资源还能实现独立的功能测试,共享给需要这个功能的所有程序使用。这就是 Windows 动态库技术。

动态库技术是 Windows 最重要的实现技术之一,Windows 的许多新功能、新特性都是通过 DLL 来实现的。其实,Windows 本身就是由许多 DLL 组成的,它最基本的三大组成模块 Kernel、GDI 和 User 都是DLL。系统的 API 函数存储在 DLL 文件中,以下是 DLL 的一些特性:

DLL 模块中包含各种导出函数,用于向外界提供服务。DLL 可以有自己的数据段,但没有自己的栈,使用与调用它的应用程序相同的栈模式 ; 一个 DLL 在内存中只有一个实例 ;DLL 实现了代码封装性 , DLL 的编制与具体的编程语言及编译器无关,可以通过 DLL 来实现混合语言编程 ,DLL 函数中的代码所创建的任何对象(包括变量)都归调用它的线程或进程所有。用户可以使用静态和动态两种方式来调用它。

DLL静态调用:

静态调用,也称为隐式调用,是由编译系统完成对要加载的 DLL 的符号进行描述,对要调用的函数的符号进行描述并写入 PE 文件 ,Windows 系统则负责对要加载的 PE 导入表中描述的 DLL 符号进行加载,并记录 DLL 调用次数,对调用函数地址的修正等操作。由于大部分工作由操作系统来完成,所以静态调用方式简单直观,在编程中被大多数的开发者所使用。(这里就是常规的直接调用,不能随意卸载)

在汇编语言编程中,调用一个动态链接库的函数通常采用的方式是:把产生动态链接库时产生的 “.lib" 库文件和 “.inc" 包含文件加入到应用程序的工程中 ; 想使用DLL 中的函数时,直接使用函数的名字即可。

“.inc” 包含文件:

类似于 C 语言的 “.h” 头文件,所以又称为头文件,该文件中包含了动态链接库中导出函数的声明,有了函数声明后 “.lib" 库文件就可以直接用符号名了。

".lib" 库文件:

包含了每一个 DLL 导出函数的符号名和可选择的标识号,以及 DLL 文件名,不含有实际的代码。库文件包含的信息进入到最终生成的应用程序中,被调用的 DLL 文件会在应用程序加载时同时加载到内存中。

例如,看以下代码:

如上所示,MessageBox 函数位于动态链接库 user32.dll 中,所以在静态引用时注明包含文件和库文件,调用相关函数时直接使用函数名。

DLL动态调用:

动态调用又称为显式调用,是由编程者通过 API 函数加载和卸载 DLL 来达到调用 DLL 函数的目的。动态调用相对于静态调用来说比较复杂,但能更加有效地使用内存,是编制大型应用程序时经常使用的一种方式。

在 Windows 系统中,与动态库调用有关的函数主要包括:

口 LoadLibrary(或 MFC 的 AfxLoadLibrary),装载动态链接库。

口 GetProcAddress,获取要引入的函数的 VA,将符号名或标识号转换为 DLL 内部地址。

口 FreeLibrary(或 MFC 的 AfxFreeLibrary),释放动态链接库。

DLL 动态链接库是实现 Windows 应用程序共享资源、节省内存空间、提高使用效率的一个重要技术手段。常见的动态库包含导出函数和资源,也有一些动态库只包含资源,如 Windows 字体资源文件,称之为资源动态链接库,通常动态库以 “.dll”、“.drv”、“.fon” 等作为后缓。

相应的 Windows 静态库通常以 .lib 结尾,Windows 自己就将一些主要的系统功能以动态库模块的形式实现。Windows 动态链接库在运行时被系统加载或映射到进程的虚拟空间中,使用从调用进程的虚拟地址空间分配的内存,成为调用进程的一部分,DLL 也只能被该进程的线程所访问,DLL 的句柄可以被调用进程使用; 同样,调用进程的句柄也可以被 DLL 使用。

导出函数起始地址实例:

程序引进动态链接库的最终目的是要调用动态链接库里的函数代码。所以,获取动态链接库中导出函数的起始地址是动态加载技术的关键所在。系统的 user32.dll 中存放了大量的与用户界面有关的函数,其中就包括弹出对话框的函数 MessageBoxA。

以下内容是使用 PEInfo 小工具获取到的关于 user32.dll 的导出函数的部分资料:

从 OD 内存图中查到了 user32 被加载到了内存的 0x774F0000 处:

那么 MessageBoxA 函数的入口地址就是 0x774F0000 + 0x00083670 = 0x77573670 处,通过 OD 跟随函数地址可以验证:

如果一个函数在进程地址空间的 VA 确定以后,最简单的调用方式是通过以下的代码来调用它:

这种方法称为硬编码,即将一些具有固定值的变量直接赋以值的方式进行编程

在编程中使用动态加载技术:

本节介绍如何在编程中使用动态加载技术,简单来说,需要经过以下三步:

步骤1 获取 kernel32.dll 的基地址。

步骤2 获取 GetProcAddress 函数的地址(进一步获取 LoadLibrary 函数的地址)。

步骤3 在代码中使用获取的函数地址编程。

获取 kernel32.dll 基地址:

1:硬编码

硬编码是所有方法里最笨的一种,但也是最简单、代码量最少的一种。

kernel32.dll 文件加载到进程中的基地址可以通过以下硬编码的方式获得:

(1) 代码运行期前通过 dumpbin.exe 获取:

通过运行 dumpbin.exe 可以在代码部署前期获取到 kernel32.dll 的基地址,该地址位于 kernel32.dll 文件的头部。

运行命令如下(黑体部分):(框起来的即为kernel32.dll加载到进程后的默认基地址)

(2) 代码运行期前通过 PEInfo 获取

运行本书第 2 章编写的小工具 PEInfo,打开文件 C:\windows\system32\kernel32.dll,也可以从输出的信息中获取到 kerel32.dll 的基地址,如下所示:(框起来的部分就是kernel32.dll加载到进程后的默认基地址)

(3) 代码调试期通过 OD 获取:

在使用 OD 调试一个进程时,kernel32.dll 会被系统装和到进程地址空间,通过 OD 菜单的选项 “查看”|“内存” 即可查看用户地址空间的分配。从图中就可以看到 kernel32.dll 在进程地址空间中的基地址,但是值是 0x767300,可能是经过了重定位:

总结:

无论如何,只要通过如上的几种方式获取到了 kernel32.dll 的基地址,那么在这个动态链接库里向外导出的所有的函数地址也就可以计算出来了。(就是简单的加法而已)

尝试一下通过这种方法获取其他动态链接库的基地址。有意思的是,你获取的大多数地址都是正确的,而有一些却是不正确的。因为以前说过,系统在加载多个模块时,如果发现两个模块的基地址相同,系统通过重定位会改变其中一个模块的基地址,以保证两个模块加载到进程地址空间的不同位置。在 Windows XP 系统中,只有两个动态链接库模块是保证在所有进程的地址空间中都存在的,而且这两个动态链接库总是被加载到该动态链接库文件头部

IMAGE_OPTIONAL_HEADER32.ImageBase 指定的位置,那就是 ntdll.dll 和 kererl32.dll。

有人说,这种硬编码方式实在是太粳糕了,在使用时经常会出现问题,如使用硬编码生成的 PE 移动到别的操作系统中运行会出现错误。不过它的诱惑也太大了,上毕竟开发者不需要编写任何代码即可获取该地址,这对代码大小有严格限制的 ShellCode 编程是一件极美的事情。

2:从进程地址空间开始搜索:

前面提到,kernel32.dll 会保证出现在每一个进程的地址空间中,只要扫描当前进程的地址空间,寻找 PE 特征字符串,分析导出表,查找 GetProcAddress 函数(笔者称为特征函数);

如果找到,则默认该 PE 特征位置附近即为 kernel32.dll 的地址空间 ; 最后,通过对齐特性获取 kernel32.dll 的基地址。这种方法是寻找 API 函数地址,乃至模块基地址最常见、最稳定可靠的方法,也算是最笨的办法。至今,许多病毒程序依然使用了这种方法。

通过该方法获取基地址的完整代码见清单 11-1:

行 30 定义了要查找的特征函数名 GetProcAddress。

行 37 一 52 构造了一个大循环。从内存的高地址 0x7ffe0000 开始,每循环一次将该地址减 10000h 字节,然后判断是否有 PE 文件的 DOS 头部特征字符串 “MZ” 。如果符合条件,则进入内嵌循环。

行 73 ~ 87 是内嵌循环,用以检测一个符合条件的 PE 文件中的导出表中是否存在特征函数。如果存在,则输出 kernel32.dll 的基地址。

这种大范围地匹配特征函数查找 kernel32.dll 的方法在使用时存在一个很明显的问题 : 如果一个进程中加载的其他模块的导出表中也有相同的特征函数,这种定位方法就会出现错误。于是,我们不得不缩小搜索的范围。由于 kernel32.dll 模块出现在所有进程的虚拟地址空间里,对该模块地址范围内的调用指令和跳转指令比比皆是,通过从深入了解 Windows 操作系统机制入手,查找 Windows 操作系统的各种机制对应的数据结构,或相关信息中是否涉及要查找

的 kernel32.dll 的相关地址是现在常用的一种方法。

3:从SEH框架开始查找:

第 10 章介绍了 Windows 的 SEH 异常处理机制。因为操作系统默认分配的机构化异常处理程序指向 kernel32_except_handler3 函数,通过确定该函数的地址,就可以将地址向前对齐从而找到 kernel32.dll 的基地址。

当找到函数地址以后,通过对该地址进行舍入余数的办法取 10000h 的整数位,然后使用上一节在内存中搜索 kernel32.dll 基地址的方法搜索 PE 特征。由于本次搜索发生在进程地址空间中,所以搜索时就不再需要通过函数 IsBadReadPtr 来判断指针的可用性了。

见代码清单 11-2:

由于该方法使用了操作系统已经公开的特性,所以,其适应性非常强,而且几乎可以在大部分版本的 NT 操作系统中使用,且代码的长度也不算大。

4:从PEB开始查找:

第 9 章线程局部存储中介绍了一个数据结构,即进程环境块(PEB)。它记录了与进程相关的各种结构,其中包含了该进程加载的其他模块的地址。

以下所示为该结构中与本章查找kernel32.dll 基地址有关的字段:(在前面进程环境块 PEB目录中)

如上所示,Ldr 指向了一个结构,该结构的详细定义为:

以上三个 LIST_ENTRY 记录了当前进程加载的模块。其中,最后一个 LIST_ENTRY 中记录了进程初始化时加载的模块 ,这个列表包含了 ntdll.dll 和 kernel32.dll,而且大多数情况下,kernel32.dll 的基地址位于第二个地址处。

LIST_ENTRY 指向了数据结构 _LDR_MODULE,该结构详细定义如下:

有了以上的分析,就可以通过 PEB 查找到 kernel32.dll 的基地址,部分代码参见代码清单11-3:

可以看出,使用这种方法最终生成的字节码应该算是比较少的了。

获取 GetProcAddress 地址:

kernel32.dll 被装载和内存后,除了一些可以丢弃的节外,其他内容都会被装入,这样 kernel32.dll 的导入表、导出表、文件头等数据都会存放在程序的地址空间中; 即使是一个没有任何函数调用的程序,在被装载后,其地址空间依然会有 kernel32.dll 的内容。

只要有了 kernel32.dll 的基地址,通过导出表就可以找到 kernel32.dll 中的两个重量级的函数:

口 LoadLibrary (动态装人某个 dll 模块)

口 GetProcAddress (从被装入的模块中获取 API 函数的地址)

事实上,有了 kernel32.dll 的基地址和函数 GetProcAddress 的 VA 以后,通过调用该函数即可获取 kernel32.dll 中其他所有函数的地址,这其中包括 LoadLibraryA 的地址。基于这个原因,在本小节的标题中只列出了 GetProcAddress 函数的地址。

以下是函数 GetProcAddress 的完整定义:

两个参数解释如下:

hModule:包含此函数的 DLL 模块的句柄。LoadLibrary、AfxLoadLibrary 或者 GetModuleHandle函数可以返回此句柄。

lpProcName:包含函数名的以 NULL 结尾的字符串,或者指定函数的序数值。如果此参数是一个序数值,它必须在一个字的低字节,高字节必须为0。为了防止调用的函数不存在,函数应该通过名字指定而不是序数值。

函数返回值:如果函数调用成功,返回值是 DLL 中的输出函数地址。如果函数调用失败,返回值是NULL。要想得到更进一步的错误信息,可以调用函数 GetLastError。

以下是通过调用 GetProcAddress 获取某个特定名称的函数的代码示例:

在知道了某个动态链接库的基地址和要调用的函数的名称的情况下,可以通过调用下面函数 _getApi 得到函数地址:

使用这个函数传入 kernel32.dll 的基地址,然后再传入要获取的两个重要函数的名称字符串,即可获得这两个函数的VA。有了这两个地址,以后所有对 DLL 函数的调用就无需交给 Windows 加载器管理,直接在程序中实施动态加载即可。

完整代码在随书文件 chapter11\getTwoImportantFuns.asm 中,该程序的运行效果如图 11-3 所示:

扔程序入OD中验证成功:

在代码中使用获取的函数地址编程:

接下来看使用动态加载技术的最后一步,即在程序设计中使用动态 API 技术的方法:

在汇编语言编程中,通常的做法是先声明函数本身,然后再声明对函数的引用,最后通过定义函数引用的实例来调用动态获取到的函数。

大致的方法如下:

如果程序中所有引用的其他动态链接库的函数全部如上述方法定义,那么通过这种方法最终编译链接可以得到一个没有导入表的 PE 文件,这是动态加载技术在程序设计中的常用方法。

其实还有一种方法,这种方法不需要我们实现定义函数:

看随书文件 chapter11\crereateDir.asm 的例子。为了能调用定义的函数,在数据段构造了如下的数据结构;

然后看主程序中对各变量的赋值、处理和调用:

使用 OD 调试最终生成的 createDir.exe 文件,在 call eax 处设置断点,此时数据区如下图所示:

从图中可以看到,第一个红框处:

0x00403000 (变量 CreateDir) 处的值为 0x0076753100。这个值恰好是虚拟内存中 kernel32 的 CreateDirectoryA 函数所处的位置。如下图所示 kernel32.dll 的基地址为 0x76730000,加上函数偏移 0x00023100, 结果刚好是0x76753100。

从图中也可以看到,第二、三个红框处:

0x00403008(jmpCreateDir)处为 0x25ff,这是短跳转指令字节码即 jmp。

0x0040300a(jmpCDOffset) 处为 0x00403000,这是指令操作数,它刚好指向 CreateDir,也就是函数 CreateDirectoryA 的真实 VA 地址。

两个位置的数据合在一起就是:

jmp dword ptr [00403000] ;也就是 jmp 76753100

这就是第二种动态调用函数的方法,在以后的编程中大家也会看到大量的使用这种方法的代码。

动态API技术编程实例:

下面我们就使用动态加载技术开始编写动态 API 技术下的 helloworld.asm,代码清单 11-5 是完整的源代码:

上述代码中,获取 kernel32.dll 基地址时使用了另外一种技术。一个汇编程序,在初次被加载到内存运行主函数前,操作系统会将 kernel32.dll 中的某个地址作为返回值压入栈。从栈中取出该值,就能通过对齐特性反查到 kernel32.dll 被加载到进程地址空间的起始地址,由函数 _getKernelBase 实现(代码行48 一 80)。

程序中用到的其他动态链接库的所有函数的调用,均采用第一种方法设计,即按照常规声明函数、定义函数、动态获取函数地址的方法。

从代码清单中可以看到,在整个程序的设计中没有见到一个真正的 API 函数调用,因为调用的都是自己声明和定义的函数,比如 _getProcAddress、_loadLibrary 和 _messageBox。程序自己从内存中取到了这些 API 的真实地址,然后仿照 API 函数本身的定义复制成另外一个自己命名的函数而已,其实最终调用的还是 Windows 动态链接库里的 API 函数。

使用PEInfo 测试最终生成的 PE 文件,显示 PE 文件没有导入表,并不是因为采用了非常规的 PE 改动技术,使得程序无法探测到导入表,而是因为程序中确实没有直接使用 Windows API 函数,自然就没有导入表了。但精彩的是,程序却依然可以运行! 这就是程序员能从动态

加载技术得到的最大的收益。

小结:

本章描述了 DLL 的静态和动态加载技术,讲述了四种常见的 kernel32.dll 基地址获取方法,然后通过遍历 PE 的导出表得到两个重量级函数 GetProcAddress 和 LoadLibrary 的地址,最后介绍了在汇编程序设计中使用动态 API 加载技术的方法和技巧,并给出了一个完全以动态加载技术实现的实例 HelloWorld.asm 。

读者掌握了 API 函数的动态定位技术,结合前面讲过的代码重定位技术,就可以创造出可移植的 ShellCode,降低为 PE 打补丁时设计补丁工具的难度,同时对理解戏嵌入 PE 病毒的编写也会有很大帮助。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

沐一 · 林

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值