在x86机器代码中调用绝对指针

时间:2013-10-23 20:59:09

标签: x86 jit machine-code

x86机器代码中call绝对指针的“正确”方法是什么?有没有一种方法可以在一条指令中完成它?

想要做什么:

我正在尝试基于“子程序线程”构建一种简化的mini-JIT(仍然)。它基本上是字节码解释器中最短的步骤:每个操作码都是作为一个单独的函数实现的,因此每个基本的字节码块都可以“JIT”到它自己的新程序中,如下所示:

{prologue}
call {opcode procedure 1}
call {opcode procedure 2}
call {opcode procedure 3}
...etc
{epilogue}

因此,我们的想法是每个块的实际机器代码只能从模板中粘贴(根据需要扩展中间部分),并且需要“动态”处理的唯一位是复制函数指针每个操作码都作为每个调用指令的一部分进入正确的位置。

我遇到的问题是了解模板call ...部分的用途。 x86似乎没有考虑到这种用法,并且支持相对和间接调用。

看起来就像我可以使用FF 15 EFBEADDE2E FF 15 EFBEADDE来假设DEADBEEF调用函数(基本上通过将内容放入汇编程序来发现这些函数)反汇编并看到什么产生了有效的结果,通过了解他们做了什么),但我不了解有关细分和特权及相关信息的东西,以便看到差异,或者这些将如何表现与更常见的call指令不同。英特尔架构手册还建议这些仅在32位模式下有效,在64位模式下“无效”。

有人可以解释这些操作码以及我是如何,或者是否会为此目的使用它们或其他人?

(通过寄存器使用间接调用也有明显的答案,但这似乎是“错误的”方法 - 假设实际存在直接调用指令。)

2 个答案:

答案 0 :(得分:5)

此处的所有内容也适用于jmp到绝对地址,指定目标的语法也相同。问题是关于JITing,但我还包括NASM和AT& T语法以扩大范围。

另请参阅Handling calls to far away intrinsic functions in a JIT了解如何分配"附近"内存,因此您可以使用rel32从JITed代码中调用提前编译的函数。

x86没有对指令中编码的绝对地址的正常(近)calljmp进行编码没有绝对的直接调用/ jmp编码,除了你不想要的jmp far。见Intel's insn set ref manual entry for call。 (有关文档和指南的其他链接,另请参阅x86 tag wiki。)大多数计算机体系结构use relative encodings for normal jumps如x86,BTW。

最佳选择(如果您可以使位置依赖代码知道自己的地址)是使用普通call rel32 ,{{1直接近呼叫编码,其中E8 rel32字段为rel32(2' s补码二进制整数)。

有关手动编码target - end_of_call_insn指令的示例,请参阅How does $ work in NASM, exactly?;在JITing做的同时也应该这么简单。

在AT& T语法中:call
在NASM语法中:call 0x1234567

也适用于具有绝对地址的命名符号(例如,使用call 0x1234567equ创建)。它没有MASM的等价物,它显然只接受标签作为目的地,因此人们有时会使用低效的变通方法来解决该工具链(和/或目标文件格式重定位类型)的限制。

这些在位置相关的代码(不是共享库或PIE可执行文件)中组装和链接得很好。但不是在x86-64 OS X中,文本部分映射到4GiB以上,因此它无法使用.set到达低地址。

在要调用的绝对地址范围内分配JIT缓冲区。的 e.g。在Linux上使用rel32来分配低2GB的内存,其中+ -2GB可以到达该区域中的任何其他地址,或者在跳转目标所在的位置附近提供非NULL提示地址。 (不要使用mmap(MAP_32BIT);如果您的提示与任何现有映射重叠,最好让内核选择不同的地址。)

(Linux非PIE可执行文件映射在低2GB的虚拟地址空间中,因此它们可以使用带有符号扩展的32位绝对地址的MAP_FIXED数组索引,或者将静态地址放入带{{{1}的寄存器中。 1}}用于零扩展绝对值。因此低2GB,而不是低4GB。But PIE executables are becoming the norm,所以不要假设主可执行文件中的静态地址是低32,除非你确保构建+链接与[disp32 + reg]一样。其他操作系统如OS X总是将可执行文件放在4GB以上。)

如果您无法使mov eax, imm32可用

但是如果您需要创建与位置无关的代码 不知道自己的绝对地址,或如果您需要调用地址来自调用者的距离超过+ -2GiB (可能是64位,但最好将代码放得足够近),你应该使用寄存器 - 间接-no-pie -fno-pie

call rel32

或AT& T语法

call

显然你可以使用任何寄存器,例如; use any register you like as a scratch mov eax, 0xdeadbeef ; 5 byte mov r32, imm32 ; or mov rax, 0x7fffdeadbeef ; for addresses that don't fit in 32 bits call rax ; 2 byte FF D0 mov $0xdeadbeef, %eax # movabs $0x7fffdeadbeef, %rax # mov r64, imm64 call *%rax ,这些寄存器在x86-64系统V中被调用但不用于arg传递.AL =可变参数的XMM args数量函数,因此在调用x86-64 System V调用约定中的可变参数函数之前,需要AL = 0中的固定值。

如果您确实需要避免修改任何寄存器,可以将绝对地址保留为内存中的常量,并使用具有RIP相对寻址模式的内存间接r10,例如

NASM r11;如果你不能破坏任何注册 AT& T call

请注意,间接调用/跳转会使您的代码容易受到Specter攻击的影响,尤其是如果您在同一进程中作为不受信任代码的沙箱的一部分进行JIT操作。 (在这种情况下,核心补丁本身并不能保护你)。

您可能需要"retpoline"而不是普通的间接分支来降低Spectre的性能。

间接跳跃也会比直接跳跃(call [rel function_pointer]稍差一些的错误预测惩罚。普通直接call *function_pointer(%rip) insn的目的地一旦被解码就知道了,一旦它检测到那里有一个分支,就会在管道的早期被解码。

间接分支通常在现代x86硬件上很好地预测,并且通常用于调用动态库/ DLL。这并不可怕,但call rel32肯定更好。

尽管直接call需要一些分支预测来完全避免管道泡沫。 (在解码之前需要进行预测,例如,假设我们刚刚获取了这个块,那么取出阶段应该取出哪个块。call rel32 slows down when you run out of branch-predictor entries的序列。 call +间接jmp next_instruction即使是完美的分支预测也会更糟,因为它的代码大小更大,而且uop更多,但这是一个非常小的效果。如果额外的mov是一个问题,如果可能的话,内联代码而不是调用代码是个好主意。

有趣的事实:call reg将汇编但不链接到Linux上的64位静态可执行文件,除非您使用链接描述文件放置mov部分/文本细分更接近该地址。 call 0xdeadbeef部分通常从.text开始在静态可执行文件(或non-PIE dynamic executable)中,即在虚拟地址空间的低2GiB中,其中所有静态代码/数据都存在于默认代码中模型。但是.text处于低32位的高半部分(即低4G而不是低2G),因此它可以表示为零扩展的32位整数但不是符号扩展32-位。并且0x400080不适合签名的32位整数,该整数将正确扩展到64位。 (从低地址回绕的负0xdeadbeef可以到达的地址空间部分是64位地址空间的前2GiB;通常地址空间的上半部分保留供内核使用。 )

它与0x00000000deadbeef - 0x0000000000400080组合正常,rel32显示:

yasm -felf64 -gdwarf2 foo.asm

但当objdump -drwC -Mintel尝试将其实际链接到静态可执行文件中时,.text从foo.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <.text>: 0: e8 00 00 00 00 call 0x5 1: R_X86_64_PC32 *ABS*+0xdeadbeeb 开始,ld0000000000400080

在32位代码ld -o foo foo.o中组装和链接就好了,因为foo.o:/tmp//foo.asm:1:(.text+0x1): relocation truncated to fit: R_X86_64_PC32 against '*ABS*'可以从任何地方到达任何地方。相对位移不必符号扩展为64位,它只是32位二进制加法,可以包围或不包围。

直接远call 0xdeadbeef编码(慢,不使用)

您可能会在calljmp的手册条目中注意到,编码中包含绝对目标地址的编码。但那些只存在于&#34;远&#34; rel32 / call也将call设置为新的代码段选择器,速度很慢(see Agner Fog's guides)

jmp(&#34;调用far,绝对,操作数和#34中给出的地址;)有一个6字节的段:偏移编码到指令中,而不是加载它作为来自正常寻址模式给出的位置的数据。所以它是对绝对地址的直接调用。

CS也将CS:EIP作为返回地址而不仅仅是EIP,因此它甚至不能与仅推送EIP的普通(近)CALL ptr16:32兼容。这不是call的问题,只是缓慢并找出要为细分部分投入的内容。

更改CS通常仅适用于从32位模式更改为64位模式,反之亦然。通常只有内核可以这样做,尽管可以在用户空间中在大多数普通操作系统下执行此操作,这些操作系统在GDT中保留32位和64位段描述符。不过,这将是一个愚蠢的计算机技巧而不是有用的东西。 (64位内核使用calljmp ptr16:32返回32位用户空间。大多数操作系统在启动期间只使用远jmp一次,以便在内核模式下切换到64位代码段。 )

主流操作系统使用平面内存模型,您永远不需要更改iret,并且它没有标准化将sysexit值用于用户空间进程。即使你想使用远cs,你也必须弄清楚在段选择器部分中放入什么值。 (轻松实现JITing:只需使用cs读取当前jmp。但很难在早期编译中移植。)

cs不存在,远程编码仅存在于16位和32位代码中。在64位模式下,只能使用10 {1}}内存操作数远{ - 1}},如mov eax, cs。或推送段:堆栈上的偏移量并使用call ptr16:64

答案 1 :(得分:1)

你只能用一条指令做到这一点。一个不错的方法是使用MOV + CALL:

0000000002347490: 48b83412000000000000  mov rax, 0x1234
000000000234749a: 48ffd0                call rax

如果要调用的过程的地址发生更改,请更改从偏移量2开始的八个字节。如果调用0x1234的代码的地址发生更改,则不必执行任何操作,因为寻址是绝对的。