gpt4 book ai didi

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

转载 作者:行者123 更新时间:2023-12-03 09:29:23 26 4
gpt4 key购买 nike

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

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

Before executing ring 0 code, CSAM [Code Scanning and Analysis Manager] scans it recursively to discover problematic instructions. PATM [Patch Manager] then performs in-situ patching, i.e. it replaces the instruction with a jump to hypervisor memory where an integrated code generator has placed a more suitable implementation. In reality, this is a very complex task as there are lots of odd situations to be discovered and handled correctly. So, with its current complexity, one could argue that PATM is an advanced in-situ recompiler.



考虑以下 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 指令,以及所有其他写入内存的指令)现在都必须被分析并可能被修补,可能是重复的,因为它可能正在修改被修补的代码。这似乎从本质上将所有访客环 0 代码退化为近乎完整的软件仿真(因为在重新编译期间不知道移动的目标),这将使虚拟化成本飙升,但这不是我得到的印象阅读文档。这不是问题吗?这是如何有效处理的?

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

    最佳答案

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

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

    QEMU 的详细信息

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

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

    Diagram of TCG Flow

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

    一个例子:首先,一些带有 return_address() 的 C 代码调用只返回返回地址和 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

    请注意 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

    ...实际上这就是我们在 native 运行时所看到的:
    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_asm QEMU 的选项以查看它是如何制作此代码的。

    首先,调用前的代码( IN 部分是 native 代码,而 OUT 是 QEMU 将其转换为的内容——抱歉 AT&T 语法,我不知道如何使用 change that in 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)

    您可以看到它实际上是手动将 native 代码的返回地址放入“堆栈”(似乎通常使用 rbp 访问)。之后,请注意没有 call指示 return_address .相反,我们有:
    0x557c9cf33a92:  movq   $0x400546,0x80(%r14)
    0x557c9cf33a9d: mov $0x7f177ad8a690,%rax
    0x557c9cf33aa7: jmpq 0x557c9cef8196

    在大部分代码中, r14似乎是指向某个内部 QEMU 数据结构的指针(即,不用于保存来自模拟程序的值)。以上推 0x400546 (它是 native 代码中 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

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

    自修改代码

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

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

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

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

    关于compilation - 动态重新编译如何处理软件虚拟化中的指令指针检查?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/45253035/

    26 4 0
    Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
    广告合作:1813099741@qq.com 6ren.com