缘起
在上篇文章《调试实战 | 调试另外一个由于全局变量初始化顺序导致的 dll 加载失败问题(上)》中,解决了由于全局变量初始化顺序不对导致的崩溃问题。但是代码里还有一处非常隐蔽的 bug
,今天继续介绍一下这个问题及对应的解决方法。
示例程序
在上篇文章代码的基础上,修改 LoadDlls
工程中的 main()
函数代码,使其加载 dll1.dll
和 dll2.dll
(上篇文章中只加载了 dll1.dll
)。修改后的关键代码如下:
1 | int main() |
直接运行崩溃了
运行完 LoadPlugins()
后,点击任意按键继续运行,会继续执行 InitPlugins()
,程序会在此函数中崩溃。因为我设置 procdump
为 JIT
调试器(具体设置方法可以参考这篇文章),程序崩溃后会自动调用 procdump
保存崩溃转储文件。
用 windbg
打开转储文件,无脑点击 !analyze -v
,让 windbg
帮我们自动分析,分析结果如下图:
从上图中的调用栈基本可以确定在执行 dll3.dll
中的代码时发生了异常。
而且这次的异常不是因为读/写非法地址导致的,而是执行到非法地址导致的(注意红色高亮部分的提示 Attempt to execute non-executable address 00007ffbdd9812e4
)。
可以猜测 00007ffbdd9812e4
是属于 dll3.dll
的(可以通过 !address 00007ffbdd9812e4
验证),而且当执行到 00007ffbdd9812e4
的时候,dll3.dll
已经被卸载了(注意底部红色高亮部分的 Unloaded
关键字)。
使用 !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
查看其内容,如下图:
可以发现,s_init_callbacks
中只有一项,是 dll3.dll
中的函数。
根据源码可知,dll3.dll
在加载的时候会自动调用 dll2!RegisterInitCallback()
注册回调函数。
1 | BEGIN_AUTO_RUN |
可以推理,s_init_callbacks
中的函数是 dll3!Dll3InitCallback
。
加载 dll3.dll
的调试符号后,查看调用栈,可以证实我们的猜想。如下图:
根据源码可知,dll3.dll
在加载的时候还会自动调用 RegisterCallback()
,相关代码如下:
1 | BEGIN_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 | void RegisterInitCallback(const char* key, void(*callback)()) |
总结
调试由于模块被卸载导致的异常,其实很简单 —— 直接在
windbg
中使用!analyze -v
基本上就可以定位到问题了这次的崩溃,直接原因在于模块被意外的卸载了,归根结底还是代码不规范导致的
如果
map
中想保存最新数据,那么不要使用map.insert
,而要使用operater []
map.insert()
在插入新数据时,如果发现对应的key
已经存在,会直接返回false
,而不会更新数据。operator []
会强制更新数据