动态重新编译如何处理软件虚拟化中的指令指针检查?

How does dynamic recompilation handle instruction-pointer checks in software virtualization?

(这个问题并不是针对 VirtualBox 或 x86 本身,但由于它们是我所知道的最好的例子,我将引用它们并询问如何VBox 处理一些场景。如果您知道 VBox 未使用的其他解决方案,请考虑提及它们。)


我看过how VirtualBox does software virtualization,但我不明白以下内容。

Before executing ring 0 code, CSAM [Code Scanning and Analysis Manager] scans it recursively to discover problematic instructions. PATM [Patch Manager] then performs in-situ patching, i.e. it replaces the instruction with a jump to hypervisor memory where an integrated code generator has placed a more suitable implementation. In reality, this is a very complex task as there are lots of odd situations to be discovered and handled correctly. So, with its current complexity, one could argue that PATM is an advanced in-situ recompiler.

考虑 ring-0 代码中的以下示例指令序列:

    call foo

foo:
    mov EAX, 1234
    mov EDX, [ESP]
    cmp EDX, EAX
    jne bar
    call do_something_special_if_return_address_was_1234
bar:
    ...

这里的被调用者正在测试调用者的 return 地址是否为 1234,如果是,它会做一些特殊的事情。显然打补丁会改变 return 地址,所以我们需要能够处理它。

VirtualBox 的文档说它发现 "problematic" 指令并就地修补它们,但我真的不明白它是如何工作的,原因有两个:

  1. 看来任何暴露指令指针的指令都是"problematic",其中call可能是最常见的(非常如此)。这是否意味着 VirtualBox 必须分析并可能修补它在 ring 0 中看到的 every call 指令?这不会使性能下降悬崖吗?他们如何以高性能处理这个问题? (他们在文档中提到的案例非常晦涩,所以我很困惑为什么他们没有提到这种常见的指令,如果它发生了。如果这不是问题,我不明白为什么。)

  2. 如果指令流碰巧被修改(例如动态地 loading/unloading 内核模块),VirtualBox 必须动态地检测到这个和垃圾收集无法到达的重新编译的指令。否则,它会发生内存泄漏。但这意味着 每个 mov 指令(和 push 指令,以及写入内存的所有其他内容)现在都必须进行分析并可能进行修补,可能会重复进行,因为它可能正在修改已打补丁的代码。这似乎本质上将 all guest ring-0 代码退化为接近完整的软件仿真(因为在重新编译期间移动的目标是未知的),这将使虚拟化成本 skyrocket,但这不是我阅读文档后的印象。这不是问题吗?这是如何有效处理的?

请注意,我不是询问硬件辅助虚拟化,如 Intel VT 或 AMD-V,我是 有兴趣阅读这些内容。我很清楚他们完全避免了这些问题,但我的问题是关于纯软件虚拟化。

至少对于 QEMU,答案似乎是即使在翻译后的代码中,也设置了单独的 emulated "stack" up 与代码在 运行ning 本地时具有的值相同,而此 "stack" 是模拟代码读取的值,它看到的值与 运行ning 相同原生。

这意味着无法将模拟代码转换为直接使用 callret 或任何其他使用堆栈的指令,因为它们不会使用模拟堆栈。因此,这些调用被跳转至各种 thunk 代码位,这些代码在调用等效的翻译代码方面做正确的事情。

QEMU 的详细信息

OP 的(合理的)假设似乎是 callret 指令将出现在翻译后的二进制文件中,并且堆栈将反映动态翻译代码的地址。实际上(在 QEMU 中)发生的是 callret 指令被删除并替换为不使用堆栈的控制流,并且堆栈上的值被设置为与他们会使用本机代码。

也就是说,OP的心智模型是代码翻译的结果有点像本地代码,有一些补丁和修改。至少在 QEMU 的情况下,情况并非如此——每个基本块都通过 Tiny Code Generator (TCG), first to an intermediate representation and then to the target architecture (even if the source and destination archs are the same, as in my case). This deck 大量翻译,对许多技术细节有很好的概述,包括如下所示的 TCG 概述。

生成的代码通常与输入代码完全不同,并且通常会增加大约 3 倍的大小。寄存器通常很少使用,您经常会看到背靠背的冗余序列。与这个问题特别相关的是,基本上所有控制流指令都大不相同,因此本机代码中的 retcall 指令几乎永远不会翻译成普通的 callret 在翻译后的代码中。

一个例子:首先,一些带有 return_address() 调用的 C 代码简单地 returns return 地址,以及一个打印此函数的 main()

#include <stdlib.h>
#include <stdio.h>

__attribute__ ((noinline)) void* return_address() {
    // stuff here?
    return __builtin_return_address(0);
}


int main(int argc, char **argv) {
    void *a = return_address();
    printf("%p\n", a);
}

这里的 noinline 很重要,因为否则 gcc 可能只是内联函数并将地址直接硬编码到程序集中,而根本不需要 call 或访问堆栈!

使用 gcc -g -O1 -march=native 编译为:

0000000000400546 <return_address>:
  400546:       48 8b 04 24             mov    rax,QWORD PTR [rsp]
  40054a:       c3                      ret    

000000000040054b <main>:
  40054b:       48 83 ec 08             sub    rsp,0x8
  40054f:       b8 00 00 00 00          mov    eax,0x0
  400554:       e8 ed ff ff ff          call   400546 <return_address>
  400559:       48 89 c2                mov    rdx,rax
  40055c:       be 04 06 40 00          mov    esi,0x400604
  400561:       bf 01 00 00 00          mov    edi,0x1
  400566:       b8 00 00 00 00          mov    eax,0x0
  40056b:       e8 c0 fe ff ff          call   400430 <__printf_chk@plt>
  400570:       b8 00 00 00 00          mov    eax,0x0
  400575:       48 83 c4 08             add    rsp,0x8
  400579:       c3                      ret    

请注意 return_address() returns [rsp] 就像 OP 的示例一样。 main() 函数将其粘贴在 rdx 中,printf 将从中读取它。

我们希望 return_address 看到的调用者的 return 地址是调用后的指令,0x400559:

  400554:       e8 ed ff ff ff          call   400546 <return_address>
  400559:       48 89 c2                mov    rdx,rax

... 事实上,这就是我们在 运行 原生时所看到的:

person@host:~/dev/test-c$ ./qemu-test 
0x400559

让我们在 QEMU 中尝试一下:

person@host:~/dev/test-c$ qemu-x86_64 ./qemu-test
0x400559

有效!请注意,QEMU 默认情况下会翻译所有代码并将其放置在远离通常的本地位置的位置(我们很快就会看到),因此我们不需要任何特殊指令来触发翻译。

这在幕后是如何运作的?我们可以使用 QEMU 的 -d in_asm,out_asm 选项来查看这段代码的作用。

首先,导致调用的代码(IN 部分是本机代码,OUT 是 QEMU 将其翻译成的代码——抱歉 AT&T 语法,我不能弄清楚如何 change that in QEMU):

IN: main
0x000000000040054b:  sub    [=15=]x8,%rsp
0x000000000040054f:  mov    [=15=]x0,%eax
0x0000000000400554:  callq  0x400546

OUT: [size=123]
0x557c9cf33a40:  mov    -0x8(%r14),%ebp
0x557c9cf33a44:  test   %ebp,%ebp
0x557c9cf33a46:  jne    0x557c9cf33aac
0x557c9cf33a4c:  mov    0x20(%r14),%rbp
0x557c9cf33a50:  sub    [=15=]x8,%rbp
0x557c9cf33a54:  mov    %rbp,0x20(%r14)
0x557c9cf33a58:  mov    [=15=]x8,%ebx
0x557c9cf33a5d:  mov    %rbx,0x98(%r14)
0x557c9cf33a64:  mov    %rbp,0x90(%r14)
0x557c9cf33a6b:  xor    %ebx,%ebx
0x557c9cf33a6d:  mov    %rbx,(%r14)
0x557c9cf33a70:  sub    [=15=]x8,%rbp
0x557c9cf33a74:  mov    [=15=]x400559,%ebx
0x557c9cf33a79:  mov    %rbx,0x0(%rbp)
0x557c9cf33a7d:  mov    %rbp,0x20(%r14)
0x557c9cf33a81:  mov    [=15=]x11,%ebp
0x557c9cf33a86:  mov    %ebp,0xa8(%r14)
0x557c9cf33a8d:  jmpq   0x557c9cf33a92
0x557c9cf33a92:  movq   [=15=]x400546,0x80(%r14)
0x557c9cf33a9d:  mov    [=15=]x7f177ad8a690,%rax
0x557c9cf33aa7:  jmpq   0x557c9cef8196
0x557c9cf33aac:  mov    [=15=]x7f177ad8a693,%rax
0x557c9cf33ab6:  jmpq   0x557c9cef8196

关键部分在这里:

0x557c9cf33a74:  mov    [=16=]x400559,%ebx
0x557c9cf33a79:  mov    %rbx,0x0(%rbp)

你可以看到它实际上是手动将 return 地址 从本地代码 放到 "stack" (这似乎通常是访问rbp)。接下来,请注意 return_address 没有 call 指令。相反,我们有:

0x557c9cf33a92:  movq   [=17=]x400546,0x80(%r14)
0x557c9cf33a9d:  mov    [=17=]x7f177ad8a690,%rax
0x557c9cf33aa7:  jmpq   0x557c9cef8196

在大部分代码中,r14 似乎是指向某些内部 QEMU 数据结构的指针(即,不用于保存模拟程序的值)。上面的代码将 0x400546(这是 return_address 函数 在本机代码 中的地址)推入 r14 指向的结构的字段中,将 0x7f177ad8a690 粘在 rax 中,然后跳转到 0x557c9cef8196。最后一个地址在生成的代码中随处可见(但它的定义没有)并且似乎是某种内部调度或 thunk 方法。据推测,它使用本机地址,或者更可能是 rax 中的神秘值来分派到翻译后的 return_address 方法,如下所示:

----------------
IN: return_address
0x0000000000400546:  mov    (%rsp),%rax
0x000000000040054a:  retq   

OUT: [size=64]
0x55c131ef9ad0:  mov    -0x8(%r14),%ebp
0x55c131ef9ad4:  test   %ebp,%ebp
0x55c131ef9ad6:  jne    0x55c131ef9b01
0x55c131ef9adc:  mov    0x20(%r14),%rbp
0x55c131ef9ae0:  mov    0x0(%rbp),%rbx
0x55c131ef9ae4:  mov    %rbx,(%r14)
0x55c131ef9ae7:  mov    0x0(%rbp),%rbx
0x55c131ef9aeb:  add    [=18=]x8,%rbp
0x55c131ef9aef:  mov    %rbp,0x20(%r14)
0x55c131ef9af3:  mov    %rbx,0x80(%r14)
0x55c131ef9afa:  xor    %eax,%eax
0x55c131ef9afc:  jmpq   0x55c131ebe196
0x55c131ef9b01:  mov    [=18=]x7f9ba51f7713,%rax
0x55c131ef9b0b:  jmpq   0x55c131ebe196

第一段代码似乎在 ebp 中设置用户 "stack"(从 r14 + 0x20 中获取,这可能是模拟的机器状态结构)并最终读取"stack"(行mov 0x0(%rbp),%rbx)并将其存储到r14mov %rbx,0x80(%r14))指向的区域。

最后,它到达 jmpq 0x55c131ebe196,它转移到 QEMU 尾声例程:

0x55c131ebe196:  add    [=19=]x488,%rsp
0x55c131ebe19d:  pop    %r15
0x55c131ebe19f:  pop    %r14
0x55c131ebe1a1:  pop    %r13
0x55c131ebe1a3:  pop    %r12
0x55c131ebe1a5:  pop    %rbx
0x55c131ebe1a6:  pop    %rbp
0x55c131ebe1a7:  retq   

请注意,我在上面的引号中使用了 "stack" 一词。这是因为这个 "stack" 是模拟程序看到的堆栈的 模拟 ,而不是 rsp 指向的真实堆栈。 rsp 指向的真实堆栈由QEMU 控制来实现模拟控制流,模拟代码不会直接访问它。

有些事情可以改变

我们在上面看到,"stack" contents 在QEMU下的模拟过程是一样的,但是堆栈的细节确实发生了变化。例如,堆栈的 address 在仿真下看起来与本机不同(即 rspvalue 而不是指向的内容通过 [rsp]).

这个函数:

__attribute__ ((noinline)) void* return_address() {
    return __builtin_frame_address(0);
}

通常 returns 地址像 0x7fffad33c100 但 returns 地址像 0x40007ffd00 在 QEMU 下。不过,这应该不是问题,因为没有有效的程序应该依赖于堆栈地址的确切绝对值。它不仅通常没有定义和不可预测,而且在最近的操作系统上它确实是 设计的 由于堆栈 ASLR(Linux 和 Windows 都实现了这个)。上面的程序 return 每次我 运行 它都是一个不同的地址(但在 QEMU 下是相同的地址)。

自修改代码

您还提到了有关何时修改指令流的问题,并给出了加载内核模块的示例。首先,至少对于 QEMU,代码只被翻译 "on demand"。可以调用但不在某些特定 运行 中的函数永远不会被翻译(您可以尝试使用根据 argc 有条件地调用的函数)。所以一般来说,将新代码加载到内核中,或者加载到用户模式仿真中的进程中,是由相同的机制处理的:代码将在第一次调用时简单地进行翻译。

如果代码实际上是自我修改——即进程写入自己的代码——那么就必须做一些事情,因为如果没有帮助,QEMU 将继续使用旧的翻译.因此,为了在不惩罚每次写入内存的情况下检测自修改代码,本机代码位于仅具有 R+X 权限的页面中。结果是写入引发了 GP 错误,QEMU 通过注意到代码已修改自身、使转换无效等来处理该错误。可以在 this thread 和其他地方找到大量详细信息。

这是一个合理的机制,我希望其他代码翻译 VM 也能做类似的事情。

请注意,在自修改代码的情况下,"garbage collection" 问题很简单:如上所述,模拟器会收到有关 SMC 事件的通知,并且由于此时必须重新翻译,它丢弃了旧的翻译。