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

缘起

最近又遇到了一个程序功能不正常的问题,深入调查后发现与全局变量初始化顺序有非常大的关系,只不过这次更加隐蔽。

之前总结了两篇与全局变量初始化顺序有关的文章,感兴趣的小伙伴儿可以参考《调试实战 | dll 加载失败之全局变量初始化篇》 和《调试实战 | 全局变量初始化顺序探究》。

在排查错误之前先简单介绍一下相关代码。

示例程序

示例程序一共包含4 个工程:LoadDlls, dll1, dll2, dll3

  • 主程序LoadDlls.exe 会加载dll1.dll

  • dll1.dll 隐式依赖了dll2.dll,所以dll1.dll 加载的时候会自动加载dll2.dll

  • dll2.dll 中的全局变量s_culprit 的构造函数会加载dll3.dll

  • dll3.dll 加载的时候会自动调用dll2.dll 的导出函数RegisterInitCallback() 和RegisterCallback()

下面是每个工程的关键代码

  • src/common/autorunner.h

    该文件是公共头文件,实现了自动注册逻辑

    // autorunner.h
    
    #pragma once
    
    class AutoRunner
    {
    public:
        AutoRunner(void (*func)())
        {
            func();
        }
    };
    
    #define STR_CAT(s1, s2) s1 ## s2
    #define NAME_WITH_LINE(name, line) STR_CAT(name, line)
    
    #define BEGIN_AUTO_RUN static AutoRunner NAME_WITH_LINE(s_auto_runner_, __LINE__) ([](){
    #define END_AUTO_RUN  });
  • LoadDlls

    该工程只有一个源文件,用来模拟加载各种插件。对应的源码如下:

    // LoadDlls.cpp
    
    #include "windows.h"
    #include <iostream>
    #include <map>
    #include <vector>
    
    typedef void (*PFN_Init)();
    
    std::map<std::string, HMODULE> LoadPlugins(const char* plugins[])
    {
        std::map<std::string, HMODULE> result;
        for (int idx = 0; ; ++idx)
        {
            constchar* plugin = plugins[idx];
            if (plugin == nullptr)
            {
                break;
            }
    
            HMODULE module = LoadLibraryA(plugin);
            if (module == nullptr)
            {
                DWORD lastError = GetLastError();
                std::cout << "[+] load [" << plugin << "] failed with error " << lastError << std::endl;
            }
            else
            {
                result[plugin] = module;
            }
        }
    
        return result;
    }
    
    void InitPlugins(const std::map<std::string, HMODULE>& loaded_plugins)
    {
        for (auto& it : loaded_plugins)
        {
            auto init_entry = (PFN_Init)GetProcAddress(it.second, "Init");
            if (init_entry != nullptr)
            {
                init_entry();
            }
        }
    }
    
    int main()
    {
        std::cout << "[+] load plugin start." << std::endl;
    
        constchar* 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);
        return0;
    }
  • dll1

    该工程非常简单,什么有用的事情都没做,但是会依赖dll2,加载dll1.dll 的时候会自动加载dll2.dll

    // dllmain1.cpp
    
    #include <windows.h>
    #include <iostream>
    #include "../common/autorunner.h"
    
    #include "../dll2/exports.h"
    
    void PrintMajorVersion()
    {
        std::cout << "Major Version of dll2.dll is " << MajorVersion() << std::endl;
    }
    
    BEGIN_AUTO_RUN
    std::cout << "I'm running in dll1.dll, which implicitly depends on dll2.dll." << std::endl;
    END_AUTO_RUN
  • dll2

    该模块提供了注册回调函数的导出接口,并实现了回调逻辑

    // exports.h
    
    #pragma once
    
    #ifdef DLL_EXPORT_DLL2
    #define DLL_EXPORT extern "C" __declspec(dllexport)
    #else
    #define DLL_EXPORT extern "C" __declspec(dllimport)
    #endif
    
    DLL_EXPORT int MajorVersion();
    
    DLL_EXPORT void RegisterInitCallback(const char* key, void(*callback)());
    DLL_EXPORT void RegisterCallback(const char* key, void(*callback)());
    
    DLL_EXPORT void Init();
    // dllmain2.cpp
    
    #include <windows.h>
    #include <map>
    #include <string>
    
    #define DLL_EXPORT_DLL2
    #include "exports.h"
    
    ///////////////////////////////////////////////////////////////////////////////
    int MajorVersion() { return1; }
    
    ///////////////////////////////////////////////////////////////////////////////
    staticstd::map<std::string, void(*)()> s_init_callbacks;
    void RegisterInitCallback(const char* key, void(*callback)())
    {
        s_init_callbacks.insert(std::make_pair(key, callback));
    }
    
    ///////////////////////////////////////////////////////////////////////////////
    class MyGlobalVariable
    {
    public:
        MyGlobalVariable() { automodule = LoadLibrary(L"dll3.dll"); }
    };
    
    MyGlobalVariable s_culprit;
    
    ///////////////////////////////////////////////////////////////////////////////
    staticstd::map<std::string, void(*)()> s_callbacks;
    
    void RegisterCallback(const char* key, void(*callback)())
    {
        s_callbacks.insert(std::make_pair(key, callback));
    }
    
    ///////////////////////////////////////////////////////////////////////////////
    void Init()
    {
        for (auto it : s_init_callbacks)
        {
            it.second();
        }
    }
  • dll3

    该模块会自动调用dll2.dll 导出的接口进行注册

    // dllmain3.cpp
    
    #include <windows.h>
    #include <iostream>
    
    #include "../common/autorunner.h"
    #include "../dll2/exports.h"
    
    ///////////////////////////////////////////////////////////////////////////////
    void Dll3InitCallback()
    {
        std::cout << "I'm callback from dll3.dll" << std::endl;
    }
    
    BEGIN_AUTO_RUN
    RegisterInitCallback("dll3", Dll3InitCallback);
    END_AUTO_RUN
    
    ///////////////////////////////////////////////////////////////////////////////
    void Dll3Callback()
    {
        std::cout << "I'm callback from dll3.dll" << std::endl;
    }
    
    BEGIN_AUTO_RUN
    RegisterCallback("dll3", Dll3Callback);
    END_AUTO_RUN

直接运行程序,从表面上看一切正常,但是在调试器下运行程序的时候会遇到一个意想不到的异常。

调试运行

打开windbg,选择需要执行的程序,确定后输入g 命令,目标程序会发生异常,自动中断到windbg 中。

d9f0e0339f3e54081d261dfb3d0ffc98.png
exception-break-to-windbg

windbg 中输入kc 查看调用栈,输出结果摘录如下(为了方便查看,输出结果有所调整,注意<---- 的部分):

0:000> kc
 # Call Site
00 dll2!std::_Tree<std::string,void (__cdecl*)(void)>::_Insert_nohint<...>()
01 dll2!std::_Tree<std::string,void (__cdecl*)(void)>::emplace<...>()
02 dll2!std::map<std::string,void (__cdecl*)(void)>::insert<...>()
03 dll2!RegisterCallback  //<----
04 dll3!<lambda_7ce22ad9d321cf7c9be3c0faf7e37347>::operator()
05 dll3!<lambda_7ce22ad9d321cf7c9be3c0faf7e37347>::<lambda_invoker_cdecl>
06 dll3!AutoRunner::AutoRunner  //<----
07 dll3!`dynamic initializer for's_auto_runner_23''
08 ucrtbased!_initterm
09 dll3!dllmain_crt_process_attach
0a dll3!dllmain_crt_dispatch
0b dll3!dllmain_dispatch
0c dll3!_DllMainCRTStartup
0d ntdll!LdrpCallInitRoutine
0e ntdll!LdrpInitializeNode
0f ntdll!LdrpInitializeGraphRecurse
10 ntdll!LdrpPrepareModuleForExecution
11 ntdll!LdrpLoadDllInternal
12 ntdll!LdrpLoadDll
13 ntdll!LdrLoadDll
14 KERNELBASE!LoadLibraryExW
15 dll2!MyGlobalVariable::MyGlobalVariable   //<----
16 dll2!`dynamic initializer for's_culprit''
17 ucrtbased!_initterm
18 dll2!dllmain_crt_process_attach
19 dll2!dllmain_crt_dispatch
1a dll2!dllmain_dispatch
1b dll2!_DllMainCRTStartup
1c ntdll!LdrpCallInitRoutine
1d ntdll!LdrpInitializeNode
1e ntdll!LdrpInitializeGraphRecurse
1f ntdll!LdrpInitializeGraphRecurse
20 ntdll!LdrpPrepareModuleForExecution
21 ntdll!LdrpLoadDllInternal
22 ntdll!LdrpLoadDll
23 ntdll!LdrLoadDll
24 KERNELBASE!LoadLibraryExW
25 KERNELBASE!LoadLibraryExA
26 KERNELBASE!LoadLibraryA
27 LoadDlls!LoadPlugins  //<----
28 LoadDlls!main
29 LoadDlls!invoke_main
2a LoadDlls!__scrt_common_main_seh
2b LoadDlls!__scrt_common_main
2c LoadDlls!mainCRTStartup
2d KERNEL32!BaseThreadInitThunk
2e ntdll!RtlUserThreadStart

windbg 中输入.frame 0x27 切换到0x27 栈帧,然后输入dv 查看局部变量,可以发现确实是在加载dll1.dll

0:000> .frame 0x27
27 000000d5`3a4ff610 00007ff7`da7debb7     LoadDlls!LoadPlugins+0xb3 [D:\MyBlogStuff\LoadDlls\src\LoadDlls\LoadDlls.cpp @ 19] 
0:000> dv
         module = 0xcccccccc`cccccccc
         plugin = 0x00007ff7`da7ea740 "dll1.dll"
            idx = 0n0
        plugins = 0x000000d5`3a4ff868
         result = { size=0x0 }

结合代码可以整理整个执行流程,大概是这样的:

  • 主程序LoadDlls.exe 会通过LoadPlugins() 调用LoadLibrary() 来加载dll1.dll,由于dll1.dll 隐式依赖了dll2.dll,所以dll1.dll 加载的时候会自动加载dll2.dll

  • dll2.dll 中的全局变量s_culprit 的构造函数(栈帧0x15)内部会调用LoadLibrary() 加载dll3.dll(栈帧0x14

  • dll3.dll 中的全局变量s_auto_runner_23 的构造函数(栈帧0x6)内部会调用dll2.dll 的导出函数RegisterCallback()(栈帧0x3

  • RegisterCallback() 内部会调用s_callbacks.insert() 把注册的回调函数保存起来,但是在保存过程中遇到了异常,中断到了windbg 中。

查看异常

根据windbg 给出的提示,可以发现是在读取地址0x00000008 的时候发生了异常,此地址明显是不可访问的。

00007ffd 57684ff1 488b4008 mov rax,qword ptr [rax+8] ds:00000000 00000008=????????????????

看上去非常像是空指针异常。这段代码是在调用  s_callbacks.insert() 的时候执行的,大概率是s_callbacks 出了问题,在windbg 中使用dx s_callbacks -r4 查看  s_callbacks 的值,如下图:

25bc81755c387b88ea188bbb2d25a88f.png
view-s_callbacks

可以发现,s_callbacks 中的值很奇怪,都是空值。看上去很像还没有初始化的样子。

结合上面整理的调用流程,可以发现是在调用dll2!s_culprit 的构造函数时接触发了对dll2!RegisterCallback() 的调用,这时dll2!s_callbacks 这个全局变量还没有初始化。

因为初始化完dll2!s_culprit,才会初始化dll2!s_callbacks

至此,可以破案了。只需要调整一下这两个全局变量的顺序,问题就解决了。修改后的代码如下:

//MyGlobalVariable s_culprit; // 移动到 s_callbacks 下面

///////////////////////////////////////////////////////////////////////////////
static std::map<std::string, void(*)()> s_callbacks;

MyGlobalVariable s_culprit;

亲自动手

相关工程代码已经上传到github 了,感兴趣的小伙伴儿可以下载验证。

总结

本次故障是因为在dll2.dll 的全局变量s_culprit 的构造函数中使用LoadLibrary() 加载了dll3.dll,而dll3.dll 中的全局变量构造函数会调用dll2!RegisterCallback(),这个函数内部会使用未初始化的全局变量dll2!s_callbacks。因为此时正在初始化dll2!s_culprit 的过程中,dll2!s_culprit 初始化完成后才会初始化dll2!s_callbacks

相较于之前的案例,这次的案例更复杂,涉及到了多个模块。单看每个模块,问题都不大,但是放到一起就触发了这个异常。

所以,尽量不要在全局变量的构造函数中做复杂的工作,尤其要避免类似LoadLibrary 的操作。

参考资料

  • https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-best-practices

彩蛋

其实,这个问题背后还有一个更隐蔽的bug,不知道你是否看出来了呢?stay tuned!

源码下载地址: https://pan.quark.cn/s/a4b39357ea24 谷歌公司设计了一款无费用且具备开源特性的网络浏览器,名为Chrome,因其卓越的速度、稳定性和安全性而广受赞誉。该浏览器运用了前沿的Web渲染引擎Blink以及JavaScript引擎V8,旨在保障网页载入与脚本运行的卓越效能。为应对无网络环境下的Chrome安装需求,特别准备了离线安装包。此压缩文件内含32位与64位两种规格的Chrome浏览器离线安装方案,具体文件名分别为"chromedev_x64-v68.0.3423.2.exe"与"chromedev_x86-v68.0.3423.2.exe"。在文件命名中,"x64"标识64位版本,适用于64位操作系统平台,而"x86"则对应32位版本,适配32位操作系统。文件名中的"v68.0.3423.2"代表Chrome的一个特定版本号,各版本可能涵盖安全补丁、性能改进或新增功能。与32位Chrome相比,64位版本具备如下长处:能够处理更多内存容量,从而提升多任务作业能力;针对现代硬件的优化使其运行更为迅猛;64位版本更具备高级别的安全防护,能更周全地抵御恶意软件的侵袭。尽管如此,32位版本对于仍在使用32位操作系统的用户,或是在系统资源需求不高的场景下,依然适用。在部署Chrome浏览器时,用户需依据其个人计算机的操作系统平台,挑选匹配的版本进行安装。通过双击相应的.exe文件,安装流程将自动启动,一般包含接受使用许可、确定安装路径及构建桌面快捷方式等环节。若在安装阶段遭遇难题,可参照提示信息或联系技术支援获取协助,同时该压缩文件发布者亦表明欢迎用户以留言形式反映问题。Chrome浏览器的主要特质涵盖:直观的用户界面设计...
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值