(此问题并非针对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的文档说它发现了问题"说明和补丁就地,但我真的不明白这是如何工作的,原因有两个:
看来任何指令暴露指令指针是"有问题",其中call
可能是最常见的(并且极其如此) 。这是否意味着VirtualBox必须分析并可能修补它在环0中看到的每条 call
指令?这不会让性能从悬崖上掉下来吗?他们如何以高性能处理这个问题? (他们在文档中提到的案例非常模糊,所以我很困惑,为什么他们没有提到这样的共同指令。如果它没有问题,我就不会这样做。明白为什么。)
如果指令流恰好被修改(例如动态加载/卸载内核模块),VirtualBox必须动态检测这个并且垃圾收集无法访问的重新编译指令。否则,它将有内存泄漏。但这意味着每条 mov
指令(和push
指令,以及写入内存的所有其他内容)现在都必须进行分析并可能进行修补,可能是重复的,因为它可能修改已修补的代码。这似乎基本上将所有来宾ring-0代码退化为近乎完整的软件仿真(因为在重新编译期间移动的目标不知道),这将使虚拟化成本突然出现< / em>,但这不是我从阅读文档中得到的印象。这不是问题吗?如何有效处理?
请注意,我 不 询问有关Intel VT或AMD-V等硬件辅助虚拟化的信息,而且我 对阅读感兴趣关于那些。我很清楚他们完全避免了这些问题,但我的问题是关于纯软件虚拟化。
答案 0 :(得分:3)
至少对于QEMU
,似乎答案是即使在已翻译的代码中,也有一个单独的模拟&#34;堆栈&#34;使用与本机运行时代码相同的值设置,并且#&#34; stack&#34;是模拟代码读取的代码,它看起来与本机运行时的值相同。
这意味着模拟代码无法直接转换为使用call
,ret
或任何其他使用堆栈的指令,因为这些指令不会使用模拟堆栈。因此,这些调用被跳转到thunk代码的各个位来代替,这些代码在调用等效的已翻译代码方面做得很好。
OP的(合理的)假设似乎是call
和ret
指令将出现在翻译的二进制文件中,而堆栈将反映动态翻译的代码的地址。实际发生的事情(在QEMU中)是删除call
和ret
指令并替换为不使用堆栈的控制流,并且堆栈上的值设置为相同值与原生代码中的值一样。
也就是说,OP的心理模型是代码转换的结果有点像本机代码,带有一些补丁和修改。至少在QEMU的情况下,情况并非如此 - 每个基本块都通过Tiny Code Generator (TCG)进行大量翻译,首先是中间表示,然后是目标体系结构(即使源和目标也是如此)拱门是一样的,就像我的情况一样)。 This deck对很多技术细节有很好的概述,包括如下所示的TCG概述。
结果代码通常与输入代码完全不同,通常会增加大约3倍。寄存器通常很少使用,您经常会看到背靠背的冗余序列。与此问题特别相关的是,基本上所有控制流指令都有很大不同,因此本机代码中的ret
和call
指令几乎不会转换为普通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),%rbx
(r14
)所指向的区域内。
最后,它转到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事件,并且由于此时必须重新翻译,因此它会抛弃旧的翻译。