除非使用某些寄存器,否则函数挂钩会崩溃

Function hook crashes unless certain registers are used

所以我正在尝试为游戏挂钩一个函数,但有一个小问题。如果 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 中?有没有办法保留这些寄存器的内容,并在返回前恢复到原来的值?

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

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

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

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

因此,您的问题的可能解决方案是像保留 EAX 寄存器一样保留 EBX 寄存器。为此,您只需在 push eax 指令的相同位置添加 push ebx 指令,并在 [=13= 相同的位置添加 pop ebx 指令] 操作说明。但是请注意,由于栈的工作方式,push和pop指令必须倒序,像这样:

挂钩开始:

push eax
push ebx

钩端:

pop ebx
pop eax

If registers such as eax, ebx, ecx, and edx are interchangeable, how come the first code sample below is crashing the game process but the second code does not crash and works as intended?

可能你的调用者在这个函数跳转到 g_dwBase_Addr + RETURN_UnfreezePlayer 之后使用 EBX 做一些重要的事情。

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

当你销毁 EBP 时,你的调用者恰好不会中断,只有当你销毁 EBX 时,这似乎是合理的。

或者,如果您要将 jump/call 插入到此代码中根本不需要函数调用的地方,那么您应该 save/restore 您修改的每个寄存器,可能包括 EFLAGS。 (查看 "call site" 以查看它是否在您 "return" 之后销毁任何寄存器;例如 addcmp 仅写入 EFLAGS,而不读取,因此如果您看到一条指令这样你就知道你不必 save/restore 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
  }

您正在使用寄存器保存跳跃目标。您也许能够分析 "caller" 并找到一个可以安全销毁的寄存器,这样您就可以使用那个 without/save 恢复。或者硬编码跳转目标地址,这样你就可以使用 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 指令在调用堆栈上的分支预测错误,因为我们得到 call/ret 预测器堆栈不匹配。这个函数中某处的虚拟 call 可以解决这个问题,只是 this ret 预测错误。 (但请注意,call next_instruction 不会工作;CPU 的特殊情况不会将其视为真正的调用。您必须实际跳过某些内容。http://blog.stuffedcow.net/2018/04/ras-microbenchmarks/#call0

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

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

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

可以使用低于 ESP 的 space,但这并不绝对安全。就像恢复寄存器后的 jmp [esp-4] 一样。 SEH 可以踩到它,调试器也可以。


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

具体修改一个即可,save/restore即可。或者 none 如果你选择一个,你可以安全地破坏 return 地址。

这两条指令之后,你再也不用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

因此 save/restore 的注册数较少。另外,这毫无意义:

    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
    }