缘起
最近又遇到了一个程序功能不正常的问题,深入调查后发现与全局变量初始化顺序有非常大的关系,只不过这次更加隐蔽。
之前总结了两篇与全局变量初始化顺序有关的文章,感兴趣的小伙伴儿可以参考《调试实战 | dll 加载失败之全局变量初始化篇》 和 《调试实战 | 全局变量初始化顺序探究》。
在排查错误之前先简单介绍一下相关代码。
示例程序
示例程序一共包含 4
个工程:LoadDlls, dll1, dll2, dll3
。
主程序
LoadDlls.exe
会加载dll1.dll
dll1.dll
隐式依赖了dll2.dll
,所以dll1.dll
加载的时候会自动加载dll2.dll
dll2.dll
中的全局变量s_culprit
的构造函数会加载dll3.dll
dll3.dll
加载的时候会自动调用dll2.dll
的导出函数RegisterInitCallback()
和RegisterCallback()
下面是每个工程的关键代码
src/common/autorunner.h
该文件是公共头文件,实现了自动注册逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// autorunner.h
class AutoRunner
{
public:
AutoRunner(void (*func)())
{
func();
}
};
LoadDlls
该工程只有一个源文件,用来模拟加载各种插件。对应的源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60// LoadDlls.cpp
typedef void (*PFN_Init)();
std::map<std::string, HMODULE> LoadPlugins(const char* plugins[])
{
std::map<std::string, HMODULE> result;
for (int idx = 0; ; ++idx)
{
const char* plugin = plugins[idx];
if (plugin == nullptr)
{
break;
}
HMODULE module = LoadLibraryA(plugin);
if (module == nullptr)
{
DWORD lastError = GetLastError();
std::cout << "[+] load [" << plugin << "] failed with error " << lastError << std::endl;
}
else
{
result[plugin] = module;
}
}
return result;
}
void InitPlugins(const std::map<std::string, HMODULE>& loaded_plugins)
{
for (auto& it : loaded_plugins)
{
auto init_entry = (PFN_Init)GetProcAddress(it.second, "Init");
if (init_entry != nullptr)
{
init_entry();
}
}
}
int main()
{
std::cout << "[+] load plugin start." << std::endl;
const char* plugins[] = { "dll1.dll", /*"dll2.dll", "dll3.dll",*/ nullptr };
auto loaded_module_map = LoadPlugins(plugins);
std::cout << "[+] load plugin done, press any key to init plugins." << std::endl;
system("pause");
InitPlugins(loaded_module_map);
return 0;
}dll1
该工程非常简单,什么有用的事情都没做,但是会依赖
dll2
,加载dll1.dll
的时候会自动加载dll2.dll
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// dllmain1.cpp
void PrintMajorVersion()
{
std::cout << "Major Version of dll2.dll is " << MajorVersion() << std::endl;
}
BEGIN_AUTO_RUN
std::cout << "I'm running in dll1.dll, which implicitly depends on dll2.dll." << std::endl;
END_AUTO_RUN
dll2
该模块提供了注册回调函数的导出接口,并实现了回调逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// exports.h
DLL_EXPORT int MajorVersion();
DLL_EXPORT void RegisterInitCallback(const char* key, void(*callback)());
DLL_EXPORT void RegisterCallback(const char* key, void(*callback)());
DLL_EXPORT void Init();
1 | // dllmain2.cpp |
dll3
该模块会自动调用
dll2.dll
导出的接口进行注册1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// dllmain3.cpp
///////////////////////////////////////////////////////////////////////////////
void Dll3InitCallback()
{
std::cout << "I'm callback from dll3.dll" << std::endl;
}
BEGIN_AUTO_RUN
RegisterInitCallback("dll3", Dll3InitCallback);
END_AUTO_RUN
///////////////////////////////////////////////////////////////////////////////
void Dll3Callback()
{
std::cout << "I'm callback from dll3.dll" << std::endl;
}
BEGIN_AUTO_RUN
RegisterCallback("dll3", Dll3Callback);
END_AUTO_RUN
直接运行程序,从表面上看一切正常,但是在调试器下运行程序的时候会遇到一个意想不到的异常。
调试运行
打开 windbg
,选择需要执行的程序,确定后输入 g
命令,目标程序会发生异常,自动中断到 windbg
中。
在 windbg
中输入 kc
查看调用栈,输出结果摘录如下(为了方便查看,输出结果有所调整,注意 <----
的部分):
1 | 0:000> kc |
在 windbg
中输入 .frame 0x27
切换到 0x27
栈帧,然后输入 dv
查看局部变量,可以发现确实是在加载 dll1.dll
。
1 | 0:000> .frame 0x27 |
结合代码可以整理整个执行流程,大概是这样的:
主程序
LoadDlls.exe
会通过LoadPlugins()
调用LoadLibrary()
来加载dll1.dll
,由于dll1.dll
隐式依赖了dll2.dll
,所以dll1.dll
加载的时候会自动加载dll2.dll
。dll2.dll
中的全局变量s_culprit
的构造函数(栈帧0x15
)内部会调用LoadLibrary()
加载dll3.dll
(栈帧0x14
)dll3.dll
中的全局变量s_auto_runner_23
的构造函数(栈帧0x6
)内部会调用dll2.dll
的导出函数RegisterCallback()
(栈帧0x3
)RegisterCallback()
内部会调用s_callbacks.insert()
把注册的回调函数保存起来,但是在保存过程中遇到了异常,中断到了windbg
中。
查看异常
根据 windbg
给出的提示,可以发现是在读取地址 0x00000008
的时候发生了异常,此地址明显是不可访问的。
00007ffd 57684ff1 488b4008 mov rax,qword ptr [rax+8] ds:00000000 00000008=????????????????
看上去非常像是空指针异常。这段代码是在调用 s_callbacks.insert()
的时候执行的,大概率是 s_callbacks
出了问题,在 windbg
中使用 dx s_callbacks -r4
查看 s_callbacks
的值,如下图:
可以发现,s_callbacks
中的值很奇怪,都是空值。看上去很像还没有初始化的样子。
结合上面整理的调用流程,可以发现是在调用 dll2!s_culprit
的构造函数时接触发了对 dll2!RegisterCallback()
的调用,这时 dll2!s_callbacks
这个全局变量还没有初始化。
因为初始化完 dll2!s_culprit
,才会初始化 dll2!s_callbacks
。
至此,可以破案了。只需要调整一下这两个全局变量的顺序,问题就解决了。修改后的代码如下:
1 | //MyGlobalVariable s_culprit; // 移动到 s_callbacks 下面 |
亲自动手
相关工程代码已经上传到 github 了,感兴趣的小伙伴儿可以下载验证。
总结
本次故障是因为在 dll2.dll
的全局变量 s_culprit
的构造函数中使用 LoadLibrary()
加载了 dll3.dll
,而 dll3.dll
中的全局变量构造函数会调用 dll2!RegisterCallback()
,这个函数内部会使用未初始化的全局变量 dll2!s_callbacks
。因为此时正在初始化 dll2!s_culprit
的过程中,dll2!s_culprit
初始化完成后才会初始化 dll2!s_callbacks
。
相较于之前的案例,这次的案例更复杂,涉及到了多个模块。单看每个模块,问题都不大,但是放到一起就触发了这个异常。
所以,尽量不要在全局变量的构造函数中做复杂的工作,尤其要避免类似 LoadLibrary
的操作。
参考资料
彩蛋
其实,这个问题背后还有一个更隐蔽的 bug
,不知道你是否看出来了呢?stay tuned!