基础概论

起源

逆向工程的概念早在计算机或者说现代技术发现之前就已经存在了,这个概念可以追溯

到工业革命时期,它首先应用于工业设计。

1980年始欧美国家许多学校及工业界开始注意逆向工程这块领域。1990年初期包括台

湾在内,各国学术界团队大量投入逆向工程的研究并发表成果。

1990年,在 IEEE Software杂志上发表了一篇有关逆向工程和设计恢复概念分类学的文章。其后,研究逆向技术、软件可视化、程序理解、数据逆向工程、软件分析以及相关工

具和方法的研究人员越来越多。

现在它已经成为安全专题会议的重要主题。

定义

逆向工程是“通过分析目标系统以识别系统的组件以及这些组件之间的相互关系并创建该系统另一种形式的表示或更高级的抽象的过程”(EEE1990)

通俗点说,逆向工程是了解软件“所作所为”的一套最重要的技术和工具。它需要将技能以及对软件开发和计算机的全面理解结合起来。与其他值得做的事情一样,强烈的好奇心和学习的愿望是唯一真正需要的前提条件。

目的

1.数据与逻辑逆向

分析已有程序,力图与源代码相比,在更高抽象层次上建立程序的表示过程一即通过逆向工程恢复程序的原设计,它是软件设计的逆过程。

利用逆向工程工具,从已存在的程序中抽取数据结构、体系结构和程序设计信息。

2.补丁技术

清晰理解一个软件系统,以便于进行增强功能、更正、增加文档、再设计或者用其它程序设计语言再编码。

工具介绍

逆向过程中主要用到的两个工具分别是Ollydbg(动态分析),IDA PRO(静态分析)

注:其实还有一个用的比较多的是Cheat Engine,常用于内存搜索

Ollydbg

Ollydbg有两个版本

​ 时下最流行的是1.1版本,得益于其插件非常丰富,虽然之前有非常多的bug,也逐渐修复,所以基本上OD1.1版本用的比较方便

2.0原生的bug就少一点呢可以设置多个,也提供了插件功能,但还是有些API没有开放,所以使用起来的话个人感觉就没有1.1版本的顺。

2.0中的内存断点可以设置多个,支持的指令集增多了,有些浮点指令在1.1中无法解析。2.0查询API断点有函数名提示

一、调试程序的几种方式

一、直接打开一个EXE文件,可以设置命令行参数

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <conio.h>

int main(int argc , char* argv[])
{
for (int i = 0; i < argc; ++i)
printf("%d: %s\n", i, argv[i]);

_getch();
return 0;
}

二、附加到一个已经存在的进程上

三、将OD设置为系统的实时调试器

​ 通过选项中的 实施调试器 设置

四、可以调试 DLL 模块

​ 实际上使用 LoadDll.exe 加载了指定的模块,并且断在了入口点

二:认识OD的菜单界面

L:显示调试信息,在程序意外结束或者产生异常的时候,可以在这里看到原因

E:模块信息,可以用于查看当前程序内所有模块的基址

M:内存信息,可以查看到目标数据在什么区段,访问权限是什么

T: 查看线程,可以在进行多线程调试的时候,挂起和恢复指定的某个或所有线程

W:窗口界面,会识别到应用程序的窗口信息,可以用于参考

H:用于查看内核对象

C:分析的时候使用的主窗口,可以查看很多信息

/:补丁窗口,能够显示出所有的对当前应用程序的修改

K:调用堆栈,可以通过它找到调用某一个函数的位置,通常会被用于查找关键代码

B:断点窗口,保存的是所有的软件断点信息

R:显示所有的参考,即查找到的所有内容,根据具体查找的项不同输出也会不同

S:显示程序的源码

… :用于显示模拟执行(跟踪)的指令,能够显示出指令运行期间寄存器的值,脱壳(混淆)

三:OD的功能

数据查看的功能,在内存窗口进行操作

支持 UNICODE 和 ASCII 码的查看,需要手动的切换

可以通过查看 长型 -> 地址 的方式查看 IAT(写壳 脱壳)

解析内存中的PE文件,在内存窗口右键->指定->PE文件头进行解析,确保地址正确

在 M 内存窗口中,通过双击任何一个文件头都可以自动的解析 PE

查找内容:

可以通过,查找当前模块中的名称查看模块的导入函数和导出函数,在 E 窗口中双击模块切换当前模块

可以通过查找指令(ctrl+f) 和 查找指令序列(ctrl+s) 查找到目标指令,使用 (Ctrl+L)查找下一个

查找二进制数据(ctrl+b),一般可以用于查找特征,可以是某些指令(IAT调用),可以是入口特征

查找模块间的调用

自动分析功能,OD可以识别2000+个内置的函数,通过设置可以进行自动分析,也可以使用 ctrl+A进行手动的分析,进入一个函数之后,需要习惯性的按下 ctrl+a

  • 调用的函数的名称和对应的参数
  • 当前函数内的局部变量和当前函数的参数
    • 函数的参数会被识别为 [ARG.N] [ebp+4+N*4]
    • 函数的局部变量会被识别为 [LOCAL.N] [ebp-n]
  • 栈内内容的分析,返回地址,参数,SEH函数

通过假定参数分析回调函数:

找到回调函数进行假定参数

之后,右键设置断点的时候可以设置消息断点

确定按钮的按下相应的是 WM_COMMAND (0x111),对它设置断点

断点断下后,可以通过栈,识别出当前是不是需要断下的位置

补丁功能:通过修改目标OPCODE为自己的指令达到目的

  • 通过右键菜单保存修改的内容,选择复制所有

在弹出的新窗口中,选中被修改的内容,右键保存文件

四:如何分析一个程序

  • 根据关键的函数下断,栈回溯
  • 查找到关键字符串,周围可能就是核心代码
  • 对于窗口程序,可以通过创建窗口的函数找到回调函数
    • CreateWindow -> RegisterClass
    • DialogBoxParamA \ CreateDialogParamA

五: OD 中支持的断点

  • 软件断点(带条件),在 B 窗口中管理断点
    • 通过双击指定的指令,或者 F2 进行设置
  • 硬件断点:通过 调试 -> 硬件断点查看
    • 能够通过右键添加硬件断点,用的相对较多,但是只能有4个
  • 内存断点:内存断点只能有1个,因a为慢
    • 通过在内存窗口中右键添加和删除

六: OD 中栈帧的布局

IDA PRO

一、使用IDA分析一个程序

打开一个可执行文件

从提供的类型中选择某一个类型进行分析

关闭 IDA 的时候,会提示是否保存数据文件(.idb),这个文件是分析时生成的5个文件和PE文件本体的合体版。以后的所有分析可以直接使用这个文件,对于 idb 文件的修改不会影响到源文件。

二、认识IDA的界面

主界面的分布和功能,主界面中存在多个窗口,如果不小心关闭了,可以再 View -> Open subview 中打开

代码显示界面

内存窗口,可以通过右键切换查看的单位和列数

导出窗口,对于 exe 显示的是 OEP

导入窗口,显示导入的函数

名称窗口,可以使用F1查看到前置图标的含义,通常有A(字符串),I(导入函数),F(普通函数)

函数窗口,保存了所有的带名称和不带名称的函数

字符串窗口

区段窗口,用的比较少,可以查看属性

签名窗口,首先就需要(shift+f5)加载一个对应的的签名

类型窗口:加载的时对应库中的类型信息

三: 查找main函数

编写一个最简单的main函数,再汇编位置的 ret 下断点

在 ret 的地方单步执行,就会跳转到调用方函数,这个时候,调用堆栈中就会显示调用关系

双击其中的某一个函数,就可以在代码选项卡出找到对应的源文件,并加以分析

分析的过程是 第一个函数 -> 第二个函数 -> 连续三个函数的中间一个 -> 第四个函数

vs2015的main函数寻找

前面都是一样的,就在后面找三个push一个call的时候是不一样的

因为在vs2015找三个push和一个call之前先要找到这样的一个特征

四:使用IDA分析程序

一、局部变量

1
2
3
4
5
6
7
8
9
10
11
int _tmain(int argc, _TCHAR* argv[])
{
// 局部变量
int nNum = 1;
float fNum = 2.5;
char ch = 'A';

printf("int %d, float %f, char %c", nNum, fNum, ch);

return 0;
}
  • 使用快捷键 n 可以给函数或者代码或者变量修改名称

    • 如果不能确定函数的实际名称,尽量不要使用内置的函数名称
    • 对于 IDA 来说,所有的操作都是不可逆的,使用 ESC 返回上一层,使用 Ctrl+Enter 进入函数
  • 在汇编窗口使用冒号 : 添加注释信息,会以 ; xxxx 的形式显示在汇编指令后

如何将数据抓换成指定的类型

二、全局变量

1
2
3
4
5
6
7
8
9
10
11
// 全局变量,静态变量
int g_nNum = 1;
static int g_nCount = 2;
float g_fNum = 2.5;
char g_ch = 'A';
int _tmain(int argc, _TCHAR* argv[])
{
printf("int %d, float %f, char %c",
g_nNum, g_fNum, g_ch);
return 0;
}
  • 交叉引用:

    • 数据的引用:可以找到所有使用了这个数据的地方
    • 函数的引用:可以找到所有使用了这个函数的地方

对于已经初始化的数据,没有代码进行赋值的,直接由编译器设置

三、数组

1
2
3
4
5
6
7
int _tmain(int argc, _TCHAR* argv[])
{
int nArr[5] = { 1, 2, 3, 4, 5 };
int n = 2;
nArr[n] = 20;
return 0;
}
  • 函数的模拟栈帧

将类型转换为数组

  • u 取消目标(局部变量\全局变量\函数)的定义

  • c 将目标地址解释为 代码(code)

  • p 将目标代码解释为函数,ida会自动分析函数,重新修复栈帧

  • 检查数组是否越界的函数 CheckStackVar

四、结构体

结构体的使用界面

结构体成员的操作

修改后的结构体显示的方式

修改结构体的原型

五、内置的运行时检测函数

一、CheckEsp: 检查堆栈是否平衡,不平衡就崩溃

1
2
3
add esp,0D4h
cmp ebp, esp ;调用之前需要使用cmp ebp,esp判断堆栈平衡
call CheckEsp

二、CheckStackValue:检查数组是否越界,越界就崩溃

1
2
3
4
5
6
7
push edx
mov ecx, ebp ; 1
push eax
lea edx, xxxxxxxx ; 2
call CheckStackValue ; 3,调用之前,使用lea edx,xxx传参,且有两个push
pop eax
pop edx
1
2
xor ecx,ebp			;调用前,操作ebp,xor xxx,ebp
call sub_xxxx ;Security_check_cookie函数

VS数据的分析

一、 基本常量类型

  • 大多数的常量是作为 OPCODE 的一部分被直接存储在代码区
    • 字符串:常量字符串保存在常量区,赋值使用的实际是所在的地址
    • 浮点数:被保存在常量区,初始化时需要通过 xmm 寄存器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#define SIZE  100      

enum class eData
{
enum_TYPE_1 = 1,
enum_TYPE_2 = 2,
enum_TYPE_3 = 3
};

struct sData
{
int n;
float fNum;
char chA;
};


int main()
{
const bool bRet = true; // 布尔常量
// mov byte ptr[ebp - 5], 1

const int nCount = SIZE; // const常量
// mov dword ptr[ebp - 14h], 64h

const char* szHello = "Hello Word"; // 字符串常量
// mov dword ptr[ebp - 20h], 217BCCh(字符串地址)

const eData data = eData::enum_TYPE_1; // 枚举常量
// mov dword ptr[ebp - 2Ch], 1

const float fNum = 1.5; // 浮点常量
// movss xmm0, dword ptr ds : [00217BD8h]
// movss dword ptr[ebp - 38h], xmm0
// mov dword ptr[ebp - 4Ch], 1

const sData stc = { 1,2.0,'1' }; // 结构体常量
// movss xmm0, dword ptr ds : [00217BDCh]
// movss dword ptr[ebp - 48h], xmm0
// mov byte ptr[ebp - 44h], 31h

return 0;
}
  • 通过解析头文件(Ctrl+F9)的方式添加头文件,头文件不能解析高标准的语法
  • 可以直接在常量上按下’M’将常量解释为 枚举类型

二、 字符串的初始化

1
2
// 字符串数组,数据量较小,使用的是 mov
char szStr[100] = { "szStr[100] Hello Word" };
1
2
// 宽字符串数组 ,数据量较大,使用的时串操作
wchar_t szWchar[100] = L"szWchar Hello Word";
1
2
// 普通的字符数组,相比于第一个,数组的大小由字符串长度决定,不需要调用 memset 
char szHello[] = "szHello[] Hello Word";

三、指针和引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
int number = 10;

int* pnumber = &number;
/*
lea eax,[number]
mov dword ptr [pnumber],eax
*/

int& rnumber = number;
/*
lea eax,[number]
mov dword ptr [rnumber],eax
*/
return 0;
}

四、C++ 对象分析

构造函数

  • 构造函数使用 ecx 传递对象的地址,在其中进行初始化
  • 初始化当前对象的虚函数表指针
  • 会在执行具体的逻辑代码前,调用父类的构造函数
  • 会将返回值设置为当前的 this 指针

析构函数

  • 构造函数使用 ecx 传递对象的地址,在其中进行初始化
  • 会在执行具体的逻辑代码前,调用父类的构造函数
  • 返回值不具备任何的意义

普通成员函数

  • 使用 ecx 传递 this 指针,其余的参数使用栈传递,函数内平衡堆栈
  • 数据成员的寻址依赖的是this指针+数据相对于对象首地址偏移

虚函数

只有同时使用虚函数和指针或引用调用才能实现动态联编

友元函数 \ 静态成员函数

友元函数和静态成员函数与普通函数之间没有区别

五、全局对象

一、全局对象的构造

编写源码,在全局对象的构造函数中设置断点,查看调用堆栈

定位到 _initterm 函数,查看其中的内容。源码的位置是 C:\Program Files (x86)\Windows Kits\10\Source\自己的SDK版本\ucrt\startup

根据函数的实现,将特征码提取出来,不同环境下的特征码可能不同

1
8B 4D FC 8B 11 89 55 F8 8B 4D F8 FF 15 ?? ?? ?? ?? FF 55 F8 EB CF

找到 __scrt_common_main_seh 函数,在调用 invoke_main 之前,它调用了 initterm 函数,根据特征,两组 push + push + call 的第二组就可以找到对应的函数了。

并不是所有的初始化函数都需要分析,有些函数是运行时库提供的,比如说第一个全局变量的初始化函数。一般来讲,全局变量的初始化会用到 ecx 调用构造函数

  • 全局变量的 this 指针通常使用 mov 指令进行赋值,局部变量一般使用 lea 指令

二、全局对象的析构

通过在析构函数设置断点进行栈回溯

分析函数获取特征码

1
8b 45 c4 89 45 d0 8b 4d d0 ff 15 ?? ?? ?? ?? ff 55 d0

六、数据结构的逆向

一、字符串对象 CString

CString 大小是 4 字节,初始化会依赖两个函数,初始化空间+构造函数

二、字符串对象 string

string 大小是 28,其中的第二个元素是一个联合体,当字符串长度大于15时,其中会存在一个指针,指向实际的字符串。

1
2
3
4
5
6
7
8
9
10
11
struct my_string
{
struct my_string* self; // 指向自己的指针
union
{
char* ptr; // 当数据大于15字节的时候
char str[0x10]; // 当数据小于16字节的时候
};
int length; // 当前占用的长度
int size; // 指向的堆空间的大小(最多能存储多少)
};

三、vector 对象

vector 占用的大小是 16 字节,初始化使用两个函数,分别是分配空间函数和构造函数

1
2
3
4
5
6
7
8
9
// vector 容器对应的结构体
template <class T>
struct my_vector
{
struct my_vector* self; // 指向自己
T* begin; // 指向堆空间起始位置
T* data_end; // 指向数据的结尾部分
T* heap_end; // 指向堆空间的结尾
};

四、list 对象

list 是一个双向循环链表,占用了 0x0C 的空间,分别保存了自己的地址,头节点的地址以及元素的个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 节点结构体
template <class T>
struct my_node
{
struct my_node* next; // 指向下一个
struct my_node* prev; // 指向前一个
T element; // 数据域
};

// 容器结构体
template <class T>
struct my_list
{
struct my_list* self; // 指向自己的指针
struct my_node<T>* head; // 头节点,不存储数据
int length;
// 元素个数
};

可以使用常规的遍历方式,查看元素

1
2
3
4
5
6
7
8
9
my_list<int>* m = (my_list<int>*)&l;
// 获取第一个元素
my_node<int>* node = m->head->next;

while (node != m->head)
{
printf("%d", node->element);
node = node->next;
}

五、map 容器

map 的底层实现是一个红黑树,占用了 0x0C 的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 树的结点结构体
struct _Tree_node
{
// 指向左右子树和跟的指针,当没有指向的时候,保存的是根节点
_Tree_node* _Left;
_Tree_node* _Parent;
_Tree_node* _Right;

// 标识当前是不是一个根节点 _Red or _Black
char _Color;
char _Isnil;

// 键值对,保存了两个类型的结构体
// value_type _Myval;
};

六、迭代器

迭代器占用了 12 字节空间,第一个指针指向了一个结构体,及保存了自己的地址,又保存了被关联容器的地址

1
2
3
4
5
6
7
8
9
10
11
12
struct s
{
void* target; // 指向的类型是不固定
struct my_iterator* self;
};

struct my_iterator
{
struct s* point; // 自己和关联的容器
struct my_iterator* prev; // 前一个迭代器
void* node; // 具体的的节点
};

不同语言程序的特征

如何确定目标程序的语言?

  • 入口点的特征(二进制特征)
  • 链接器版本
  • 区段的名称

1. BC++ 程序

  • 入口点的特征

BC++ 编写的程序,调用IAT函数会使用到 FF25 ?? ?? ?? ??

链接器版本是: 5.0

二进制特征,后面会有 GetModuleHandle 函数的调用,由于大多数查壳工具使用的都是特征码匹配,例如 PEID 使用了 userdb.txt 中的特征,只要伪造二进制特征就可以让大多数的查壳工具失效。

1
EB 10 66 62 3A 43 2B 2B 48 4F 4F 4B 90

区段的特征:不同功能对应的数据放置在了相应的区段中

2.Delphi 程序

入口特征

由于 Delphi 和 BC++ 程序都是宝蓝公司的,所以 IAT 调用特征相同

链接器版本:2.25

二进制特征: B8 和 E8 后面都是不同的,未知的可以使用 ?? 代替

1
55 8B EC 83 C4 F0 B8 ?? ?? ?? ?? E8 ?? ?? ?? ??

区段特征:代码段(CODE)和数据段(DATA)以及未初始化的数据(BSS)

3.汇编程序

特征,小,入口点直接就是逻辑代码

链接器版本:5.12

4.VC6.0\易语言

入口点特征:

链接器版本

区段特征

二进制特征:

1
55 8B EC 6A FF 68 ?? ?? ?? ?? 68 ?? ?? ?? ?? 64 A1 00 00 00 00 50 64 89 25 00 00 00 00

5、MFC程序的逆向

通过调用堆栈窗口,找到调用方,提取 OnInitDialog 的特征码

打开 OD,搜索查找到的特征指令,给每一条指令设置断点

不同版本的VS,MFC的特征是不一样的.

1
2
3
4
5
6
7
8
Debug 动态、静态编译
CALL DWORD PTR SS:[EBP-8]

Release 动态编译
CALL DWORD PTR SS:[EBP+0C]

Release 静态编译
CALL DWORD PTR SS:[EBP+14]

VS 程序的连接器版本对应的VS版本

  • VS 版本的不同会导致编写出的程序特征(链接器、入口点、区段)
  • debug 下编译的程序区段的数量相对于 Release 多一些
  • release 的入口存在 call ???????? jmp ????????,debug可能是两个call
VS 版本 链接器版本
VC 6.0 6.0
VC2003 7.0 / 7.1
VS2005 8.0
VS2008 9.0
VS2010 10.0
VS2012 11.0
VS2013 12.0
VS2015 14.0
VS2017 14.1
VS2019 14.2