7

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


我已经阅读了 VirtualBox 如何进行软件虚拟化,但我不明白以下内容。

在执行 ring 0 代码之前,CSAM [代码扫描和分析管理器]递归地扫描它以发现有问题的指令。PATM [补丁管理器]然后执行原位补丁,即它用跳转到集成代码生成器已放置更合适实现的管理程序内存来​​替换指令。实际上,这是一项非常复杂的任务,因为有很多奇怪的情况需要被发现和正确处理。因此,以目前的复杂性,有人可能会说 PATM 是一种先进的原位重编译器。

考虑 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:
    ...

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

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

  1. 似乎任何暴露指令指针的指令都是“有问题的”,其中call可能是最常见的(而且非常常见)。这是否意味着 VirtualBox 必须分析并可能修补它在 ring 0 中看到的每条指令? call这不是让性能一落千丈吗?他们如何以高性能处理这个问题?(他们在文档中提到的案例非常模糊,所以我很困惑为什么他们没有提到如果发生这种常见的指令。如果这不是问题,我不明白为什么。)

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

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

4

1 回答 1

5

至少对于QEMU,似乎答案是即使在翻译后的代码中,也有一个单独的模拟“堆栈”,其设置与本机运行时的代码具有相同的值,而这个“堆栈”就是一个由仿真代码读取,它看到的值与本机运行时相同。

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

QEMU 的详细信息

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

也就是说,OP 的心智模型是代码翻译的结果有点像原生代码,有一些补丁和修改。至少在 QEMU 的情况下,情况并非如此——每个基本块都通过Tiny Code Generator (TCG)大量转换,首先转换为中间表示,然后转换为目标架构(即使源和目标架构是和我一样)。该套牌很好地概述了许多技术细节,包括如下所示的 TCG 概述。

TCG 流程图

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

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

#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()返回[rsp]就像 OP 的示例一样。该main()函数将其粘贴在 中rdx,将从哪里printf读取它。

我们期望调用者的返回地址是return_address调用之后的指令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 默认翻译所有代码并将其远离通常的本地位置(我们将很快看到),因此我们不需要任何特殊指令来触发翻译。

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

首先,导致调用的代码(该IN部分是本机代码,OUTQEMU 将其翻译为 - 对不起 AT&T 语法,我不知道如何在 QEMU 中更改它):

IN: main
0x000000000040054b:  sub    $0x8,%rsp
0x000000000040054f:  mov    $0x0,%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    $0x8,%rbp
0x557c9cf33a54:  mov    %rbp,0x20(%r14)
0x557c9cf33a58:  mov    $0x8,%ebx
0x557c9cf33a5d:  mov    %rbx,0x98(%r14)
0x557c9cf33a64:  mov    %rbp,0x90(%r14)
0x557c9cf33a6b:  xor    %ebx,%ebx
0x557c9cf33a6d:  mov    %rbx,(%r14)
0x557c9cf33a70:  sub    $0x8,%rbp
0x557c9cf33a74:  mov    $0x400559,%ebx
0x557c9cf33a79:  mov    %rbx,0x0(%rbp)
0x557c9cf33a7d:  mov    %rbp,0x20(%r14)
0x557c9cf33a81:  mov    $0x11,%ebp
0x557c9cf33a86:  mov    %ebp,0xa8(%r14)
0x557c9cf33a8d:  jmpq   0x557c9cf33a92
0x557c9cf33a92:  movq   $0x400546,0x80(%r14)
0x557c9cf33a9d:  mov    $0x7f177ad8a690,%rax
0x557c9cf33aa7:  jmpq   0x557c9cef8196
0x557c9cf33aac:  mov    $0x7f177ad8a693,%rax
0x557c9cf33ab6:  jmpq   0x557c9cef8196

关键部分在这里:

0x557c9cf33a74:  mov    $0x400559,%ebx
0x557c9cf33a79:  mov    %rbx,0x0(%rbp)

您可以看到它实际上是手动将本机代码的返回地址放入“堆栈”(通常似乎使用 访问rbp)。之后,请注意没有call指示return_address. 相反,我们有:

0x557c9cf33a92:  movq   $0x400546,0x80(%r14)
0x557c9cf33a9d:  mov    $0x7f177ad8a690,%rax
0x557c9cf33aa7:  jmpq   0x557c9cef8196

在大多数代码中,r14似乎是指向某些内部 QEMU 数据结构的指针(即,不用于保存来自仿真程序的值)。上面的0x400546内容(这是本机代码return_address中函数的地址)推入 指向的结构的字段中,插入并跳转到。最后一个地址出现在生成的代码中的所有位置(但它的定义没有),并且似乎是某种内部调度或 thunk 方法。据推测,它使用本机地址,或者更可能是使用推入的神秘值来调度到翻译后的方法,如下所示:r140x7f177ad8a690rax0x557c9cef8196raxreturn_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    $0x8,%rbp
0x55c131ef9aef:  mov    %rbp,0x20(%r14)
0x55c131ef9af3:  mov    %rbx,0x80(%r14)
0x55c131ef9afa:  xor    %eax,%eax
0x55c131ef9afc:  jmpq   0x55c131ebe196
0x55c131ef9b01:  mov    $0x7f9ba51f7713,%rax
0x55c131ef9b0b:  jmpq   0x55c131ebe196

代码的第一部分似乎设置了用户“堆栈” ebp(从中获取它r14 + 0x20,这可能是模拟的机器状态结构)并最终从“堆栈”(行mov 0x0(%rbp),%rbx)中读取并将其存储到指向的区域中通过r14( mov %rbx,0x80(%r14))。

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

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

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

有些事情可以改变

我们在上面看到模拟进程看到的“堆栈”内容在 QEMU 下是相同的,但堆栈的细节确实发生了变化。例如,堆栈的地址在仿真下看起来与本机不同(即, 的rsp不是 指向的东西[rsp])。

这个功能:

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

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

自修改代码

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

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

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

请注意,在自修改代码的情况下,“垃圾收集”问题很简单:如上所述,模拟器被告知 SMC 事件,并且由于此时必须重新翻译,所以它丢弃了旧的翻译.

于 2017-07-22T21:18:49.443 回答