【问题标题】:Why DLL function calls are not compiled to relative CALL instructions为什么 DLL 函数调用未编译为相关 CALL 指令
【发布时间】:2018-05-10 08:51:50
【问题描述】:

当我调用一个函数 DLLFunction(int) 时,它是在 DLL 中定义的。我的 Intel X86 PC 上的 Visual Studio 2013 将其编译为以下指令

CALL [__imp__DLLFunction@4] // Call absolute indirect address FF 15 00 90 40 00 CALL [00409000h] // original absolute CALL instruction FF 15 00 90 39 01 CALL [01399000h] // After address fixup by OS loader // __imp__DLLFunction@4 is the IAT entry address for DLLFunction, where there stores the address for DLLFunction().

调用者图像中IAT的RVA(相对视觉地址)为0x9000,其中存储了导入函数的地址。 RVA Import Function Address 0x9000 0x60fd1014 // DLLFunction 0x9004 0x60fdxxxx // someOtherDLLFunction0 0x9008 0x60fdxxxx // someOtherDLLFunction1 ...

为什么编译器不生成相关的CALL指令?

如果使用相对 CALL 指令,加载器不需要像这样为所有这些 CALL 指令固定地址。

【问题讨论】:

    标签: visual-studio dll x86 dllimport


    【解决方案1】:

    CALL [__imp__DLLFunction@4] 不是调用通过间接跳转将控件引导到导入函数的常用存根,而是通过 IAT 中的指针直接调用导入函数。

    当使用__declspec(dllimport) 注释外部函数时会发生这种情况(并且可能以任何使编译器了解程序员意图的方式)。

    没有它,编译器会生成一个相对(近)调用,并且链接器会添加存根。

    :401005 E806000000     call 401010h              ;Relative near call to the stub
    ... The stub ...
    :401010 FF25F4B04000   jmp DWORD PTR [0040b0f4]  ;Indirect abs jump
    

    意图明确,上面的代码转换为

    :401005 FF15F4B04000   call DWORD PTR [0040b0f4]
    

    这是使用绝对间接调用。
    这样可以避免跳转,但需要在加载时进行额外的修复,相对间接调用实际上会更好,但不幸的是,它不存在。

    x86-64 代码可以使用 RIP 相对寻址来缓解修复问题。

    【讨论】:

    • 我想知道这是否真的发生了。这与使用 GOT(全局偏移表)中的函数指针使用 -fno-plt:间接 call 编译时在 Linux 上发生的情况基本相同。这比 OP 声称发生的事情更有意义。
    • @PeterCordes AFAIK Windows 可执行文件以这种方式工作。在发布之前,我用 VS 2013 对其进行了测试。我从来没有遇到过像 OP 这样的案例,但在过去的几年中可能发生了一些变化。
    【解决方案2】:

    使用相对地址要么需要在加载 DLL 函数后修补所有对 DLL 函数的相对调用,要么链接器需要对被调用函数的大小要求进行有根据的猜测,并用加载的函数替换加载器。

    两种方法都有更糟糕的最坏情况成本:要么修补可能数百个相关调用,要么无论如何都需要在相对跳转之后发出间接调用,如果动态加载的函数不适合预先分配的空间。

    人们也可以推测,修补可执行文件会导致病毒扫描程序出现大量误报,这需要非常熟悉 dll 加载的标准过程。

    【讨论】:

    • 你好Aki,如果编译器生成relative CALL,则不需要将所有relative CALL都打补丁到DLL函数中,因为目标调用过程在调用线程的 IAT 中。当 DLL 加载到进度的地址空间时,IAT 由 OS 加载程序修复。
    • 我认为 Windows 确实为未动态加载的 DLL 应用了修复程序。代码页运行时修复的一大亮点是线程安全。交叉修改代码通常不安全,如果两个线程导入不同的 DLL,VirtualProtect 也可能会出现竞争。
    • 您似乎暗示,操作系统会在重新定位代码之前加载所有依赖的 dll?
    【解决方案3】:

    更新:我没有仔细阅读问题:根据 OP,间接call 目标仍在可执行文件中,并且本身就是间接jmp

    下面的答案是讨论让call rel32直接进入 DLL。


    这需要在每条call 指令处修改机器代码,以便在动态加载 DLL 期间放入正确的偏移量。 (您不知道 DLL 将在哪个地址加载,因此您不知道可执行文件链接时可执行文件与 DLL 之间的rel32 距离。)

    使用函数指针表将所有重定位内容放在一个可以在动态链接时高效编写的地方。

    如果加载的 DLL 距离需要调用它的代码超过 2GB,它在 64 位代码中也无法达到足够远的距离。


    IIRC,Windows 确实支持您为正常链接的 DLL 描述的方案(在编译时使用链接器,而不是使用运行时 dll 导入)。 每个 DLL 都有一个“首选”加载地址,调用它的代码乐观地使用call rel32 指令,因此如果该地址不可用,每个调用站点都需要修复。这些修复在进程加载时发生。启用ASLR 后,DLL 不会每次都加载到相同的地址。

    一旦进程已经在运行,它的代码页将是只读的,所以如果需要这些修复,那就是个问题。这可能就是动态 DLL 导入不使用这种机制的原因。 (实现可以使用VirtualProtect 使代码页可用于这些修正,但让两个不同的线程同时导入 DLL 不是线程安全的。一个线程可能会创建一个页面完成后只读,但另一个线程仍在向该页面写入修复,导致错误。)

    此外,交叉修改代码通常也不安全。其他线程可能在您应用修复的同一函数中运行指令。您可以使用xchg 或其他方式自动存储新的rel32。这可能是安全的。

    顺便说一句,在 Linux 上,即使像 libc 这样的“普通”库也是通过这样的间接级别调用的(来自全局偏移表的函数指针)。见Sorry state of dynamic libraries on Linux

    这部分是在运行时动态链接的开销(加载时间)与加载后的性能之间进行权衡。

    【讨论】:

    • 嗨彼得,你的答案是相反的。这样对吗?如果使用 relative CALL,那不需要在每个调用指令处修改机器代码以在动态加载 DLL 期间放入正确的偏移量。
    • @ZhiboShen:不,您不知道 DLL 相对于主可执行文件的加载位置,但所有相对偏移量都硬编码了该距离。 call rel32内部与位置无关的库或可执行文件中工作,但需要在单独加载的库之间进行修复。
    • 嗨彼得,X86 上的 Visual Studio 使用 IAT。来自另一个 DLL 的函数的绝对地址存储在调用线程的 IAT 中,必须由 OS 加载程序修复。 CALL rel32 正在从 IAT 调用 rel32。虽然我们不知道 DLL 会相对于主线程加载到哪里,但 IAT 和 .text 都在调用线程的地址区域中,它们的地址相关性保持不变。
    • @ZhiboShen:Windows 上动态链接的很多细节我都不熟悉。抱歉,我没有仔细阅读问题并错过了关于 call 目标是间接 jmp [mem] 的部分。这似乎是一个错过的优化。能否请您更新问题,使其也包含在代码块中,以便快速浏览的人不要错过它?
    • @ZhiboShen:你检查过call在DLL导入后仍然通过jmp吗?也许有什么东西更新了调用目标表,所以它绕过了jmp?例如在至少被调用一次之后,使用调试器单步进入call
    猜你喜欢
    • 2014-04-01
    • 1970-01-01
    • 2014-10-07
    • 1970-01-01
    • 1970-01-01
    • 2018-05-17
    • 1970-01-01
    • 2014-08-01
    • 2013-08-26
    相关资源
    最近更新 更多