【逆向分析原理剖析】
正向开发的过程是通过编写代码,并将其编译成软件。相对而言,逆向分析的起点则是现有的软件产品。
此种分析的目的在于通过软件行为反推其潜在的代码实现,这要求我们深入理解编译后软件的执行机制。
软件运行过程
- 软件被加载至计算机内存中。
- 中央处理单元(CPU)读取内存中的指令。
- CPU依据这些指令读取必要的数据并进行相应的运算处理。
- 在运算过程中,所处理的数据会被暂时存储在CPU内部的寄存器中。
- 当执行过程中涉及到多任务或需要保存当前状态以便后续恢复时,CPU会将当前环境信息压入堆栈中。
(这一过程涉及操作系统的调用机制以及计算机组成原理,虽然不需要详尽专业知识,但应有一个基本认识。)
代码语言的变化
- C/C++语言:作为高级编程语言,其设计旨在提高编程的效率和可读性,便于开发者理解和使用。
- 汇编语言:属于低级编程语言,更接近计算机硬件的指令集,直接指导硬件操作。
在进行逆向分析时,通常会接触到大量的汇编语言代码,因此,掌握汇编语言是进行此项工作的必要条件。
软件加载过程
磁盘 >>内存>>寄存器
1、代码编译成软件,先放在磁盘(C盘,D盘这些)
2、开始运行的时候,就会加载进内存(平时说的内存条)
3、真正运行的是在CPU(也就是所谓的芯片),里面存数据的地方叫寄存器。
软件的构成
软件的外部
包含:一个主要程序(exe后缀),多个独立库(dll后缀)。
内存一开始加载exe,有必要的时候,exe再把dll加载进内存来。
exe或者dll在内存的开始位置,叫做基址。(每次加载,随机放置,防止后固定,初始基址不固定。在Windows操作系统中,.exe或.dll文件设计时有一个预设的基址,但由于地址空间布局随机化(ASLR)的安全机制,每次加载时这些文件可能被随机放置在不同的内存地址,从而使得初始基址不固定。)
exe或者dll里面和基址的距离,叫做偏移。(每次加载,内部不变,偏移固定)
【基址(Base Address)
基址是指内存区域的起始地址,它是一个固定的地址值,通常由操作系统或硬件设备分配。在多任务操作系统中,基址用于确保不同程序或任务之间的内存隔离。在硬件设备如外设的内存映射中,基址用于定位设备内存的起始位置。
偏移地址(Offset Address)
偏移地址是指从基址开始到目标内存位置的距离,通常以字节为单位。偏移地址是一个相对值,它表示目标地址与基址之间的差值。通过基址加上偏移地址,可以计算出实际的内存地址。
使用场景
在编程和硬件设计中,基址和偏移地址常用于访问数组、结构体或内存映射的设备寄存器。例如,一个数组的基址是其在内存中的起始地址,而访问数组中的特定元素则需要通过基址加上该元素相对于数组起始位置的偏移地址来实现。
示例
假设一个数组在内存中的基址是0x1000,要访问数组中的第3个元素,如果每个元素占用4字节,则偏移地址是(3-1) * 4 = 8字节。因此,第3个元素的实际地址是0x1000 + 8 = 0x1008】
打个比喻:exe和dll相当小舟,要放在内存这个。
大海上只要有空位,小舟就可以随便在哪。
小舟不管怎么放,所占的长度固定的。
软件的内部
【这里数据可视为变量】
软件 = 代码 + 数据
数据 = 静态数据(数据不会变)+ 动态数据(数据会改变)
动态数据 = 全局数据(多个函数共用)+ 局部数据(单个函数私有)
代码和静态数据在软件运行过程中不会改变,位置固定,可便于使用。
全局数据,因共用于多个函数,位置固定,亦便于使用。
因此,这三种数据的偏移量是恒定不变的。
内存地址 = 基址 + 偏移。
基址可以通过调用 GetModuleHandle 函数获得。
由于偏移量保持不变,因此可以计算出相应的内存地址。
然而,由于局部数据在软件运行过程中会临时生成和销毁,其偏移量是会发生变化的,因此无法直接计算出其内存地址。
局部数据是软件运行过程中临时产生而又消失的数据。
因此,要获取局部数据,只能在软件运行过程中进行拦截,这通常指的是所谓的HOOK技术。
逆向分析目的
逆向分析的主要目标分为以下两个方面:
- 实现功能的调用
- 获取所需数据
基于上述原理,我们可以得出以下结论: - 功能的调用
在实现功能调用时,相关代码是固化的。通过识别并定位到准确的偏移量,便能够实现对功能的调用。 - 数据的获取
对于全局数据,一旦确定了其偏移量,便可以轻松获取。而对于局部数据,则需要通过拦截机制来实现获取,这也意味着需要寻找相关代码的偏移量来进行拦截。
综上所述,逆向分析的关键核心在于准确地找到这些固定的偏移量。
堆栈、栈帧(先进先出)与函数调用过程分析
函数调用是程序设计中的重要环节,也是程序员应聘时常被问及的,本文就函数调用的过程进行分析。
首先,我们需要明确程序在内存中划分的不同区域:
- 栈区(Stack):该区域由编译器自动进行内存分配与释放操作。其主要用于存储函数调用时的参数值、局部变量等。该区的内存管理方式与数据结构中的栈相类似。
- 堆区(Heap):此区域通常由程序员负责分配与释放。若程序员未进行释放操作,则程序结束时可能由操作系统进行回收。需要注意的是,此区域与数据结构中的堆概念是不同的,其内存分配方式类似于链表。
- 全局区(Static):该区域用于存储全局变量和静态变量。
- 文字常量区:此区域用于存放字符串常量,程序结束后由系统自动释放。
- 程序代码区:该区用于存储函数体的二进制代码。
相应的内存区域分配示意图如下所示:
其次是堆和栈的申请方式:
栈是一种由操作系统自动管理的内存分配方式,其访问速度相对较快。在Windows操作系统中,栈是按照从小地址向大地址方向进行扩展的数据结构,形成一个连续的内存空间,其默认大小设定为2MB。
相比之下,堆空间则需要由程序员主动申请,并且需要明确其大小,这导致其访问速度相对较慢。在C语言中,我们使用malloc函数来申请堆空间;而在C++中,则使用new操作符。堆空间是按照从大地址向小地址方向进行扩展的非连续内存区域,其大小受限于计算机系统的虚拟内存总量。这使得堆空间的获取和使用具有较高的灵活性,并且可以使用的内存空间更为广泛。
栈帧结构和函数调用过程
在函数调用过程中,栈扮演着至关重要的角色,其功能主要包括参数传递、局部变量分配、保存返回地址以及存储寄存器状态以便后续恢复。
栈帧(Stack Frame):函数调用涉及将数据和控制信息从代码的一部分传递到另一部分,每个栈帧与特定的过程调用相对应。每当函数被调用时,都会创建一个唯一的栈帧以维护必要的调用信息。在x86架构中,基址指针(ebp)用于指向当前栈帧的底部(即较高地址),而堆栈指针(esp)则指向栈帧的顶部(即较低地址)。
函数调用约定如下:
- _cdecl:在参数传递时,按从右至左的顺序将参数压入栈中,并由调用者负责在函数返回前将这些参数从栈中弹出。由于该方式要求编译器为每个函数调用生成清理栈的代码,因此相较于_stdcall方式,采用_cdecl的代码体积通常更大。此方法支持可变参数。对于C语言函数,按照命名约定,函数名前需添加下划线。而在C++中,除非明确使用extern “C”,否则会采用不同的命名修饰方式。
- _stdcall:在参数传递时,同样按从右至左的顺序将参数压入栈中,但参数的清理工作由被调用者完成。此类调用在函数名前添加下划线前缀,并在其后加上“@”符号和参数的字节数,以标识其调用约定。
- _fastcall:此种调用约定的主要优势在于其速度,因为它通过寄存器传递参数。与__stdcall相似,_fastcall的不同之处在于,其前两个参数通过寄存器传递。需要注意的是,通过寄存器传递的这两个参数是从左至右的,即第一个参数传入ECX,第二个参数传入EDX,而其他参数则按照从右至左的顺序压入栈中。函数返回值仍然通过EAX寄存器传出。
分析调用过程:
在堆栈中变量分布是从高地址到低地址分布,EBP是指向栈底的指针,在过程调用中不变,又称为帧指针。
ESP指向栈顶,程序执行时移动,ESP减小分配空间,ESP增大释放空间,ESP又称为栈指针。3个参数以从左向右的顺序压入堆栈,及从param3到param1,栈内分布如下图:
然后是返回地址入栈:
通过跳转指令进入函数后,函数地址入栈后,EBP入栈,然后把当前ESP的值给EBP,汇编指令如下:
push ebp
mov ebp esp
此时栈顶和栈底指向同一位置,栈内分布如下:
然后是 int var1 = param1; int var2 = param2; int var3 = param3;按申明顺序依次存储。