至少对于QEMU
,似乎答案是即使在翻译后的代码中,也有一个单独的模拟“堆栈”,其设置与本机运行时的代码具有相同的值,而这个“堆栈”就是一个由仿真代码读取,它看到的值与本机运行时相同。
这意味着模拟代码不能直接转换为 use call
、ret
或任何其他使用堆栈的指令,因为这些指令不会使用模拟堆栈。因此,这些调用被替换为跳转到各种 thunk 代码位,这些代码在调用等效的翻译代码方面做正确的事情。
QEMU 的详细信息
OP 的(合理的)假设似乎是指令将出现在已翻译的二进制文件中,call
并且ret
堆栈将反映动态翻译代码的地址。实际发生的情况(在 QEMU 中)是call
andret
指令被删除并替换为不使用堆栈的控制流,并且堆栈上的值被设置为与本机代码中的值相同。
也就是说,OP 的心智模型是代码翻译的结果有点像原生代码,有一些补丁和修改。至少在 QEMU 的情况下,情况并非如此——每个基本块都通过Tiny Code Generator (TCG)大量转换,首先转换为中间表示,然后转换为目标架构(即使源和目标架构是和我一样)。该套牌很好地概述了许多技术细节,包括如下所示的 TCG 概述。
生成的代码通常与输入代码完全不同,并且通常会增加大约 3 倍的大小。寄存器的使用通常相当谨慎,您经常会看到背靠背的冗余序列。与这个问题特别相关的是,基本上所有的控制流指令都是完全不同的,因此本机代码中ret
的call
指令几乎永远不会翻译成纯代码call
或ret
翻译后的代码。
一个例子:首先,一些 C 代码带有一个return_address()
简单地返回返回地址的调用,以及main()
打印这个函数的 a:
#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
...事实上,这就是我们在本地运行它时看到的:
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
部分是本机代码,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
关键部分在这里:
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
在大多数代码中,r14
似乎是指向某些内部 QEMU 数据结构的指针(即,不用于保存来自仿真程序的值)。上面的0x400546
内容(这是本机代码return_address
中函数的地址)推入 指向的结构的字段中,插入并跳转到。最后一个地址出现在生成的代码中的所有位置(但它的定义没有),并且似乎是某种内部调度或 thunk 方法。据推测,它使用本机地址,或者更可能是使用推入的神秘值来调度到翻译后的方法,如下所示:r14
0x7f177ad8a690
rax
0x557c9cef8196
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 将继续使用旧的翻译。因此,为了在不惩罚每次写入内存的情况下检测自修改代码,本机代码仅存在于具有 R+X 权限的页面中。结果是写入引发了 GP 错误,QEMU 通过注意到代码已修改自身、使翻译无效等来处理该错误。很多细节可以在这个线程和其他地方找到。
这是一个合理的机制,我希望其他代码翻译虚拟机也能做类似的事情。
请注意,在自修改代码的情况下,“垃圾收集”问题很简单:如上所述,模拟器被告知 SMC 事件,并且由于此时必须重新翻译,所以它丢弃了旧的翻译.