前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >DllMain中不当操作导致死锁问题的分析--导致DllMain中死锁的关键隐藏因子

DllMain中不当操作导致死锁问题的分析--导致DllMain中死锁的关键隐藏因子

作者头像
方亮
发布2019-01-16 15:56:14
1.3K0
发布2019-01-16 15:56:14
举报
文章被收录于专栏:方亮方亮

        有了前面两节的基础,我们现在切入正题:研究下DllMain为什么会因为不当操作导致死锁的问题。首先我们看一段比较经典的“DllMain中死锁”代码。(转载请指明出于breaksoftware的csdn博客)

代码语言:javascript
复制
//主线程中
HMODULE h = LoadLibraryA(strDllName.c_str());
代码语言:javascript
复制
// DLL中代码
static DWORD WINAPI ThreadCreateInDllMain(LPVOID) {
    return 0;
}

BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
					 )
{
    DWORD tid = GetCurrentThreadId();
    switch (ul_reason_for_call)   
    {
    case DLL_PROCESS_ATTACH: {
            printf("DLL DllWithoutDisableThreadLibraryCalls_A:\tProcess attach (tid = %d)\n", tid);
            HANDLE hThread = CreateThread(NULL, 0, ThreadCreateInDllMain, NULL, 0, NULL);
            WaitForSingleObject(hThread, INFINITE);
            CloseHandle(hThread);
        }break;
    case DLL_PROCESS_DETACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
        break;
    }
    return TRUE;
}

        简要说下DLL中逻辑:设计该段代码的同学希望在DLL第一次被映射到进程内存空间时,创建一个工作线程,该工作线程内容可能很简单。为了尽可能简单,我们让这个工作线程直接返回0。这样从逻辑和效率上看,都不会因为我们的工作线程写的有问题而导致死锁。然后我们在DllMain中等待这个线程结束才从返回。

        粗略看这个问题,我们很难看出这个逻辑会导致死锁。但是事实就是这样发生了。我们跑一下程序,发现程序输出一下结果

        后就停住了,光标在闪动,貌似还是在等待我们输入。可是我们怎么敲击键盘都没有用:它死锁了。

        我是在VS2005中调试该程序,于是我们可以Debug->Break All来冻结所有线程。

        我们先查看主线程(3096)的堆栈

           堆栈不长,我全部列出来

17

ntdll.dll!_KiFastSystemCallRet@0()

16

ntdll.dll!_NtWaitForSingleObject@12()

15

kernel32.dll!_WaitForSingleObjectEx@12()

14

kernel32.dll!_WaitForSingleObject@8()

13

DllWithoutDisableThreadLibraryCalls_A.dll!DllMain(HINSTANCE__ * hModule=0x10000000, unsigned long ul_reason_for_call=1, void * lpReserved=0x00000000)

12

DllWithoutDisableThreadLibraryCalls_A.dll!__DllMainCRTStartup(void * hDllHandle=0x10000000, unsigned long dwReason=1, void * lpreserved=0x00000000)

11

DllWithoutDisableThreadLibraryCalls_A.dll!_DllMainCRTStartup(void * hDllHandle=0x10000000, unsigned long dwReason=1, void * lpreserved=0x00000000)

10

ntdll.dll!_LdrpCallInitRoutine@16()

9

ntdll.dll!_LdrpRunInitializeRoutines@4()

8

ntdll.dll!_LdrpLoadDll@24()

7

ntdll.dll!_LdrLoadDll@16()

6

kernel32.dll!_LoadLibraryExW@12()

5

kernel32.dll!_LoadLibraryExA@12()

4

kernel32.dll!_LoadLibraryA@4()

3

DllMainSerial.exe!wmain(int argc=3, wchar_t * * argv=0x003b7000)

2

DllMainSerial.exe!__tmainCRTStartup()

1

DllMainSerial.exe!wmainCRTStartup()

0

kernel32.dll!_BaseProcessStart@4()

      我们看下这个堆栈。大致我们可以将我们程序分为4段:

        0 启动启动我们程序

        1~6 我们加载Dll。

        7~10 系统为我们准备DLL的加载。

        11~17 DLL内部代码执行。

        我们关注一下14~17这段对WaitForSingleObject的调用逻辑。15、16步这个过程显示了Kernel32中的WaitForSingleObjectEx在底层是调用了NtDll中的NtWaitForSingleObject。在NtWaitForSingleObject内部,即17步,我们看到的“_KiFastSystemCallRet@0”。这儿要说明下,这个并不是意味着我们程序执行到这个函数。我们看下这个函数的代码

        KiFastSystemCallRet函数是内核态(Ring0层)逻辑回到用户态(Ring3层)的着陆点。与之相对应的KiFastSystemCall函数是用户态进入内核态必要的调用方法。因为内核态代码我们是无法查看的,所以动态断点只能设置到KiFastSystemCallRet开始处。所以实际死锁是因为NtWaitForSingleObject在底层调用了KiFastSystemCall进入内核,在内核态中死锁的。

        我们在《DllMain中不当操作导致死锁问题的分析--死锁介绍》中介绍过,死锁存在的条件是相互等待。主线程中,我们发现其等待的是工作线程结束。那么工作线程在等待主线程什么呢?我们看下工作线程的调用堆栈

        我们对这个堆栈进行编号

6

ntdll.dll!_KiFastSystemCallRet@0()

5

ntdll.dll!_NtWaitForSingleObject@12()  + 0xc bytes

4

ntdll.dll!_RtlpWaitForCriticalSection@4()  + 0x8c bytes

3

ntdll.dll!_RtlEnterCriticalSection@4()  + 0x46 bytes

2

ntdll.dll!__LdrpInitialize@12()  + 0xb4bf bytes

1

ntdll.dll!_KiUserApcDispatcher@20()  + 0x7 bytes

0

ntdll.dll!_RtlAllocateHeap@12()  + 0x9b48 bytes

       我们看到倒数两步(5、6)和主线程中最后两步(16、17)是相同的,即工作线程也是在进入内核态后死锁的。我们知道主线程在等工作线程结束,那么工作线程在等什么呢?我们追溯栈,请关注“ntdll.dll!__LdrpInitialize@12() + 0xb4bf bytes”处的代码 

        我们看到,是因为_RtlEnterCriticalSection在底层调用了NtWaitForSingleObject。那么我们关注下_RtlEnterCriticalSection的参数_LdrpLoaderLock,它是什么?我们借助下IDA查看下LdrpInitialize反编译代码

代码语言:javascript
复制
……
v4 = *(_DWORD *)(*MK_FP(__FS__, 0x18) + 0x30);
v3 = *MK_FP(__FS__,0x18);
 ……
  *(_DWORD *)(v4 + 0xa0) = &LdrpLoaderLock;
  if ( !(unsigned __int8)RtlTryEnterCriticalSection(&LdrpLoaderLock) )
  {
  ……
    RtlEnterCriticalSection(&LdrpLoaderLock);
  }
  ……
  if ( *(_DWORD *)(v4 + 0xc) )
  {
    ……
    LdrpInitializeThread(a1);
  }
  else
  {
……
    v17 = LdrpInitializeProcess(a1, a2, &v11, v14, v15);
……
  }
……

        由RtlTryEnterCriticalSection 可知LdrpLoaderLock是_RTL_CRITICAL_SECTION类型。在尝试进入临界区之前,LdrpLoaderLock将被保存到某个结构体变量v4的某个字段(偏移0xA0)中。那么v4是什么类型呢?这儿可能要科普下windows x86操作系统的一些知识:

        在windows系统中每个用户态线程都有一个记录其执行环境的结构体TEB(Thread Environment Block)。TEB结构体中第一个字段是一个TIB(ThreadInformation Block)结构体,该结构体中保存着异常登记链表等信息。在x86系统中,段寄存器FS总是指向TEB结构。于是FS:[0]指向TEB起始字段,也就是指向TIB结构体。我们用Windbg查看下TEB的结构体,该结构体很大,我只列出我们目前关心的字段

代码语言:javascript
复制
lkd> dt _TEB
nt!_TEB
   +0x000 NtTib            : _NT_TIB
   +0x01c EnvironmentPointer : Ptr32 Void
   +0x020 ClientId         : _CLIENT_ID
……

        NtTib就是TIB结构体对象名。 我们再看下TIB结构体

代码语言:javascript
复制
lkd> dt _NT_TIB
nt!_NT_TIB
   +0x000 ExceptionList    : Ptr32 _EXCEPTION_REGISTRATION_RECORD
   +0x004 StackBase        : Ptr32 Void
   +0x008 StackLimit       : Ptr32 Void
   +0x00c SubSystemTib     : Ptr32 Void
   +0x010 FiberData        : Ptr32 Void
   +0x010 Version          : Uint4B
   +0x014 ArbitraryUserPointer : Ptr32 Void
   +0x018 Self             : Ptr32 _NT_TIB

        该结构体其他字段不解释,我们只看最后一个字段(FS:[18])指向_NT_TIB结构体的指针Self。正如其名,该字段指向的是TIB结构体在进程空间中的虚拟地址。为什么要指向自己?那我们是否可以直接使用FS:[0]地址?不可以。举个例子:我用windbg挂载到我电脑上一个运行中的calc(计算器)。我们查看fs:[0]指向空间保存的值,7ffdb000是TIB的Self字段。

        我们查看TIB结构体去匹配该地址指向的空间的。

        可以看到7ffdb000所指向的空间的各字段的值和FS:[0]指向的空间的值一致。但是如果我们这样输入就会失败

        介绍完这些后,我们再回到IDA反汇编的代码中。v4 = *(_DWORD*)(*MK_FP(__FS__, 0x18) + 0x30);这段中MK_FP不是一个函数,是一个宏。它的作用是在基址上加上偏移得出一个地址。于是MK_FP(__FS__, 0x18)就是FS:[0x18],即TIB的Self字段。在该地址再加上0x30得到的地址已经超过了TIB空间,于是我们继续查看TEB结构体

        发现0x30偏移的是PEB(Process Environment Block)。

代码语言:javascript
复制
lkd> dt _PEB
nt!_PEB
   +0x000 InheritedAddressSpace : UChar
   +0x001 ReadImageFileExecOptions : UChar
……
+0x09c GdiDCAttributeList : Uint4B
   +0x0a0 LoaderLock       : Ptr32 Void
   +0x0a4 OSMajorVersion   : Uint4B

        可以发现该结构体偏移0xa0处是一个名字为LoaderLock的变量。

        《windows核心编程》中有关于DllMain序列化执行的讲解,大致意思是:线程在调用DllMain之前,要先获取锁,等DllMain执行完再解开这个锁。这样不同线程加载DLL就可以实现序列化操作。而在微软官方文档《Best Practices for Creating DLLs》中也有对这个说法的佐证

代码语言:javascript
复制
The DllMain entry-point function. This function is called by the loader when it loads or unloads a DLL. The loader serializes calls to DllMain so that only a single DllMain function is run at a time .

        其中还有段关于这个锁的介绍

代码语言:javascript
复制
The loader lock. This is a process-wide synchronization primitive that the loader uses to ensure serialized loading of DLLs. Any function that must read or modify the per-process library-loader data structures must acquire this lock before performing such an operation. The loader lock is recursive, which means that it can be acquired again by the same thread.

        在该文中多处对这个锁的说明值暗示这个锁是PEB中的LoaderLock。

        那么刚才为什么要*(_DWORD *)(v4 + 0xa0) = &LdrpLoaderLock;?因为该LdrpLoaderLock是进程内共享的变量。这样每个线程在执行初期,会先进入该临界区,从而实现在进程内DllMain的执行是序列化的。于是我们得出以下结论:

进程内所有线程共用了同一个临界区来序列化DllMain的执行。

结合《DllMain中不当操作导致死锁问题的分析--进程对DllMain函数的调用规律的研究和分析》中介绍的规律

二 线程创建后会调用已经加载了的DLL的DllMain,且调用原因是DLL_THREAD_ATTACH。

我们发现

代码语言:javascript
复制
HANDLE hThread = CreateThread(NULL, 0, ThreadCreateInDllMain, NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);

        主线程进入临界区去调用DllMain时进入了临界区,而工作线程也要进入临界区去执行DllMain。但是此时临界区被主线程占用,工作线程便进入等待状态。而主线程却等待工作线程退出才退出临界区。于是这就是死锁产生的原因。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2012年11月05日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
文件存储
文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档