3.1 程序的真正入口

VS C++开发的程序在调试时总是从main或WinMain函数开始,这就让开发者误认为它们是程序的第一条指令执行处,这个认知其实是错误的。main或WinMain函数需要有一个调用者,在它们被调用前,编译器其实已经做了很多事情,所以main或WinMain是“语法规定的用户入口”,而不是“应用程序入口”。应用程序被操作系统加载时,操作系统会分析执行文件内的数据[1],并分配相关资源,读取执行文件中的代码和数据到合适的内存单元,然后才是执行入口代码,入口代码其实并不是main或WinMain函数,通常是mainCRTStartup、wmainCRTStartup、WinMainCRTStartup或wWinMainCRTStartup,具体视编译选项而定。其中mainCRTStartup和wmainCRTStartup是控制台环境下多字节编码和Unicode编码的启动函数,而WinMainCRTStartup和wWinMainCRTStartup则是Windows环境下多字节编码和Unicode编码的启动函数。在开发过程中,C++也允许程序员自己指定入口。

本章将详细讲解在进入入口函数main和WinMain之前,VS C++都做了哪些事情。


[1]关于执行文件内的数据组织,请查阅PE格式相关资料,推荐《加密与解密(第3版)》(段钢著)。

3.2 了解VS2019的启动函数

VS C++在控制台和多字节编码环境下的启动函数为mainCRTStartup,由系统库KERNEL32.dll负责调用,在mainCRTStartup中再调用main函数。使用VS2019进行调试时,入口断点总是停留在main函数的首地址处。如何挖掘main函数之前的代码呢?我们可以利用VS2019的栈回溯功能。在调试环境下,依次选择菜单“调试”→“窗口”→“调用堆栈”,打开出栈窗口(快捷键:Ctrl+Alt+C)。如图3-1所示,此窗口显示了程序启动后,函数的调用流程。

图3-1 栈回溯窗口

图3-1中显示了程序运行时调用的8个函数,依次是__RtlUserThreadStart@8、__RtlUserThreadStart、@BaseThreadInitThunk@12、mainCRTStartup、__scrt_common_main、__scrt_common_main_seh、invoke_main和main。其中@BaseThreadInitThunk@12调用mainCRTStartup,我们无法查看mainCRTStartup函数之前的高级源码,而VS 2019则提供了mainCRTStartup函数的源码,安装完整版的VS2019并下载符号文件就可以查看。双击调用栈窗口中的mainCRTStartup函数,查看函数的内部实现,如代码清单3-1所示。

笔记--------------------------------------------start

设置符号路径,点击菜单【工具】=》【选项】=》【调试】=》【符号】,设置符号文件位置和缓存目录。

点【确定】设置成功后,编译调试

原因是_RtlUserThreadStart@8、__RtlUserThreadStart、@BaseThreadInitThunk@12、mainCRTStartup、__scrt_common_main、__scrt_common_main_seh、invoke_main这些函数是非用户代码,即属于外部代码,

右键【外部代码】=》【显示外部代码】

查看外部函数的源码

笔记--------------------------------------------end

代码清单3-1 mainCRTStartup函数在VS2019中的代码片段

extern "C" int mainCRTStartup()
{
    return __scrt_common_main();
}

static __forceinline int __cdecl __scrt_common_main()
{
    //初始化缓冲区溢出全局变量
    __security_init_cookie();
    return __scrt_common_main_seh();
}

static __declspec(noinline) int __cdecl __scrt_common_main_seh()
{
    //用于初始化C语法中的全局数据
    if (_initterm_e(__xi_a, __xi_z) != 0)
        return 255;

    //用于初始化C++语法中的全局数据
    _initterm(__xc_a, __xc_z);

    //初始化线程局部存储变量
    _tls_callback_type const* const tls_init_callback = __scrt_get_dyn_tls_init_callback();
    if (*tls_init_callback != nullptr && __scrt_is_nonwritable_in_current_image(tls_init_callback))
    {
        (*tls_init_callback)(nullptr, DLL_THREAD_ATTACH, nullptr);
    }

    //注册线程局部存储析构函数
    _tls_callback_type const * const tls_dtor_callback = __scrt_get_dyn_tls_dtor_callback();
    if (*tls_dtor_callback != nullptr && __scrt_is_nonwritable_in_current_image(tls_dtor_callback))
    {
        _register_thread_local_exe_atexit_callback(*tls_dtor_callback);
    }

    //初始化完成调用main()函数
    int const main_result = invoke_main();

    //main()函数返回执行析构函数或atexit注册的函数指针,并结束程序
    if (!__scrt_is_managed_app())
        exit(main_result);
}

static int __cdecl invoke_main()
{
    //调用main函数,传递命令行参数信息
    return main(__argc, __argv, _get_initial_narrow_environment());
}

代码清单3-1展示了在VS2019控制台程序的默认启动函数中做了一系列初始化工作。下面详细解读启动函数的工作流程。

__security_init_cookie函数:初始化缓冲区溢出全局变量,用于在函数中检查缓冲区是否溢出。

_initterm_e函数:用于全局数据和浮点寄存器的初始化,该函数由两个参数组成,类型为“_PIFV *”,这是一个函数指针数组,其中保留了每个初始化函数的地址。初始化函数的类型为_PIFV,其定义原型如下所示。

typedef int  (__cdecl* _PIFV)(void);

如果初始化失败,返回非0值,程序终止运行。一般而言,_initterm_e初始化的都是C语言支持库中所需的数据。参数_xi_a为函数指针数组的起始地址,_xi_z为结束地址,具体如代码清单3-2所示。

代码清单3-2 _initterm_e函数的代码片段

extern "C" int __cdecl _initterm_e(_PIFV* const first, _PIFV* const last)
{
  for (_PIFV* it = first; it != last; ++it)
  {
    if (*it == nullptr)
      continue;

    int const result = (**it)();
    if (result != 0)
      return result;
  }

  return 0;
}

_initterm函数:C++全局对象和IO流等的初始化都是通过这个函数实现的,可以利用_initterm函数进行数据链初始化。这个函数由两个参数组成,类型为“_PVFV *”,这也是一个函数指针数组,其中保留了每个初始化函数的地址。初始化函数的类型为_PVFV,其定义原型如下所示。

typedef void (_cdecl *_PVFV)(void);

也就是说,这个初始化函数是无参数也无返回值的。大家知道,C++规定全局对象和静态对象必须在main函数前构造,在main函数返回后析构。所以,这里的_PVFV函数指针数组就是用来代理调用构造函数的,具体如代码清单3-3所示。

代码清单3-3 _initterm函数的代码片段

extern "C" void __cdecl _initterm(_PVFV* const first, _PVFV* const last)
{
  for (_PVFV* it = first; it != last; ++it)
  {
    if (*it == nullptr)
       continue;

    (**it)();
  }
}

C++所需数据的初始化操作会在如代码清单3-3所示的_initterm函数调用时执行,一般都是全局对象或静态对象初始化函数。关于全局对象初始化流程的更多内容请见第10章。

__scrt_get_dyn_tls_init_callback函数:获取线程局部存储(TLS)变量的回调函数,用于初始化使用__declspec(thread)定义的变量。__scrt_get_dyn_tls_dtor_callback函数:获取线程局部存储变量的析构回调函数,用于注册析构回调函数。invoke_main函数:该函数获取main函数所需的3个参数信息之后,当调用main函数时,便可以将_argc、_argv、env这3个全局变量作为参数,传递到main函数中。

exit函数:执行析构函数或atexit注册的函数指针,并结束程序。VS编译器的版本不同,mainCRTStartup函数也可能会有所不同,GCC和Clang编译器的入口函数与所选择的库相关。本书只针对VS2019版本进行讲解,其他VS版本或编译器入口函数也需要做一些同样的初始化工作,读者可自行分析。

笔记--------------------------------------------start

D:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\crt\src\vcruntime\exe_common.inl

_initterm()函数和_initterm_e()函数的声明在D:\Windows Kits\10\Include\10.0.19041.0\ucrt\corecrt_startup.h文件中

函数实现在D:\Windows Kits\10\Source\10.0.19041.0\ucrt\startup\initterm.cpp文件中

笔记--------------------------------------------end

3.3 main函数的识别

有了3.2节的知识作铺垫,识别VS2019正常编译程序的main函数就非常简单了。

识别main函数的原理如同识别一个人。要对一个人进行识别,首先是观察此人外观,找出他身体和面貌上的特征。然后将这些特征与自己认识的人的特征相匹配,从而判断此人身份。那么,C++下的main函数都有哪些特征呢?从代码清单3-1中可以总结出main函数有如下特征。

有3个参数,分别是命令行参数个数、命令行参数信息和环境变量信息,而且main函数是启动函数中唯一具有3个参数的函数。同理,WinMain也是启动函数中唯一具有4个参数的函数。main函数返回后需要调用exit函数,结束程序根据main函数调用的特征,找到入口代码第一次调用exit函数处,离exit最近的且有3个参数的函数通常就是main函数。

x64dbg在加载程序时直接暂停在应用程序的入口处,而不会直接定位到main函数处,需要分析者手动查找定位。通过main函数的特性查找到所在的位置,如代码清单3-4所示。

代码清单3-4 x64dbg反汇编信息

0040135F |  | mov eax,dword ptr ds:[<&__initenv>]
00401364 |  | mov edx,dword ptr ds:[405010]
0040136A |  | mov dword ptr ds:[eax],edx
0040136C |  | mov eax,dword ptr ds:[405010]
00401371 |  | mov dword ptr ss:[esp+8],eax      ;参数3 env入栈
00401375 |  | mov eax,dword ptr ds:[405014]
0040137A |  | mov dword ptr ss:[esp+4],eax      ;参数2 argv入栈
0040137E |  | mov eax,dword ptr ds:[405018]
00401383 |  | mov dword ptr ss:[esp],eax        ;参数1 argc入栈
00401386 |  | call x86_gcc.401510               ;main函数
0040138B |  | mov ecx,dword ptr ds:[405008]
00401391 |  | mov dword ptr ds:[40500C],eax
00401396 |  | test ecx,ecx
00401398 |  | je x86_gcc.40146C
0040139E |  | mov edx,dword ptr ds:[405004]
004013A4 |  | test edx,edx
004013A6 |  | jne x86_gcc.4013B2
004013A8 |  | call <JMP.&_cexit>                ;第一次调用exit函数

识别出代码清单3-4中的exit()函数后,对应前面讨论的main函数特性继续向上寻找。为了准确识别main函数,可以考察传递参数的个数,如果具有3个参数,便是main函数的调用,双击即可进入main函数的实现中。

笔记--------------------------------------------start

32位编译

	cl  /Fe:x86_vs.exe *.cpp
	clang -m32 -O0 -o x86_clang.exe *.cpp
	gcc -m32 -o x86_gcc.exe *.cpp

64位编译

	cl  /Fe:x64_vs.exe *.cpp
	clang -m64 -O0 -o x64_clang.exe *.cpp
	gcc -m64 -o x64_gcc.exe *.cpp

0040135F | A1 68714000        | mov eax,dword ptr ds:[<__initenv>]               
00401364 | 8B15 10604000      | mov edx,dword ptr ds:[406010]                    
0040136A | 8910               | mov dword ptr ds:[eax],edx                       
0040136C | A1 10604000        | mov eax,dword ptr ds:[406010]                    
00401371 | 894424 08          | mov dword ptr ss:[esp+8],eax  ;参数3 env入栈                  
00401375 | A1 14604000        | mov eax,dword ptr ds:[406014]                    
0040137A | 894424 04          | mov dword ptr ss:[esp+4],eax  ;参数2 argv入栈                  
0040137E | A1 18604000        | mov eax,dword ptr ds:[406018]                    
00401383 | 890424             | mov dword ptr ss:[esp],eax    ;参数1 argc入栈                  
00401386 | E8 35020000        | call x86_gcc.4015C0           ;main函数                  
0040138B | 8B0D 08604000      | mov ecx,dword ptr ds:[406008]                    
00401391 | A3 0C604000        | mov dword ptr ds:[40600C],eax                    
00401396 | 85C9               | test ecx,ecx                                     
00401398 | 0F84 CE000000      | je x86_gcc.40146C                                
0040139E | 8B15 04604000      | mov edx,dword ptr ds:[406004]                    
004013A4 | 85D2               | test edx,edx                                     
004013A6 | 75 0A              | jne x86_gcc.4013B2                               
004013A8 | E8 47120000        | call <JMP.&_cexit>            ;第一次调用exit函数                  

笔记--------------------------------------------end

IDA下的main函数识别更为简便,它会直接分析出main函数所在的位置并显示出来。如果IDA无法识别,可根据前面讨论的main函数特性,利用exit函数定位,如果IDA无法识别出exit函数,就需要加载sig文件重新识别,如图3-2所示。

图3-2 IDA分析查找启动函数

笔记--------------------------------------------start

笔记--------------------------------------------end

3.4 本章小结

本章先对传统C语言教材中提及的main函数入口论提出了质疑,以执行文件的反汇编代码为依据,提出了“应用程序入口”和“语法规定的用户入口”这两个概念,并且分析了VS2019在用户入口前的部分行为。虽然这里是以对main入口的分析为主,但是其他入口的行为基本一致,各个VS版本的原理也基本相同,仅有少许变动,读者可以尝试针对其他VS版本的应用程序入口进行练习,亲自分析一下。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐