Loading... # 《加密与解密》-逆向分析技术 ## 32位软件逆向技术 ### 启动函数 * win32应用程序都必须在源码里实现一个WinMain函数 * Windows程序首先执行的是启动函数的相关代码(由编译器生成),启动代码初始化完成后才会调用WinMain函数 * 分析程序可略过启动代码重点分析WinMain ### 函数 关注点:入口参数、返回值、函数功能 识别函数:call指令的操作数为所调用函数的首地址;ret指令为结束函数的标志 #### 函数传递参数 * 栈:参数压栈要有约定 | 约定类型 | \_\_cdecl(C规范) | pascal | stdcall | Fastcall | | ---------------------------------- | ------------------ | -------- | --------- | ------------ | | 参数传递顺序 | 右到左 | 左到右 | 右到左 | 寄存器和栈 | | 平衡栈者 | 调用者 | 子程序 | 子程序 | 子程序 | | 允许使用VARARG(参数个数不确定) | 是 | 否 | 是 | —— | stdcall调用约定是Win32 API采用的约定方式 现有函数test1(par1,par2,par3),三种约定的汇编代码如下: ~~~asm ; __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 ~~~ 函数执行过程: 1. 调用者将函数执行完毕时应返回的地址、参数压入栈 2. 函数使用ebp+偏移量对栈中的参数进行寻址并取出 3. 函数使用ret或retf指令返回,CPU将EIP设置为栈中保存的地址,并继续执行 stdcall约定调用函数test2(par1,par2),其汇编代码如下 ~~~asm 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稍有不同 * 全局变量 #### 函数的返回值 1. return返回值:一般情况下,函数返回值放在eax中返回,处理结果超过eax的容量,高32位放到edx中 2. 通过参数按传引用方式返回值:传值或传引用 * 传值:建立参数的一份副本,并把它传给调用函数,调用函数内修改参数值不影响原始值 * 传引用:允许调用函数修改原始变量的值 ### 数据结构 局部变量:函数内部定义的一个变量,作用域和生命周期局限于所在函数内;其分配空间通常使用栈和寄存器 全局变量:作用于整个程序,一直存在;通常位于数据区块(.data)的一个固定地址处(硬编码地址) 数组:相同数据类型的元素的集合,在内存中按顺序连续存放;汇编状态下访问数组一般通过基址+变址寻址实现 ### 虚函数 C++中对象模型的核心概念,虚函数是在程序运行时定义的函数,其地址无法在编译时确定,只能在即将调用时确定。所有虚函数的引用通常放在一个专用数组——虚函数表(VTBL)。 调用虚函数时 1. 程序取出虚函数表指针(VPTR),得到虚函数表的地址 2. 到虚函数表中取出对应函数地址 3. 调用函数 ### 控制语句 **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 ~~~asm 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++类的成员函数调用约定 ~~~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; } ~~~ 汇编代码如下: ~~~asm ; 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$略,涉及了数学计算 最后修改:2024 年 05 月 05 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 0 如果觉得我的文章对你有用,请随意赞赏