缘起
在前面两篇文章中,应该算是彻底理清了项目中存在的两个问题。感兴趣的小伙伴儿可以参考《调试实战 | 调试另外一个由于全局变量初始化顺序导致的 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 []
会强制更新数据