逆向分析技术
32位程序
启动函数
在C/C++程序中启动函数作用基本相同,包括检索指向新进程的命令行指针、检索指向新进程的环境变量指针、全局变量初始化、内存栈初始化等。所有初始化完成后调用main函数。
进入点返沪后启动函数调用C运行库的exit函数,将返回值(nMainRetVal)传递给它,最后调用系统函数ExitProcess退出。
启动代码汇编如下:
函数
在调用其他函数时在代码中保存了一个返回地址,该地址与参数一起传递给被调用函数。使用call 和 ret 调用函数及返回调用地址。call 指令和跳转指令类似,但是call 保存返回信息,即将之后的指令地址压入栈顶部,当遇到ret指令时返回这个地址。也就是所:call 参数地址就是函数的入口地址,ret指令则时结束函数的执行(也有例外)。这一机制很容易将函数调用和跳转分开。
int Add(int x,int y);
main( )
{
int a=5,b=6;
Add(a,b);
return 0;
}
Add(int x,int y)
{
return(x+y);
}
但也存在间接调用,即通过寄存器传递函数地址或动态计算函数地址调用:
函数参数传递
- 栈传递
要定义参数在栈中顺序,约定调用函数后谁平衡栈 - 寄存器传递
参数放在哪个寄存器中 - 通过全局变量隐含参数传递
栈传递
函数对参数和局部变量的操作都是通过栈进行定义的,使用专门的寄存器ebp进行寻址。
- 调用者将子程序执行完毕时应返回的地址、参数压入栈
- 子程序使用“ebp指针+偏移量”对栈中参数进行寻址并取出,完成操作
- 子程序使用 ret 或 retf 指令返回。此时,CPU将 eip 置为栈中保存的地址,并继续执行
栈操作的对象只能是双操作数(占4字节)。例如,按stdcall约定调用函数 test2(Par1, Par2),未优化的、使用ebp寻址参数的汇编大致如下:
使用 enter 和 leave 可以简化汇编。enter 语句就是“push ebp”“mov ebp, esp”“sub esp, xxx”,而 leave 语句就是 “add esp, xxx”“pop ebp”。所以上面程序又是:
有时编译器会对程序进行优化,直接使用esp对参数进行寻址。esp的值在函数执行期间会发生变化,该变化出现在每次有数据进栈时。要想确定哪个变量进行了寻址,就要知道程序当前位置esp的值,为此必须从函数开始部分进行跟踪:
寄存器传递
各个编译器对于Fastcall的实现标准不同。msvc在采用 Fastcall 规范传递参数时,左边两个不大于4字节的参数分别放在 ecx 和 edx 寄存器中,寄存器用完后使用栈,其余参数仍按从右到左的顺序入栈,被调用函数返回前清理传送参数的栈。浮点数、远指针、__int64类型总是通过栈转递。
vs6.0 编译函数调用:
另一个规范调用 thiscall 也用到了寄存器传递参数,thiscall 时 C++ 中非静态类成员函数的默认调用约定,对象每个函数隐含接收this参数。采用thiscall约定时,函数参数按从右到左的顺序入栈,被调用的函数在返回前清理传送参数的栈,仅通过ecx寄存器传送一个额外的参数 this 指针。
编译得:
名称修饰约定
为了允许使用操作符重载和函数重载,C++编译器会重写入口点符号名。在vc++中,函数修饰名由编译类型(C或C++)、函数名、类名、调用约定、返回类型、参数等共同确定。
C 编译时函数名修饰约定规则如下:
- stdcall 调用约定在输出函数名前面加一个下划线前缀,后面加一个“@”符及其参数得字节数,格式为
_functionname@number
- __cdecl 调用约定仅在输出函数名前加一个下划线前缀,格式为
_functionname
- Fastcall 调用约定在输出函数名前加一个“@”符,后加一个“@”符及其参数字节数,
@functionname@number
C++ 编译时修饰约定规则:
- stdcall 调用以 ?标识函数名开始;函数名后以“@@YG”标识参数表得开始;参数表第一项位该函数返回值类型,其后为参数类型,指针标识在器所指数据类型前;在参数后,以“@Z”标识整个名字的结束,没有参数则以“Z”结束。格式为
?functionname@@YG*****@Z
或?functionname@@YG*XZ
- __cdecl 与 stdcall 规则类似,只是参数表的开始标识由
@@YG
变为了@@YA
- Fastcall 与 stdcall 规则类似,参数表开始标识由
@@YG
变为@@YI
函数返回值
return 操作符返回值
一般放入eax寄存器,大小超过eax寄存器的高32位放到edx寄存器中
通过参数按引用方式返回值
当把变量的地址传递给函数时,可以在函数中用间接引用运算符修改调用函数内存单元中该变量的值。
数据结构
利用栈存放局部变量
局部变量在栈中分配,函数执行后释放这些栈。程序调用“sub esp, 8”位局部变量分配空间,用 [ebp-xxx] 寻址调用这些变量,参数调用相对于ebp偏移量是正的,即 [ebp+xxx]。在优化模式下,通过esp寄存器直接对局部变量和参数进行寻址。当退出时使用 add esp, 8 平衡栈。也有些编译器给esp加一个负值,也有编译器使用 push reg 取代 sub esp, 4 以节省几字节空间。
利用寄存器存放局部变量
除了栈占用2个寄存器,编译器会利用剩下的6个通用寄存器尽可能有效地存放局部变量,如果寄存器不够用,编译器就会将变量放到栈中。
全局变量
它一直存在,放在全局变量的内存区中。全局变量通产位于数据区块(.data)的一个固定地址处,当程序要访问全局变量时,一般会用一个固定的硬编码地址直接对内存寻址。
编译器将全局变量放入可读写区域,放入只读区域就是一个常量。静态变量与全局变量类似,但是静态变量作用范围有限,仅在定义这些变量的函数内有效。
数组
在汇编状态下一般通过基址加变址寻址实现的
=>
虚函数
虚函数的地址不能在编译时确定,只能在调用即将进行时确定。对虚函数的引用通常放在一个数组——虚函数表(Virtual Table VTBL)中,数组的每个元素存放的就是类中虚函数的地址。调用虚函数时,程序先取出虚函数表指针(Virtual Table Pointer VPTR)得到虚函数表的地址,再根据这个地址到虚函数表中取出该函数的地址,最后调用该函数。VPTR是一个指针,所有虚函数入口都列在VTBL中。
#include <stdio.h>
class CSum
{
public:
virtual int Add(int a, int b)
{
return (a + b);
}
virtual int Sub(int a, int b )
{
return (a - b);
}
};
void main()
{
CSum* pCSum = new CSum ;
pCSum->Add(1,2);
pCSum->Sub(1,2);
}
这段代码调用new为class分配内存,成功后eax保存分到的内存指针,然后将对象实例指向CSum类VTBL 004050A0h。004050A0h 数据:
VTBL 中有两组数据:
[VBTL]=401040h
[VTBL+4]=401050h
控制语句
IF-THEN-ELSE
整数使用cmp比较,浮点数用fcom、fcomp等进行比较。
SWITCH-CASE
未经优化的汇编
优化后汇编会用 dec eax 代替 cmp 指令,使指令更短、执行速度更快。而且,在优化后,编译器会合理排列 switch 后的各个 case 节点,以最优方式找到所需节点。
如果个case取值表示一个算术级数,那么编译器会利用一个跳转表实现
由编译器编译后,“jmp dword ptr[4*eax+004010B0]”指令相当于switch(a),根据eax进行索引,找到相应case处理代码指针。
转移指令机器码
- 短转移:无条件转移和条件转移机器码均为2字节,转移范围为 -128~127 字节
- 长转移:无条件转移机器码5字节,条件转移机器码6字节。这是因为条件转移要用2字节表示转移类型(例如 je jg jns),其他4字节表示偏移量。而无条件转移使用1字节就可表示转移类型 jmp
- 子程序调用类型:call 指令有两种,一类是平常接触的,类似于长转移;另一类参数涉及寄存器、栈等,比较复杂,例如 “call dword ptr [eax+2]”
条件转移指令的转移范围是16位模式遗留下来的。
短转移指令机器码计算实例
长转移指令机器码计算实例
条件设置指令(SETcc)
形式是“SETcc r/m8”其中 r/m8 表示8位寄存器或单字节内存单元。条件设置指令根据处理器定义的16种条件测试一些标志位,把结果记录到目标操作数种。当条件满足时,目标操作数置1,否则置0.这16种条件于条件转移指令jcc的条件相同
循环
条件分支都是低地址向高地址跳转,而循环相反
未优化
优化后
数学运算符
编译器可能对其进行优化
加减
c=a+b+78h 被优化未 lea c, [a+b+78]
乘法
编译为 mul、imul 指令速度较慢。可以使用左移指令 shl 实现乘法运算。 同时,eax * 5 可以写为 lea eax, [eax+4*eax]
除法
编译为 div、idiv 的代价相当为乘法的10倍cpu时间。可用位移或其他算法节省时间。
64 位汇编
寄存器
x64的通用寄存器第一个字母从E改为了R,大小拓展到了64位,数量增加了8ge(R8 ~ R18),扩充了8个128位XMM寄存器(常用于优化代码)。64位寄存器与x86下32位寄存器兼容,例如RAX(16位)、EAX(低32位)、AX(低16位)、AL(低8位)、AH(8~15位)。x64新拓展的寄存器高低位访问,使用WORD、BYTE、DWORD 后缀,例如 R8(64位)、R8D(低32位)、R8W(低16位)、R8B(低8位)
x86有 stdcall __cdecl Fastcall 等方式,但x64只有1种寄存器快速调用约定。前4个参数使用寄存器传递,多余的放入栈中,入栈顺序从右到左,由函数调用方平衡栈空间。前4个参数的寄存器是固定的,依次是 RCX、RDX、R8、R9,其他参数从右向左依次入栈。任何大于8字节或不是1、2、4、8字节的参数必须使用引用传递。所有浮点参数的传递都使用XMM寄存器完成,它们在XMM0、XMM1、XMM2、XMM3中传递。
参数使用4个寄存器,在函数内部这4个寄存器可能不够使用,为了避免这个问题,可使用预留栈空间,方法是函数调用者多申请32字节栈空间,当寄存器不够用时,把寄存器保存的值放入预留栈空间中。预留栈空间由调用者提前申请,函数调用者平衡空间。
参数为结构体
如果参数为结构体,且小于8字节,在传递结构体参数时,应直接把整个结构体内容放入寄存器中。在函数中通过访问寄存器的低32位和高32位分别访问结构体的成员。
进行逆向时应根据函数对参数的使用特征来判断函数参数是否为一个结构体类型
当参数为结构体且大于8字节,传递参数时,先把结构体内容复制到栈空间中,再把结构体地址当成函数的参数传递(引用传递)。在函数内部通过“结构体地址+偏移”的方式访问结构体内容。
类的成员函数调用、参数传递方式和普通函数没有很大区别。唯一区别时,成员函数调用会隐藏地传递一个this指针。
函数返回值
使用RAX保存返回值,浮点类型由MMX0返回。当返回值大于8字节时,将栈空间地址作为参数间接访问。
整数除法
有符号数,除数为2n
有符号除法,除数为-2n
有符号除法,除数为正非2n
有符号除法,除数为负非2n
无符号除法,除数为2n —— shr 右移操作
无符号除法,除数非2n地情况
整数取模
虚函数
- 如果一个类至少有一个虚函数,这个类就有一个指向虚表的指针
- 不同的类虚表不同,相同的类虚表共享一个虚表
- 虚表指针放在对象首地址处
- 虚表地址在全局数据区域中
- 虚表每个元素都指向一个类成员函数指针
- 虚表不一定以0结尾
- 虚表的成员函数顺序,按类声明排序
- 虚表在构造函数中倍初始化
- 虚表在析构函数中被赋值
msvc中,代码中调用析构函数时向虚表中析构函数传递this指针和数字0;delete变量时向虚表中析构函数传递this指针和数字1;数字为0时不从堆上删除对象,为1时从堆上删除
gcc中,虚表中有两个析构函数,一个用于手动调用,一个用于delete删除。
多重继承
构造函数调用顺序:
- 虚基类构造函数(多个按继承顺序)
- 普通基类构造函数(多个按继承顺序)
- 对象成员的构造函数(多个按定义顺序)
- 派生构造函数
析构函数调用顺序:
- 派生类析构函数
- 对象成员析构函数(多个按定义顺序)
- 普通基类析构函数(多个按继承顺序)
- 虚基类析构函数(多个按继承顺序)
派生类虚表填充过程如下:
- 复制基类虚表
- 如果派生类虚函数中有覆盖基类的虚函数,使用派生类虚函数覆盖对应表项
- 如果派生类有新增虚函数,将其放在虚表后面
多重继承时按继承顺序调用两个基类构造函数,然后执行自己构造代码。然后初始化虚表,根据每个基类派生出一个虚表,派生类新增的虚表挂在第一个虚表的后面。
菱形继承