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

Meh*_*dad 6 virtualization x86 virtualbox compilation x86-emulation

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


我已经了解了VirtualBox如何进行软件虚拟化,我不明白以下几点.

在执行ring 0代码之前,CSAM [Code Scanning and Analysis Manager]会递归扫描它以发现有问题的指令.PATM [Patch Manager]然后执行原位修补,即它将指令替换为管理程序内存,其中集成代码生成器已经放置了更合适的实现.实际上,这是一项非常复杂的任务,因为有很多奇怪的情况需要被发现和正确处理.因此,凭借其目前的复杂性,人们可以争辩说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:
    ...
Run Code Online (Sandbox Code Playgroud)

这里的被调用者正在测试查看调用者的返回地址是否是1234,如果是,则执行特殊操作.显然,修补将改变返回地址,因此我们需要能够处理它.

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

  1. 看来暴露指令指针的任何指令都是"有问题的",其中call可能是最常见的(并且极其如此).这是否意味着VirtualBox必须分析并可能修补它在环0中看到的每条 call指令?这不会让表演从悬崖上掉下来吗?他们如何以高性能处理这个问题?(他们在文档中提到的案例非常模糊,所以我很困惑,为什么他们没有提到这样的共同指令,如果它发生的话.如果不是问题,我不明白为什么.)

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

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

Bee*_*ope 5

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

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

QEMU 的详细信息

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

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

TCG流程示意图

生成的代码通常与输入代码完全不同,并且通常会增加大约 3 倍的大小。寄存器通常很少使用,您经常会看到背靠背的冗余序列。对这个问题特别相关的是,基本上所有的控制流指令是完全不同的,所以retcall在本机代码指令几乎从来没有转化为平原callret在翻译代码。

一个例子:首先,一些带有return_address()调用的C 代码只返回返回地址,amain()打印此函数:

#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);
}
Run Code Online (Sandbox Code Playgroud)

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    
Run Code Online (Sandbox Code Playgroud)

请注意,return_address()返回[rsp]就像 OP 的示例一样。该main()函数将其粘贴在 中rdxprintf从哪里读取它。

我们期望调用者的返回地址是调用后的return_address指令,0x400559

  400554:       e8 ed ff ff ff          call   400546 <return_address>
  400559:       48 89 c2                mov    rdx,rax
Run Code Online (Sandbox Code Playgroud)

...实际上这就是我们在本机运行时所看到的:

person@host:~/dev/test-c$ ./qemu-test 
0x400559
Run Code Online (Sandbox Code Playgroud)

让我们在 QEMU 中尝试一下:

person@host:~/dev/test-c$ qemu-x86_64 ./qemu-test
0x400559
Run Code Online (Sandbox Code Playgroud)

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

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

首先,调用前的代码(该IN部分是本机代码,这OUT就是 QEMU 将其转换为的内容——抱歉 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
Run Code Online (Sandbox Code Playgroud)

一个关键部分在这里:

0x557c9cf33a74:  mov    $0x400559,%ebx
0x557c9cf33a79:  mov    %rbx,0x0(%rbp)
Run Code Online (Sandbox Code Playgroud)

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

0x557c9cf33a92:  movq   $0x400546,0x80(%r14)
0x557c9cf33a9d:  mov    $0x7f177ad8a690,%rax
0x557c9cf33aa7:  jmpq   0x557c9cef8196
Run Code Online (Sandbox Code Playgroud)

在大部分代码中,r14似乎是指向某个内部 QEMU 数据结构的指针(即,不用于保存来自模拟程序的值)。上述猛推0x400546(它是地址return_address功能在本机代码)到该结构的场指向r14,棒0x7f177ad8a690rax,并跳转到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    $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
Run Code Online (Sandbox Code Playgroud)

代码的第一位似乎在ebp(从中获取它r14 + 0x20,这可能是模拟的机器状态结构)设置用户“堆栈”并最终从“堆栈”(行mov 0x0(%rbp),%rbx)中读取并将其存储到指向的区域中通过r14mov %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   
Run Code Online (Sandbox Code Playgroud)

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

有些事情可以改变

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

这个功能:

__attribute__ ((noinline)) void* return_address() {
    return __builtin_frame_address(0);
}
Run Code Online (Sandbox Code Playgroud)

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

自修改代码

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

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

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

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

  • 是的,也许 VirtualBox 让代码处于更自然的状态,但是从各种构造的几个 `-d asm_in,asm_out` 运行来看,很明显 QEMU 从根本上改变了原生代码到难以识别的地步它,至少在我的机器上是默认的。也就是说,它似乎仍然直接访问任意地址的内存(例如,当我写了一个像 [this](https://gist.github.com/travisdowns/7ed6e1703aeeafa499b405190a47683b) 的循环时),但它只执行一次迭代在做“thunk”的事情之前。 (2认同)