文章目录
- 程序初始化
- 1 入口点
- 截胡 mainCRTStartup()
- 2 默认库
- 3 CRT初始化
- 符号替换技巧
- 4 全局变量动态初始化
- 5 TLS 回调函数
- 附录
- 附录1 编译器特殊节与 CRT 节
程序初始化
1 入口点
写一个简单的程序 Example.cpp
#include <print>int main() {std::println("Hello world!");
}
行尾打上断点运行,命中断点后可以看到调用栈
虽然C++法典 cppreference 提到, main() 函数应当是程序的启动点
但是实际上这调用堆栈一看就知道程序入口明显不是 main() 函数,前面还有一堆父调用, main() 函数只是个弟弟.
沿着调用堆栈走,在我们自己编译出来的程序 Example.exe 里被调用的第一个函数应该是
- int mainCRTStartup(void*)
/ENTRY (入口点符号) 提到, 程序链接时用选项 /ENTRY:函数名
指定程序入口点, 如果未提供该选项, 默认以下列几个函数之一作为入口点
- 子系统为控制台时(
/SUBSYSTEM:CONSOLE
), 选择下列函数之一- mainCRTStartup()
- wmainCRTStartup()
- 子系统为窗口时(
/SUBSYSTEM:WINDOWS
), 选择下列函数之一- WinMainCRTStartup()
- wWinMainCRTStartup()
入口点函数事实上作为一个线程的启动函数来被系统调用, 因此参考函数指针 PTHREAD_START_ROUTINE, 函数原型应为
- DWORD WINAPI (LPVOID ThreadParameter);
入口点函数接收一个参数, 实践上这个参数是PEB的地址
如果是子线程, 这个参数由启动线程的用户在填启动参数时自行指定
这些入口点函数名直接冲着 CRT 库去了.在调用堆栈窗口双击函数可以直接看到随编译器附带的 CRT 库源码,也可以在 MSVC 的目录里 crt\src\vcruntime\exe_main.cpp
的附近找到源码.
截胡 mainCRTStartup()
可以通过下面的小技巧添加一点代码截胡 mainCRTStartup()
// 向链接器添加命令行选项 通知链接器修改入口点名称 省的跑项目属性页里用鼠标点点点
// 手动配置见 项目属性页>配置属性>链接器>高级>入口点
#pragma comment(linker, "/ENTRY:start")// CRT启动函数藏在某处, 揪他出来
extern "C" int __stdcall mainCRTStartup(void* param);// 现在start是入口点
extern "C" int __stdcall start(void* param) {return mainCRTStartup(param);
}
现在调用堆栈里, 我们程序里被调用的第一个函数就是我们自己的函数, 没有中间商赚差价了🤤
2 默认库
Visual Studio 能开箱即用, 让人感觉不到 CRT 库的存在, 编译器编译时自行为链接器附加的链接选项起了很大作用
用 link 对 OBJ 文件进行链接时, 除了命令行可以提供链接选项, 还有 OBJ 文件自己也可以为链接器提供链接选项
这些选项一般是程序中通过 pragma 预处理指令夹带进来的
comment pragma 允许源码携带这些选项: /DEFAULTLIB /EXPORT /INCLUDE /MANIFESTDEPENDENCY /MERGE /SECTION 给链接器
#pragma comment(linker, "链接器选项") // 链接器选项指令 #pragma comment(lib, "库名") // 默认库指令 等效于 #pragma comment(linker, "/DEFAULTLIB:库名")
默认库不能在属性页手动配置
使用 dumpbin 可以导出 OBJ 文件内部夹带了哪些要带给链接器的选项
dumpbin /directives OBJ文件路径
如果是 Release 编译的 OBJ 需要手动到 项目属性页>配置属性>C/C++>优化>全程序优化 设为否, 即去掉
/GL
选项才能进行导出
新建一个空文件, 编译出 OBJ 文件后交给dumpbin, 可以看到编译器为每个 OBJ 文件都附加了2个默认库选项, 即使是空文件
届时链接如果没有 /NODEFAULTLIB
选项的话, 一旦用到默认库中的符号, 默认库就将会被链接到最终程序中. 同时这些默认库又夹带有自己的链接器选项, 这些选项又会增加一批默认库, 就像印度人一样最终一大批默认库选项被添加到链接器选项中
除此之外, 在包含C++头文件时, 会间接包含到里面一个基础头文件 use_ansi.h , 该头文件会添加默认库选项 msvcprt[d].lib 或 libcpmt[d].lib
C 运行时 (CRT) 和 C++ 标准库 (STL) .lib 文件 提到, /MD
的默认库还会添加一些 CRT 相关的 DLL 依赖, /MT
的默认库则将 CRT 全部静态链接到程序中, 没有 CRT 相关的 DLL 依赖.
文档中中文页面的表格出了问题, 英文页面又会自动跳转到中文页面😅
可以用浏览器 InProvate 模式访问 英文页面 C runtime (CRT) and C++ standard library (STL) .lib files 查看正确的表格
选项 自动定义的预处理器宏 \MT
_MT
\MTd
_MT
_DEBUG
\MD
_MT
_DLL
\MDd
_MT
_DLL
_DEBUG
另外 Visual Studio 新建项目也会默认在链接器附加依赖项中填写了一些依赖库
默认库夹带关系图大致如下
CRT 相关的 DLL 主要为 MSVCP140.dll, VCRUNTIME140.dll, api-ms-XX.dll 这些. 而其中 api-ms-XX.dll 系列的 DLL 大多最终会依赖到 ucrtbase.dll 上. 不过我们的程序并不会直接依赖 ucrtbase.dll.
由于 CRT 库不能保证一直没有漏洞, 因此不定期的系统更新随时有可能将 CRT 的 DLL 换掉, 为减小更新规模, ucrtbase.dll 按模块被拆分成了一大批 api-ms-XX.dll, 同时将接口版本写在 DLL 名字中, 缓解了 DLL 加载时没法指定版本造成接口不兼容的问题
3 CRT初始化
CRT 库的功能就是初始化 C/C++ 运行环境, 官方文档见 CRT初始化
下面列出初始化顺序表, 可以搭配源码对照看, 也可以跳过
- gs_support.c:157 __security_init_cookie(): cookie值初始化
- 在为编译器开启
/GS
选项后,编译器将为每个函数开头和返回时分别插入一段代码, 一个栈底设置 cookie, 一个 __security_check_cookie() 函数调用检查cookie是否被修改 - 该技术防不住恶意的溢出攻击, 只能简单防一下自己程序bug
- 在为编译器开启
- utility.cpp:184 __scrt_initialize_crt()
- __isa_available_init(): CPU特性检查
- MSVC 的 initialization.cpp:104 __vcrt_initialize(): VCRUNTIME C环境初始化
- 在用
/MD
/MDd
编译时, 该函数会被替换成 ucrt_stubs.cpp 的 __scrt_stub_for_acrt_initialize() 空函数, 实际初始化在DLL加载时完成 - locks.cpp:25 __acrt_initialize_locks(): 锁初始化
- per_thread_data.cpp:63 __vcrt_initialize_ptd(): 线程初始化
- 在用
- Windows Kits 的 initialization.cpp:281 __acrt_initialize(): UCRT C环境初始化
- 在用
/MD
/MDd
编译时, 该函数会被替换成 ucrt_stubs.cpp 的 __scrt_stub_for_acrt_initialize() 空函数, 实际初始化在DLL加载时完成 - initialize_global_variables(): 初始化全局变量
- initialize_pointers(): 初始化全局指针变量
- winapi_thunks.cpp:191 __acrt_initialize_winapi_thunks(): 加载系统DLL获取Windows API
- initialize_global_state_isolation()
- locks.cpp:25 __acrt_initialize_locks()
- heap_handle.cpp:21 __acrt_initialize_heap(): CRT堆初始化, 直接取进程堆做CRT堆
- per_thread_data.cpp:28 __acrt_initialize_ptd(): 初始化线程
- ioinit.cpp:224 __acrt_initialize_lowio(): 初始化stdio
- argv_data.cpp:61 __acrt_initialize_command_line(): 初始化argv
- mbctype.cpp:893 __acrt_initialize_multibyte(): 初始化当前代码页
- initialize_environment(): 初始化环境变量
- initialize_c(): 初始化onexit表
- 在用
- exe_common.inl:253 _initterm_e(__xi_a, __xi_z): C全局变量初始化
- exe_common.inl:256 _initterm(__xc_a, __xc_z): C++全局变量初始化
- exe_common.inl:273 tls_init_callback(nullptr, DLL_THREAD_ATTACH, nullptr): 触发TLS回调
- tlsdyn.cpp:79 __dyn_tls_init() 初始化
thread_local
线程变量
- tlsdyn.cpp:79 __dyn_tls_init() 初始化
符号替换技巧
用 dumpbin 导出 msvcrt.lib 和 libcmt.lib 可以发现这两个库都夹带了链接器选项, 下面是链接器选项节选
/alternatename:__acrt_initialize=__scrt_stub_for_acrt_initialize/alternatename:__acrt_uninitialize=__scrt_stub_for_acrt_uninitialize/alternatename:__acrt_uninitialize_critical=__scrt_stub_for_acrt_uninitialize_critical/alternatename:__acrt_thread_attach=__scrt_stub_for_acrt_thread_attach/alternatename:__acrt_thread_detach=__scrt_stub_for_acrt_thread_detach/alternatename:_is_c_termination_complete=__scrt_stub_for_is_c_termination_complete/alternatename:__vcrt_initialize=__scrt_stub_for_acrt_initialize/alternatename:__vcrt_uninitialize=__scrt_stub_for_acrt_uninitialize/alternatename:__vcrt_uninitialize_critical=__scrt_stub_for_acrt_uninitialize_critical/alternatename:__vcrt_thread_attach=__scrt_stub_for_acrt_thread_attach/alternatename:__vcrt_thread_detach=__scrt_stub_for_acrt_thread_detach/defaultlib:kernel32.lib/defaultlib:vcruntime.lib/defaultlib:ucrt.lib
/ALTERNATENAME
在 链接器文档 中没有提及, 这篇第三方文章 提到了该选项的功能: 前者符号找不到时改为用后者符号作为前者的实现
也就是当 __vcrt_initialize() 和 __acrt_initialize() 找不到实现时, 指定 __scrt_stub_for_acrt_initialize() 为实现, 这个函数定义于 ucrt_stubs.cpp, 实际是个空函数
使用 /MD
选项时链接 msvcrt.lib, 该库会增加 vcruntime.lib 和 ucrt.lib 为默认库, 在这两个库中确实找不到 __vcrt_initialize() 和 __acrt_initialize(), 两个函数被替换成空函数, 实际的CRT初始化在加载 VCRUNTIME140.dll 和 ucrtbase.dll 时完成
使用 /MT
选项时链接 libcmt.lib, 该库会增加 libvcruntime.lib 和 libucrt.lib 为默认库, 最终找到了 __vcrt_initialize() 和 __acrt_initialize(), 在我们程序内自己初始化 CRT
4 全局变量动态初始化
在 C++ 中我们可以在函数外声明一些全局变量, 可以用函数返回值给全局变量赋值,实现让一些函数比 main() 更早执行
earlier() 会比入口点函数更早执行吗?
结论是不会, 而且在监视窗口可以看到在 start 函数时, static_coffee 还是默认值 0
将编译器选项切换为
/MD
或/MDd
, 让 CRT 在 DLL 加载时初始化, 这样我们可以在入口点直接就能用上 C/C++ 函数
但是进程退出时无论 DLL 加载的 CRT, 还是静态链接的 CRT, C/C++ 函数均不可用
而另一个全局变量 static_babe 由于有确定的初始值, 其初始值则由操作系统加载器在加载时随机器码一起被加载到内存中, 在程序运行去前就抢先完成了赋初值
在 earlier() 内断点可以看到调用堆栈中出现了名字很口语的函数
CRT初始化 提到, 这些专为初始化全局变量的"动态初始化函数"的 地址 将由编译器放在 .CRT$XCU
节中
下面用 dumpbin 导出编译器编译出的 OBJ 文件的数据
dumpbin /all (OBJ文件路径)
如果是 Release 编译的 OBJ 需要手动到 项目属性页>配置属性>C/C++>优化>全程序优化 设为否, 即去掉
/GL
选项才能进行导出
可以到 项目属性页>配置属性>生成事件>生成后事件>命令行 添加
dumpbin /all $(IntDirFullPath)Example.obj > $(IntDirFullPath)Example.txt
在每次构建后都能自动调用 dumpbin 方便分析
可以看到 .CRT$XCU
确实有动态初始化函数地址, 顾名思义这就是 static_coffee 的动态初始化函数
#105节 (COFF 符号表中称作 SECT105) 长度 8B
初始化为 00 00 00 00 00 00 00 00
初始化后在偏移 0x00000000 处填写 8B 长度的 static_coffee 的动态初始化函数地址
这些数据编译器并未直接暴露符号出来给 CRT 库访问, CRT 库通过利用链接器对数据按节名排序的规则, 在特殊节首尾分别定义 .CRT$XCA
和 .CRT$XCZ
, 并在其中分别定义空指针, 通过 2 个空指针的地址包围数据区域来定位特殊节的数据
使用 section pragma 指令来声明一个节
#pragma section("节名", 权限1, 权限2, ...)
然后使用 declspec 来将一个符号定义在该节
__declspec(allocate("节名")) int variable;
internal_shared.h 内有 CRT 库的所有节定义, 完整总结放在附录 1
这些动态初始化函数本身还是在
.text$di
节中的, 分组的节(仅限对象) 提到节名中$
以后的字符会被丢弃, 也就是.text
节
至于全局变量在加载到内存时的值则可以到 dumpbin 导出的 COFF 符号表中查
SECT3 声明在 .data 节中, 长度为 0x4
- static_babe 声明在 SECT3 偏移 0x00000000 处, SECT3 的原始数据正是 0x0000BABE
SECT4 声明在 .tls 节中, 长度为 0x8
- thread_beef 声明在 SECT4 偏移 0x00000000 处, SECT4 前 4B 数据正是 0x0000BEEF
- thread_coffee 声明到 SECT4 偏移 0x00000004 处, 紧接在 thread_beef 之后, SECT4 后 4B 数据为 0x00000000
SECT116 声明在 .bss 节中, 长度为 0x4
- static_coffee 声明在 SECT116 偏移 0x00000000 处, SECT116 为 Uninitialized Data, 没有数据
.tls 节 提到, .tls节的数据会被系统给每个线程都分别复制一份
实际运行程序查看变量地址也可以得到验证
static_coffee 和 static_babe 的地址处于 Example.exe 模块的地址范围内, 被调试器发现并给出了较为可读的注释
而 thread_coffee 和 thread_beef 则分配在堆上, 只有裸数字
稍微留意一下会发现 static_coffee 和 static_babe 的地址离得非常近, 不到一个页 (PAGE_SIZE=4KB) 的距离
直接查看程序二进制文件可以看到 .data 节完完整整给 static_coffee 预留了空间, 并不像 .bss 节那样不占用程序文件大小
这是因为零值初始化的内存区域太小, 只要定义个大数组做零值初始化, 之后所有零值初始化的变量就全部跟非零初始化分开到不同节了
上面提到了很多 .CRT 节中定义的东西, 但注意力不错的话会发现最终程序中其实并没有 .CRT 节
这是因为链接 mrvcrt.lib 时, 该库夹带了选项/merge:.CRT=.rdata
给链接器, 最后 .CRT 节的数据全部进入到了 .rdata 节中
当最终输出程序时, 链接器发现 .CRT 节依然存在的话, 反而还会报警告😅
5 TLS 回调函数
TLS 回调函数执行得比入口点还早🤭
每个线程都有独属于自己的内存空间(简称 TLS), 在线程的主要代码运行前, 需要先行运行一段线程内存空间初始化的代码, 分配由系统进行, 而数据初始化则由 TLS 回调函数进行. 并且当线程结束时也会触发一次 TLS 回调进行销毁
Windows 提供了一个机制允许用户设置 TLS 回调用于初始化 TLS
.tls 节 提到, 编译器会将特殊名称的变量 _tls_used 识别为 TLS 目录, 就是 TLS 回调入口
默认库 msvcrt.lib 和 libcmt.lib 已经提供好了 _tls_used 符号, 源码见 tlssup.cpp
我们可以利用链接器对节按名称排序的特性, 插入我们自己的 TLS 回调函数
void __stdcall tls_callback(void* dllHandle, int reason, void*) {std::println("Hello tls callback");
}
// 没有线程动态初始化函数时 _tls_used 会被优化掉, 用 /INCLUDE 把他救回来做 TLS 目录
// 手动配置见 属性页>配置属性>链接器>输入>强制符号引用
#pragma comment(linker, "/INCLUDE:_tls_used")
// 声明一个节, 插入到 .CRT$XLA 和 .CRT$XLZ 之间
// .CRT$XLB 这个节名将比 CRT 安装在 .CRT$XLC 和 .CRT$XLD 两个 TLS 回调还要早被调用
#pragma section(".CRT$XLB", read)
extern "C" __declspec(allocate(".CRT$XLB")) void* __xl_b = tls_callback;
TLS 回调函数 提到回调函数原型为 PIMAGE_TLS_CALLBACK 指向的函数类型
VOID NTAPI (PVOID DllHandle, DWORD Reason, PVOID Reserved);
观察堆栈可以发现只有主线程/子线程启动时的 TLS 回调是系统调用的, 其他时候的调用都是 CRT 的手笔
事件 | 调用方 | TLS回调 | 线程变量初始化/销毁 |
---|---|---|---|
主线程启动 | 系统 | 执行 Reason=DLL_PROCESS_ATTACH=1 | 不执行 |
主线程CRT初始化 | CRT | 不执行 | 执行 |
子线程启动 | 系统 | 执行 Reason=DLL_THREAD_ATTACH=2 | 执行 |
子线程结束 | CRT | 执行 Reason=DLL_THREAD_DETACH=3 | 执行 |
主线程结束 | CRT | 执行 Reason=DLL_PROCESS_DETACH=0 | 执行 |
主线程的线程变量初始化被 CRT 推迟到主线程启动后 CRT 初始化时进行
附录
附录1 编译器特殊节与 CRT 节
节 | 定义 | 符号 | 数据 | 注释 |
---|---|---|---|---|
.CRT$XLA | tlssup.cpp | __xl_a | 空 | TLS回调列表起始 |
.CRT$XLC | tlsdyn.cpp | __xl_c | __dyn_tls_init地址 | |
.CRT$XLD | tlsdtor.cpp | __xl_d | __dyn_tls_dtor地址 | |
.CRT$XLZ | tlssup.cpp | __xl_z | 空 | 列表末尾 |
.CRT$XIA | initializers.cpp | __xi_a | 空 | 列表起始 |
.CRT$XIAA | exe_common.inl | pre_c_initializer | pre_c_initialization地址 | |
.CRT$XIAB | PGO 初始化 | |||
.CRT$XIAC | exe_common.inl | post_pgo_initializer | ||
.CRT$XIC | winapisupp.cpp lconv_unsigned_char.cpp thread_safe_statics.cpp | 多个 | 一些函数地址 | |
.CRT$XIU | 编译器 | C全局变量动态初始化函数地址列表 | ||
.CRT$XIYA | tmsta.cpp | initializer | initialize_threading_model_for_sta地址 | |
.CRT$XIYAA | XAML Designer Threading Model | |||
.CRT$XIYB | VCCorLib Main | |||
.CRT$XIZ | initializers.h | __xi_z | 空 | 列表末尾 |
.CRT$XCA | initializers.cpp | __xc_a | 空 | 列表起始 |
.CRT$XCAA | exe_common.inl | pre_cpp_initializer | pre_cpp_initialization地址 | |
.CRT$XCC | 编译器 | C++全局变量动态初始化函数地址 | 未使用 | |
.CRT$XCL | 编译器 | C++全局变量动态初始化函数地址 | 未使用 | |
.CRT$XCU | 编译器 | C++全局变量动态初始化函数地址列表 | ||
.CRT$XCZ | initializers.cpp | __xc_a | 空 | 列表末尾 |
.CRT$XDA | tlsdyn.cpp | __xd_a | 空 | 列表起始 |
.CRT$XDC | 编译器 | C++线程变量动态初始化函数地址 | 未使用 | |
.CRT$XDL | 编译器 | C++线程变量动态初始化函数地址 | 未使用 | |
.CRT$XDU | 编译器 | C++线程变量动态初始化函数地址列表 | ||
.CRT$XDZ | tlsdyn.cpp | __xd_z | 空 | 列表结尾 |
下面的节CRT库未使用, CRT库的主要销毁函数不列在节中, 而是在onexit列表中
节 | 定义 | 符号 | 数据 | 注释 |
---|---|---|---|---|
.CRT$XPA | initializers.cpp | __xp_a | C预销毁函数列表起始 | |
.CRT$XPB | concrt\utils.cpp | pterm | _concrt_static_cleanup地址 | CRT ConcRT预销毁 |
.CRT$XPX | CRT预销毁列表 | |||
.CRT$XPXA | CRT stdio预销毁 | |||
.CRT$XPZ | initializers.cpp | __xp_z | 列表末尾 | |
.CRT$XTA | initializers.cpp | __xt_a | C销毁函数列表起始 | |
.CRT$XTZ | initializers.cpp | __xt_z | 列表末尾 |
民间也有文章总结 .CRT Section Usage
从 c1xx.dll 导出字符串, 可以看到编译器可能会用到的所有节名
CRT 源码指出.CRT$XCC
和.CRT$XCL
是等可能会被用到的特殊节, 但网上粗略查找一番找不到实例😅