虚拟地址(VA): 在一个程序运行起来的时候,会被加载到内存中,并且每个进程都有自己的4GB,这个4GB当中的某个位置叫做虚拟地址,由物理地址映射过来的,4GB的空间,并没有全部被用到。
- 基地址(Imagebase):磁盘中的文件加载到内存当中的时候可以加载到任意位置,而这个位置就是程序的基址。EXE默认的加载基址是400000h,DLL文件默认基址是10000000h。需要注意的是基地址不是程序的入口点。
- 相对虚拟地址(RVA):为了避免PE文件中有确定的内存地址,引入了相对虚拟地址的概念。RVA是在内存中相对与载入地址(基地址)的偏移量,所以你可以发现前三个概念的关系:虚拟地址(VA)= 基地址+相对虚拟地址(RVA)
- 文件偏移地址(FOA):当PE文件储存在某个磁盘当中的时候,某个数据的位置相对于文件头的偏移量。
- 入口点(OEP):首先明确一个概念就是OEP是一个RVA,然后使用OEP +Imagebase ==入口点的VA,通常情况下,OEP指向的不是main函数。
虚拟内存技术使得每个进程都可以独占整个内存空间,地址从零开始,直到内存上限。 每个进程都将这部分空间(从低地址到高地址)分为六个部分:
- TEXT段:整个程序的代码,以及所有的常量。这部分内存是是固定大小的,只读的。
- DATA段,又称GVAR:初始化为非零值的全局变量。
- BSS段:初始化为0或未初始化的全局变量和静态变量。
- HEAP(堆空间):动态内存区域,使用
malloc或new申请的内存。 - 未使用的内存。
- STACK(栈空间):局部变量、参数、返回值都存在这里,函数调用开始会参数入栈、局部变量入栈;调用结束依次出栈。
DOS头
- e_magic:DOS头的标记位,值为4D5Ah。ASCII为”MZ“,判断一个文件是否为PE文件是会用。
- e_lfanew:这是一个RVA,代表了PE文件头到基址的偏移量,我们可以用它来找到PE文件头的位置
PE内存加载
在解析PE文件之前,我们首先要做的则是将PE文件从磁盘中读入到内存,有两种方式可以实现,
(1)一种是通过ReadFile函数将完整的数据读入内存,该方法会消耗更多的内存资源这里并不推荐使用,
(2)第二种方式则是采用映射的模式,所谓的映射则是将一个磁盘中的部分数据读入内存,当需要使用该片区域时由操作系统动态的装载一部分,该方式也是笔者推荐的一种实现模式;一般来说映射文件的流程是,使用CreateFile()打开一个磁盘文件,接着使用CreateFileMapping()函数创建文件的内存映像,最后使用MapViewOfFile()读取映射中的内存并返回一个句柄,后面的程序就可以通过该句柄操作打开后的文件。
- CreateFile:用来创建或打开文件的API函数,它可以接受一个文件名作为输入参数,并返回一个文件句柄。文件句柄是用来标识打开的文件的唯一标识符,后续对该文件的操作需要使用这个句柄。
- CreateFileMapping:用来创建文件的内存映像的API函数。它可以将一个文件映射到内存中,这样我们就可以像访问内存一样访问文件。这个函数需要传入一个文件句柄以及一个映像的大小。它返回一个句柄,表示创建的内存映像。
- MapViewOfFile:用来读取映射中的内存的API函数。它需要传入一个映像的句柄以及一个偏移量,用来指定从哪个位置开始读取内存。该函数返回一个指向映射内存的指针,我们可以使用它来读取或修改映射内存中的数据。
常用API函数
DebugActiveProcess() //使调试器能够附加到活动进程并对其进行调试
DebugActiveProcessStop() //阻止调试器调试指定的进程
ContinueDebugEvent() //使调试器能够继续以前报告调试事件的线程
DebugBreak() //导致当前进程中出现断点异常
DebugBreakProcess() //导致指定进程中发生断点异常。这允许调用线程向调试器发出信号以处理异常。
FatalExit() //将执行控制传输到调试器。此后调试器的行为是特定于所使用的调试器类型的。
FlushInstructionCache() //将指令缓存刷新为指定的进程。
GetThreadContext() //检索指定线程的上下文。
GetThreadSelectorEntry() //检索指定选择器和线程的描述符表项。
IsDebuggerPresent() //确定调用进程是否由用户模式调试器调试.
OutputDebugString() //将字符串发送到调试器以供显示。
ReadProcessMemory() //读取指定进程某区域的数据
SetThreadContext() //设置指定线程的上下文。
WaitForDebugEvent() //等待正在调试的进程中发生调试事件。
WriteProcessMemory() //将数据写入指定进程中的内存区域。要写入的整个区域必须是可访问的,否则操作失败。
调试原理
调试器主要是基于 CPU 的异常机制实现的,通过迫使被调试进程触发一个 精心构造的异常事件来实现
中断类型:
- 软件中断:软件断点是通过在目标进程中特定的内存地址处写入“ 0XCC ”机器码实现的 。 Windows 提供了函数 ReadProcessMemory()和WriteProcessMemory()来完成内存数据的读写
- 硬件中断:硬件断点的实现需要借助 CPU 的调试寄存器。同时设置硬件断点时需要确定四个调试地址寄存器(DR0
DR3)是否有可以使用的。首先获取目标进程里的所有线程,随后获取所有线程的 CPU寄存器状态。通过获取的 CPU 寄存器的值,在可用的调试地址寄存器里(DR0DR3 中的一个)存储目标断点地址,最后在 DR7 寄存器相应的位上设置断点的属性和长度, - 内存中断:内存断点的思想即把指定内存页设置为保护页。大致实现过程:首先查询一个内存块以便找到基地址(页面在虚拟内存中的起始地址),随后在确定了页面大小之后,将该页面的权限设置为保护页(guard)
调试过程
建立调试会话:
- 从调试器本身 启动目标程序:方法一常用于分析病毒和恶意代码,因为它 能在程序运行之前完全的控制程序
调试器装载一个可执行文件就是从调试器本身运行这个程序,此时被调试程 序相当于是子进程,调试器对其有控制权限。利用 CreateProcessW()函数可创建进程。其中的参数 dwCreationFlags 用于指定新进程的创建方式,只需设置 dwCreationFlags=DEBUG_PROCESS 或 DEBUG_ONLY_THIS_PROCESS,即可让新进程具有调试特性,表示以子进程的 方式运行。这种方法之间的区别是:前者会接收所创建进程及其子进程的调试事 件,而后者只会接收目标进程的调试事件,而不接收其子进程的调试事件。
- 调试器附加到目标进程:强行地进入到已经运行了的进程 内部,此时它跳过了程序开头的启动代码,直接分析程序真正的代码
(1)附加到目标进程,需要先获得它的句柄。句柄的获取可由 kernel.dll 库的 OpenProcess()函数完成。
其中,参数 dwDesirdAccess 决定了对打开的进程拥有怎样的权限,因为要进行调试,可把其设置成 PROCESS_ALL_ACCESS,即对目标进程拥有所有控制权。参数 dwProcessId 用来指定需要获得句柄的进程 ID(PID)。被调试器进程 的句柄会在 OpenProcess()成功调用时返回。
(2)最后利用 DebugActiveProcess()函数将调试器动态的附加到目标进程上
函数调用约定
-
stdcall(pascal)-C++的标准调用方式
- 参数从右向左压入堆栈
- 函数自身清理堆栈
- 函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸
-
cdecl
- 参数从右向左压入堆栈
- 调用者负责清理堆栈
- C调用约定允许函数的参数的个数是不固定的,这也是C语言的一大特色。
- 仅在函数名前加上一个下划线前缀,格式为_functionname。
-
fastcall
- 函数的第一个和第二个DWORD参数(或者尺寸更小的)通过ecx和edx传递,其他参数通过从右向左的顺序压栈
- 函数自身清理堆栈
- 函数名修改规则同stdcall:函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸
-
thiscall-C++类成员函数缺省的调用约定
-
naked call
默认初始化规则
定义基本数据类型变量(单个值、数组)的同时
可以指定初始值,如果未指定C++会去执行默认初始化(default-initialization)。 那么什么是"默认初始化"呢?
- 栈中的变量(函数体中的自动变量)和堆中的变量(动态内存)会保有不确定的值;
- 全局变量和静态变量(包括局部静态变量)会初始化为零。
所以函数体中的变量定义是这样的规则:
int i; // 不确定值int i = int(); // 0int *p = new int; // 不确定值int *p = new int(); // 0
静态和全局变量的初始化
未初始化的和初始化为零的静态/全局变量编译器是同样对待的,把它们存储在进程的BSS段(这是全零的一段内存空间)中。所以它们会被"默认初始化"为零。
来看例子:
int g_var;
int *g_pointer;
static int g_static;
int main()
{int l_var;int *l_pointer;static int l_static;cout<<g_var<<endl<<g_pointer<<endl<<g_static<<endl;cout<<l_var<<endl<<l_pointer<<endl<<l_static<<endl;};
输出:
0 // 全局变量
0x0 // 全局指针
0 // 全局静态变量
32767 // 局部变量
0x7fff510cfa68 // 局部指针
0 // 局部静态变量
动态内存中的变量在上述代码中没有给出,它们和局部变量(自动变量)具有相同的"默认初始化"行为。
成员变量的初始化
成员变量分为成员对象和内置类型成员,其中成员对象总是会被初始化的。而我们要做的就是在构造函数中初始化其中的内置类型成员。 还是先来看看内置类型的成员的"默认初始化"行为:
class A
{
public:
int v;
};
A g_var;
int main()
{
A l_var;
static A l_static;
cout<<g_var.v<<' '<<l_var.v<<' '<<l_static.v<<endl;
return 0;
}
输出:
0 2407223 0
可见内置类型的成员变量的"默认初始化"行为取决于所在对象的存储类型,而存储类型对应的默认初始化规则是不变的。 所以为了避免不确定的初值,通常会在构造函数中初始化所有内置类型的成员。Effective C++: Item 4一文讨论了如何正确地在构造函数中初始化数据成员。 这里就不展开了,直接给出一个正确的初始化写法:
class A
{
public:
int v;
A(): v(0);
};
Struct变量的初始化
Struct结构体中的某些成员变量没有被指定默认值,那么它们将被初始化为0或NULL(对于指针类型的成员变量)
struct Person
{
char name[20];
int age;
float height;
char gender;
} p = {"John", 30, 1.75, 'M'};
在这个例子中,我们定义了一个名为Person的结构体,其中包含了name、age、height和gender四个成员变量。在定义结构体变量p时,我们使用了大括号{}指定了结构体的初始化默认值,其中每个值的顺序和结构体定义时的成员变量顺序相同。
如果某些成员变量没有被指定默认值,那么它们将被初始化为0或NULL(对于指针类型的成员变量)。例如,下面的代码中,age和height没有被指定默认值,它们将被初始化为0。