原文为360的安全专家很久之前的一篇文章,偶然发现后收获不少。特以笔记的形式记录下来, 便于加深理解。
Intel有公开的指令集格式文档,你需要的是第二卷的上半部分,指令集从A到M。这篇文档的难度超出一般人想象,里面有众多晦涩的标识、与硬件紧密相关的介绍,拿到这后,即使直接翻到目录的CALL 指令一节,也不见得能够弄清楚。不相信?我们就翻到那里看看:
这里注意一个细节, 即第四列64-bit Mode。能看到最后一行是Invalid, 这意味着在64位环境下该指令无效, 由于段寄存器这个概念在64位中已经不再使用, 猜测是这个缘由。
虽然很明确的列出,第一列是指令的二进制形式,第二列是指令的汇编形式,但是面对着 E8 cw, FF/2这样的标识,一样不知道究竟对应的二进制格式是什么样的。
那好,我们就从理解这些标识开始。文档向前翻,有一个专门的节(3.1.1 Instruction Format)讲述这些标识的含义。这里抽出其中两个用得着的翻译一下:
表格中的“Opcode”列列出了所有的所有可能的指令对应的二进制格式。有可能的话,指令代码使用十六进制显示它们在内存当中的字节。除了这些16进制代码之外的部分使用下面的标记:
注意这里用了”所有“这个字眼。以当前为例, 意味着所有的call指令都能用上述规则来表达。
cb, cw, cd, cp, co, ct — opcode后面跟着的一个1字节(cb),2字节(cw),4字节(cd),6字节 (cp),8字节(co) 或者 10字节(ct) 的值。这个值用来表示代码偏移地址,有可能的话还包括代码段寄存器的值。
这里只是举例。call指令后只有cw和cd, 和其cb,cp,co,ct无关。
/digit — digit为0到7之间的数字,表示指令的 ModR/M byte 只使用 r/m字段作为操作数,而其reg字段作为opcode的一部分,使用digit指定的数字。
MODR/M规则要牢牢记住。
7 6 Mod
5 4 3 reg/opcode (此时digit被认为是opcode的一部分)
2 1 0 R/M
红字部分不知道什么含义?没关系,我们先不看它。对于cb/cw之类的,基本上能够简单看明白其中的一些指令含义了:
E8 cw 的含义是:字节 0xE8 后面跟着一个2字节操作数表示要跳转到的地址与当前地址的偏移量。
E8 cd 的含义是:字节 0xE8 后面跟着一个4字节的操作数表示要跳转的地址与当前地址的偏移量。
9A cd 的含义是:字节 0x9A 后面跟着一个6字节的操作数表示要跳转的地址和代码段寄存器的值。
关于9A后跟6字节是有点疑惑的。但是由于64位不考虑9A, 所以先忽略作罢。
E8 cw也是忽略的。因为正常工作情况下cpu都是保护模式使用4字节。
所以这里只有E8 cd, 即跟随了4字节的偏移量。
那么,同样的0xE8开头的指令,CPU如何区分后面的操作数是2字节还是4字节?答案是和CPU的模式有关,在实模式下,0xE8接受2字节操作数,而32位保护模式下接受4个字节,64位保护模式下同样接受4字节,同时需要对该操作数进行带符号扩展。
https://zh.wikipedia.org/wiki/X86-64。
按照维基百科上的理解,现有的X86_64是AMD基于IA_32架构上做出的扩展, 现有的X86_64并不是基于IA64的, IA64做出来之后貌似无人问津。
而实模式和保护模式google到的结过是这样的, CPU开始加电是实模式, 正式开始工作就是保护模式了。所以我们这里统一认为E8在64位下后跟4字节。
因此,CALL指令的前两种格式是:E8 xx xx xx xx,和 9A xx xx xx xx xx xx。一个是5字节长,一个是7字节长。其实E8 那种,就是我们在汇编指令里面写 CALL lable之后产生的,最常见的CALL指令。
9A指令之前已经描述过在64位不可用。所以这里我们就忽略了。这里只认E8 xx xx xx xx。
然后是下面的FF /2。这个是0xFF字节后面跟上一个blablabla的东西。这个blablabla的东西是什么呢?要解释这个,首先需要知道红字标出来的部分,即ModR/M是什么东西。
这个要先回到最基本的一个问题:IA32的指令格式。
其中每个部分是什么含义呢?
首先是指令前缀。有印象的应该记得当年学习微机原理的时候提到过得循环前缀 repnz/repne,这个前缀就是被编码在指令的前面部分的。每个前缀最多一个字节,一条指令最多4个前缀。
关于指令前缀摘抄自X86的维基百科。
https://zh.wikipedia.org/wiki/X86
分为4组,每组用1个字节编码。每组在指令中至多指定1个前缀值。4组的顺序可以任意。
第1组锁与重复(Lock and repeat)
- 锁(LOCK)编码为:F0H。用于互斥访问共享内存的操作。
- 非零时重复(REPNE/REPNZ)编码为:F2H。用于字符串操作指令。
- 为零时重复(REP/REPE/REPZ)编码为:F3H。用于字符串操作指令。
第2组
- 段覆盖(Segment override):CS、SS、DS、ES、FS、GS的段覆盖前缀的编码分别是2EH、36H、3EH、26H、64H、65H.
- 分支提示(Branch hints),用于条件分支指令J_cc_。提示分支不发生编码为2EH;提示分支发生编码为3EH。
- 第3组操作数长度覆盖(Operand-size override)编码为66H。用于在16位与32位操作数切换。
- 第4组地址长度覆盖(Address-size override)编码为67H.用于在16位与32位地址切换。
然后是指令代码(opcode),这部分标识了指令是什么。这个是指令当中唯一必需的部分。前面例子当中的 0xE8,0xFF都是opcode。
再后面就是我们要重点关心的 ModR/M字段了,还有和它密切相关的SIB字节。手册2.1.3当中有对于它们的详细描述。
许多指令需要引用到一个在内存当中的值作为操作数,这种指令需要一个称为寻址模式标识字节(addressing-form specifier byte),或者叫做ModR/M字节紧跟在主opcode后面。ModR/M字节包含下面三个部分的信息:
- mod(模式)域,连同r/m(寄存器/内存)域共同构成了32个可能的值:8个寄存器和24个寻址模式。
- reg/opcode(寄存器/操作数)域指定了8个寄存器或者额外的3个字节的opcode。究竟这三个字节用来做什么由主opcode指定。
- r/m(寄存器/内存)域可以指定一个寄存器作为操作数,或者可以和mod域联合用来指定寻址模式。有时候,它和mod域一起用来为某些指令指定额外的信息。
个人的理解是这样的:
MOD有四种取值类型
00: 寄存器间接寻址(register indirect addressing)
01: 寄存器相对寻址或基址相对寻址(register relative addressing) disp8
10: 寄存器相对寻址或基址相对寻址(register relative addressing) disp32
11: 寄存器寻址(register addressing)
这一段有些晦涩。其意思解释一下是这样的:一个指令往往需要引用一个在内存当中的值,典型的就是如mov:
MOV eax, dword ptr [123456]
MOV eax, dword ptr [esi]
将0x123456地址处的内容送入eax(长度为双字)。
将esi寄存器中保存的地址处的内容送入eax(长度为双字)。
这其中的 123456 或者 esi 就是 MOV 指令引用的内存地址,而MOV关心的是这个地址当中的内容。这个时候,需要某种方式来为指令指定这个操作数的类型:是一个立即数表示的地址,还是一个存放在寄存器当中的地址,或者,就是寄存器本身。
这一句话点醒了我。它说明了汇编为什么要这么写, 以及为什么有SIB和MODR/M.
这个用来区分操作数类型的指令字节就是 ModR/M,确切的说是其中的5个位,即mod和r/m域。剩下的三个位,可能用来做额外的指令字节。因为,IA32的指令个数已经远超过一个字节所能表示的256个了。因此,有的指令就要复用第一个字节,然后依据ModR/M当中的reg/opcode域进行区分。
重申, MODR/M规则要牢牢记住。
7 6 Mod
5 4 3 reg/opcode
2 1 0 R/M
这里的”复用第一个字节“, 应该说的是指令前缀。
现在回头看前面的红字标识的部分,能不能理解 /digit 这种表示法了?
对于SIB的介绍,我们先忽略,看看对于CALL指令的枚举我们已经能做什么了。
CALL指令的表示法:FF /2,是 0xFF 后面跟着一个 /digit 表示的东西。就是说,0xFF后面需要跟一个 ModR/M 字节,ModR/M字节使用 reg/opcode 域 = 2 。那么,reg/opcode = 2 的字节有32个,正如ModR/M的解释,这32个值代表了32种不同的寻址方式。是哪32种呢?手册上面有张表:
原文说"reg/opcode = 2 的字节有32个", 其实从表里能看到, opcode为任何数(0-7), 对应的可能性都是32种(4*8)。
非常复杂的一张表。现在就看看这张表怎么读。
首先是列的定义。由于 reg/opcode 域可以用来表示opcode,也可以用来表示reg,因此同一个值在不同的指令当中可能代表不同的含义。在表当中,就表现为每一列的表头都有很多个不同的表示。我们需要关心的就是 opcode 这一个。注意看我用红圈圈出来的部分,这一列就是 opcode=2 的一列。而我们需要的 CALL 指令,也就是在这一列当中,0xFF后面需要跟着的内容。
大部分情况下, 我们只关注reg/opcode对应的是opcode的情况。
行的定义就是不同的寻址模式。正如手册所说,mod + R/M域,共5个字节,定义了32种寻址模式。0x10 – 0x17 对应于寄存器寻址。例如指令 CALL dword ptr [eax] :[eax]寻址对应的是 0x10,因此,该指令对应的二进制就是 FF 10。同理, CALL dword ptr [ebx] 是 FF 13,CALL dword ptr [esi] 是 FF 16,这些指令都是2个字节。有人也许问 CALL word ptr [eax] 是什么?抱歉,这不是一个合法的32位指令。
思考1:
大学里学过的一点点汇编早特么忘了。https://blog.csdn.net/zzzyyyyyy66/article/details/80329591
这里的 call dword ptr [eax] 意思为: 将eax对应的值作为地址,并返回以此开始的4个字节。 然后以这4个字节对应的【内容】为地址,发起调用。
而call eax意思为: 认为当前eax的内容就是地址,直接发起调用。
这里还要纠正另外一个问题, 根据维基百科对于X86的解释, 这里应该是【寄存器间接寻址】, 而不是【寄存器寻址】。
思考2:
行是寻址模式。"Value of ModR/M Byte"实际上描述了ModR/M的8位作为一个整数计算出来的16进制的值。
64位下, 这里实际操作的是rax. 比如FF 10:
32位下: call dword ptr [eax]
64位下: call qword ptr [rax]
这里有个例外是0x14使用了SIB, 0x15使用了立即数。这2个并不是寄存器。这个回头会说。
0x50-0x57部分需要带一个 disp8,即 8bit 立即数,也就是一个字节。这个是基地址+8位偏移量的寻址模式。例如 CALL dword ptr [eax+10] 就是 FF 50 10 。注意虽然表当中写的是 [eax] + disp8 这种形式,但是并不表示是取得 eax 指向的地址当中的值再加上 disp8,而是在eax上加上disp8再进行寻址。因此写成 [eax+disp8] 更不容易引起误解。后面的disp32也是一样的。这个类型指令是3个字节。
这里0x54是个例外,使用了SIB. 0x50-0x57的范围内限制了理解数是disp8, 那么比如FF 50 10 20 33就会被识别为:
call qword ptr[rbp+0x10]
and byte ptr[rbx], dh
很明显cpu识别到10就知道FF这个call指令到此为止了, 从20开始又是一条新的指令。
需要再一次注意这种写法是在进行地址相加而不是值。
0x90 – 0x97部分需要带 disp32,即4字节立即数。这个是基地址+32位偏移量。例如 CALL dword ptr [eax+12345] 就是 FF 90 00 01 23 45。有趣的是, CALL dword ptr [eax+10] 也可以写成 FF 90 00 00 00 10。至于汇编成哪个二进制形式,这是汇编器的选择。这个类型的指令是6个字节。
FF 90 00 01 23 45 实际在本机执行的结果是: call qworld ptr [rax + 0x45230100]. 这是因为本机采用了大端存储(big endian), 而原作者应该是小端存储(little endian)。
0xD0 – 0xD7部分则直接是寄存器。这边引用的寄存器的类型有很多,但是在CALL指令当中只能引用通用寄存器,因此 CALL eax 就是 FF D0,臭名昭著的 CALL esp 就是 FF D4。注意 CALL eax 和 CALL [eax] 是不一样的。这些指令也是2个字节。
这里应该叫寄存器寻址。
仔细的人也许主要到了,在表当中,0x14, 0x15, 0x54和0x94是不一样的。0x15比较简单,这个要求 ModR/M后面跟上一个32位立即数作为地址。即常见的 CALL dword ptr [004F778e] 这种格式的,直接跳转到一个固定内存地址处存放的值,常见于调用Windows的导出表。对应的二进制是 FF 15 00 4F 77 8E ,有6个字节。
这里觉得例子也要注意大小端序的问题。
0x14,0x54,0x94部分是最复杂的,因为这个时候,ModR/M不足以指定寻址方式,而是需要一个额外的字节,这个字节就是指令当中的第4个字节,SIB。同样在手册的2.1.3,紧跟着ModR/M的定义:
某些特定的ModR/M字节需要一个后续字节,称为SIB字节。32位指令的基地址+偏移量,以及 比例*偏移量 的形式的寻址方式需要SIB字节。 SIB字节包括下列信息:
- scale(比例)域指定了放大的比例。
- index(偏移)域指定了用来存放偏移量 的寄存器。
- base (基地址)域用来标识存放基地址的寄存器。
0x14, 0x54, 0x94就是这里所说的“特定的ModR/M字节。这个字节后面跟着的SIB表示了一个复杂的寻址方式,典型的见于虚函数调用:
CALL dword ptr [ecx+4*eax]
就是调用ecx指向的虚表当中的第eax个虚函数。这个指令当中,因为没有立即数,因此FF后面的字节就是0x14,而 [ecx+4*eax] 就需要用SIB字节来表示。在这个指令当中,ecx就是 Base,4是Scale,eax是Index。
注意他说的这个SIB的使用场景。
那么,Base, Scale和Index是如何确定的呢?手册上同样有一张表(又是巨大的表):
列是Base,行是IndexScale,例如[ecx+4eax] 就是0x81。
ecx是base为2的那一列。4*eax在左边表行有显示, 就对应了0x81。
根据这张表,CALL dword ptr [ecx+4*eax] 就是 FF 14 81 。由此可见,对于 0x14系列的来说,CALL指令就是 3个字节。
而 0x54 带 8bit 立即数,就是对应于 CALL指令:CALL dword ptr [ecx+4*eax+xx],这个指令就是 FF 54 81 xx,是4个字节。
同理,0x94带32位立即数,对应于CALL指令:CALL dword ptr [ecx+4*eax+xxxxxxxx],这个指令就是 FF 94 81 xx xx xx xx,是7个字节。
注意这个CALL dword ptr [ecx+4*eax+xx]。这里的xx其实是在ModR/M中就明确了, 就是那个54对应的disp8。
同理ecx+4*eax+xxxxxxxx], 这里ModR/M就是94了。
OK,截止到目前,我们基本上能够列出常见的CALL指令的格式了:
指令 | 二进制形式 | 实际指令(本机) |
---|---|---|
CALL rel32 | E8 xx xx xx xx | CALL rel32 |
CALL dword ptr [EAX] | FF 10 | CALL qword ptr [RAX] |
CALL dword ptr [ECX] | FF 11 | CALL qword ptr [RCX] |
CALL dword ptr [EDX] | FF 12 | CALL qword ptr [RDX] |
CALL dword ptr [EBX] | FF 13 | CALL qword ptr [RBX] |
CALL dword ptr [REG*SCALE+BASE] | FF 14 xx | CALL qword ptr [REG*SCALE+BASE] |
CALL dword ptr [abs32] | FF 15 xx xx xx xx | CALL qword ptr [abs32] |
CALL dword ptr [ESI] | FF 16 | CALL qword ptr [RSI] |
CALL dword ptr [EDI] | FF 17 | CALL qword ptr [RDI] |
CALL dword ptr [EAX+xx] | FF 50 xx | CALL qword ptr [RAX+xx] |
CALL dword ptr [ECX+xx] | FF 51 xx | CALL qword ptr [RCX+xx] |
CALL dword ptr [EDX+xx] | FF 52 xx | CALL qword ptr [RDX+xx] |
CALL dword ptr [EBX+xx] | FF 53 xx | CALL qword ptr [RBX+xx] |
CALL dword ptr [REG*SCALE+BASE+off8] | FF 54 xx xx | CALL qword ptr [REG*SCALE+BASE+off8] |
CALL dword ptr [EBP+xx] | FF 55 xx | CALL qword ptr [RBP+xx] |
CALL dword ptr [ESI+xx] | FF 56 xx | CALL qword ptr [RSI+xx] |
CALL dword ptr [EDI+xx] | FF 57 xx | CALL qword ptr [RDI+xx] |
CALL dword ptr [EAX+xxxxxxxx] | FF 90 xx xx xx xx | CALL qword ptr [RAX+xxxxxxxx] |
CALL dword ptr [ECX+xxxxxxxx] | FF 91 xx xx xx xx | CALL qword ptr [RCX+xxxxxxxx] |
CALL dword ptr [EDX+xxxxxxxx] | FF 92 xx xx xx xx | CALL qword ptr [RDX+xxxxxxxx] |
CALL dword ptr [EBX+xxxxxxxx] | FF 93 xx xx xx xx | CALL qword ptr [RBX+xxxxxxxx] |
CALL dword ptr [REG*SCALE+BASE+off32] | FF 94 xx xx xx xx xx | CALL qword ptr [REG*SCALE+BASE+off32] |
CALL dword ptr [EBP+xxxxxxxx] | FF 95 xx xx xx xx | CALL qword ptr [RBP+xxxxxxxx] |
CALL dword ptr [ESI+xxxxxxxx] | FF 96 xx xx xx xx | CALL qword ptr [RSI+xxxxxxxx] |
CALL dword ptr [EDI+xxxxxxxx] | FF 97 xx xx xx xx | CALL qword ptr [RDI+xxxxxxxx] |
CALL EAX | FF D0 | CALL RAX |
CALL ECX | FF D1 | CALL RCX |
CALL EDX | FF D2 | CALL RDX |
CALL EBX | FF D3 | CALL RBX |
CALL ESP | FF D4 | CALL RSP |
CALL EBP | FF D5 | CALL EBP |
CALL ESI | FF D6 | CALL RSI |
CALL EDI | FF D7 | CALL RDI |
CALL FAR seg16:abs32 | 9A xx xx xx xx xx xx | 64位不支持,此处不做描述 |
重新翻阅了intel的文档,发现call又有补充了:
FF /3
REX.W + FF /3