【问题标题】:Function hook crashes unless certain registers are used除非使用某些寄存器,否则函数挂钩会崩溃
【发布时间】:2020-04-21 16:51:06
【问题描述】:

所以我正在尝试为游戏挂钩一个功能,但有一个小问题。如果 eax、ebx、ecx 和 edx 等寄存器可以互换,为什么下面的第一个代码示例会导致游戏进程崩溃,而第二个代码却没有崩溃并按预期工作?

// Crashes game process
void __declspec(naked) HOOK_UnfreezePlayer()
{
    __asm push eax

    if ( !state->player.frozen || !state->ready )
        __asm jmp hk_Disabled

    __asm
    {
        mov eax, g_dwBase_Addr
        mov ebx, [eax + LOCAL_PLAYER_INFO_OFFSET]
        add ebx, 0x4
        mov ecx, [ebx]
        add ecx, 0x40
        lea edx, [esi + 0x0C]
        cmp edx, ecx
        je hk_Return

        hk_Disabled:
        movss [esi + 0x0C], xmm0

        hk_Return:
        pop eax
        mov ecx, g_dwBase_Addr
        add ecx, RETURN_UnfreezePlayer
        jmp ecx
    }
}

// Works
void __declspec(naked) HOOK_UnfreezePlayer()
{
    __asm push eax

    if ( !state->player.frozen || !state->ready )
        __asm jmp hk_Disabled

    __asm
    {
        mov ecx, g_dwBase_Addr
        mov edx, [ecx + LOCAL_PLAYER_INFO_OFFSET]
        add edx, 0x4
        mov ebp, [edx]
        add ebp, 0x40
        lea ecx, [esi + 0x0C]
        cmp ecx, ebp
        je hk_Return

        hk_Disabled:
        movss [esi + 0x0C], xmm0

        hk_Return:
        pop eax
        mov ecx, g_dwBase_Addr
        add ecx, RETURN_UnfreezePlayer
        jmp ecx
    }
}

我认为崩溃可能是由于我的汇编代码覆盖了寄存器 eax、ebx、ecx 等中的重要数据。例如,如果游戏在 eax 中存储了一个重要值,然后该数据丢失了,因为我的if 语句将结构指针移动到 eax 中?有没有办法保留这些寄存器的内容,并在返回之前将它们恢复到原来的值?

【问题讨论】:

  • 在标准调用约定中,只有 EAX、ECX 和 EDX 被调用破坏。您必须不触摸或保存/恢复调用者值的其他寄存器。在naked 函数中,编译器不会像通常那样为您执行此操作。
  • @PeterCordes:我不确定标准调用约定是否适用于此,因为 OP 正在讨论将钩子插入游戏,可能是通过在任意位置向其中注入代码。跨度>

标签: c++ visual-c++ x86 inline-assembly calling-convention


【解决方案1】:

如果 eax、ebx、ecx 和 edx 等寄存器可以互换,为什么下面的第一个代码示例会导致游戏进程崩溃,而第二个代码却没有崩溃并按预期工作?

在此函数跳转到 g_dwBase_Addr + RETURN_UnfreezePlayer 后,您的调用者可能正在使用 EBX 处理重要的事情。

如果您要挂钩现有的函数调用,则 EAX、ECX 和 EDX 在标准调用约定中被调用破坏,而其他整数 reg 被保留调用。

您的调用者在您销毁 EBP 时碰巧没有中断,只有在您销毁 EBX 时才中断,这似乎是合理的。

或者,如果您要将此代码的跳转/调用插入到根本不期望函数调用的地方,那么您应该保存/恢复您修改的每个寄存器,可能包括 EFLAGS . (查看“调用站点”以查看“返回”后它是否会破坏任何寄存器;例如,addcmp 只写入 EFLAGS,而不读取,所以如果你看到这样的指令,你就知道你没有'不必保存/恢复 EFLAGS。同样,mov 的目标是只写的。)


具体来说,在你做任何其他事情之前,在你的函数顶部:

  _asm {
      push  eax
      push  ecx
      push  edx
      // and whatever other register you need
  }

在底部,在跳跃前按匹配顺序弹出它们

  _asm {
      // and whatever other register you need
      pop   edx
      pop   ecx
      pop   eax
      jmp   target
  }

您正在使用寄存器来保存跳转目标。您可能能够分析“调用者”并找到一个可以安全销毁的寄存器,因此您可以使用该寄存器而无需/保存还原。或者对跳转目标地址进行硬编码,这样您就可以使用jmp rel32 而不是间接的jmp reg

或者(以显着的性能成本)您可以将 jmp 替换为 push / ret

  _asm {
     push eax    // extra dummy slot we can replace with a return address
     push eax
     push ecx
     push edx

  ...

     pop  edx
     pop  ecx
     //pop  eax

     mov  eax, g_dwBase_Addr
     add  eax, RETURN_UnfreezePlayer
     mov  [esp+4], eax       // store into the dummy slot
     pop  eax
     ret                     // branch mispredict guaranteed
  }

使用 push/ret 的等价物可以保证这个 ret 的分支错误预测,以及未来的 ret 指令在调用堆栈上的错误预测,因为我们的调用/ret 预测器堆栈不匹配。此函数中某处的虚拟 call 可以解决此问题,从而导致 this ret 错误预测。 (但请注意,call next_instruction 不起作用;CPU 是特殊情况,不要将其视为真正的调用。您必须实际跳过某些东西。http://blog.stuffedcow.net/2018/04/ras-microbenchmarks/#call0

您可能很想xchg [esp], eax / ret,但这非常慢:带有内存操作数的 xchg 意味着 lock 前缀(完整的内存屏障,微码原子交换)。

在最初推送时为“返回地址”保留一个插槽似乎是最有效的,否则您可能会推送一个返回地址,mov 加载保存的 EAX 值,然后pop [esp+4] 将该返回地址向上复制 4 个字节。但是,在发现 ret 错误预测之前,额外的副本会增加延迟。

如果这不必是线程安全的,您可以在存储目标地址后使用jmp [target_address]。或者如果g_dwBase_Addr + RETURN_UnfreezePlayer 是一个常量,只需将其保存在某个静态变量中,这样您就可以jmp dword ptr [target_address] 而不是每次都计算目标。

可以使用 ESP 下方的空间,但这并不是绝对安全的。在恢复寄存器后就像jmp [esp-4]。 SEH 可以踩到它,调试器也可以。


您可以优化您的函数以使用更少的寄存器

具体来说,你只需要修改一个,所以你可以保存/恢复它。或者根本没有,如果你选择一个你可以安全地破坏返回地址。

在这两条指令之后,你再也不会使用 EAX。

mov eax, g_dwBase_Addr
mov ebx, [eax + LOCAL_PLAYER_INFO_OFFSET]

所以你可以使用 EAX 代替 EBX:

mov eax, g_dwBase_Addr
mov eax, [eax + LOCAL_PLAYER_INFO_OFFSET]
; then use EAX everywhere you were using EBX in later instructions

所以要保存/恢复的寄存器更少。另外,这是没有意义的:

    add ebx, 0x4         ;  add eax, 4        // with changes from above
    mov ecx, [ebx]       ;  mov ecx, [eax]

可以在寻址模式下+4mov ecx, [eax + 4]

add/lea -> cmp 也可以优化。 ecx + 0x40 == esi + 0xcecx + 0x40 - 0xc == esi 是一回事。

    // no push or pop needed, destroying only ECX
    _asm {
        mov ecx, g_dwBase_Addr
        mov ecx, [ecx + LOCAL_PLAYER_INFO_OFFSET]
        mov ecx, [ecx+4]

        add ecx, 0x40 - 0x0C
        cmp ecx, esi               // ecx+0x40 == esi+0x0C
        je hk_Return

        hk_Disabled:
        movss [esi + 0x0C], xmm0    // regs from the caller

        hk_Return:
          // Assuming we can destroy caller's ECX.
        mov ecx, g_dwBase_Addr
        add ecx, RETURN_UnfreezePlayer
        jmp ecx
    }

【讨论】:

    【解决方案2】:

    当挂钩已编译的程序时,寄存器当然不能互换,因为各个寄存器的含义由挂钩程序的代码和挂钩在该代码中的位置定义。因此,您必须检查被挂钩的代码和挂钩的位置,以确定被挂钩的代码是否依赖于某些被保留的寄存器的内容。

    使用开头的 push eax 指令和结尾的 pop eax 指令,您已经在保留 EAX 寄存器的内容并在之后恢复它。您可以对 EBX 和 EDX 寄存器执行相同操作,或者简单地使用 PUSHAD/POPAD 指令来保存所有通用寄存器。根据游戏中钩子的位置,您可能还必须保留 EFLAGS 寄存器,这需要 PUSHFD/POPFD 指令。

    保存和恢复 ECX 寄存器并不容易,因为钩子正在使用该寄存器来计算完成后要跳转到的地址。

    但是,由于您说第二个代码示例有效,而第一个代码示例导致挂钩程序崩溃,因此问题很可能仅在于修改了 EBX 寄存器。这是因为第一个代码示例修改了 EBX 寄存器,而第二个代码示例没有。

    因此,您的问题的可能解决方案是保留 EBX 寄存器,就像保留 EAX 寄存器一样。为此,您只需在push eax 指令的相同位置添加一条push ebx 指令,并在与pop eax 指令相同的位置添加一条pop ebx 指令。但是请注意,由于堆栈的工作方式,push 和 pop 指令必须是相反的顺序,如下所示:

    钩子开始:

    push eax
    push ebx
    

    钩端:

    pop ebx
    pop eax
    

    【讨论】:

    • 关于 FLAGS 的好点子。请注意,pushad / popad 比单个推送/弹出要慢,即使您确实想保存所有 8 个(包括 ESP)并恢复 7 个不包括 ESP。
    • 你是对的。 pushad / popad 阻止了钩子使游戏崩溃。我还尝试了 push/pop eax、ecx、edx,但游戏仍然崩溃,所以我想其他寄存器也必须恢复。
    • @jzr448:在您之前的评论中,在您单独保存和恢复的寄存器列表中(没有 pushad/popad 指令),您没有提到 EBX,而只提到了 EAX、ECX 和 EDX。据我所知,EBX 似乎是最重要的一个。您是否也尝试保存和恢复那个?另外,请注意推送指令和弹出指令必须是相反的顺序。我已经相应地更新了我的答案。
    • 根据Agner Fog's testingpopa 在 Skylake 上是 18 uop,每 8 个周期吞吐量 1 个。 8x pop 只有 8 微指令,每时钟可以运行 2 微指令。 pusha/popa 适合代码大小,但它们在英特尔上的微编码效率不高。 AMD Zen 显然做得更好。 popa 只有 9 个微指令,每 4 个周期有 1 个吞吐量,接近于 8x pop。当然,在现实生活中,您不想保存/恢复 ESP,因此您应该与之比较的最坏情况是 7 倍推送/弹出。
    • @AndreasWenzel:stackoverflow.com/tags/x86/info 中的其他 x86 性能/微架构链接。 Agner 尚未更新 Ice Lake,但 uops.info 已更新。 (而且他们的测试方法比 Agner 的更加自动化和详细,因此没有拼写错误)
    猜你喜欢
    • 2012-08-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-04-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多