《加密与解密》-逆向分析技术
32位软件逆向技术
启动函数
- win32应用程序都必须在源码里实现一个WinMain函数
- Windows程序首先执行的是启动函数的相关代码(由编译器生成),启动代码初始化完成后才会调用WinMain函数
- 分析程序可略过启动代码重点分析WinMain
函数
关注点:入口参数、返回值、函数功能
识别函数:call指令的操作数为所调用函数的首地址;ret指令为结束函数的标志
函数传递参数
- 栈:参数压栈要有约定
| 约定类型 | __cdecl(C规范) | pascal | stdcall | Fastcall |
| ---------------------------------- | ------------------ | -------- | --------- | ------------ |
| 参数传递顺序 | 右到左 | 左到右 | 右到左 | 寄存器和栈 |
| 平衡栈者 | 调用者 | 子程序 | 子程序 | 子程序 |
| 允许使用VARARG(参数个数不确定) | 是 | 否 | 是 | —— |
stdcall调用约定是Win32 API采用的约定方式
现有函数test1(par1,par2,par3),三种约定的汇编代码如下:
; __cdecl
push par3
push par2
push par1
call test1
add esp, 0c ; 平衡栈,12字节的参数空间
; pascal
push par1
push par2
push par3
call test1
; stdcall
push par3
push par2
push par1
call test1
函数执行过程:
- 调用者将函数执行完毕时应返回的地址、参数压入栈
- 函数使用ebp+偏移量对栈中的参数进行寻址并取出
- 函数使用ret或retf指令返回,CPU将EIP设置为栈中保存的地址,并继续执行
stdcall约定调用函数test2(par1,par2),其汇编代码如下
push par2
push par1
call test2 ; 这中间会push eip
{
push ebp ; 保存当前栈帧的ebp指针
mov ebp, esp ; 设置新的栈帧的ebp指针,使其指向栈顶
mov eax, dword ptr [ebp+0C] ; 参数1赋给eax
mov ebp, dword ptr [ebp+08] ; 参数2赋给ebx
sub esp, 8 ; 局部变量要在栈中留出空间
...
add esp, 8 ; 释放局部变量占用的栈
pop ebp ; 恢复之前栈帧的ebp指针
ret 8 ; 相当于ret + add esp, 8 前者pop eip后者释放参数占用的栈
}
- 寄存器:不同编译器实现的Fastcall稍有不同
- 全局变量
函数的返回值
- return返回值:一般情况下,函数返回值放在eax中返回,处理结果超过eax的容量,高32位放到edx中
通过参数按传引用方式返回值:传值或传引用
- 传值:建立参数的一份副本,并把它传给调用函数,调用函数内修改参数值不影响原始值
- 传引用:允许调用函数修改原始变量的值
数据结构
局部变量:函数内部定义的一个变量,作用域和生命周期局限于所在函数内;其分配空间通常使用栈和寄存器
全局变量:作用于整个程序,一直存在;通常位于数据区块(.data)的一个固定地址处(硬编码地址)
数组:相同数据类型的元素的集合,在内存中按顺序连续存放;汇编状态下访问数组一般通过基址+变址寻址实现
虚函数
C++中对象模型的核心概念,虚函数是在程序运行时定义的函数,其地址无法在编译时确定,只能在即将调用时确定。所有虚函数的引用通常放在一个专用数组——虚函数表(VTBL)。
调用虚函数时
- 程序取出虚函数表指针(VPTR),得到虚函数表的地址
- 到虚函数表中取出对应函数地址
- 调用函数
控制语句
if-then-else:cmp比较,不修改操作数,只影响几个标志位如零标志、进位标志、符号标志、溢出标志;编译器也会使用test或or等较短的逻辑指令替换cmp
例如“test eax, eax”,若eax值为0,则其逻辑与运算结果为0,ZF=1
switch-case:编译后实质上是多个if-then语句嵌套组合
“dec eax”相当于“cmp eax, 1”,指令更短、执行速度更快
转移指令机器码的计算:根据转移距离(大概理解为调用地址)的不同,可以分为
- 短转移:无条件转移和条件转移的机器码均为2字节
- 长转移:无条件转移的机器码为5字节,条件转移的机器码为6字节(无条件1字节,有条件2字节,后面4字节表示转移偏移量)
- 子程序调用指令(call):一类平常的类似长转移,一类调用的参数涉及寄存器、栈等,例如“call dword ptr [eax+2]”
常见的转移指令机器码
转移类别 | 标志位 | 含义 | 短转移机器码 | 长转移机器码 |
---|---|---|---|---|
CALL | —— | call调用指令 | E8xxxxxxxx | E8xxxxxxxx |
JMP | —— | 无条件转移 | EBxx | E9xxxxxxxx |
JO | OF=1 | 一处 | 70xx | 0F80xxxxxxxx |
JNO | OF=0 | 无溢出 | 71xx | 0F81xxxxxxxx |
JB/JC/JNAE | CF=1 | 低于/进位/不高于等于 | 72xx | 0F82xxxxxxxx |
JAE/JNB/JNC | CF=0 | 高于等于/不低于/无进位 | 73xx | 0F83xxxxxxxx |
JE/JZ | ZF=1 | 相等/等于0 | 74xx | 0F84xxxxxxxx |
JNE/JNZ | ZF=0 | 不相等/不等于0 | 75xx | 0F85xxxxxxxx |
JBE/JNA | CF=1或ZF=1 | 低于等于/不高于 | 76xx | 0F86xxxxxxxx |
JA/JNBE | CF=0且ZF=0 | 高于/不低于等于 | 77xx | 0F87xxxxxxxx |
JS | SF=1 | 符号为负 | 78xx | 0F88xxxxxxxx |
JNS | SF=0 | 符号为正 | 79xx | 0F89xxxxxxxx |
JP/JPE | PF=1 | 1的个数为偶数 | 7Axx | 0F8Axxxxxxxx |
JNP/JPO | PF=0 | 1的个数为奇数 | 7Bxx | 0F8Bxxxxxxxx |
JL/JNGE | SF!=OF | 小于/不大于等于 | 7Cxx | 0F8Cxxxxxxxx |
JGE/JNL | SF=OF | 大于等于/不小于 | 7Dxx | 0F8Dxxxxxxxx |
JLE/JNG | SF!=OF或ZF=1 | 小于等于/不大于 | 7Exx | 0F8Exxxxxxxx |
JG/JNLE | SF=OF且ZF=0 | 大于/不小于等于 | 7Fxx | 0F8Fxxxxxxxx |
无条件短转移EB00h~EB7Fh是向后转移,EB80h~EBFFh是向前转移。例如EB03表示向后跳转3h的位移量
无条件长转移长度5字节,机器码E9(1字节),加上跳转地址和当前地址的差-5(4字节),其中5是当前地址指令的长度
条件设置指令(SETcc):SETcc r/m8,其中r/m8表示8位寄存器或单字节内存单元
纯算法实现逻辑判断:巧妙设计优化指令,把一些逻辑分支转换成算术操作
循环语句
一般使用ecx寄存器作为计数器,test eax, eax指令判断eax是否为0等
数学运算符
- add和sub指令经常被lea替换,可以更快完成计算,例如“lea ecx, [eax+ebx+78]”
- 乘法运算符一般被编译成mul、imul指令,但他们速度慢;提高速度通常可以使用左移指令shl和add结合
- 除法运算符一般被编译成div、idiv指令,运算代价很高;同样可以使用右移shr指令来加速;除法指令中需要使用符号扩展指令cdq,其把eax寄存器中的数视为有符号数,将符号位(eax的最高位)扩展到edx,若eax最高位1,则edx=ffffffffh,否则edx=00000000h,这样就将eax中32位带符号的数变成了edx:eax中带符号的数,满足了64位运算指令的需要
文本字符串
字符串存储格式:
- C字符串:ASCIIZ字符串,Z表示以0结尾
- DOS字符串:以$结尾,基本见不到了
- PASCAL字符串:字符串头部定义了一个字节,用于指示当前字符串的长度,但长度不超过255,只存在于Borland的Turbo Pascal和16位Delphi
Delphi字符串:32位Delphi增加了对长字符串的支持
- 双字节:表示长度的字段扩展为2字节,长度最大65535
- 四字节:扩展为4字节,字符串长度达到4GB,很少用
字符寻址指令:
mov:将当前指令所在内存复制并放到目的寄存器中,操作数可以是常量,也可以是指针
- mov eax, [401000h] 直接寻址
- mov eax, [ecx] 寄存器间接寻址
lea:装入有效地址,操作数就是地址
- lea eax, [401000h] 将值401000h写入eax寄存器,等价于mov eax, 401000h
字母大小写转换:就是ascii值加减20h
计算字符串长度:strlen
mov ecx, FFFFFFFF ; 标志,很可能是求长度
sub eax, eax ; eax清零
repnz ; 重复串操作,直到ecx=0
scasb ; AL的值与edi指向的附加段中的数据逐一比较
not ecx ; ecx=字符长度+1
dec ecx ; ecx真是长度
je xxxx ; ecx=0则字符串长度为0
指令修改技巧
- 替换1字节:nop
替换2字节:
- nop nop
- mov edi,edi
- push eax; pop eax
- inc eax; dec eax
- jmp xx
寄存器清0:
- mov eax, 00000000h
- push 0; pop eax
- sub eax, eax / xor eax, eax
测试寄存器值是否为0
- cmp eax, 00000000h; je _label_
- or eax, eax / test eax, eax; je _label_
置寄存器为FFFFFFFFh
- mov eax, ffffffffh
- xor eax, eax / sub eax, eax; dec eax
- stc; sbb eax, eax
转移指令:
- jmp _label_
- push _label_; ret
很多指令为eax寄存器做了优化,尽量使用eax寄存器
64位软件逆向技术
寄存器
- x64为AMD64和Intel64的合称,是指与现有x86兼容的64位CPU
- x64系统通用寄存器的名称第一个字母从E改为R,大小扩展为64位,数量增加了8个,R8~R15,扩充了8个128位XMM寄存器
- 64位寄存器与x86下的32位寄存器兼容
函数
调用约定:
- x64只有1种寄存器快速调用约定,前四个参数使用寄存器传递,如果超过4个,多余的参数放到栈里,入栈顺序为从右到左
- 第一个参数RCX,第二个RDX,第三个R8,第四个R9
- 任何大于8字节或者不是1、2、4、8字节的参数必须由引用来传递,即地址传递
- 所有浮点参数的传递都是使用XMM寄存器完成
- 参数既有浮点又有整型,则按顺序取,比如第一个浮点就取XMM0,第二个整型就取RDX,第三个整型取R8,第四个浮点取XMM3
- 函数前4个参数虽然使用寄存器传递,但也可以使用栈(预留栈空间),这种情况主要在寄存器不够用的情况下使用
参数传递:
参数为结构体时:大小可能超过8字节
- 不超过8字节:直接把整个结构体的内容放在寄存器
- 大于8字节:先把结构内容复制到栈空间,再把结构体地址当成函数的参数来传递(引用传递);函数内部通过“结构体地址+偏移”来访问结构体内容
thiscall传递:C++类的成员函数调用约定
#include"stdafx.h"
class CAdd {
public:
int Add (int nNum1, int nNum2) {
return nNum1 + nNum2;
}
};
int _tmain(int argc, _TCHAR& argv[]) {
CAdd Object;
printf("%d\r\n", Object.Add(1, 2));
return 0;
}
汇编代码如下:
; main
mov [rsp+10h], rdx ; 将参数2保存到预留栈空间
mov [rsp+8], ecx ; 将参数1保存到预留栈空间
push rdi ; 保存环境
sub rsp, 40h ; 申请栈空间
mov ecx, 10h ; 初始化栈空间默认值为0xCC
mov eax, 0CCCCCCCCh
rep stosd
mov ecx, [rsp+50h]
mov r8d, 2 ; 参数3:nNum2
mov edx, 1 ; 参数2:nNum1
lea rcx, [rsp+24h] ; 参数1:this指针
call sub_14000100A ; 调用CAdd::Add(int, int)函数
mov edx, eax
lea rcx, 14006790
call cs:printf
xor eax, eax
mov edi, eax
mov rcx, rsp
lea rdx, stru_1400067E0
call _RTC_CheckStackVars ; 调用数组越界检查函数
mov eax, edi
add rsp, 40h ; 释放栈空间
pop rdi ; 恢复环境
retn ; 函数返回
; Add
mov [rsp+18h], r8d ; 将参数3保存到预留栈空间中
mov [rsp+10h], edx ; 将参数2保存到预留栈空间中
mov [rsp+8], rcx ; 将参数1保存到预留栈空间中
push rdi ; 保存环境
mov eax, [rsp+20h] ; eax=参数3
mov ecx, [rsp+18h] ; ecx=参数2
add ecx, eax ; 参数2+参数3
mov eax, ecx ; 将计算结果保存到返回值中
pop rdi ; 恢复环境
retn
由上可知,类的成员函数调用、参数传递方式与普通函数区别在于成员函数会隐含地传递一个this指针参数
控制语句
switch-case语句:当分支数大于等于6时会进行优化,会通过case表(里面涉及了二叉平衡树,提高计算效率)计算跳转地址,而不是多个if-else
转移指令机器码的计算:
其中call/jmp direct x64和x86类似,但call/jmp memory direct稍有不同:x86里地址为绝对地址4字节,但x64地址如果也为绝对地址,指令长度会增加,因此使用相对地址=地址差-跳转指令长度
循环语句
do循环:先执行语句块,再进行表达式判断,表达式结果为真继续执行语句块
while循环:先进行表达式判断,再执行语句块,表达式结果为真继续执行语句块
for循环:由赋初值、循环条件、循环步长3条语句组成;特征是第一次进入循环进行了一次jmp跳转到循环条件,不进行循环步长处理
数学运算符
整数加减法:常量折叠指的是表达式中出现2个以上常量进行计算时,编译器可以在编译期间计算出结果,用计算结果替换表达式,这样程序运行期间不需要计算,提高程序性能
整数的除法:
- 有符号除法,除数为$2^n$:若$x\ge0$,则$\frac{x}{2^n}=x>>n$;反之$\frac{x}{2^n}=(x+(2^n-1))>>n$
- 有符号除法,除数为$-2^n$:若$x\ge0$,则$\frac{x}{-2^n}=-(x>>n)$;反之$\frac{x}{-2^n}=-((x+(2^n-1))>>n)$
- 有符号除法,除数为正非$2^n$略,涉及了数学计算