调试实战 | 调试另外一个由于全局变量初始化顺序导致的 dll 加载失败问题(中)

缘起

上篇文章《调试实战 | 调试另外一个由于全局变量初始化顺序导致的 dll 加载失败问题(上)》中,解决了由于全局变量初始化顺序不对导致的崩溃问题。但是代码里还有一处非常隐蔽的 bug,今天继续介绍一下这个问题及对应的解决方法。

示例程序

在上篇文章代码的基础上,修改 LoadDlls 工程中的 main() 函数代码,使其加载 dll1.dlldll2.dll(上篇文章中只加载了 dll1.dll)。修改后的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

直接运行崩溃了

运行完 LoadPlugins() 后,点击任意按键继续运行,会继续执行 InitPlugins(),程序会在此函数中崩溃。因为我设置 procdumpJIT 调试器(具体设置方法可以参考这篇文章),程序崩溃后会自动调用 procdump 保存崩溃转储文件。

windbg 打开转储文件,无脑点击 !analyze -v,让 windbg 帮我们自动分析,分析结果如下图:

exception-when-excute-from-unloaded-dll3

从上图中的调用栈基本可以确定在执行 dll3.dll 中的代码时发生了异常。

而且这次的异常不是因为读/写非法地址导致的,而是执行到非法地址导致的(注意红色高亮部分的提示 Attempt to execute non-executable address 00007ffbdd9812e4)。

可以猜测 00007ffbdd9812e4 是属于 dll3.dll 的(可以通过 !address 00007ffbdd9812e4 验证),而且当执行到 00007ffbdd9812e4 的时候,dll3.dll 已经被卸载了(注意底部红色高亮部分的 Unloaded 关键字)。

使用 !address 00007ffbdd9812e4 查看该地址的信息,如下图:

address-00007ffbdd9812e4

从上图可知,00007ffbdd9812e4 确实是属于 dll3.dll 的,但是 dll3.dll 已经被卸载了。00007ffbdd9812e4 所在的页面是 MEM_FREE 的,而且是 PAGE_NOACCESS 的。

但是,程序为什么会执行到一个已经被卸载的模块中的地址呢?

追本溯源

从调用栈可以得知,dll3.dll 中的函数是由 dll2!Init() 调用的, 通过查看 dll2!Init() 源码,可以发现 dll2!Init() 函数中会遍历 s_init_callbacks。在 windbg 中输入dx dll2!s_init_callbacks 查看其内容,如下图:

view-s_init_callbacks

可以发现,s_init_callbacks 中只有一项,是 dll3.dll 中的函数。

根据源码可知,dll3.dll 在加载的时候会自动调用 dll2!RegisterInitCallback() 注册回调函数。

1
2
3
BEGIN_AUTO_RUN
RegisterInitCallback("dll3", Dll3InitCallback);
END_AUTO_RUN

可以推理,s_init_callbacks 中的函数是 dll3!Dll3InitCallback

加载 dll3.dll 的调试符号后,查看调用栈,可以证实我们的猜想。如下图:

compare-callstacks-after-reload-dll3-symbol

根据源码可知,dll3.dll 在加载的时候还会自动调用 RegisterCallback(),相关代码如下:

1
2
3
BEGIN_AUTO_RUN
RegisterCallback("dll3", Dll3Callback);
END_AUTO_RUN

因为调用 RegisterCallback() 的时候发生了异常(在上篇文章中已经分析过了),导致 dll3.dll 加载失败,dll3.dll 会被自动卸载 。但是通过 RegisterInitCallback() 注册的回调函数已经保存在了 dll2!s_init_callbacks 中,没有被清除。

当后面调用 dll2!Init() 时,会调用 dll3.dll 注册到 dll2!s_init_callbacks 中的函数(Dll3InitCallback),因为 dll3.dll 已经被卸载了,对应的函数地址也无效了,也就会发生上文中的异常。

深入思考

如果在 LoadDlls.exe 中也显式加载了 dll3.dll,还会不会崩溃呢?答案是可能崩溃,也可能不崩溃

因为 RegisterInitCallback()内部更新数据时使用的是 map.insert(),这会导致一个问题 —— 如果 map 中已经存在相同的 key,那么 insert() 会失败,不会更新数据。

试想,如果显式加载 dll3.dll 成功,但是 dll3.dll 的基址变了。map 中保存的还是旧的无效地址,而不是新函数地址。

如果 dll3.dll 的基址没有发生变化,新函数地址与旧函数地址一样,程序可以非常“幸运”的正常运行。

要想规避这种问题,可以换一种写法,修改后的代码如下:

1
2
3
4
5
void RegisterInitCallback(const char* key, void(*callback)())
{
//s_init_callbacks.insert(std::make_pair(key, callback));
s_init_callbacks[key] = callback;
}

总结

  • 调试由于模块被卸载导致的异常,其实很简单 —— 直接在 windbg 中使用 !analyze -v 基本上就可以定位到问题了

  • 这次的崩溃,直接原因在于模块被意外的卸载了,归根结底还是代码不规范导致的

  • 如果 map 中想保存最新数据,那么不要使用 map.insert,而要使用 operater []

  • map.insert() 在插入新数据时,如果发现对应的 key 已经存在,会直接返回 false,而不会更新数据。operator [] 会强制更新数据

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%