《加密与解密》-逆向分析技术

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

函数执行过程:

  1. 调用者将函数执行完毕时应返回的地址、参数压入栈
  2. 函数使用ebp+偏移量对栈中的参数进行寻址并取出
  3. 函数使用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稍有不同
  • 全局变量

函数的返回值

  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调用指令E8xxxxxxxxE8xxxxxxxx
JMP——无条件转移EBxxE9xxxxxxxx
JOOF=1一处70xx0F80xxxxxxxx
JNOOF=0无溢出71xx0F81xxxxxxxx
JB/JC/JNAECF=1低于/进位/不高于等于72xx0F82xxxxxxxx
JAE/JNB/JNCCF=0高于等于/不低于/无进位73xx0F83xxxxxxxx
JE/JZZF=1相等/等于074xx0F84xxxxxxxx
JNE/JNZZF=0不相等/不等于075xx0F85xxxxxxxx
JBE/JNACF=1或ZF=1低于等于/不高于76xx0F86xxxxxxxx
JA/JNBECF=0且ZF=0高于/不低于等于77xx0F87xxxxxxxx
JSSF=1符号为负78xx0F88xxxxxxxx
JNSSF=0符号为正79xx0F89xxxxxxxx
JP/JPEPF=11的个数为偶数7Axx0F8Axxxxxxxx
JNP/JPOPF=01的个数为奇数7Bxx0F8Bxxxxxxxx
JL/JNGESF!=OF小于/不大于等于7Cxx0F8Cxxxxxxxx
JGE/JNLSF=OF大于等于/不小于7Dxx0F8Dxxxxxxxx
JLE/JNGSF!=OF或ZF=1小于等于/不大于7Exx0F8Exxxxxxxx
JG/JNLESF=OF且ZF=0大于/不小于等于7Fxx0F8Fxxxxxxxx

无条件短转移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$略,涉及了数学计算
最后修改:2024 年 05 月 05 日
如果觉得我的文章对你有用,请随意赞赏