逆向基础 OS-specific (四)

68章 Windows Nt


68.1 CRT(win32)


程序一开始就从main()函数执行的?事实并非如此。如果我们用IDA或者HIEW打开一个可执行文件,我们可以看到OEP(Original Entry Point)指向了其它代码块。这些代码做了一些维护和准备工作之后再把控制流交给我们的代码。这就是所谓的startup-code或叫CRT code(C RunTime)。

main()函数通过一个数组接收命令行传递过来的参数,环境变量与此类似。通常情况下,传递一个字符串到程序之后,CRT code会用空格来分割它们。CRT code同样也准备了一个envp来存放环境变量。如果是GUI版本的win32程序,入口函数需要使用WinMain()来代替main()函数,它也有自己的参数。

#!c
int CALLBACK WinMain(
    _In_ HINSTANCE hInstance,
    _In_ HINSTANCE hPrevInstance,
    _In_ LPSTR lpCmdLine,
    _In_ int nCmdShow
);

CRT code同样会准备好它所需要的所有参数。

此外,main()函数的返回值是它的退出码。CRT code将它作为ExitProcess()的参数。

通常,每个编译器都有它自己的CRT code。

下面是MSVC 2008特有的CRT code。

#!c
___tmainCRTStartup proc near

var_24 = dword ptr -24h
var_20 = dword ptr -20h
var_1C = dword ptr -1Ch
ms_exc = CPPEH_RECORD ptr -18h

    push 14h
    push offset stru_4092D0
    call __SEH_prolog4
    mov eax, 5A4Dh
    cmp ds:400000h, ax
    jnz short loc_401096
    mov eax, ds:40003Ch
    cmp dword ptr [eax+400000h], 4550h
    jnz short loc_401096
    mov ecx, 10Bh
    cmp [eax+400018h], cx
    jnz short loc_401096
    cmp dword ptr [eax+400074h], 0Eh
    jbe short loc_401096
    xor ecx, ecx
    cmp [eax+4000E8h], ecx
    setnz cl
    mov [ebp+var_1C], ecx
    jmp short loc_40109A


loc_401096: ; CODE XREF: ___tmainCRTStartup+18
            ; ___tmainCRTStartup+29 ...
    and [ebp+var_1C], 0

loc_40109A: ; CODE XREF: ___tmainCRTStartup+50
    push 1
    call __heap_init
    pop ecx
    test eax, eax
    jnz short loc_4010AE
    push 1Ch
    call _fast_error_exit
    pop ecx

loc_4010AE: ; CODE XREF: ___tmainCRTStartup+60
    call __mtinit
    test eax, eax
    jnz short loc_4010BF
    push 10h
    call _fast_error_exit
    pop ecx

loc_4010BF: ; CODE XREF: ___tmainCRTStartup+71
    call sub_401F2B
    and [ebp+ms_exc.disabled], 0
    call __ioinit
    test eax, eax
    jge short loc_4010D9
    push 1Bh
    call __amsg_exit
    pop ecx

loc_4010D9: ; CODE XREF: ___tmainCRTStartup+8B
    call ds:GetCommandLineA
    mov dword_40B7F8, eax
    call ___crtGetEnvironmentStringsA
    mov dword_40AC60, eax
    call __setargv
    test eax, eax
    jge short loc_4010FF
    push 8
    call __amsg_exit
    pop ecx

loc_4010FF: ; CODE XREF: ___tmainCRTStartup+B1
    call __setenvp
    test eax, eax
    jge short loc_401110
    push 9
    call __amsg_exit
    pop ecx

loc_401110: ; CODE XREF: ___tmainCRTStartup+C2
    push 1
    call __cinit
    pop ecx
    test eax, eax
    jz short loc_401123
    push eax
    call __amsg_exit
    pop ecx
loc_401123: ; CODE XREF: ___tmainCRTStartup+D6
    mov eax, envp
    mov dword_40AC80, eax
    push eax ; envp
    push argv ; argv
    push argc ; argc
    call _main
    add esp, 0Ch
    mov [ebp+var_20], eax
    cmp [ebp+var_1C], 0
    jnz short $LN28
    push eax ; uExitCode
    call $LN32

$LN28: ; CODE XREF: ___tmainCRTStartup+105
    call __cexit
    jmp short loc_401186


$LN27: ; DATA XREF: .rdata:stru_4092D0
    mov eax, [ebp+ms_exc.exc_ptr] ; Exception filter 0 for function 401044
    mov ecx, [eax]
    mov ecx, [ecx]
    mov [ebp+var_24], ecx
    push eax
    push ecx
    call __XcptFilter
    pop ecx
    pop ecx

$LN24:
    retn

$LN14: ; DATA XREF: .rdata:stru_4092D0
    mov esp, [ebp+ms_exc.old_esp] ; Exception handler 0 for function 401044
    mov eax, [ebp+var_24]
    mov [ebp+var_20], eax
    cmp [ebp+var_1C], 0
    jnz short $LN29
    push eax ; int
    call __exit

$LN29: ; CODE XREF: ___tmainCRTStartup+135
    call __c_exit

loc_401186: ; CODE XREF: ___tmainCRTStartup+112
    mov [ebp+ms_exc.disabled], 0FFFFFFFEh
    mov eax, [ebp+var_20]
    call __SEH_epilog4
    retn

在这里我们看到代码调用了GetCommandLineA(),setargv()和setenvp()去填充argc,argv,envp全局变量。

最后,使用这些参数去调用main()函数。

有些函数调用了与自身类似的函数,如heap_init(),ioinit()。

如果你尝试在CRT code代码中使用malloc(),它将异常退出下面的错误:

runtime error R6030
- CRT not initialized

在C++中,全局对象的初始化也同样发生在main()函数执行之前的CRT:51.4.1。

main()函数的返回值传给cexit()或$LN32,后者调用doexit()。

能否摆脱CRT?这个当然,如果你知道你在做什么的话。

MSVC的链接器可以通过/ENTRY选项设置入口函数。

#!c++
#include <windows.h>
int main()
{
    MessageBox (NULL, "hello, world", "caption", MB_OK);
};

让我们用MSVC 2008来编译它。

#!bash
cl no_crt.c user32.lib /link /entry:main

我们可以获得一个大小为2560字节的runnable.exe。它有一个PE头,调用MessageBox的指令,数据段中有两串字符串,而MessageBox函数导入自user32.DLL。

这个程序能够正常运行,但你不能在main()函数里面使用WinMain()的四个参数。准确点来说你能,但是这些参数并没有在执行的时候准备好。

#!bash
cl no_crt.c user32.lib /link /entry:main /align:16

它会报一个链接警告:

LINK : warning LNK4108: /ALIGN specified without /DRIVER; image may not run

我们可以获得一个720字节的exe文件。它可以在Windows 7 x86上正常运行,但是没办法在x64上运行(当你运行它的时候会将先是一条错误信息)。更多的优化可能可以提高执行效率,但如你所见,很快就出现了兼容问题。

68.2 Win32 PE


PE是Windows下的可执行文件格式。

.exe,.dll,.sys文件它们之间的区别是,.exe和.sys文件通常没有导出表,只有导入表。

DLL文件和其它PE文件类似,有一个入口点(OEP)(DllMain()函数),但一般情况下很少DLL带有这个函数。

.sys通常是一个设备驱动程序。

作为驱动程序,Windows需要检验它的PE文件并保证它是正确的。

从Windows Vista开始,一个驱动程序文件必须拥有数字签名,否则它会被拒绝加载。

每个PE文件都由一段打印“This program cannot be run in DOS mode.”的DOS程序块开始。如果你的程序运行于DOS或者Windows 3.1(这些OS并不识别PE文件格式),这个DOS程序块将被执行打印。

68.2.1 术语

  • Module(模块) - 一个exe/dll文件。
  • Process(进程) - 加载到内存中并正在运行的程序,通常由一个exe文件和多个dll文件组成。
  • Process memory(进程内存) - 进程所在容所。每个进程都拥有自己的内存。通常是加载的模块,栈内存,堆内存等等。
  • VA(虚拟地址) - 可以被程序所使用的地址。
  • Base address(基地址) - 模块被加载到进程内存后的地址。
  • RVA(相对虚拟地址) - VA地址减去基地址后的地址。PE文件中有许多地址使用RVA地址。
  • IAT(导入地址表)- 一个导入符号地址的数组。通常由一个IMAGE_DIRECTORY_ENTRY_IAT数据目录指向IAT。值得注意的是,IDA可会给IAT分配一个名为.idata的pseudo-section,即使IAT是其它section的一部分。
  • INT(导入名称表) - 一个导入符号名的数组。

68.2.2 Base address

问题是,模块(DLL)的开发者不可能事先知道哪些地址分配给哪些模块使用的。

这就是为什么两个具有相同基地址的DLL需要一个加载到这个基地址而另外一个加载到进程的其它空闲内存处并调整第二个DLL的虚拟地址。

通常情况下,MSVC链接器生成.exe文件的基地址是0x400000,并把代码段安排在0x401000。这意味着该代码段的RVA地址是0x1000。DLL的基地址通常被MSVC链接器安排在0x10000000。

还有一种情况下加载模块时会导致基地址浮动。

这就是ASLR(Address Space Layout Randomization(地址空间布局随机化))。

一个shellcode想要执行必须调用到系统的函数。

在老的操作系统当中(如果是WindowsNT,则在Windows Vista之前),系统的DLL(如kernel32.dll,user32.dll)总是加载到已知的地址。如果我们还记得的话,它们的版本是很少有变动的。因为函数的地址是固定的,shellcode可以直接调用它们。

为了避免这种情况,ASLR每次在加载模块的时候都会随机安排它们的基地址。

支持ASLR的程序在PE头中会设置IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE标识表明其支持ASLR。

68.2.3 Subsystem

还有一个subsystem字段, 通常是: - native (sys驱动程序) - console (控制台程序) - GUI (图形程序)

68.2.4 OS version

PE文件还规定了可以加载它的最小Windows版本号。有一个表保存了PE的版本号和相应的Windows开发代号。

举个例子,MSVC 2005编译的.exe文件运行在Windows NT4(version 4.00)。但MSVC 2008不是(生成文件的版本是5.00,至少运行于Windows 2000)。

MSVC 2012生成的.exe文件默认是6.00版本,最低平台要求至少是Windows Vista。但可以通过更改编译选项,强制编译器支持Windows XP。

68.2.5 Sections

一部分section似乎存在于所有可执行文件格式里面。

下面的标志位用于区分代码和常量数据:

  • 当IMAGE_SCN_CNT_CODE或IMAGE_SCN_MEM_EXECUTE被置位,表示该section是一个可执行代码。
  • 在数据section中,IMAGE_SCN_CNT_INITIALIZED_DATA,IMAGE_SCN_MEM_READ和IMAGE_SCN_MEM_WRITE被置位。
  • 在未初始化section和空section中,IMAGE_SCN_CNT_UNINITIALIZED_DATA, IMAGE_SCN_MEM_READ和IMAGE_SCN_MEM_WRITE被置位。
  • 在常量数据section(写保护)中,IMAGE_SCN_CNT_INITIALIZED_DATA和IMAGE_SCN_MEM_READ被置位,但不可以置位 IMAGE_SCN_MEM_WRITE。当一个进程尝试在这个section写数据时,进程会崩溃掉。

每个section在PE文件可能有一个名字,但是它并不是很重要。通常(但不总是)代码section的名字是.text,数据section是.data,常量数据section是.rdata(readable data)。其它流行的名字还有:

  • .idata—imports section(导入section)。IDA可能会创建一个类似(68.2.1)的pseudo-section。
  • .edata—exports section(导出section)。
  • .pdata—在Windows NT(MIPS,IA64,x64)包含了所有异常信息。
  • .reloc—relocs section(重定位section)
  • .bss—uninitialized data(未初始化数据(BSS))
  • .tls—thread local storage(线程局部存储(TLS))
  • .rsrc—resources(资源)
  • .CRT—可能存在古老的MSVC版本编译出来的二进制文件里面。

PE文件的打包器/加密器经常打乱section名字或者把名字替换为自己的。

MSVC允许你任意命名section。

一些编译器和链接器可以添加一个用于调试符号和其他调试信息的section(例如MinGW)。但不包括MSVC现在的版本(提供单独的PDB文件用于这个目的)。

这是PE文件的section结构体定义:

typedef struct _IMAGE_SECTION_HEADER {
    BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
    union {
        DWORD PhysicalAddress;
        DWORD VirtualSize;
    } Misc;
    DWORD VirtualAddress;
    DWORD SizeOfRawData;
    DWORD PointerToRawData;
    DWORD PointerToRelocations;
    DWORD PointerToLinenumbers;
    WORD NumberOfRelocations;
    WORD NumberOfLinenumbers;
    DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

一些相关的字段的解释:PointerToRawData是在磁盘文件中的偏移,VirtualAddress在Hiew中是装载到内存中的RVA。

68.2.6 Relocations (relocs)

也称为FIXUP-s(在Hiew)。

他们也存在于几乎所有的可执行文件格式。

显然,模块可以被加载到各种基地地址,但如何处理全局变量?一个解决方案是使用位置无关代码(67.1章),但它并不是总是有用的。

这就是重定位表存在的理由:当模块加载到不同的基地址的时候,它们的入口地址都需要修正。

举个例子,有一个全局变量的地址是0x410000,它是这样访问的:

A1 00 00 41 00    mov eax, [000410000]

模块的基地址是0x400000,全局变量的RVA地址是0x10000。

如果模块加载到0x500000这个基地址,那么全局变量实际的地址必须是0x510000。

我们可以看到,在0xA1字节之后,变量的地址编码到MOV指令中的。

这就是为什么0xA1字节之后的4个字节地址写在了重定位表。

如果模块加载到不同的基地址,操作系统加载器枚举重定位表中所有地址,查找每个32位的地址,减去原来的基地址(我们这里得到了RVA),并添加新的基地址。

如果模块加载到原来的基地址,那么不做任何事情。

所有的全局变量都可以这样处理。

重定位表可能有各种类型,但是在x86处理器的Windows中,通常是IMAGE_REL_BASED_HIGHLOW。

顺便说一下,重定位表在Hiew是隐藏的。相关例子请查看(Figure 7.12)。

OllyDbg会用下划线标识哪些使用了重定位表。相关例子请查看(Figure 13.11)。

68.2.7 Exports and imports

众所周知,任何可执行文件都必须使用操作系统提供的服务和其它一些动态链接库。

可以说,一个模块(通常是DLL)的函数通常都是导出提供给其它模块使用(.exe文件或其它DLL)。

这种情况下,每个DLL都有一个导出(exports)表,由模块的函数加它们的地址组成。

每个exe或dll文件也有一个导入(imports)表,里面包含了程序执行所需函数对应的DLL文件名。

在加载main.exe文件之后,操作系统加载器开始处理导入表:它加载所需的DLL文件,接着在DLL的导入表查找对应函数名字的地址,然后把它们的地址写到main.exe模块的IAT((Import Address Table)导入表)。

我们可以看到,加载器必须大量比较函数名,但字符串比较效率并不是很高。所以有一个支持“ordinals”或“hints”的东西,表示函数存储在表中的序号,用于代替它们的函数名。

这使得它们可以更快地加载DLL。Ordinals在导出表中永远都存在。

举个例子:一个使用MFC库的程序都是通过ordinals加载mfc*.dll,在这种程序中,INT(Import Name Table)是不存在MFC函数名字的。

使用IDA加载这类程序的时候,如果告诉它mfc*.dll文件路径,则可以看到函数名。如果不告诉IDA这些DLL路径,它会显示诸如mfc80_123而不是函数名。

Imports section

编译器通常会给导入表及其相关内容分配一个单独的section(名字类似.idata),但这不是一个强制规定。

因为术语混乱,导入表是一个比较令人困惑的地方。让我们尝试一下整理这些信息。

Figure 68.1: A scheme that unites all PE-file structures related to imports

Figure 68.1: A scheme that unites all PE-file structures related to imports

里面主要的结构是IMAGE_IMPORT_DESCRIPTOR数组。每个被加载进来的DLL占用一个元素。

每个元素包含一个文本字符串(DLL名字)的RVA地址。

OriginalFirstThink是INT表的RVA地址。这是一个RVA地址的数组,里面每个成员都指向一个函数名的文本字符串。每个函数名的字符串之前是一个16位的("hint")-"ordinal"整数。

加载的时候,如果可以通过ordinal找到函数,那么就不需要使用字符串比较来查找函数。数组的最后一个元素是0。还有一个FirstThunk字段指向IAT表,这个地方是加载器重写需要重新解析函数的地址的RVA地址。

需要加载器重写地址的函数在IDA中加了诸如这种标记:__imp_CreateFileA。

加载器至少有两种方法重写地址:

  • 代码会有诸如调用__imp_CreateFileA的指令,因为导入函数的地址在某种意义上是一个全局变量,当模块加载到不同的基地址时,call指令的地址被添加到重定位表中。 但是,显然这种方法可能会扩大重定位表。因为有可能从这个模块大量调用导入的函数。而且,重定位表太大的话会减慢模块的加载速度。

  • 每个导入函数给它分配一条jmp指令,使用jmp指令加上重定位表的地址跳转到导入函数。这些入口点被称之为“thunks”,所有调用导入函数仅需要调用相对应的“thunk”,这种情况下不需要额外的重定位操作,因为这些CALL都使用相对地址,不需要额外的调整操作。

这两种方法可以组合使用。可能的话,链接器给那些被调用太多次的函数创建一个“thunk”,然而默认情况下不是这样。

顺便说一下,FirstThunk指向的函数地址数组不必要位于IAT section。举个例子,我曾经写的PE_add_import工具可以给.exe文件添加一个导入函数。在早些时候,这个工具可以让你的函数调用其它DLL文件的函数。我的工具添加了类似下面的代码:

MOV EAX, [yourdll.dll!function]
JMP EAX

FirstThunk指向第一条指令,换句话说,当加载yourdll.dll的时候,加载器在代码中写入function函数的正确地址。

还值得注意的是代码段通常是写保护的,因此我的工具在code section添加了一个IMAGE_SCN_MEM_WRITE标志位。否则,程序在加载的时候会爆出错误码为5(访问失败)的异常错误。

有人可能会问:如果我提供一个程序与一组不变的DLL文件,是有可能加快加载过程?

是的,它可以提前把函数的地址写入到导入表的FirstThunk数组。IMAGE_IMPORT_DESCRIPTOR结构有一个Timestamp字段。如果这个变量存在,则加载器会比较这个变量和DLL文件日期时间。如果它们相等,那么加载器不做任何事情,所以加载过程可以很快完成。这就是所谓的“old-style binding”。为了加快程序的加载,Matt Pietrek. “An In-Depth Look into the Win32 Portable Executable File Format”,建议你的程序安装在最终用户的计算机后不久做捆绑。

PE文件的打包器/加密器也可以压缩/加密导入表。在这种情况下,Windows的加载器当然不会加载所有需要的DLL。因此打包器/加密器只能通过LoadLibrary()和GetProcAddress()来获取所需函数。

安装在Windows系统中的标准DLL文件,IAT往往是位于PE文件的开头。据说,这是一种优化。加载时.exe文件不是全部加载到内存,它是“映射”和加载部分需要被访问到的内存。可能微软的开发者认为这样加载比较快。

68.2.8 Resources

资源在PE文件只是一组图标,图片,文本字符串,对话框描述。因为把它们从主代码分离了出来,所以多国语言程序很容易实现,只需要根据操作系统设置的语言去选择文本或图片的语言。

作为一个副作用,通过使用诸如ResHack的编辑器,即使在没有专业知识的情况下,也可以轻松地编辑和保存可执行文件的资源。

68.2.9 .NET

.NET的程序并不编译成机器码,而是编译成字节码。严格地说,是在.exe文件里面使用字节码代替x86机器。然而,进入入口点(OEP)