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的文档说它发现了"有问题"的指令并对它们进行了就地修补,但我真的不明白它是如何工作的,原因有两个:
看来暴露指令指针的任何指令都是"有问题的",其中call可能是最常见的(并且极其如此).这是否意味着VirtualBox必须分析并可能修补它在环0中看到的每条 call指令?这不会让表演从悬崖上掉下来吗?他们如何以高性能处理这个问题?(他们在文档中提到的案例非常模糊,所以我很困惑,为什么他们没有提到这样的共同指令,如果它发生的话.如果不是问题,我不明白为什么.)
如果指令流恰好被修改(例如动态加载/卸载内核模块),VirtualBox必须动态检测这个并且垃圾收集无法访问的重新编译指令.否则,它将有内存泄漏.但这意味着现在必须分析每个 mov指令(和push指令以及写入内存的所有其他内容),并且可能需要重复进行修补,因为它可能正在修改已修补的代码.这似乎基本上将所有 guest-ring-0代码简化为近乎完整的软件仿真(因为在重新编译期间移动的目标是未知的),这将使虚拟化成本急剧上升,但这不是我得到的印象阅读文档.这不是问题吗?如何有效处理?
请注意,我不是在询问像英特尔VT或AMD-V这样的硬件辅助虚拟化,我对阅读这些虚拟化并不感兴趣.我很清楚他们完全避免了这些问题,但我的问题是关于纯软件虚拟化.
至少对于QEMU,似乎答案是即使在翻译后的代码中,也有一个单独的模拟“堆栈”,其设置的值与本机运行时代码具有的值相同,而这个“堆栈”就是一个由模拟代码读取,它看到的值与本机运行时相同。
这意味着模拟代码不能直接转换为 use call、ret或任何其他使用堆栈的指令,因为这些指令不会使用模拟堆栈。因此,这些调用被跳转到 thunk 代码的各个位所取代,这些代码在调用等效的翻译代码方面做了正确的事情。
该OP的(合理的)假设似乎是call和ret指令会出现在二进制翻译和堆栈将反映动态转换代码的地址。实际发生的情况(在 QEMU 中)是call和ret指令被删除并替换为不使用堆栈的控制流,并且堆栈上的值设置为与它们在本机代码中相同的值。
也就是说,OP 的心智模型是代码翻译的结果有点像原生代码,有一些补丁和修改。至少在 QEMU 的情况下,情况并非如此——每个基本块都通过Tiny Code Generator (TCG) 进行大量转换,首先转换为中间表示,然后转换为目标架构(即使源和目标架构是与我的情况相同)。该套牌很好地概述了许多技术细节,包括如下所示的 TCG 概述。
生成的代码通常与输入代码完全不同,并且通常会增加大约 3 倍的大小。寄存器通常很少使用,您经常会看到背靠背的冗余序列。对这个问题特别相关的是,基本上所有的控制流指令是完全不同的,所以ret并call在本机代码指令几乎从来没有转化为平原call或ret在翻译代码。
一个例子:首先,一些带有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()函数将其粘贴在 中rdx,printf从哪里读取它。
我们期望调用者的返回地址是调用后的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,棒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 $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)中读取并将其存储到指向的区域中通过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
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)
通常返回类似的地址,0x7fffad33c100但0x40007ffd00在 QEMU下返回类似的地址。不过,这应该不是问题,因为没有有效的程序应该依赖于堆栈地址的确切绝对值。它不仅通常没有定义和不可预测,而且在最近的操作系统上,由于堆栈ASLR(Linux 和 Windows 都实现了这一点),它确实被设计为完全不可预测。上面的程序每次我本地运行时都会返回一个不同的地址(但在 QEMU 下是相同的地址)。
您还提到了有关何时修改指令流的问题,并举了一个加载内核模块的示例。首先,至少对于 QEMU,代码仅“按需”翻译。可以调用但不在某些特定运行中的函数永远不会被翻译(您可以尝试使用根据 有条件地调用的函数argc)。所以一般来说,将新代码加载到内核中,或者加载到用户模式仿真中的进程中,是由相同的机制处理的:代码将在第一次被调用时被简单地翻译。
如果代码实际上是自我修改的——即,进程写入它自己的代码——那么必须做一些事情,因为没有帮助 QEMU 将继续使用旧的翻译。因此,为了检测自修改代码而不惩罚每次对内存的写入,本机代码仅存在于具有 R+X 权限的页面中。结果是写入引发 GP 错误,QEMU 通过注意到代码已修改自身、使翻译无效等来处理该错误。可以在此线程和其他地方找到大量详细信息。
这是一种合理的机制,我希望其他代码翻译 VM 也能做类似的事情。
请注意,在自修改代码的情况下,“垃圾收集”问题很简单:如上所述,模拟器被告知 SMC 事件,并且由于此时必须重新翻译,因此将旧翻译扔掉.
| 归档时间: |
|
| 查看次数: |
243 次 |
| 最近记录: |