所谓的大端模式(Big-endian),是指数据的高字节,保存在内存的低地址中,而数据的低字节,保存在内存的高地址中,这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放;
所谓的小端模式(Little-endian),是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低,和我们的逻辑方法一致。
例如:对于0x11223344的存储如下
大端模式
低地址—>高地址
小端模式
高地址—>低地址
二进制数有两个特点:它由两个基本数字0,1组成,二进制数运算规律是逢二进一。
为区别于其它进制数,二进制数的书写通常在数的右下方注上基数2,或加后面加B表示。
例如:二进制数10110011可以写成(10110011)2,或写成10110011B
八进制的基R=8=2^3,有数码0、1、2、3、4、5、6、7,并且每个数码正好对应三位二进制数,所以八进制能很好地反映二进制。八进制用下标8或数据后面加O表示例如:二进制数据(11101010.010110100)2对应八进制数据(352.264)8或352.264O.
例如:十六进制数4AC8可写成(4AC8)16,或写成4AC8H。
例如:
把(1001.01)2二进制计算。
解:(1001.01)2
=81+40+20+11+0(1/2)+1(1/4)
=8+0+0+1+0+0.25
=9.25
把(38A.11)16转换为十进制数
解:(38A.11)16
=3×16的2次方+8×16的1次方+10×16的0次方+1×16的-1次方+1×16的-2次方
=768+128+10+0.0625+0.0039
=906.0664
例:将25转换为二进制数
解:25÷2=12余数1
12÷2=6余数0
6÷2=3余数0
1÷2=0余数1
所以25=(11001)2
例:将25转换为十六进制数
解:25÷16=1余数9
1÷16=0余数1
所以25=(19)16
二进制数与十六进制数之间的转换
由于4位二进制数恰好有16个组合状态,即1位十六进制数与4位二进制数是一一对应的.所以,十六进制数与二进制数的转换是十分简单的.
(1)十六进制数转换成二进制数,只要将每一位十六进制数用对应的4位二进制数替代即可――简称位分四位.
例:将(4AF8B)16转换为二进制数.
解:4AF8B
01001010111110001011
所以(4AF8B)16=(1001010111110001011)2
(2)二进制数转换为十六进制数,分别向左,向右每四位一组,依次写出每组4位二进制数所对应的十六进制数――简称四位合一位.
例:将二进制数(000111010110)2转换为十六进制数.
解:000111010110
1D6
所以(111010110)2=(1D6)16
转换时注意最后一组不足4位时必须加0补齐4位
数制转换的一般化
1)R进制转换成十进制
N=5A.8H=516^1+A16^0+8*16^-1=80+10+0.5=90.5
2)十进制转换R进制
逻辑运算又称为布尔运算。通常用来测试真假值,最常见到的逻辑运算就是循环的处理,用来判断是否该离开循环或继续执行循环内的指令。
逻辑常量只有两个,0和1,用来表示两个对立的逻辑状态。在逻辑代数中,有与、或、非三种基本的逻辑运算。
“与”运算是一种二元运算,它定义了两个变量A和B的一种函数关系。用语句来描述它,这就是:当且仅当变量A和B都为1时,函数F为1。
下表是与运算的真值表:
“或”运算是另一种二元运算,它定义了变量A、B与函数F的另一种关系。用语句来描述它,这就是:只要变量A和B中任何一个为1,则函数F为1。
下表是或运算的真值表:
逻辑“非”运算是一元运算,它定义了一个变量(记为A)的函数关系。用语句来描述之,这就是:当A=1时,则函数F=0;反之,当A=0时,则函数F=1
下表是非运算的真值表:
左移用来将一个数的各位二进制位全部左移若干位。例如:
循环左移类似于逻辑左移,不同的是,循环左移会将左边移出的位添补到左边。例如原数x3x2x1x0,循环左移一位后,变为x2x1x0x3。
可见,所有的位顺序向左移1位,最低位由最高位循环移入。
此方法和循环左移类似,只是多了一个符号位,举例来说:
原数:CX3X2X1X0,循环左移一位后变为X3X2X1X0C。
算术右移用来将一个数的各位二进制位全部右移若干位,然后在左侧用原符号位补齐。在汇编语言中,如果最高位为1,则补1,否则补0。如将10000000算术右移7位,应该变成11111111。
逻辑右移是将各位依次右移指定位数,然后在左侧补0.不考虑符号位。例如将10000000逻辑右移7位,变为00000001。
循环右移类似于逻辑右移,不同的是,循环右移会将右边移出的位添补到左边。例如原数x3x2x1x0,循环右移一位后,变为x0x3x2x1。
可见,所有的位顺序向右移1位,最高位由最低位循环移入。
此方法和循环右移类似,只是多了一个符号位,举例来说:
原数:CX3X2X1X0,循环右移一位后变为X0CX3X2X1。
流程控制语句的识别时进行逆向分析和还原高级代码的基础,详细的理解此基础可以更好的理解高级语言中流程控制的内部实现机制,对开发和调试大有益处。
If语句是分支结构的重要组成部分。If语句的功能是现对运算条件进行比较,然后根据比较结果选择对应的语句块来执行。If语句只能判断两种情况:0为假值,非0为真值。如果为真值,则进入语句块内执行语句;如果为假值,则跳过if语句块,继续执行程序的其他语句。要注意的是,if语句转换的条件跳转指令与if语句的判断结果是相反的。
If语句的一般流程如下:
//先执行各类影响标志位的指令
//其后是各种条件跳转指令
jxxxxxx
if语句是一个单分支结构,if…else…组合后是一个双分支结构。两者完成的功能有所不同。从语法上看,if…else…只比if语句多出了一个else。else有两个功能,如果if判断成功,则跳过else分支语句块;如果if判断失败,则进入else分支语句块中。有了else语句的存在,程序在进行流程选择时,必会经过两个分支中的一个。
if…else…的大致流程如下:
jxxelse_begin//该地址为else语句块的首地址
if_begin
if_end
jmpelse_end//跳转到else语句块的结束地址
else_begin
else_end
多分支结构类似于if…else…的组合方式,在if…else…的else之后再添加一个elseif进行二次比较,这样就可以进行多次比较,再次选择程序流程,形成多分支流程。它的c++语法格式为:if…elseif…elseif…,可重复后缀为elseif。当最后为else时,便到了多分支结构的末尾处。
一般流程如下:
jxx指出了下一个elseif的起始点,而jmp指出了整个多分支结构的末尾地址以及当前if或者elseif语句块的末尾。最后的else块的边界也很容易识别,如果发现多分支块内的某一段代码在执行前没有判定,即可定义为else块。
//会影响标志位的指令
jxxelse_if_begin//跳到下一条elseif语句块的首地址
jmpend//跳转到多分枝结构的结尾地址
else_if_begin//elseif语句块的起始地址
//可影响标志位的指令
jxxelse_begin
else_if_end:
jmpend
else_begin:
end
当每个条件跳转指令的跳转地址之前都紧跟jmp指令,并且他们的跳转地址值都一样时,可视为一个多分支结构。
switch是比较常用的多分支结构,使用起来也非常方便,并且效率也高于if…elseif多分枝结构。switch语句将所有的条件跳转都放置在了一起,并没有发现case语句块的踪影,通过条件跳转指令,跳转到相应的case语句块中。因此每个case的执行是由switch比较结果引导跳过来的。
movreg,mem//取出switch中考察的变量
//影响标志位的指令
jmpend//跳到switch语句块的结尾地址出
end:
当分支数小于4的情况下,VC6.0会采取模拟ifelse的方法
当分支数大于3,并且case的判定值存在明显线性关系组合时,它会制作一份case地址数组(case地址表),这个数组保存了每个case语句块的首地址,并且数组下标以0起始。如果每两个case值之间的差值小于等于6,并且case语句数大于等于4,编译器就会形成这种线性结构。
对于非线性的switch结构,会进行索引表优化,需要两张表:一张为case语句块地址表,另一张为case语句块索引表
地址表中的每一项保存了一个case语句块的首地址,有几个case语句就有几项。此情况适用于差值小于等于255的情况,大于255的话可以通过树方式优化。
do循环的工作流程清晰,识别起来也相对简单。根据其特性,先执行语句块,再进行比较判断,当条件成立时,会继续执行语句块。
if语句的比较是相反的,并且跳转地址大于当前代码的地址,是一个向下跳转的过程;而do中的跳转地址小于当前代码的地址,是一个向上跳转的过程,所以条件跳转的逻辑与源码中的逻辑相同
do循环的一般流程:
do_begin
;影响标记位的指令
jxxdo_begin
while循环和do循环正好相反,在执行循环语句块之前,必须要进行条件判断,根据比较结果再选择是否执行循环语句块。识别while循环,查看条件跳转地址,如果这个地址上面有一个jmp指令,并且此指令跳转到的地址小于当前代码地址,那么明显是一个向上跳转的地址。要完成语句循环,就需要修改程序流程,回到循环语句处,因此向上跳转就成了循环语句的明显特征。在条件跳转的地址附近会有jmp指令修改程序流程。
while循环用了两次跳转,因此比do循环效率低一些。
while循环的一般流程:
while_begin
jxxwhile_end
jmpwhile_begin
while_end:
for循环是三种循环结构中最复杂的一种。for循环由赋初值,设置循环条件,设置循环步长这三条语句组成。由于for循环更符合人类的思维方式,在循环结构中被使用的频率也很高。
for循环的一般流程:
movmem/reg,xxx//赋初值
jmpfor_cmp//跳到循环条件判定部分
for_step:
//修改循环变量step
movreg,step
addreg,xxxx//修改循环变量的计算过程,在实际分析中,视算法不同而不同
movstep,eax
for_cmp://循环条件判定部分
movecx,dwordptrstep
//判定循环变量和循环终止条件stepend的关系,满足条件则退出for循环
cmpecx,stepend
jxxfor_end//条件成立则结束循环
jmpfor_step//向上跳转,修改流程回到步长计算部分
for_end:
在计数器变量被赋初值后,利用jmp跳过第一次步长计算,然后,可以通过三个跳转指令还原for循环的各个组成部分:第一个jmp指令之前的代码为初始化部分;从第一个jmp指令到循环条件比较处(也就是上面代码中for_cmp标号的位置)之间的代码为步长计算部分;在条件跳转指令jxx之后寻找一个jmp指令,这jmp指令必须是向上跳转的,且其目标是到步长计算的位置,在jxx和这个jmp(也就是上面代码的省略号所在的位置)之间的代码是循环语句块。
从数据结构角度看,栈是一种用来存储数据的容器。放入数据的操作称为压入(push),从栈中取出数据的操作被称为弹出(pop)。存取数据的一条基本规则是后进先出。
X86架构有对栈的内建支持。用于这种支持的寄存器包括esp和ebp。其中esp是栈指针,包含了指向栈顶的内存地址。当数据被压入或弹出栈时,这个寄存器的值相应的改变。Ebp是栈基址寄存器,在一个函数中保持不变,因此程序把它当成定位器,用来确定局部变量和参数的位置。
与栈有关的指令包括push,pop,call,leave,enter和ret。在内存中,栈被分配成自顶向下的,最高的地址最先被使用。当一个值被压入栈时,使用低一点的地址。
栈只能用于短期存储。他经常用于保存局部变量、参数和返回地址。主要用途是管理函数调用之间的数据交换。不同的编译器对这种管理方法的具体实现有所不同,但大部分常见约定都使用ebp的地址来引用局部变量与参数。
栈的布局:
在经典的操作系统中,栈总是向下(低地址)增长的。
栈保存一个函数调用所需要的维护信息,常被称为堆栈帧或者是活动记录,堆栈帧一般包括:
(1)函数的返回地址和参数;
(2)临时变量:包括函数的非静态局部变量以及编译器生成的其他局部变量;
(3)保存的上下文:包括在函数调用前后保持不变的寄存器。
堆是组织内存的另一种重要方法,是程序运行期动态申请内存空间的主要途径。与栈空间是由编译器产生的代码自动分配和释放不同,堆上的空间需要程序员自己写代码来申请(HeapAlloc)和释放(HeapFree),而且分配和释放操作应该严格匹配,忘记释放或多次释放都是不正确的。
异常分为3类,错误,陷阱和中止
导致错误类异常的情况通常可以被纠正,而且一旦纠正后,程序可以无损失的恢复执行。此类异常的一个最常见的例子就是内存页错误。页错误异常的发生是因为它是虚拟内存的基础。因为物理内存的空间有限,所以操作系统会把某些在那时不用的内存以页为单位交换到外部存储器上。当有程序访问到这些不在物理内存种的页所对应的内存地址时,CPU便会产生一个页错误异常(缺页错误、缺页异常),并转去执行该异常的处理程序,后者会调用内存管理器的函数把对应的内存页交换回物理内存,然后再让CPU返回到导致该异常的那条指令处恢复执行。当第二次执行刚才导致异常的指令时,对应的内存页已经在物理内存中(错误情况被纠正),因此就不会再产生页错误异常了。
当CPU报告错误类异常时,CPU将其状态恢复成导致该异常的指令被执行之前的状态。而且在CPU转去执行异常处理程序前,在栈中保存的CS和EIP指针是指向导致异常的这条指令的(而不是下一条指令)。因此,当异常处理程序返回继续执行时,CPU接下来执行的第一条指令仍然是刚才导致异常的那条指令。所以,如果导致异常的情况还没有被消除,那么CPU会再次产生异常。
当CPU报告陷阱类异常时,导致该异常的指令已经执行完毕,压入栈的CS和EIP值是导致该异常的指令执行后紧接着要执行的下一条指令。值得说明的是,下一条指令并不一定是与导致异常的指令相邻的下一条。如果导致异常的指令是跳转指令或函数调用指令,那么下一条指令可能是内存地址不相邻的另一条指令。
导致陷阱类异常的情况通常也是可以无损失的恢复执行的。比如INT3指令导致的断点异常就属于陷阱类异常,该异常会使CPU中断到调试器,从调试器返回后,被调试程序可以继续执行。
中止类异常主要用来报告严重的错误,比如硬件错误和系统表中包含非法值或不一致的状态等。这类异常不允许恢复继续执行。首先,当这类异常发生时,CPU并不总能保证报告的异常的指令地址是精确地。另外,出于安全性的考虑,这类异常可能是由于导致该异常的程序执行非法操作导致的,因此就应该强迫其中止退出。
中断和异常从产生的根源来看有着本质的区别,但是系统(CPU和操作系统)是用统一的方式来响应和管理他们的。中断和异常处理的核心数据结构是中断描述符表(IDT)。当中断和异常发生时,CPU通过查找IDT表来定位处理例程的地址,然后转去执行该处理例程。这个查找的过程是在CPU内部执行的。通常,系统软件(操作系统和BIOS固件)在系统初始化阶段就准备好中断处理例程和IDT表,然后把IDT表的位置通过IDTR寄存器告诉CPU。
实模式下IVT(中断向量表)位于物理地址0开始的1KB内存区中,每个IVT表项的长度是4个字节,共有256个表项,与x86CPU的256个中断向量一一对应。实模式下,每个IVT表项的资格字节分为两部分,高两个字节为中断例程的段地址,低两个字节为中断例程的偏移地址。因为是在实模式下,所以段地址左移4位再加上偏移地址便可以得到20位的中断例程地址。
下面是IA-32CPU相应中断和异常的全过程:
中断通常是由CPU外部的输入输出设备(硬件)所触发的,供外部设备通知CPU“有事情要处理”,因此又叫中断请求。中断请求的目的是希望CPU暂时停止执行当前正在执行的程序,转去执行中断请求所对应的中断处理例程。
中断机制为CPU和外部设备间的通信提供了一种高效的方法,有了中断机制,CPU就可以不用去频繁的查询外部设备的状态了,因为外部设备有事需要处理时,他可以发出中断请求通知CPU。
在硬件级,中断是由一块专门芯片来管理的,通常称为中断控制器。他负责分配中断资源和管理各个中断源发出的中断请求。为了便于标识各个中断请求,中断管理器通常用IRQ后面加上数字来表示不同路的中断请求信号。
cdecl调用约定又称为C调用约定,是c/c++语言缺省的调用约定。参数按照从右至左的方式入栈,函数本身不清理栈,此工作有调用者负责,返回值在eax中。由于由调用者清理栈,所以允许可变参数函数存在。
stdcall很多时候被称为pascal调用约定。pascal语言是早期很常见的一种教学用计算机程序设计语言,其语法严谨,参数按照从右至左的方式入栈,函数自身清理堆栈,返回值在eax中。
fastcall的调用方式运行相对快,因为它通过寄存器来传递参数。它使用ecx和edx传送两个双字或更小的参数,剩下的参数按照从右至左的方式入栈,函数自身清理堆栈,返回值在eax中。
这是pascal语言的调用约定,跟stdcall一样,参数按照从右至左的方式入栈,函数自身清理堆栈,返回值在eax中,vc已经废弃了这种调用方式,因此在写vc程序时,建议使用stdcall。
这是c++语言特有的一种调用方式,用于类成员函数的调用约定。如果参数确定,this指针存放于ecx寄存器,函数自身清理堆栈;如果参数不确定,this指针在所有参数入栈后再入栈,调用者清理栈。Thiscall不是关键字,程序员不能使用。参数按照从右至左的方式r入栈。
目标文件有三种类型:
可重定位文件(RelocatableFile)包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
可执行文件(ExecutableFile)包含适合于执行的一个程序,此文件规定了exec()如何创建一个程序的进程映像。
共享目标文件(SharedObjectFile)包含可在两种上下文中链接的代码和数据。首先链接编辑器可以将它和其它可重定位文件和共享目标文件一起处理,生成另外一个目标文件。其次,动态链接器(DynamicLinker)可能将它与某个可执行文件以及其它共享目标一起组合,创建进程映像。
目标文件全部是程序的二进制表示,目的是直接在某种处理器上直接执行。
ELF文件是运行在unix平台下的可执行文件,在学习其文件格式前,首先了解一下文件中使用的数据表示方式。如下表所示:
目标文件既要参与程序链接,又要参与程序执行。出于方便性和效率考虑,目标文件格式提供了两种并行视图,分别反应了这些活动的不同需求。
如上图所示,在文件开始处是一个ELF头部,用来描述整个文件的组织。节区部分包含链接视图的大量信息:指令,数据,符号表,重定位信息等等。
程序头部表,如果存在的话,告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
节区头部表包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称,节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件可有可无。
注意:尽管头部显示的各个组成部分是有顺序的,实际上除了ELF头部表以外,其他节区和段都没有规定的顺寻。
文件的最开始几个字节给出如何解释文件的提示信息。这些信息独立于处理器,也独立于文件中的其余内容。ELFHeader部分可以用下面的数据结构表示:
#defineEI_NIDENT16
typedefstruct{
unsignedchare_ident[EI_NIDENT];
Elf32_Halfe_type;
Elf32_Halfe_machine;
Elf32_Worde_version;
Elf32_Addre_entry;
Elf32_Offe_phoff;
Elf32_Offe_shoff;
Elf32_Worde_flags;
Elf32_Halfe_ehsize;
Elf32_Halfe_phentsize;
Elf32_Halfe_phnum;
Elf32_Halfe_shentsize;
Elf32_Halfe_shnum;
Elf32_Halfe_shstrndx;
}Elf32_Ehdr;
e_ident:目标文件标识,标志此文件是一个ELF文件。
e_type:目标文件的类型,取值取下
e_machine:给出文件的目标体系结构类型,取值如下:
特定处理器的ELF名称会使用机器名来进行区分。
e_version:目标文件版本。
e_entry:程序入口的虚拟地址。如果目标文件没有程序入口,可以为0.
e_phoff:程序头部表格(ProgramHeaderTable)的偏移量(按字节计算)。如果文件没有程序头部表格,可以为0。
e_shoff:节区头部表格(SectionHeaderTable)的偏移量(按字节计算)。如果文件没有节区头部表格,可以为0。
e_ehsize:ELF头部的大小(以字节计算)。
e_phentsize程序头部表格的表项大小(按字节计算)。
e_phnum程序头部表格的表项数目。可以为0。
e_shentsize节区头部表格的表项大小(按字节计算)。
e_shnum节区头部表格的表项数目。可以为0。
节区中包含目标文件中的所有信息,除了:ELF头部,程序头部表格,节区头部表格。节区满足以下条件:
ELF头部中,e_shoff成员给出从文件头到节区头部表格的偏移字节数;e_shnum给出表格中条目数目;e_shentsize给出每个项目的字节数。从这些信息中可以确切地定位节区的具体位置、长度。
每个节区头部可以用如下数据结构描述:
Elf32_Wordsh_name;
Elf32_Wordsh_type;
Elf32_Wordsh_flags;
Elf32_Addrsh_addr;
Elf32_Offsh_offset;
Elf32_Wordsh_size;
Elf32_Wordsh_link;
Elf32_Wordsh_info;
Elf32_Wordsh_addralign;
Elf32_Wordsh_entsize;
}Elf32_Shdr;
各个成员含义如下:
很多节区中包含了程序和控制信息。下面的表格中给出了系统使用的节区以及他们的类型和属性。
SHF_WRITE这些节区包含初始化了的数据,将出现在程序的内存映像中。.data1SHT_PROGBITSSHF_ALLOC+
SHF_EXECINSTR此节区包含了可执行的指令,是进程终止代码的一部分。程序正常退出时,系统将安排执行这里的代码。.gotSHT_PROGBITS此节区包含全局偏移表。.hashSHT_HASHSHF_ALLOC此节区包含了一个符号哈希表。.initSHT_PROGBITSSHF_ALLOC+
SHF_EXECINSTR此节区包含程序的可执行指令。字符串表:
字符串表节区包含以NULL结尾的字符序列,通常称为字符串。ELF目标问件通常使用字符串来表示符号和节区名称。对字符串的引用通常以字符串在字符串表中的下标给出。
一般,第一个字节(索引为0)定义为一个空字符串。类似的,字符串表的最后一个字节也定义为NULL,以确保所有字符串都以NULL结尾。
例如:对于各个节区而言,节区头部的sh_name成员包含其对应的节区头部字符串表节区的索引,此节区由ELF头的e_shstrndx成员给出。
目标文件的符号表中包含用来定位、重定位程序中符号定义和引用的信息。符号表索引是对此数组的索引。索引0表示表中的第一表项,同时也作为未定义符号的索引。
符号表项的格式如下:
Elf32_Wordst_name;
Elf32_Addrst_value;
Elf32_Wordst_size;
unsignedcharst_info;
unsignedcharst_other;
Elf32_Halfst_shndx;
}Elf32_sym;
各个字段的含义如下:
可执行目标文件在ELF头部的e_phentsize和e_phnum成员中给出其自身程序头部的大小,程序头部的数据结构如下图:
Elf32_Wordp_type;
Elf32_Offp_offset;
Elf32_Addrp_vaddr;
Elf32_Addrp_paddr;
Elf32_Wordp_filesz;
Elf32_Wordp_memsz;
Elf32_Wordp_flags;
Elf32_Wordp_align;
}Elf32_phdr;
各个字段说明如下:
p_type:此数组元素描述的段的类型,或者如何解释此数组元素的信息。
p_offset:此成员给出从文件头到该段第一个字节的偏移。
p_vaddr:此成员给出段的第一个字节将被放到内存中的虚拟地址。
p_filesz:此成员给出段在文件映像中所占的字节数。可以为0。
p_memsz:此成员给出段在内存映像中占用的字节数。可以为0。
p_align:可加载的进程段的p_vaddr和p_offset取值必须合适,相对于对页面大小的取模而言。此成员给出段在文件中和内存中如何对齐。数值0和1表示不需要对齐。否则p_align应该是个正整数,并且是2的幂次数,p_vaddr和p_offset对p_align取模后应该相等
Mach-o格式是OSX系统上的可执行文件格式,类似于Windows的PE与linux的ELF。每个mach-o文件头包含一个mach-o头,然后载入命令(LoadCommands),最后是数据块(Data)。下面来对整个mach-o的格式进行详细分析。
Mach-o文件的格式如下图所示:
由如下几部分组成:
Header:保存了mach-o的一些基本信息,包括了平台,文件类型,LoadCommands的个数等等。
LoadCommands:这一段紧跟Header。加载mach-o文件时会使用这里的数据来确定内存的分布。
Data:每一个segment的具体数据都保存在这里,这里包含了具体的代码,数据等等。
Headers的定义可以在开源的内核代码中找到。
*The32-bitmachheaderappearsattheverybeginningoftheobjectfilefor
*32-bitarchitectures.
*/
structmach_header{
uint32_tmagic;/*machmagicnumberidentifier*/
cpu_type_tcputype;/*cpuspecifier*/
cpu_subtype_tcpusubtype;/*machinespecifier*/
uint32_tfiletype;/*typeoffile*/
uint32_tncmds;/*numberofloadcommands*/
uint32_tsizeofcmds;/*thesizeofalltheloadcommands*/
uint32_tflags;/*flags*/
};
/*Constantforthemagicfieldofthemach_header(32-bitarchitectures)*/
#defineMH_MAGIC0xfeedface/*themachmagicnumber*/
#defineMH_CIGAM0xcefaedfe/*NXSwapInt(MH_MAGIC)*/
/*
*The64-bitmachheaderappearsattheverybeginningofobjectfilesfor
*64-bitarchitectures.
structmach_header_64{
uint32_treserved;/*reserved*/
/*Constantforthemagicfieldofthemach_header_64(64-bitarchitectures)*/
#defineMH_MAGIC_640xfeedfacf/*the64-bitmachmagicnumber*/
#defineMH_CIGAM_640xcffaedfe/*NXSwapInt(MH_MAGIC_64)*/根据mach_header和mach_header_64的定义,很明显可以看出,Headers的主要作用是帮助系统迅速的定位mach-o文件的运行环境,文件类型。
Magic:0xfeedface是32位,0xfeedfacf是64位。
Cputype,cpusubtype:确定CPU的平台与版本(ARM-V7)
Filetype:文件类型(执行文件,库文件,core,内核扩展…)
Ncmds,sizeofncmds:LoadCommands的个数和长度
Flags:dyld加载时需要的标志位
Reserved:只有64位的时候才存在的字段,暂时没有用
Load_Command的数据结构如下:
structload_command{
uint32_tcmd;/*typeofloadcommand*/
uint32_tcmdsize;/*totalsizeofcommandinbytes*/
LoadCommands直接就跟在Header后面,所有command占用内存的总和在Mach-OHeader里面已经给出了。在加载过Header之后就是通过解析LoadCommand来加载接下来的数据了。
加载数据时,主要加载的就是LC_SEGMET或者LC_SEGMENT_64。LC_SEGMET和LC_SEGMENT_64的数据结构如下所示:
structsegment_command{/*for32-bitarchitectures*/
uint32_tcmd;/*LC_SEGMENT*/
uint32_tcmdsize;/*includessizeofsectionstructs*/
charsegname[16];/*segmentname*/
uint32_tvmaddr;/*memoryaddressofthissegment*/
uint32_tvmsize;/*memorysizeofthissegment*/
uint32_tfileoff;/*fileoffsetofthissegment*/
uint32_tfilesize;/*amounttomapfromthefile*/
vm_prot_tmaxprot;/*maximumVMprotection*/
vm_prot_tinitprot;/*initialVMprotection*/
uint32_tnsects;/*numberofsectionsinsegment*/
structsegment_command_64{/*for64-bitarchitectures*/
uint32_tcmd;/*LC_SEGMENT_64*/
uint32_tcmdsize;/*includessizeofsection_64structs*/
uint64_tvmaddr;/*memoryaddressofthissegment*/
uint64_tvmsize;/*memorysizeofthissegment*/
uint64_tfileoff;/*fileoffsetofthissegment*/
uint64_tfilesize;/*amounttomapfromthefile*/
Section的数据结构如下:
structsection{/*for32-bitarchitectures*/
charsectname[16];/*nameofthissection*/
charsegname[16];/*segmentthissectiongoesin*/
uint32_taddr;/*memoryaddressofthissection*/
uint32_tsize;/*sizeinbytesofthissection*/
uint32_toffset;/*fileoffsetofthissection*/
uint32_talign;/*sectionalignment(powerof2)*/
uint32_treloff;/*fileoffsetofrelocationentries*/
uint32_tnreloc;/*numberofrelocationentries*/
uint32_tflags;/*flags(sectiontypeandattributes)*/
uint32_treserved1;/*reserved(foroffsetorindex)*/
uint32_treserved2;/*reserved(forcountorsizeof)*/
structsection_64{/*for64-bitarchitectures*/
uint64_taddr;/*memoryaddressofthissection*/
uint64_tsize;/*sizeinbytesofthissection*/
uint32_treserved3;/*reserved*/
除了同样有帮助内存映射的变量外,在了解mach-o格式的时候,只需要知道不同的Section有着不同的作用就可以了。
PE是PortableExecutableFormat(可移植的执行体)简写,它是目前Windows平台的主流可执行文件格式。
每个PE文件是以一个DOS程序开始的,有了它,一旦程序运行在DOS下执行,DOS就能识别出这是有效的执行体,然后运行紧随MZheader之后的DOSstub(DOS块)。DOSstub实际上是一个有效的EXE,平常把DOSMZ头与DOSstub合称为DOS文件头。
PE文件的第一个字节起始于一个传统的MS-DOS头部,被称作IMAGE_DOS_HEADER。其结构如下:
typedefstruct_IMAGE_DOS_HEADER{//DOS.EXEheader
WORDe_magic;//Magicnumber
WORDe_cblp;//Bytesonlastpageoffile
WORDe_cp;//Pagesinfile
WORDe_crlc;//Relocations
WORDe_cparhdr;//Sizeofheaderinparagraphs
WORDe_minalloc;//Minimumextraparagraphsneeded
WORDe_maxalloc;//Maximumextraparagraphsneeded
WORDe_ss;//Initial(relative)SSvalue
WORDe_sp;//InitialSPvalue
WORDe_csum;//Checksum
WORDe_ip;//InitialIPvalue
WORDe_cs;//Initial(relative)CSvalue
WORDe_lfarlc;//Fileaddressofrelocationtable
WORDe_ovno;//Overlaynumber
WORDe_res[4];//Reservedwords
WORDe_oemid;//OEMidentifier(fore_oeminfo)
WORDe_oeminfo;//OEMinformation;e_oemidspecific
WORDe_res2[10];//Reservedwords
LONGe_lfanew;//Fileaddressofnewexeheader
}IMAGE_DOS_HEADER,*PIMAGE_DOS_HEADER;
其中有两个值比较重要,分别是e_magic合e_lfanew。e_magic字段需要被设置为值5A4D,再ASCII表示法里,它的ASCII值为“MZ”。e_lfanew字段是真正的PE文件头的相对偏移,其指出真正PE头的文件偏移位置,它占用4字节,位于文件开始偏移3Ch字节中。
PNTHeader=ImageBase+dosHeader->e_lfanew
实际上又两个版本的IMAGE_NT_HEADER结构,一个是为32位的可执行文件准备的,另一个是64位版本,在后面的讨论中不做考虑,他们几乎没有区别。
IMAGE_NT_HEADER由三个字段组成:
IMAGE_NT_HEADERSSTRUCT
{
DWORDSignature;//PE文件头标志,为ASCII的“PE”,+0h
IMAGE_FILE_HEADERFileHeader;//+4h
IMAGE_OPTIONAL_HEADER32OptionalHeader;//+18h
}
Signature字段被设置为00004550h,ASCII码字符是“PE00”.“PE\0\0”是PE文件头的开始,DOS头部的e_lfanew字段正是执行“PE\0\0”。
typedefstruct_IMAGE_FILE_HEADER{
WORDMachine;//+04h
WORDNumberOfSections;//+06h文件的区块数目
DWORDTimeDateStamp;//+08h
DWORDPointerToSymbolTable;//+0Ch
DWORDNumberOfSymbols;//+10h
WORDSizeOfOptionalHeader;//+14hIMAGE_OPTIONAL_HEADER32结构大小
WORDCharacteristics;//+16h文件属性
}IMAGE_FILE_HEADER,*PIMAGE_FILE_HEADER;
IMAGE_FILE_HEADER(映像文件头)结构包含了PE文件的一些基本信息,最重要的一个域指出了IMAGE_OPTIONAL_HEADER的大小。
Machine:可执行文件的目标CPU类型。PE文件可以在多种机器上使用,不同平台指令机器码是不同的,下表所示是几种典型的机器类型标志。
NumberOfSections:区块数目,块表紧跟在IMAGE_NT_HEADERS后面
TimeDateStamp:表明文件是何时被创建的。
PointerToSymbolTable:COFF符号表的问啊金偏移位置
NumberOfSymbols:如果有COFF符号表,它代表其中的符号数目。
SizeOfOptionalHeader:紧跟着IMAGE_FILE_HEADER后面的数据的大小。在PE文件中,这个数据结构叫IMAGE_OPTIONAL_HEADER,其大小依赖于32位还是64位文件,对于32位文件,这个域通常是00E0h;对于64位文件,这个域是00F0h。这些值要求的最小值,较大的值也可能出现。
IMAGE_OPTIONAL_HEADER结构
可选映像头(IMAGE_OPTIONAL_HEADER)是一个可选的结构,但实际上IMAGE_FILE_HEADER结构不足以定义PE文件属性,因此可选映像头中定义了更多的数据,完全不必考虑两个结构区别在哪里,两者连起来就是一个完整的PE文件头结构。IMAGE_OPTIONAL_HEADER32结构如下:
typedefstruct_IMAGE_OPTIONAL_HEADER
//
//Standardfields.
+18hWORDMagic;//标志字,ROM映像(0107h),普通可执行文件(010Bh)
+1AhBYTEMajorLinkerVersion;//链接程序的主版本号
+1BhBYTEMinorLinkerVersion;//链接程序的次版本号
+1ChDWORDSizeOfCode;//所有含代码的节的总大小
+20hDWORDSizeOfInitializedData;//所有含已初始化数据的节的总大小
+24hDWORDSizeOfUninitializedData;//所有含未初始化数据的节的大小
+28hDWORDAddressOfEntryPoint;//程序执行入口RVA
+2ChDWORDBaseOfCode;//代码的区块的起始RVA
+30hDWORDBaseOfData;//数据的区块的起始RVA
//NTadditionalfields.以下是属于NT结构增加的领域。
+34hDWORDImageBase;//程序的首选装载地址
+38hDWORDSectionAlignment;//内存中的区块的对齐大小
+3ChDWORDFileAlignment;//文件中的区块的对齐大小
+40hWORDMajorOperatingSystemVersion;//要求操作系统最低版本号的主版本号
+42hWORDMinorOperatingSystemVersion;//要求操作系统最低版本号的副版本号
+44hWORDMajorImageVersion;//可运行于操作系统的主版本号
+46hWORDMinorImageVersion;//可运行于操作系统的次版本号
+48hWORDMajorSubsystemVersion;//要求最低子系统版本的主版本号
+4AhWORDMinorSubsystemVersion;//要求最低子系统版本的次版本号
+4ChDWORDWin32VersionValue;//莫须有字段,不被病毒利用的话一般为0
+50hDWORDSizeOfImage;//映像装入内存后的总尺寸
+54hDWORDSizeOfHeaders;//所有头+区块表的尺寸大小
+58hDWORDCheckSum;//映像的校检和
+5ChWORDSubsystem;//可执行文件期望的子系统
+5EhWORDDllCharacteristics;//DllMain()函数何时被调用,默认为0
+60hDWORDSizeOfStackReserve;//初始化时的栈大小
+64hDWORDSizeOfStackCommit;//初始化时实际提交的栈大小
+68hDWORDSizeOfHeapReserve;//初始化时保留的堆大小
+6ChDWORDSizeOfHeapCommit;//初始化时实际提交的堆大小
+70hDWORDLoaderFlags;//与调试有关,默认为0
+74hDWORDNumberOfRvaAndSizes;//下边数据目录的项数,这个字段自WindowsNT发布以来//一直是16
+78hIMAGE_DATA_DIRECTORYDataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
//数据目录表
}IMAGE_OPTIONAL_HEADER32,*PIMAGE_OPTIONAL_HEADER32;
下面讲解几个比较重要的字段:
AddressOfEntryPoint:程序执行入口RVA,对于DLL,这个入口点是在进程初始化和关闭时以及线程创建/毁灭时被调用。在大多数可执行文件中,这个地址并不直接指向Main,WinMain或DllMain,而是指向运行时库代码并由他来调用上述函数。在DLL中这个域能被设为0,前面提到的通知消息都不能收到。链接器/NOENTRY开关可以设置这个域为0.
ImageBase:文件在内存中首选装入地址。如果有可能(也就是说,目前如果没有其他占据这个块地址,它是正确对齐的并且是一个合法的地址等),加载器试图在这个地址装入PE文件,如果可执行文件是在这个地址转入的,那么加载器将跳过应用基址重定位的步骤。
SectionAlignment:当被装入内存时的区块对齐大小。每个块被装入的地址必定是本字段指定数值的整数倍。默认的对齐尺寸是目标CPU的页尺寸。
FileAlignment:磁盘上PE文件内的区块对齐大小。组成块的原始数据必须保证从本字段的倍数地址开始。对于x86可执行文件,这个值通常是200h或1000h,这是为了保证块总是从磁盘的扇区开始。这个值必须是2的幂,其最小值为200h,并且,如果SectionAlignment小于CPU的页尺寸,这个域必须与SectionAlignment匹配。
Subsystem:一个标明可执行文件所期望的子系统的枚举值,取值如下:
DataDirectory[16]:数据目录表,由数个相同IMAGE_DATA_DIRECTORY结构组成,指向输出表,输入表,资源块等数据。IMAGE_DATA_DIRECTORY结构的定义如下:
typedefstruct_IMAGE_DATA_DIRECTORY{
DWORDVirtualAddress;//数据块的起始RVA
DWORDSize;//数据块长度
}IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY;
数据目录列表如下:
紧跟着IMAGE_NT_HEADER后的是区块表,他是一个IMAGE_SECTION_HEADER结构数组。每个IMAGE_SECTION_HEADER结构包含了它所关联区块的信息,如位置,长度,属性,该数组的数目由IMAGE_NT_HEADERS.FileHeader.NumberOfSections指出。
IMAGE_SECTION_HEADER结构定义如下:
typedefstruct_IMAGE_SECTION_HEADER{
BYTEName[IMAGE_SIZEOF_SHORT_NAME];//8个字节的区块名
union{//区块尺寸
DWORDPhysicalAddress;
DWORDVirtualSize;
}Misc;
DWORDVirtualAddress;//区块的RVA地址
DWORDSizeOfRawData;//文件对齐后的尺寸
DWORDPointerToRawData;//文件偏移
DWORDPointerToRelocations;
DWORDPointerToLinenumbers;
WORDNumberOfRelocations;
WORDNumberOfLinenumbers;
DWORDCharacteristics;//区块的属性
}IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER;
VirtualSize:指出实际的,被使用的区块大小,是区块在没对齐处理前的实际大小。如果VirtualSize大于SizeOfRawData,那么SizeOfRawData是来自可执行文件初始化数据的大小,与VirtualSize相差的字节用零填充。
VirtualAddress:该块装载到内存中的RVA。这个地址是按照内存页对齐的,它的数值总是SectionAligment的整数倍。
SizeOfRawData:该块在磁盘文件中所占的大小。在可执行文件中,该字段包含经过FileAlignment调整后的块的长度。例如:指定FileAlignment的大小为200h,如果VirtualSize中的块的长度为19Ah个字节,这一块应保存的长度为200h个字节。
PointerToRawData:该块在磁盘文件中所占的偏移。程序进编译或汇编后生成原始数据,这个字段用于给出原始数据在文件中的偏移。
PE文件头的可选映像头中数据目录表的第二成员指向输入表。输入表以一个IMAGE_IMPORT_DESCRIPTOR(IID)数组开始。每个被PE文件隐式的连接进来的DLL都有一个IID。在这个数组中,没有字段指出该结构数组的项数,但他的最后一个单元是NULL,可以由此计算出该数组的项数。例如:某个PE文件从两个DLL文件中引入函数,就存在两个IID结构来描述这些DLL文件,并在两个IID结构的最后由一个内容全为0的IID结构作为结束。
typedefstruct_IMAGE_IMPORT_DESCRIPTOR{
union{//+00h
DWORDCharacteristics;
DWORDOriginalFirstThunk;//指向输入名称表INT
DWORDTimeDateStamp;//+04h
DWORDForwarderChain;//+08h
DWORDName;//+0ChDLL名称的指针
DWORDFirstThunk;//10h指向输入地址表IAT。
}IMAGE_IMPORT_DESCRIPTOR;
OriginalFirstThunk(Characteristics):包含指向输入名称表(INT)的RVA,INT是一个IMAGE_THUNK_DATA结构的数据,数组中的每个IMAGE_THUNK_DATA结构指向IMAGE_IMPORT_BY_NAME结构,数组最后以一个内容为0的IMAGE_THUNK_DATA的结构结束。
Name:DLL名字的指针。一个以00结尾的ASCII字符的RVA地址,该字符串包含输入的DLL名。
FirstThunk:包含指向输入地址表(IAT)的RVA,IAT是一个IMAGE_THUNK_DATA结构的数组。
OriginalFirstThunk和FirstThunk很相似。他们指向两个本质上相同的数组IMAGE_THUNK_DATA,名字叫做输入名称表(ImportNameTable,INT)和输入地址表(ImportAddressTable,IAT)。
两个数组都有IMAGE_THUNK_DATA结构类型的元素,他是一个指针大小的联合。每个IMAGE_THUNK_DATA元素对应一个从可执行文件输入的函数。两个数组的结束是通过一个值为0的IMAGE_THUNK_DATA元素来表示的。IMAGE_THUNK_DATA实际上是一个双字,该结构在不同时刻由不同的含义,定义如下:
typedefstruct_IMAGE_THUNK_DATA{
union{
PBYTEForwarderString;//指向一个转向者字符串的RVA
PDWORDFunction;//被输入的函数的内存地址
DWORDOrdinal;//被输入的API的序数值
PIMAGE_IMPORT_BY_NAMEAddressOfData;//指向IMAGE_IMPORT_BY_NAME
}u1;
}IMAGE_THUNK_DATA;
当IMAGE_THUNK_DATA值的最高位为1时,表示函数以序号的方式输入,这是低31位(或者一个64位可执行文件的低63位)被看作是一个函数序号。当双字的最高位为0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构。
typedefstruct_IMAGE_IMPORT_BY_NAME{
WORDHint;
BYTEName[1];
}IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;
Hint:指示本函数在其所驻留DLL的输出表中的序号。该域被PE装载器用来在DLL的输出表里快速查询函数。
Name:函数输入函数的函数名,函数名是一个ASCII码字符串,以NULL结尾。虽然这里将Name的大小定义为字节,起始它是可变尺寸域。
输入地址表(IAT)
由OriginalFirstThunk所指向的那个数组是单独的一项,而且不可改写,称为INT。FirstThunk所指向的数组是由PE装载器重写的。PE装载器搜索OriginalFirstThunk,如果找到,加载程序迭代搜索数组中的每个指针,找到每个IMAGE_IMPORT_BY_NAME结构所指向的输入函数的地址,然后加载器用函数真正入口地址来替代由FirstThunk指向的IMAGE_THUNK_DATA数组里的元素值。Jmpdwordptr[xxxxxxxx]中的[xxxxxxxx]是指FirstThunk数组中的一个入口,因此它称为输入地址表(IAT)。因此,当PE文件装在内存后准备执行时,所有函数入口地址被排列在一起,此时,输入表中其他部分就不重要了,程序依靠IAT提供的函数地址就可以正常运行。
有些情况下,一些函数仅有序号引出,也就是说,不能用函数名来调用他们,只能用他们的位置来调用。
另一种情况是程序OriginalFirstThunk的值为0。在初始化时,系统根据FirstThunk的值找到指向函数名的地址串,由地址串找到函数名,再根据函数名得到入口地址,然后用入口地址取代FirstThunk指向的地址串中的原值。
输出表中的主要成分是一个表格,内含函数名称,输出序数等。序数是指定DLL中某个函数的16位数字,在所指向的DLL里是独一无二的。
输出表是数据目录表的第一个成员,指向IMAGE_EXPORT_DIRECTORY(IED)结构,定义如下:
typedefstruct_IMAGE_EXPORT_DIRECTORY{
DWORDTimeDateStamp;
WORDMajorVersion;
WORDMinorVersion;
DWORDName;//模块的真实名称
DWORDBase;//基数,加上序数就是函数地址数组的索引数
DWORDNumberOfFunctions;//AddressOfFunctions中元素个数
DWORDNumberOfNames;//AddressOfNames中元素个数
DWORDAddressOfFunctions;//指向函数地址数组
DWORDAddressOfNames;//函数名字的指针地址
DWORDAddressOfNameOrdinals;//指向输出序列号数组
}IMAGE_EXPORT_DIRECTORY,*PIMAGE_EXPORT_DIRECTORY;
输出表的设计是为了方便PE装载器工作。首先,模块必须保存所有输出函数的地址,供PE装载器查询。模块将这些信息保存在AddressOfFunctions域所指向的数组中,而数组元素数目存放在NumberOfFunctions域中。如果有些函数是通过名字引出的,那么模块必定也在文件中保留了这些信息。这些名字的RVA存放在一个数组中,供PE装载器查询。该数组由AddressOfName指向,NumberOfNames包含名字数组。Pe装载器知道函数名,并想以此获取这些函数的地址。至今为止已有两个模块:名字数组和地址数组,PE参考指出使用到地址数组的索引为连接两者的纽带,因此PE装载器在名字数组中找到匹配名字的同时,它也获取指向地址表中对应元素的索引。这些索引保存在由AddressOfNameOrdinals域所指向的另一个数组中。由于该数组起到联系名字和地址的作用,所以起元素数目必定和名字数组相同。
在WindowsNT/XP/2003系统中,Windows内核加载器指的是NTLDR文件,而在Vista/Windows7中,指的是bootmgr文件。这里主要说ntldr文件。它位于系统分区的根目录下,如C:/ntldr,是一个隐藏的,只读的文件,去除“隐藏受保护的操作系统文件”就可以看见。Ntldr的主要作用是引导和加载操作系统。
Ntldr是可执行的16进制文件,有两部分组成:前半部分是startup.com,称为su模块(一部分是16位程序,在实模式下运行,另一部分是32位程序,在保护模式下运行)。后半部分是osloader.exe,称为loader模块(32位程序,主要在保护模式下运行)。
引导驱动器读取第一个扇区到0x7c00后,控制权交给MBR,MBR代码再搜索系统活动分区表,加载活动分区第一扇区到特定的内存地址(如物理内存地址0xd000).这个扇区称为操作系统分区引导记录(PartitionBootRecord,PBR)。MBR接着将控制权交给PBR。PBR代码解析FAT和NTFS格式找到引导内核的文件NTLDR,并将NTLDR文件加载到指定物理内存地址(0x20000),最后将控制权转移交给NTLDR。NTLDR的SU模块首先获得控制权,前半部分主要是在实模式下工作,检测物理内存,开始A20地址线,重定位GDT和IDT。开启保护模式后,SU解析osloader.exe文件,将其加载到物理地址0x00400000,最后控制权交给Loader。
MBR在windows启动之前已经被填充好,它并不属于任何一个操作系统。MBR包含代码和数据两部分,前半部分是启动引导代码,后半部分是一张磁盘分区表,记录每个分区在磁盘上的位置,大小及分区类型。MBR只有512B,具有一下特征:
MBR找到NTLDR文件在磁盘上的地址后,需要将其加载到物理地址0x20000,加载的方式是调用一个BIOS中断13h/AH=42h进行读取。BIOS中断13h/AH=42h一次最多只能读取127扇区。
SU模块是NTLDR文件的前半部分,负责实模式下的初始化工作,为NTLDR文件的另一部分Loader模块提供支持。SU模块的主要工作流程如下:
检测物理内存,实际上借助BIOS中断15H/E820完成。获得的内存块存储在物理地址0x70000,是以数组的形式存储。SU模块并不对物理内存进行管理,它只管收集物理内存,内存管理要到Loader之后。
计算机中的物理内存分成若干区域,有些区域是可用的,还有少量区域则被保留。SU将调用ConstructMemoryDescriptors函数来检测可用的内存区域,生成一个内存描述符链表结构,由MemoryDescriptorList链表指针所指。这条内存描述链将被Loader使用。内存描述链的每个表项都是MEMORY_DESCRIPTOR结构,用来描述一块内存区域的基址和长度。内存描述符结构如下:
typedefstruct_MEMORY_DESCRIPTOR{
ULONGBlockBase;//物理块基址
ULONGBlockSize;//物理块大小
}MEMORY_DESCRIPTOR,*PMEMORY_DESCRIPTOR;
这个结构提供两个字段,BlockBase指明物理内存的起始地址,BlockSize指明物理内存块的大小。还有一个结构是提供给BIOS中断15H/E820使用的结构,用来检测和返回物理内存块的信息,它描述一块物理内存的具体信息:
E820Framestruc
ErrorFlagdd//错误标志
Keydd//是否还有物理内存块存在
DescSizedd//物理内存块描述信息的大小,即Descriptor结构大小
BaseAddrLowdd//物理内存块起始地址低32位
BaseAddrHighdd//物理内存块起始地址高32位
SizeLowdd//物理内存块大小的低32位
SizeHighdd//物理内存块大小的高32位
MemoryTypedd//物理内存块的类型,设为1时表示可用,为0表示不可用
E820Frameends
ConstructMemoryDescriptors
;ConstructMemoryDescriptors的c语言版本
BOOLEANConstructMemoryDescriptors(VOID)
ULONGStartAddr,EndAddr;
E820FRAMEE820Frame;
MemoryDescriptorList->BlockSize=0;//初始化首个链表为0
MemoryDescriptorList->BlockBase=0;//初始化首个链表为0
//循环检测物理地址
do{
E820Frame.Size=sizeof(E820Frame.Descriptor);
Int15E820(&E820Frame);
if(E820Frame.ErrorFlag||E820Frame.Size(sizeof(E820Frame.Descriptor))//这里作者在书上的括号写错了
break;
//获取开始地址和结束地址
StartAddr=E820Frame.Descriptor.BaseAddrLow;
//高于4G的内存并不使用
if(E820Frame.Descriptor.BaseAddrHigh==0)
if(EndAddr //EndAddr字长是4B及32位最多表示4G //如果EndAddr //终于晓得为啥子32位只支持4G寻址了 EndAddr=0xFFFFFFF; //仅需要内存类型为BiosMemoryUsable(1)的内存 if(E820Frame.Descriptor.MemoryType==1) //插入内存描述符链表 }while(E820Frame.Key);//如果key不等于0说明还有内存块继续循环 returnTRUE; 内存描述链表指针MemoryDescriptorList指向的第一个表项(MEMORY_DESCRIPTOR结构)的BlockBase和BlockSize均初始化为0,指示第一个内存块为空,也就表示当前没有检测到内存块。 下面进入循环检测物理内存阶段。调用Int15E820函数进行物理内存块的枚举(其内部使用BIOS中断INT15H/E820)。循环条件是E820Frame.key,当该值不为0时,表示还有物理内存块存在,需要继续进行检测,这个Key值是调用INT15H后由ebx寄存器返回的结果值。 Key值为0时,表示已无物理内存块存在,可以停止检测。调用Int15E820函数返回一块物理内存的具体信息,若该物理内存信息无错误,计算该块物理内存的起始地址和结束地址,因为这里最多只支持4GB内存,所以内存地址的高字节只能为0.若参数值MemroyType为BiosMemoryusable(1),表示物理内存可用,那么调用InsertDescriptor函数就可以将物理内存块插入到物理内存描述链表中。 开启A20地址线,可以寻址大于1MB的物理内存,如果不开启A20,后面无法加载osloader.exe到物理内存空间0x400000,并且地址线的第20位只能为0,那么能被访问的内存只能是奇数M段,即1M,3M,5M……。这样可以访问的内存不是连续的。只有开启A20地址线,才能访问到连续的内存。 GDT是一个由段描述符及其他描述符构成的表,而IDT是一个中断和异常描述符的表,这两个表的存放位置是紧连着的,在SU数据段中已经设置好。GDT属于保护模式范畴,IDT属于中断和异常。 Relocatex86Structures函数用来把在SU数据段的GDT和IDT移到物理地址0x1e000. Relocatex86Structures: pushbp movbp,sp pushsi;保存寄存器 pushdi pushes ;把指针移动到GDT开始,并计算需要移动的大小 movsi,GDTBase movax,SYSTEM_PAGE_BASE>>4 moves,ax xordi,di movcx,IDTEnd-GDTBase ;用一个循环移动数据到0x1e000,循环次数为IDTEnd-GDTBase .loop moval,[si] esmov[di],al incsi incdi deccx jnz.loop;循环复制 ;返回寄存器 popes popdi popsi movsp,bp popbp retn 保护模式是从80286系列开始出现的一种新的运行模式。在实模式中,采用的是16位地址模式,最多只能寻址10FFEFh的地址空间,同时,它的分段是针对所有的物理空间进行的,系统程序和用户程序都能访问素有的地址,如果某个存放了系统程序的内存空间呗用户程序修改了,将会造成无法预料的错误。引入保护模式就是为了解决上述两个缺陷。一方面,在保护模式中,采用的是32位地址模式,全部32条地址线都能使用,因此最多能寻址232B=4GB内存空间。另一方面,保护模式中还引入了段保护机制,使得物理内存不能再被直接访问,程序使用的都是虚拟地址,需要通过操作系统页表将这些虚拟地址转换为物理地址,才能被访问。 GDT表已经在SU数据段中建立,但是需要让处理器知道在哪里。GDTR寄存器就是用来专门存放GDT表的入口,使用lgdt指令加载进GDTR寄存器。CPU以后就根据GDTR寄存器来访问GDT表。GDTR是48位寄存器,前16位指明GDT表的大小,后32位指明GDT的地址。 执行下面代码就能进入保护模式: EnableProtectPaging: ;BIOS留下的标志寄存器flags,在进入保护模式前,我们把它设为0,同时ES和GS也设为0 pushdword0 popfd movbx,sp movdx,[bx+2] xorax,ax movgs,ax ;FS指向PCR pushKePcrSelector popfs ;加载GDT和IDT,关中断,因为在进入保护模式前我们暂时不能处理中断 cli ;利用指令lgdt,将GDT表加载进GDTR寄存器 lgdt[GDTregister] ;利用指令lidt,将IDT表加载进IDT寄存器 lidt[IDTregister] moveax,cr0 ;若dx=0,表示只开启保护模式,不开启分页模式 ordx,dx jz.set_protect ;设置保护模式和分页机制,将CR0寄存器的bit0位置1开启保护模式,bit31位置1开启分页机制 oreax,PROTECT_MODE|ENABLE_PAGING movcr0,eax jmp.set_end .set_protect: oreax,PROTECT_MODE;开启保护模式 .set_end: ;将SU代码选择子58h加载进CS段寄存器 pushSuCodeSelector push.restart retf .restart: ;将SU数据选择子60h加载进DS,SS段寄存器 pushax,SuCodeSelector movds,ax movss,ax xorbx,bx ;加载局部描述符,只能在保护模式下使用这条指令 lldtbx jnz.return movbx,KeTssSelector ;ltr是一条特权指令,一般在操作系统初始化过程中执行,用来初始化任务寄存器,之后任务寄存器的内容通过每次任务切换来改变 ltrbx .return: 进入保护模式后,我们访问的地址一般叫线性地址,段寄存器变成了段描述符的选择子,不再参与地址转译。 oloader.exe是标准的PE文件,在运行前,必须按照标准格式加载进内存。Osloader.exe文件在编译链接时嵌套在startup.com末尾,所以解析时只需定位到startup.com的结尾,即osloader.exe文件的开始。首先需要解析osloader.exe文件头,然后按内存对齐值复制个个段,复制完成后返回osloader.exe映像代码入口。 将Loader加载进来之后,SU把系统控制权移交给Loader。所谓移交控制权,实际上就是跳转到osloader.exe的入口点,即交由Loader的NtProcessstartup函数去执行。 在注册表路径HKEY_LOCAL_MACHINE\Software\Microsoft\WindowsNT\CurrentVersioin\Windows\下,AppInit_Dlls键的值可能会包含一个DLL的文件名活一组DLL的文件名(通过空格或逗号分隔)。将自己写的DLL文件的路径值写入AppInit_Dlls中,再创建一个名为LoadAppInit_Dlls,类型为DWORD的注册表项,并将其值设为1.当User32.dll被映射到一个新的进程时,会受到DLL_PROCESS_ATTACH通知。当User32.dll对它进行处理的时候,会获取上述注册表键的值,并调用LoadLibrary来载入这个字符串中指定的每个DLL。 HHOOKWINAPISetWindowsHookEx( InintidHook, InHOOKPROClpfn, InHINSTANCEhMod, InDWORDdwThreadId ); idHook表示要安装的挂钩的类型,lpfn是一个函数的地址,在窗口即将处理一条消息的时候,系统应该调用这个函数,hMod标识一个DLL,这个DLL包含了lpfn函数,dwThreadId表示要给哪个线程安装挂钩。如果这个参数传0,表示要给系统中所有GUI线程安装挂钩。 例如进程A使用SetWindowsHookEx(WH_GETMESSAGE,GetMsgProc,hInstDll,0)函数安装挂钩后: 简单来说就是DLL文件替换。通俗说法如下: A.exe想要调用B.dll,并且使用里面的FunC函数,这样的话我们把B.Dll改名BB.Dll(有的不用,直接根据路径劫持),然后我们自己写一个B.Dll(假的)里面有一个FunC这个函数,然后我们在这个函数里加载BB.Dll(原B.Dll),并且调用里面的FunC函数,之后我们在干一些自己的事,对于A.exe来说通常没什么异常感觉,这样我们的目的就达到了,记住此时的你,也就是B.dll(假的)的权限和内存归属都是A的,也即是你和A是一家的了,类似于代码注入之后直接修改内存一样。 WIndows上的Dll加载有一个默认的规则,就是先在主程序目录下查找B.dll,如果没有就在系统路径下找,如果还没有,就去环境变量路径里找,就因为这个我们可以轻松的在相应的位置给做劫持,然后问题就是如果实现劫持,就要知道B.Dll里面的所有函数名字以及函数参数,这个地方比较不好搞,此地不考虑。 APC注入的原理是利用当线程被唤醒时APC中的注册函数会被执行的机制,并以此去执行我们的DLL加载代码,进而完成DLL注入的目的,其具体流程如下: 1)当EXE里某个线程执行到SleepEx()或者WaitForSingleObjectEx()时,系统就会产生一个软中断。 2)当线程再次被唤醒时,此线程会首先执行APC队列中的被注册的函数。 3)利用QueueUserAPC()这个API可以在软中断时向线程的APC队列插入一个函数指针,如果我们插入的是Loadlibrary()执行函数的话,就能达到注入DLL的目的。 InlineHook InlineHook的工作方式如下所示: IDT=InterruptDescriptorTable中断描述表。IDT是一个有256个入口的线形表,每个IDT的入口是8字节的描述符,所以整个IDT表的大小为256*8=2048bytes,每个中断向量关联了一个中断处理过程。所谓的中断向量就是把每个中断或者异常用一个0-255的数字识别。Intel称这个数字为向量(vector)。 对于中断描述表,操作系统使用IDTR寄存器来记录idt位置和大小。IDTR寄存器是48位寄存器,用于保存idt信息。其中低16位代表IDT的大小,大小为7FFH,高32位代表IDT的基地址。我们可以利用指令sidt读出IDTR寄存器中的信息,从而找到IDT在内存中的位置。 IDT有三种不同的描述符或者说是入口,分别是: 1。任务门描述符 2。中断门描述符 3。陷阱门描述符 也就是说,在保护模式下,80386只有通过中断门、陷阱门或任务门才能转移到对应的中断或异常处理程序。 异常被分为不同的两类:处理器产生的异常(Faults,Traps,Aborts)和编程安排的异常(用汇编指令intorint3触发)。后一种就是我们经常说到的软中断。 下图是三种描述符的图示: 其中:后两种描述符,非常的相似,只有1个bit位的差别。在处理上,采用相同的处理方式。如图所示,在这后两类的描述符里面记录了一个中断服务程序(ISR)的地址offset.在IDT的256个向量中,除3个任务门入口外,其他都是这两种门的入口。并且所有的trap/interruptgate的入口,他们的segmentselector都是一样的,即:08h.我们察看GDT中Selector=8的描述符,描述的是00000000h~0ffffffffh的4G地址空间。因此,在描述符中的中断服务程序(ISR)的地址offset就代表了函数的入口地址。windows在处理的时候,按照下图方式,来处理这两类的描述符入口。即:根据segmentselector在GDT中找出段基地址等信息,然后跟描述符中的中断服务程序(ISR)的地址offset相加得到代码段中的函数入口地址。然后调用该函数。 这个过程,我写得比较直接,在操作系统执行这过程时,还有很多的出错判断和异常保护,这里我们略过。 下图是任务门描述符的情况 首先,根据IDT中任务门描述符的TSSSegmentSelector,我们在GDT中找出这个选择子。在这个选择子中,对应一个tss描述符,即:任务状态段描述符。这个描述符大小为068h,即104字节。下面是这个任务状态段描述符的格式。 在这个描述符中记录了任务状态段的位置和大小。 根据任务状态段描述符中的baseAddress,找到TSS的内存位置。然后就可以进行任务切换。所谓任务切换是指,挂起当前正在执行的任务,恢复或启动另一任务的执行。在任务切换过程中,首先,处理器中各寄存器的当前值被自动保存到TR所指定的TSS中;然后,下一任务的TSS的选择子被装入TR;最后,从TR所指定的TSS中取出各寄存器的值送到处理器的各寄存器中。由此可见,通过在TSS中保存任务现场各寄存器状态的完整映象,实现任务的切换。TR寄存器可见部分保存了tssselector,不可见部分,保存了任务状态段的位置和大小. 任务状态段TSS的基本格式如下图所示。 从图中可见,TSS的基本格式由104字节组成。这104字节的基本格式是不可改变的,但在此之外系统软件还可定义若干附加信息。 知道了IDT的基本知识后,再来理解IDTHook的原理就比较简单了。就是将将系统原来的中断处理函数地址替换为我们自己的函数的地址。这样系统在处理相应的中断时,就会调用我们的处理函数。 比如:出现页错误,调用IDT中的0x0E。或用户进程请求系统服务(SSDT)时,调用IDT中的0x2E。而系统服务的调用是经常的,这个中断就能触发。所以方法就是先在系统中找到IDT,然后确定0x2E在IDT中的地址,最后用我们的函数地址去取代它,这样以来,用户的进程(可以特定设置)调用系统服务,我们的hook函数即被激发。 使用sidt指令可以在内存中找到IDT的地址,返回一个IDTINFO结构的地址。这个结构中国含有IDT的高半地址和低半地址。IDT有最多256个入口。将IDT看作是一排有256间房组成的线性结构,那么只要知道了整个入口结构,就相当于知道了每间房的长度,先获取所有的入口idt_entrys,那么第0x2E个房间的地址就可以确定了。即idt_entrys[0x2E]。找到目标入口后,将我们的函数与其原来的函数进行替换即可。 SSDT既SystemServiceDispathTable。在WindowsNT下,NT的executive(NTOSKRNL.EXE的一部分)提供了核心系统服务。由于子系统不同,API函数的函数名也不同。例如,要用Win32API打开一个文件,应用程序会调用CreateFile(),而要用POSIXAPI,则应用程序调用open()函数。这两种应用程序最终都会调用NTexecutive中的NtCreateFile()系统服务。 用户模式(Usermode)的所有调用,如Kernel32,User32.dll,Advapi32.dll等提供的API,最终都封装在Ntdll.dll中,然后通过Int2E或SYSENTER进入到内核模式,通过服务ID,在SystemServiceDispatcherTable中分派系统函数。例如下图: SSDT就是一个表,这个表中有内核调用的函数地址。从上图可见,当用户层调用FindNextFile函数时,最终会调用内核层的NtQueryDirectoryFile函数,而这个函数的地址就在SSDT表中,如果我们事先把这个地址改成我们特定函数的地址,那么就实现了SSDTHook。 下面来介绍以下SSDT的结构: KeServiceDescriptorTable是由内核(ntoskrnl.exe)导出的一个表,这个表是访问SSDT的关键,结构形式如下: typedefstructServiceDescriptorTable{ PVOIDServiceTableBase; PVOIDServiceCounterTable(0); unsignedintNumberOfServices; PVOIDParamTableBase; ServiceTableBase:SystemServiceDispatchTable的基地址。 NumberOfServices:由ServiceTableBase描述的服务的数目。 ServiceCounterTable:此域用于操作系统的checkedbuilds,包含着SSDT中每个服务被调用次数的计数器。这个计数器由INT2Eh处理程序(KiSystemService)更新。 ParamTableBase:包含每个系统服务参数字节数表的基地址。 SystemServiceDispathTable(SSDT):系统服务分发表,给出了服务函数的地址,每个地址4子节长。 SystemServiceParameterTable(SSPT):系统服务参数表,定义了对应函数的参数字节,每个函数对应一个字节。如在0x804AB3BF处的函数需0x18字节的参数。 要对SSDT进行Hook,首先需要改变SSDT的内存保护,因为系统对SSDT都是只读的,不能写。如果视图去写,就会造成蓝屏。一般可以修改内存的方法有通过cr0寄存器和MemoryDescriptorList(MDL)。 通过cr0寄存器: Windows对内存的分配,是采用的分页管理,其中有个cr0寄存器,其中第一位叫做保护属性位,控制着页的读或写属性。如果为1,则可以读/写执行;如果为0,则只可以读执行。所以我们要将这一位设为1. 通过MDL 将原来的SSDT的区域映射到我们自己的MDL区域中,并把这个区域设置成可写就行了。 接下来获得SSDT中函数的地址。使用四个有用的宏。 SYSTEMSERVICEmacro:可以获得由ntoskrnl.exe导出函数,以Zw开头函数的地址,这个函数的返回值就是Nt函数,Nt*函数的地址就在SSDT中。 SYSCALL_INDEXmacro:获得Zw函数的地址并返回与之通信的函数在SSDT中的索引。这两个宏之所以能工作,是因为所有的Zw函数都开始于opcode:MOVeax,ULONG,这 里的ULONG就是系统调用函数在SSDT中的索引。 HOOK_SYSCALL和UNHOOK_SYSCALLmacros:获得Zw*函数的地址,取得他的索引, 自动的交换SSDT中索引所对应的函数地址和我们hook函数的地址。 右侧的服务分布就通过KeServiceDescriptorTableShadow。 SSSDTHook和SSDTHook的方式差不多,在此不再进行介绍。 IAT即ImportAddressTable是PE(可以理解为EXE)的输入地址表,我们知道一个程序运行时可以要调用多个模块,或者说要调用许多API函数,但这些函数不一定都在EXE本身中,例如你调用Messagebox来显示一个对话框时,你只需要调用它,你并没有编写Messagebox的函数的实现过程,Messagebox的函数的实现过程实际上是在user32.dll这个库文件中,当这个程序运行时会在user32.dll中找到Messagebox并调用它。 下图是导入表中的部分结构图: IMAGE_THUNK_DATA指向IMAGE_IMPORT_BY_NAME结构的RVA,OriginalFirstThunk和FirstThunk所指向的这两个数组大小取决于PE文件从DLL中引入函数的数目。当PE文件被装载到内存时,PE装载器将查找IMAGE_THUNK_DATA和IMAGE_IMPORT_BY_NAME这些结构数组,以此决定引入函数的地址。然后用引入函数真实地址来替代由FirstThunk指向的IMAGE_THUNK_DATA数组里的元素值。因此当PE文件准备执行时,上图已转换下图所示: 所以IATHook的原理就是把后面的目标函数的地址改成我们自己写的函数的地址。这样,当在此调用目标函数的时候,就会调用我们的函数的地址。 x86系列处理器从其第一代英特尔8086开始就提供了一条专门用来支持调试的指令,即INT3。简单的说,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析。当我们调试程序时,可以在可能有问题的地方插入一条INT3指令,使CPU执行到这一点时停下来。这便是软件调试中经常用到的断点功能,因此INT3指令又称为断点指令。 当我们在调试器中对代码的某一行设置断点时,调试器会把这里的本来指令的第一个字节保存起来,然后写入一条INT3指令。因为INT3指令的机器码是0xCC,仅有一个字节,所以设置和取消断点时也只需要保存和恢复一个字节。 当CPU执行到INT3指令时,由于INT3指令的设计目的就是中断到调试器,因此,CPU执行执行这条指令的过程也就是产生断点异常并转去执行异常处理的过程。在跳转到处理历程之前,CPU会保存当前的执行上下文,包括段寄存器,程序指针寄存器等内容。 在保护模式下,在保存当前执行上下文之后,cpu会从IDTR寄存器中获得IDT的地址,在IDT表中查询异常处理函数。 在调试器下,我们看不到动态替换到程序的INT3指令。大多数调试器的做法是在调试目标被中断到调试器之前,会先将所有断点位置被替换为INT3的指令恢复成原来的指令,然后再把控制权交给用户。 当用户结束分析希望恢复被调试程序时,调试器通过调试API通知调试子系统,这会导致系统内核的异常分发函数返回到异常处理例程,然后异常处理例程通过IRET/IRETD指令触发一个异常返回动作,使CPU恢复执行上下文,从发生异常的位置继续执行。注意,这时的程序指针是指向断点所在的那条指令的,此时刚才的断点指令已经被替换成本来的指令,于是程序会从断点位置的原来指令继续执行。 软件断点虽然使用方便,但是也有局限性: IA-32处理器定义了8个调试寄存器,分别称为DR0-DR7。在32位模式下,他们都是32位的;在64位模式下,都是64位的。下面以32位的情况来介绍。 DR4和DR5是保留的,当调试扩展功能被启用(CR4寄存器的DE位设为1)时,任何对DR4和DR5的引用都会导致一个非法指令异常,当此功能被禁止时,DR4和DR5分别是DR6和DR7的别名寄存器,即等价于访问后者。其他的6个寄存器: 通过以上寄存器可以最多设置4个断点,基本分工是DR0-DR3用来指定断点的内存(线性地址)或I/O地址。DR7用来进一步定义断点的中断条件。DR6的作用是当调试事件发生时,向调试器报告事件的详细信息,以供调试器判断发生的是何种事件。 调试地址寄存器(DR0-DR3)用来指定断点的地址。对于设置在内存中的断点,这个地址应该是断点的线性地址而不是物理地址,因为CPU是在线性地址被翻译为物理地址之前来做断点匹配工作的。这意味着,在保护模式下,我们不能使用调试寄存器来针对一个物理内存地址设置断点。 调试控制寄存器(DR7)中,有24位被划分成四组分别与四个调试地址寄存器相对应。 R/W0-R/W3:读写域,分别与DR0-DR3四个调试地址寄存器相对应,用来指定被监控的访问类型,含义如下: LEN0-LEN3:长度域。分别与DR0-DR3四个调试地址寄存器相对应,用来指定要监控的区域长度,含义如下: L0-L3:局部断点启用:分别与DR0-DR3四个调试地址寄存器相对应,用来启用或禁止对应断点的局部匹配,如果该值设为1,当CPU在当前任务中检测到满足所定义的断点条件时便中断,并且自动清除此位,如果该位设为0,便禁止此断点。 G0-G3:全部断点启用。分别与DR0-DR3四个调试地址寄存器相对应,用来全局启用和禁止对应的断点。如果该位设为1,当CPU在任何任务中检测到满足所定义的断电条件时都会中断;如果该位设为0,便禁止此断点。与L0-L3不同,断点条件发生时,CPU不会自动清除此位。 LE和GE:这个在P6以下系列CPU上不被支持,在升级版的系列里面:如果被置位,那么cpu将会追踪精确的数据断点。LE是局部的,GE是全局的。 GD:启用或禁止调试寄存器的保护。当设为1时,如果CPU检测到将修改调试寄存器(DR0-DR7)的指令时,CPU会在执行这条指令前产生一个调试异常。 我们可以通过设置读写域来指定断点的访问类型。读写域占两个二进制位,可以指定4中访问方式。这里介绍三种典型的方式: 读/写内存中的数据时中断:这种断点又被称为数据访问断点。利用数据访问断点,可以监控全局变量或局部变量的读写操作。 执行内存中的代码时中断:这种断点又被称为代码访问断点或指令断点。代码访问断点在实现的功能上看与软件断点类似,都是当CPU执行指定地址开始的指令时中断。但是通过寄存器实现的代码访问断点与软件断点相比有个优点,就是不需要像软件断点那样像目标代码中插入断点指令。例如:当我们调试位于ROM上的代码(比如BIOS中的POST程序)时,根本没有办法向那里插入软件断点(INT3)指令,因为目标内存是只读的。另外,软件断点的另一个局限性是,只有当目标代码被加载进内存后才可以向该区域设置软件断点。而调试寄存器断点没有这些限制,因为只要把需要终端的内存地址放入调试地址寄存器(DR0-DR3),并设置好调试控制寄存器(DR7)的相应位就可以了。 读写I/O(输入输出)端口时中断:这种断点又被称为I/O访问断点。I/O访问断点对于调试使用输入输出端口的设备驱动程序非常有用。也可以利用I/O访问断点来监视I/O空间的非法读写操作,提高系统的安全性。因为某些恶意程序在实现破坏动作时,需要对特定的I/O端口进行读写操作。 改变内存分页的属性,如内存访问断点设为不可访问属性。由于分页粒度的限制,无法保证精度,最小改变一页的属性,不过内存断点不改变指令,不会被自校验检测到,并且没有个数限制,同时可以对一整段内存下断。归属于硬件断点。 本文介绍的原理以大家所熟知的OllyDbg为例进行讲解。 Ollydbg的断点功能是基于异常处理来实现的,通过捕获程序执行过程中的异常信息来中断程序的执行流程。Ollydbg常用的断点类型有三种:INT3断点,内存断点,硬件断点。每种断点都是一种制造异常的方法,首先使程序在运行过程中产生错误,然后由Ollydbg的异常处理来接管,从而实现断点的功能。 调试程序的第一步就是使用OllyDbg来加载程序,加载的过程是通过创建新进程来完成的。OllyDbg通过CreateProcess以调试的方式开启新进程。在创建调成程序前,OllyDbg需要进行一些必要的检查工作。 异常就是程序运行过程中产生的错误。OllyDbg利用异常机制捕获调试程序在运行过程中产生的异常,对异常进行排查,从而实现断点功能,使程序暂停运行。OllyDbg将异常处理过程放置在一个大消息循环中,捕获异常的流程如下: 当进入最后一步时,程序已经被成功断下,调试程序出于挂起状态,等待调试者的处理。异常处理首先检查调试事件类型,如果调试信息为异常,则进入异常处理部分,判断异常类型。先判断异常是否为INT3断点所产生的,如果是,则通过跳转指令执行对应的代码。下面介绍INT3断点的捕获过程:OllyDbg将调试程序停留在正确的INT3断点处,在显示反汇编代码的过程中,没有直接显示断点处机器码0xCC或0xCD,而是通过查找断点信息表中所对应的原机器码的信息来进行显示,以防止因修改指令造成的指令混乱。 在调试人员发出在此运行的指令后,OllyDbg将会先修复INT3断点处的内存数据,然后再次运行修复后的指令代码。INT3断点处的指令被执行后,此处将会被再次设置为INT3断点。 如果检测INT3断点失败,则会开始内存断点的异常检查。内存断点的设置过程是通过修改内存属性来达到触发异常的目的的。因此,内存断点的触发便是内存访问类错误。其流程如下: 硬件断点的捕获过程是由调试寄存器来完成的,因此OllyDbg没有捕获处理过程。 INT3断点是最常用的断点,其工作流程时通过修改机器码为0xCC来制造异常。当程序执行0xCC代码时会触发INT3异常,OllyDbg将捕获此异常并等待用户的处理。跳过INT3断点则是将0xCC处的代码恢复,在此运行,以保证程序的正常运行。 OllyDbg实现INT3断点的主要流程如下: 内存断点用来监控内存,它可以对内存数据的访问和写入进行监控。内存断点的设置主要依靠两个API来完成:VirtualQuery和VirtualProtectEx。通过VirtualQuery来获取原内存页的属性,以便于还原;通过VirtualProtectEx修改内存页属性,以制造内存访问异常。被调试的目标进程发生异常后,首先处理这个异常的是调试器。因此调试器可以成功捕获这个异常。内存断点的处理过程是由异常处理部分来完成。 在寄存器中,有一些寄存器专门用于调试,称为调试寄存器,调试寄存器一共有8个:Dr0-Dr7;对于Dr0-Dr3四个寄存器,作用是存放中断的地址,Dr4和Dr5一般不使用,保留,Dr6和Dr7这两个寄存器的作用是用来记录Dr0-Dr3中下断的地址的属性,比如:对这个401000是硬件读还是写,或者是执行;是对字节还是对字,或者是双字。 关于硬件断点的详细信息,请参阅断点部分的硬件断点知识。 SEH即结构化异常处理(StructuredExceptionHandling),当程序出现错误时,系统把当前的一些信息压入堆栈,然后转入我们设置好的异常处理程序中执行,在异常处理程序中我们可以终止程序或者修复异常后继续执行。 异常处理处理分两种,顶层异常处理和线程异常处理,下面介绍的是线程异常处理。每个线程的FS:[0]处都是一个指向包含异常处理程序的结构的指针,这个结构又可以指向下一个结构,从而形成一个异常处理程序链。当发生异常时,系统就沿着这条链执行下去,直到异常被处理为止。 下面以最常见的OllyDbg调试器为例讲解调试器单步执行时的工作方式。 当在调试器中选择“步过”某条指令时,程序自动在下一条语句停下来,这其实也属于一种中断,而且可以说是最常用的一种形式了,当我们需要对某段语句详细分析,想找出程序的执行流程和注册算法时必须要进行这一步。是80386以上的INTELCPU中EFLAGS寄存器,其中的TF标志位表示单步中断。当TF为1时,CPU执行完一条指令后会产生单步异常,进入异常处理程序后TF自动置0。调试器通过处理这个单步异常实现对程序的中断控制。持续地把TF置1,程序就可以每执行一句中断一次,从而实现调试器的单步跟踪功能。 单步执行中包含StepIn和StepOver两种: StepIn: StepIn即逐条语句执行,遇到函数调用时进入函数内部,其实现方式如下: StepOver: StepOver即逐条语句执行,遇到函数调用时不进入函数内部,其实现方式如下: