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

缘起

在前面两篇文章中,应该算是彻底理清了项目中存在的两个问题。感兴趣的小伙伴儿可以参考《调试实战 | 调试另外一个由于全局变量初始化顺序导致的 dll 加载失败问题(上)》《调试实战 | 调试另外一个由于全局变量初始化顺序导致的 dll 加载失败问题(中)》

在上篇文章的末尾提到一种情况

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

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

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

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

本文通过实战来验证上文中的结论,什么情况下会崩溃,什么情况下不崩溃。

显式加载 dll3.dll

修改 LoadDlls 工程中的 main() 函数代码,使其加载 dll1.dlldll2.dlldll3.dll。修改后的关键代码如下:

1
2
3
4
5
6
7
8
int main()
{
std::cout << "[+] load plugin start." << std::endl;

const char* plugins[] = { "dll1.dll", "dll2.dll", "dll3.dll", nullptr }; //<---- 只修改了这里
// ...
return 0;
}

本以为这样修改后,应该偶尔会崩溃(因为默认开启了 ASLR,模块的加载基址应该会随机才对)。结果发现,每次运行都不崩溃,而且功能一切正常,着实有些出乎意料。

function-normal

看来 dll3.dll 每次都能加载到上一次被加载的基址。那怎么才能让 dll3.dll 加载到其它基址呢?

改变加载基址

最朴素的想法是,如果在显式加载 dll3.dll 之前,又加载了很多其它 dll,把原本 dll3.dll 加载的基址占用掉,那么再次加载 dll3.dll 的基址肯定会发生变化,大概率会崩溃。

按照这个思路,修改后的关键代码如下:

1
2
3
4
5
6
7
8
int main()
{
std::cout << "[+] load plugin start." << std::endl;

const char* plugins[] = { "dll1.dll", "dll2.dll", "dll4.dll", "dll3.dll", nullptr }; //<---- 只修改了这里
// ...
return 0;
}

编译完成后,把 dll1.dll 复制一份,修改名字为 dll4.dll,然后运行 LoadDlls.exe,果然崩溃了。

app-crashed

小提示: 如果删除 dll4.dll,再次运行程序,又不崩溃了

调查一下崩溃原因,看看是不是跟我们预期的一样。

简单分析

windbg 打开转储文件后,点击 !analyze -v,分析结果如下:

windbg-analyze-v

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

view-disassembly-of-flush

从上图可知,rax 的值最开始来源于 rsp+0x50mov rax, qword ptr [rsp+50h]),而 rsp+0x50 的值又来源于 rcxmov qword ptr [rsp+8], rcxsub 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 的内容,如下图:

view-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* 查看相关模块加载情况,如下图:

view-modules

从上图可知,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
2
3
4
5
6
0:000> ln 0x00007ffd8c5512e4
Browse module
Set bu breakpoint

(00007ffd`8c5512e4) dll3!ILT+735(?Dll3InitCallbackYAXXZ) | (00007ffd`8c5512e9) dll3!ILT+740(?flush?$basic_ostreamDU?$char_traitsDstdstdQEAAAEAV12XZ)
Exact matches:

可以发现与 dll3!ILT+735(?Dll3InitCallbackYAXXZ) 完全匹配。

至此,所有疑问都已经解开了。dll3.dll 加载的时候会注册回调函数,由于异常 dll3.dll 会被自动卸载,但是注册回调函数并没有取消注册,dll4.dll 紧接着被加载到了 dll3.dll 旧基址,再次加载 dll3.dll,新的 dll3.dll 被加载到了其他位置。dll3.dll 最开始注册的回调函数变成了 dll4.dll 中的函数。

因为这是实际项目中遇到的问题,非常有代表性,而且崩溃的代码与业务代码毫不相干,很难查!

总结下来,主要有两大问题:

  1. 在全局变量的构造函数中调用 LoadLibrary() 加载新的 dll,这是很危险的操作,应该尽量避免。
  2. 当使用 insert() 而不是 operator[]map 中插入数据时,如果对应的 key 已经存在,不会更新数据。

总结

  • lm 可以显示模块信息(包括已经卸载的模块信息),lmm dll* 可以显示以 dll 开头的模块
  • map.insert() 在插入新数据时,如果发现对应的 key 已经存在,会直接返回 false,而不会更新数据。operator [] 会强制更新数据
BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%