首页 > 技术 > CALL指令有多少种写法

CALL指令有多少种写法

最近有一个需求,给你个地址,看看这个地址前面是不是一个CALL指令(请同学们自行联想该需求的来源)。作为团队的救火队员+炮灰,这个简单的事情自然落在了我的头上。

这个事情很简单,作为一个善于站在别人肩膀上的程序员我们可以考虑使用 libdisasm;如果要考虑x64,就试试udis86;如果需要用Python,就有Python包装好的 pydasm。不过这两个400KB+的库,显然不值得为了一个CALL指令导入到编译出来大小仅仅100K不到的项目代码里面。

那么就自己抽一个CALL指令解码逻辑出来好了。这个逻辑的复杂性在于,你无法知道前面一个CALL指令有多长。因此,首先需要枚举出所有的CALL指令格式。

Intel有公开的指令集格式文档,你需要的是第二卷的上半部分,指令集从A到M。这篇文档的难度超出一般人想象,里面有众多晦涩的标识、与硬件紧密相关的介绍,拿到这后,即使直接翻到目录的CALL 指令一节,也不见得能够弄清楚。不相信?我们就翻到那里看看:

CALL指令格式一览表

CALLæŒ‡ä»¤æ ¼å¼ä¸€è§ˆè¡¨

虽然很明确的列出,第一列是指令的二进制形式,第二列是指令的汇编形式,但是面对着 E8 cw, FF/2这样的标识,一样不知道究竟对应的二进制格式是什么样的。

那好,我们就从理解这些标识开始。文档向前翻,有一个专门的节(3.1.1 Instruction Format)讲述这些标识的含义。这里抽出其中两个用得着的翻译一下:

表格中的“Opcode”列列出了所有的所有可能的指令对应的二进制格式。有可能的话,指令代码使用十六进制显示它们在内存当中的字节。除了这些16进制代码之外的部分使用下面的标记:

cb, cw, cd, cp, co, ct — opcode后面跟着的一个1字节(cb),2字节(cw),4字节(cd),6字节 (cp),8字节(co) 或者 10字节(ct) 的值。这个值用来表示代码偏移地址,有可能的话还包括代码段寄存器的值。

/digit — digit为0到7之间的数字,表示指令的 ModR/M byte 只使用 r/m字段作为操作数,而其reg字段作为opcode的一部分,使用digit指定的数字。

红字部分不知道什么含义?没关系,我们先不看它。对于cb/cw之类的,基本上能够简单看明白其中的一些指令含义了:

E8 cw 的含义是:字节 0xE8 后面跟着一个2字节操作数表示要跳转到的地址与当前地址的偏移量。
E8 cd 的含义是:字节 0xE8 后面跟着一个4字节的操作数表示要跳转的地址与当前地址的偏移量。
9A cd 的含义是:字节 0x9A 后面跟着一个6字节的操作数表示要跳转的地址和代码段寄存器的值。

那么,同样的0xE8开头的指令,CPU如何区分后面的操作数是2字节还是4字节?答案是和CPU的模式有关,在实模式下,0xE8接受2字节操作数,而32位保护模式下接受4个字节,64位保护模式下同样接受4字节,同时需要对该操作数进行带符号扩展。

因此,CALL指令的前两种格式是:E8 xx xx xx xx,和 9A xx xx xx xx xx xx。一个是5字节长,一个是7字节长。其实E8 那种,就是我们在汇编指令里面写 CALL lable之后产生的,最常见的CALL指令。

然后是下面的FF /2。这个是0xFF字节后面跟上一个blablabla的东西。这个blablabla的东西是什么呢?要解释这个,首先需要知道红字标出来的部分,即ModR/M是什么东西。

这个要先回到最基本的一个问题:IA32的指令格式。

IA32,64指令格式

IA-32,Intel 64æŒ‡ä»¤æ ¼å¼

其中每个部分是什么含义呢?

首先是指令前缀。有印象的应该记得当年学习微机原理的时候提到过得循环前缀 repnz/repne,这个前缀就是被编码在指令的前面部分的。每个前缀最多一个字节,一条指令最多4个前缀。

然后是指令代码(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域一起用来为某些指令指定额外的信息。

这一段有些晦涩。其意思解释一下是这样的:一个指令往往需要引用一个在内存当中的值,典型的就是如mov:

MOV eax, dword ptr [123456]
MOV eax, dword ptr [esi]

这其中的 123456 或者 esi 就是 MOV 指令引用的内存地址,而MOV关心的是这个地址当中的内容。这个时候,需要某种方式来为指令指定这个操作数的类型:是一个立即数表示的地址,还是一个存放在寄存器当中的地址,或者,就是寄存器本身。

这个用来区分操作数类型的指令字节就是 ModR/M,确切的说是其中的5个位,即mod和r/m域。剩下的三个位,可能用来做额外的指令字节。因为,IA32的指令个数已经远超过一个字节所能表示的256个了。因此,有的指令就要复用第一个字节,然后依据ModR/M当中的reg/opcode域进行区分。

现在回头看前面的红字标识的部分,能不能理解 /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种呢?手册上面有张表:

32字节寻址模式下的ModR/M字节

32字节寻址模式下的ModR/M字节

非常复杂的一张表。现在就看看这张表怎么读。

首先是列的定义。由于 reg/opcode 域可以用来表示opcode,也可以用来表示reg,因此同一个值在不同的指令当中可能代表不同的含义。在表当中,就表现为每一列的表头都有很多个不同的表示。我们需要关心的就是 opcode 这一个。注意看我用红圈圈出来的部分,这一列就是 opcode=2 的一列。而我们需要的 CALL 指令,也就是在这一列当中,0xFF后面需要跟着的内容。

行的定义就是不同的寻址模式。正如手册所说,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位指令。

0x50-0x57部分需要带一个 disp8,即 8bit 立即数,也就是一个字节。这个是基地址+8位偏移量的寻址模式。例如 CALL dword ptr [eax+10] 就是 FF 50 10 。注意虽然表当中写的是 [eax] + disp8 这种形式,但是并不表示是取得 eax 指向的地址当中的值再加上 disp8,而是在eax上加上disp8再进行寻址。因此写成 [eax+disp8] 更不容易引起误解。后面的disp32也是一样的。这个类型指令是3个字节。

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个字节。

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。

那么,Base, Scale和Index是如何确定的呢?手册上同样有一张表(又是巨大的表):

32位寻址模式当中的SIB字节

32位寻址模式当中的SIB字节

列是Base,行是Index*Scale,例如[ecx+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个字节。

OK,截止到目前,我们基本上能够列出常见的CALL指令的格式了:

指令 二进制形式
CALL rel32 E8 xx xx xx xx
CALL dword ptr [EAX] FF 10
CALL dword ptr [ECX] FF 11
CALL dword ptr [EDX] FF 12
CALL dword ptr [EBX] FF 13
CALL dword ptr [REG*SCALE+BASE] FF 14 xx
CALL dword ptr [abs32] FF 15 xx xx xx xx
CALL dword ptr [ESI] FF 16
CALL dword ptr [EDI] FF 17
CALL dword ptr [EAX+xx] FF 50 xx
CALL dword ptr [ECX+xx] FF 51 xx
CALL dword ptr [EDX+xx] FF 52 xx
CALL dword ptr [EBX+xx] FF 53 xx
CALL dword ptr [REG*SCALE+BASE+off8] FF 54 xx xx
CALL dword ptr [EBP+xx] FF 55 xx
CALL dword ptr [ESI+xx] FF 56 xx
CALL dword ptr [EDI+xx] FF 57 xx
CALL dword ptr [EAX+xxxxxxxx] FF 90 xx xx xx xx
CALL dword ptr [ECX+xxxxxxxx] FF 91 xx xx xx xx
CALL dword ptr [EDX+xxxxxxxx] FF 92 xx xx xx xx
CALL dword ptr [EBX+xxxxxxxx] FF 93 xx xx xx xx
CALL dword ptr [REG*SCALE+BASE+off32] FF 94 xx xx xx xx xx
CALL dword ptr [EBP+xxxxxxxx] FF 95 xx xx xx xx
CALL dword ptr [ESI+xxxxxxxx] FF 96 xx xx xx xx
CALL dword ptr [EDI+xxxxxxxx] FF 97 xx xx xx xx
CALL EAX FF D0
CALL ECX FF D1
CALL EDX FF D2
CALL EBX FF D3
CALL ESP FF D4
CALL EBP FF D5
CALL ESI FF D6
CALL EDI FF D7
CALL FAR seg16:abs32 9A xx xx xx xx xx xx

有了这个列表,写一段代码来完成最初我们的需求也就不难了。

标签: ,
  1. ç« ç« 
    2010年5月18日15:52 | #1

    请教一下,如果CALL指令是其他指令的操作数或操作数的一部分,如add指令,然后这个add指令和前一条add指令的操作数可能组成一个新的可识别的指令,像这种情况,如何识别某个地址的前一条指令到底是一个call指令或者是其他的指令,谢谢。。。

  2. 2010年5月19日00:33 | #2

    @ç« ç« 
    你说的这个问题的确存在,例如前面一个指令正好是 mov eax, ffffff10,则也会被认为是一个call指令,因为代码检查到前面是 ff 10 就直接认为是一个call [eax] 了。

    这种是没有办法的,因为CPU执行是自上而下解析,指令集只要保证正向解析的时候没有歧义即可;而这边的代码是自下而上的推算,是无法避免歧义的。因此这种手段只是启发式规则的当中的一部分,必须和其他的规则合并考虑才行。

  3. 2010年12月12日01:15 | #3

    哇,楼主你真的好厉害~光是研究文档就很需要耐心和毅力了,你居然把英文手册给研究清楚了……

    intel的指令集手册我也有收集,但是光看目录的规模就头大……

    这个Mod R\M我以前看一本x86教程的时候就有学,当时就是看到这里,实在没看懂就放弃了后来没再碰,今天看到博主的文章,感到博主真的相当的niubility!讲的太清楚了!

    今天真的收获很大!我本来是发觉windows里默认情况下,栈上的数据可以畅通无阻的执行,于是后面实验的时候搜“call 指令编码 e8”找到这里的。

    对了,关于楼上章章童鞋说的这个情况,我特意做了个实验,小改了一下博主你的程序,
    在内存中从IsAddressAfterCallInstruction这个函数一路搜索到main函数后面一段,
    发觉还真是很多章章童鞋说的这个情况。
    代码在:http://codepad.org/J8gKdO8B
    (似乎有听说某些系统里的可执行内存区域是不可读的,这样的话我这种做法肯定不行,不过windows里没有这种情况)。

    也就是说,有一半以上的call指令都是由于指令中的立即数产生的误报~感觉如果自己再探索一下这个方面,过滤掉一些误报也会很有趣的~~

    祝博主一切顺利~~

  4. 2010年12月12日02:07 | #4

    你的这种搜法当然会有大量的误报,因为是完全基于静态的匹配。
    静态分析还是使用CPU的方式,按指令执行顺序从前向后扫描更靠谱一些。

    实际的应用当中这段小代码是用来在动态执行当中中断下面,并且进行反向推断使用的。
    一个常用的场景是shellcode检测。在这种情况下面,检测的时候,无法获取之前的调用序列,因此需要通过反推的方式进行。
    而去误报的话,可以通过二次校验,即根据指令计算该call指令的目标,看该目标是否是一段合理的可执行内存。

  5. 2010年12月12日10:52 | #5

    @Zhang Cong
    呵呵,谢谢大虾解答~

    另外,在看到章章的这种说法之前,我想到的一种情况其实就是,
    这些“可疑的地址”之前可能根本就不是有效的指令。
    也就是说某些字节是跟在return或是jmp这一类指令的后面,完全没有执行的机会,因此有垃圾数据也不奇怪。

  6. user
    2011年10月28日13:39 | #6

    好问,解决了我的很多困惑

  7. 2020年4月2日18:30 | #7

    和您碰到了相同的问题,技术永远在进步,基础永远不变。学习了。我前几天在年会上应该听过和您相同的名字,太巧了,希望有一天能成为和你一样的大佬。

  1. 2010年4月5日03:37 | #1