缘起
在前面两篇文章中,应该算是彻底理清了项目中存在的两个问题。感兴趣的小伙伴儿可以参考《调试实战 | 调试另外一个由于全局变量初始化顺序导致的 dll 加载失败问题(上)》和 《调试实战 | 调试另外一个由于全局变量初始化顺序导致的 dll 加载失败问题(中)》。
在上篇文章的末尾提到一种情况
如果在
LoadDlls.exe中也显式加载了dll3.dll,还会不会崩溃呢?答案是可能崩溃,也可能不崩溃。因为
RegisterInitCallback()内部更新数据时使用的是map.insert(),这会导致一个问题 —— 如果map中已经存在相同的key,那么insert()会失败,不会更新数据。试想,如果显式加载
dll3.dll成功,但是dll3.dll的基址变了。map中保存的还是旧的无效地址,而不是新函数地址。如果
dll3.dll的基址没有发生变化,新函数地址与旧函数地址一样,程序可以非常“幸运”的正常运行。
本文通过实战来验证上文中的结论,什么情况下会崩溃,什么情况下不崩溃。
显式加载 dll3.dll
修改 LoadDlls 工程中的 main() 函数代码,使其加载 dll1.dll、dll2.dll 和 dll3.dll。修改后的关键代码如下:
1 | int main() |
本以为这样修改后,应该偶尔会崩溃(因为默认开启了 ASLR,模块的加载基址应该会随机才对)。结果发现,每次运行都不崩溃,而且功能一切正常,着实有些出乎意料。

看来 dll3.dll 每次都能加载到上一次被加载的基址。那怎么才能让 dll3.dll 加载到其它基址呢?
改变加载基址
最朴素的想法是,如果在显式加载 dll3.dll 之前,又加载了很多其它 dll,把原本 dll3.dll 加载的基址占用掉,那么再次加载 dll3.dll 的基址肯定会发生变化,大概率会崩溃。
按照这个思路,修改后的关键代码如下:
1 | int main() |
编译完成后,把 dll1.dll 复制一份,修改名字为 dll4.dll,然后运行 LoadDlls.exe,果然崩溃了。

小提示: 如果删除
dll4.dll,再次运行程序,又不崩溃了
调查一下崩溃原因,看看是不是跟我们预期的一样。
简单分析
用 windbg 打开转储文件后,点击 !analyze -v,分析结果如下:

可以发现在执行 movsxd rax,dword ptr [rax+4] 的时候崩溃了,而且这段反汇编代码属于 MSVCP140D!std::basic_ostream<char,std::char_traits<char> >::flush()。在 windbg 中查看相关反汇编代码,如下图:

从上图可知,rax 的值最开始来源于 rsp+0x50(mov rax, qword ptr [rsp+50h]),而 rsp+0x50 的值又来源于 rcx(mov qword ptr [rsp+8], rcx,sub rsp, 48h)。
在遍历调用 s_init_callbacks 保存的回调函数的时候并不会使用 rcx,因此 rcx 的值是是随机的,那么使用了 rcx 而崩溃是可以理解的。
还有一个小问题:为什么 dll2!Init() 会调用 MSVCP140D!std::basic_ostream<char,std::char_traits<char> >::flush() 呢?
进一步调查
使用命令 dx dll2!s_init_callbacks 查看 dll2!s_init_callbacks 的内容,如下图:

可以发现 dll2!s_init_callbacks 中保存的函数地址是 0x7ffd 929e12e4,对应的函数是 dll4!@ILT+735(?flush@?$basic_ostream@DU?$char_traits@D@std@@@std@@QEAAAEAV12@XZ),该函数最终会调用MSVCP140D!std::basic_ostream<char,std::char_traits<char> >::flush。
使用 lmm dll* 查看相关模块加载情况,如下图:

从上图可知,dll4.dll 的基址与已经卸载的 dll3.dll 的基址是一样的,都是 00007ffd 929d0000,新加载的 dll3.dll 的基址已经变成了 00007ffd 8c540000。
s_init_callbacks 中保存的函数地址相对于模块基址的偏移是 0x7ffd 929e12e4 - 00007ffd 929d0000 = 0x112e4,换算成在 dll3.dll 中的地址是 0x00007ffd 8c540000 + 0x112e4 = 0x00007ffd 8c5512e4。在 windbg 中使用 ln 0x00007ffd8c5512e4 查看于该地址对应的符号,输出结果如下:
1 | 0:000> ln 0x00007ffd8c5512e4 |
可以发现与 dll3!ILT+735(?Dll3InitCallbackYAXXZ) 完全匹配。
至此,所有疑问都已经解开了。dll3.dll 加载的时候会注册回调函数,由于异常 dll3.dll 会被自动卸载,但是注册回调函数并没有取消注册,dll4.dll 紧接着被加载到了 dll3.dll 旧基址,再次加载 dll3.dll,新的 dll3.dll 被加载到了其他位置。dll3.dll 最开始注册的回调函数变成了 dll4.dll 中的函数。
因为这是实际项目中遇到的问题,非常有代表性,而且崩溃的代码与业务代码毫不相干,很难查!
总结下来,主要有两大问题:
- 在全局变量的构造函数中调用
LoadLibrary()加载新的dll,这是很危险的操作,应该尽量避免。 - 当使用
insert()而不是operator[]向map中插入数据时,如果对应的key已经存在,不会更新数据。
总结
lm可以显示模块信息(包括已经卸载的模块信息),lmm dll*可以显示以dll开头的模块map.insert()在插入新数据时,如果发现对应的key已经存在,会直接返回false,而不会更新数据。operator []会强制更新数据