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

时间:2017-07-22 09:39:04

标签: compilation x86 virtualbox virtualization x86-emulation

(此问题并非针对VirtualBox或x86本身,但由于它们是我所知道的最佳示例,因此我将引用他们并询问VBox如何处理某些情况。如果您了解VBox未使用的其他解决方案,请考虑提及它们。)

我已阅读how VirtualBox does software virtualization,但我不了解以下内容。

  

在执行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:
    ...

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

VirtualBox的文档说它发现了问题"说明和补丁就地,但我真的不明白这是如何工作的,原因有两个:

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

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

  3. 请注意,我 询问有关Intel VT或AMD-V等硬件辅助虚拟化的信息,而且我 对阅读感兴趣关于那些。我很清楚他们完全避免了这些问题,但我的问题是关于纯软件虚拟化。

1 个答案:

答案 0 :(得分:3)

至少对于QEMU,似乎答案是即使在已翻译的代码中,也有一个单独的模拟&#34;堆栈&#34;使用与本机运行时代码相同的值设置,并且#&#34; stack&#34;是模拟代码读取的代码,它看起来与本机运行时的值相同。

这意味着模拟代码无法直接转换为使用callret或任何其他使用堆栈的指令,因为这些指令不会使用模拟堆栈。因此,这些调用被跳转到thunk代码的各个位来代替,这些代码在调用等效的已翻译代码方面做得很好。

QEMU的详细信息

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

也就是说,OP的心理模型是代码转换的结果有点像本机代码,带有一些补丁和修改。至少在QEMU的情况下,情况并非如此 - 每个基本块都通过Tiny Code Generator (TCG)进行大量翻译,首先是中间表示,然后是目标体系结构(即使源和目标也是如此)拱门是一样的,就像我的情况一样)。 This deck对很多技术细节有很好的概述,包括如下所示的TCG概述。

Diagram of TCG Flow

结果代码通常与输入代码完全不同,通常会增加大约3倍。寄存器通常很少使用,您经常会看到背靠背的冗余序列。与此问题特别相关的是,基本上所有控制流指令都有很大不同,因此本机代码中的retcall指令几乎不会转换为普通call或{{1}在翻译的代码中。

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

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 会像OP的示例一样返回return_address()[rsp]函数将其添加到main(),其中rdx将从中读取它。

我们希望printf看到的来电者的回复地址是通话后的指令return_address

0x400559

......确实是我们在本地运行它时所看到的:

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

让我们在QEMU中尝试:

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

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

这如何在幕后工作?我们可以使用QEMU的person@host:~/dev/test-c$ qemu-x86_64 ./qemu-test 0x400559 选项来查看它对此代码的影响。

首先,导致呼叫的代码(-d in_asm,out_asm部分是本机代码,而IN是QEMU将其转换为对不起的AT&amp; T语法,我可以&#39 ;弄清楚如何change that in QEMU):

OUT

关键部分在这里:

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

您可以看到它实际上是手动将返回地址从本机代码放到&#34;堆栈&#34; (通常可以使用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 似乎是指向某个内部QEMU数据结构的指针(即,不用于保存模拟程序中的值)。上面将r14(本地代码中的0x400546函数的地址)推送到return_address指向的结构字段中,将{r14 { 1}}在0x7f177ad8a690中,然后跳转到rax。最后一个地址出现在生成的代码中的所有位置(但它的定义没有),并且似乎是某种内部调度或thunk方法。据推测,它使用原生地址或更可能使用0x557c9cef8196中推送的神秘值来发送到翻译后的rax方法,如下所示:

return_address

第一位代码似乎设置了用户&#34; stack&#34;在---------------- 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获取,这可能是模拟的机器状态结构)并最终从&#34;堆栈中读取&#34; (行r14 + 0x20)并将其存储在mov 0x0(%rbp),%rbxr14)所指向的区域内。

最后,它转到mov %rbx,0x80(%r14),转移到QEMU结尾例程:

jmpq  0x55c131ebe196

请注意,我使用&#34; stack&#34;在引号中。这是因为这个&#34;堆栈&#34;是模拟程序看到的堆栈的 emulation ,而不是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指向的真实堆栈由QEMU控制以实现模拟控制流,并且模拟代码不直接访问它。

有些事情可以改变

我们在上面看到&#34; stack&#34;模拟过程看到的内容在QEMU下是相同的,但堆栈的细节确实会发生变化。例如,堆栈的地址在仿真下看起来与本机不同(即rsp而不是{{1}指向的东西})。

此功能:

rsp

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

自修改代码

您还提到了关于何时修改指令流的问题,并给出了加载内核模块的示例。首先,至少对于QEMU,代码只能按需转换#34;。可以调用但在某些特定运行中不会被转换的函数永远不会被转换(您可以使用根据0x7fffad33c100有条件调用的函数来尝试它)。因此,通常,将新代码加载到内核中或加载到用户模式仿真中的进程中,由相同的机制处理:代码将在第一次调用时进行转换。

如果代码实际上是自修改 -i.e。,进程会写入自己的代码 - 那么就必须要做一些事情,因为没有帮助QEMU会继续使用旧的翻译。因此,为了检测自修改代码而不惩罚每次写入内存,本机代码仅存在于具有R + X权限的页面中。结果是写入引发了QEMU处理的GP错误,指出代码已经修改了自身,使转换无效,等等。很多细节可以在this thread和其他地方找到。

这是一种合理的机制,我希望其他代码转换虚拟机可以做类似的事情。

注意,在自修改代码的情况下,&#34;垃圾收集&#34;问题很简单:如上所述,模拟器会被告知SMC事件,并且由于此时必须重新翻译,因此它会抛弃旧的翻译。