引言
此前我们学习了 bp 寄存器,我们知道 bp 的作用是为访问栈空间数据提供方便,其默认绑定的段寄存器就是 SS 段寄存器。在此前的博文中博主提及到,bp 的作用其实不止方便访问栈空间数据这一条,对于栈如此重要的空间,特意安排了一个 bp 过来,其深意远不止这些。
注意,在本系列的汇编学习教程中,对于 bp 的掌握要求就是做到使用 bp 来灵活寻址栈空间即可,之所以本篇博文会对 bp 再进入一探究竟,是因为 bp 在逆向分析的工作中重要性不言而喻。那么 bp 究竟是哪里重要了?它还有什么不为人知的一面呢?不再多说,就让我们赶快开始本篇的学习吧~
从强指定开始
我们的探究之旅将从源程序中的强指定开始。
我们知道强指定的存在可以让 bx、bp 不止局限于他们俩默认绑定的段寄存器,还可以与其他的段寄存器绑定。比如在源程序中对 bx 使用强指定:源程序如下:
assume cs:code
code segment
start:
mov ax,cs:[bx]
mov ax,ss:[bx]
mov ax,ds:[bx]
mov ax,es:[bx]
mov ax,4c00H
int 21H
code ends
end start
我们将其使用 MASM 工具进行编译,LINK 工具链接后,使用 Debug 工具打开,U 命令查看内存中的汇编指令:
我们可以看到,对 bx 使用强指定段寄存器,编译后的汇编指令除了 ds:[bx] 之外,其余的均在MOV 指令之上出现具体的段寄存器标识。
bp的强指定
我们知道,bp 默认绑定的段寄存器是 SS 段寄存器,同样,做为 bx 的兄弟,bp 也可以通过强指定的形式与其他段寄存器榜上关系。
使用 Notepad++ 编写汇编,使用 MASM 工具编译,看是否编译通过,代码如下:
assume cs:code
code segment
start:
mov ax,cs:[bp]
mov ax,ss:[bp]
mov ax,ds:[bp]
mov ax,es:[bp]
mov ax,4c00H
int 21H
code ends
end start
编译结果如下:
可以看到正常通过编译,那么接下来我们链接生成 exe 文件,使用 Debug 工具查看编译生成的汇编指令:
观看汇编指令,我们对比 bx,发现了不一致的地方:MASM 工具编译后生成的汇编语句,偏移地址变成了 BP+0,也就是说默认采用了 [bp+idata] 的寻址方式。而 bx 寄存器强指定后生成的汇编语句,还是保持了原本的寻址方式 [bx]。
思考
为什么 bp 不采取 [bp],而是 [bp+idata] 呢?
我们要知道,编译器之所以会对 bx、bp 的强指定采取不同的编译形式,一定是向我们透露一些较为关键的信息,而这个信息也就是我们今天所要探究的主题。
要想弄明白其中的区别对待,我们首先先来回顾一下此前学习的 bp 寄存器功能:做为 SS 段寄存器的左膀右臂之一,为访问栈段空间提供方便。使用 bp 寻址栈空间时,需要先 mov bp,sp,将当前栈顶赋值给 bp,然后以当前栈顶为起始地址,通过 [bp+idata] 的寻址方式来访问栈空间内数据。
现在我们对上面的描述进行举一反三:既然是通过 [bp+idata] 的寻址方式进行寻址,idata 作为偏移值,其 bp 值做为起始偏移地址,那么可不可以这样理解:[bp] 对于CPU来说实际上是并无法完成寻址的。
如果你想到了这一层,说明本篇博文的讲解你开始入门了。
那么话说回来,为啥 [bp] 就没法寻址了?bp 不是可以动态修改的吗?
话虽说如此,当然要区分情况。在寻址栈内空间场景,一旦 bp 被赋予当前栈顶后,是无法再次被不断修改的。为什么无法再次不断修改 bp?因为 bp 此时所代表的是当前栈顶位置,同时也是对当前栈顶的一个记录保存,这其实就是所谓的【栈帧技术】。
栈帧
要想彻底搞明白其中的缘由,我们需要知道什么是【栈帧】。
什么是帧?我们知道视频帧、音频帧,那么帧实际上就是对某一时刻的数据记录。视频帧就是对某一时刻画面的数据,音频帧就是某一时刻声音的数据,而栈帧,就是某一时刻栈顶的记录。谁来记录当前栈顶位置?答案是:bp。在汇编开发中,我们使用 bp 寄存器来保存记录此时栈顶位置,即:mov bp,sp,在其后再通过 bp 来恢复当前栈顶,即:mov sp,bp。记录了栈顶位置的 bp,称之为【栈帧】。
疑问又来了,为何要记录当前栈顶呢?那是因为在汇编中,我们需要通过修改 sp 的值来开辟一段临时空间用来存放程序中的临时变量(重点),当临时空间使用完毕后,sp 还需要回到之前的栈顶位置,所以就必须要记录当前栈顶。
临时变量
在日常的开发中,一个程序内我们可能会写上多个方法,在方法内我们又会定义好多个变量,这些方法内的变量其生命周期就是方法的执行周期,进入方法的时候这些变量开始被定义,方法执行结束后,这些变量也随之销毁,这就是临时变量。
举个例子,现在有两个方法,分别是方法 a(),方法 b():
int a(int x) {
int y = 2;
return y+x;
}
int b(int x) {
int y = x*2;
int z = x/2;
return y-z;
}
上述代码示例中,方法 a() 内,定义了一个临时变量:y,y 被赋予了初始值:2。方法 b() 内,定义了两个临时变量:y 和 z,其值分别是传入参数值乘2和除2。
当开始执行方法 a() 的时候,其方法内的 y 变量才会被声明、赋值,方法 a() 执行结束后,y 变量随之被销毁。当开始执行方法 b() 的时候,其方法内的 y 和 z 变量才会被声明、赋值,方法 b() 执行结束后,y 和 z 变量随之被销毁。对于高级语言开发中,我们是完全不需要考虑这些方法内临时变量是如何被声明赋值,以及如何被销毁的,这些都是系统为我们处理好的事情,完全不用我们操心。
但是在汇编开发中,我们则需要考虑其临时变量的生命周期,毕竟这里可没有一个像 Java GC 垃圾回收机制那样的东西给我们服务,所有的操作需要都需要我们自己来做。
处理临时变量
现在我们开始思考,在汇编中该如何处理临时变量?
从临时变量的生命周期来看,实际上只有两块需要我们来考虑思量的:
1、方法开始执行时,其临时变量所占用的内存空间如何分配?
这块对应着生命周期中创建部分
2、方法执行结束后,其临时变量所占用的内存空间如何释放?
这块对应着生命周期中销毁部分
首先第一个问题:如何开辟空间给临时变量?你可能会想到申请空间,此前我们学习到的,使用 dw、db 关键字向系统申请空间。可是这样的做法并不适合于临时变量,因为临时变量的生命周期是短暂的,只存活于方法中,而向系统申请的内存空间可不会随着方法执行完毕而销毁,他会一直存在,所以这并不符合临时变量的定义。
向系统申请临时空间适用于程序内定义的静态变量和常量,因为这类变量生命周期是跟随着程序的生命周期而走,需要这种不会被系统随时回收掉的空间来当安身之所。
第二个问题:如何释放临时变量所在的内存空间?首先我们需要理解什么是【释放内存】?举个例子,现在我们将内存空间 2000H:0H~2000H:100H 这段空间来存放临时变量,对其使用 ds:[bx] 进行数据访问,bx 表示其寻址的偏移地址,初始值为0。假设方法执行完毕后,需要对临时变量进行清空和销毁,该如何做?答案很简单,直接将 bx 值赋值为0即可。而这也就意味着,我们释放了 2000H:0~2000H:100H 之间的内存空间。你可能会疑惑,怎么释放掉了?空间内数据也没有清空啊!实际上,虽然空间内数据没有清空,但是由于其偏移指针进行了初始化归位,也就意味着此前的数据会被接下来的新数据给覆盖掉,无论如何也访问不到此前的数据了,这就是空间释放。
结合第一个问题我们来看一下:是否可以在内存中找一段空间(比如 2000H:0H~2000H:100H),我们将其用来存放方法内的临时变量,使用 ds 或者 es 当作段地址,bx 做为偏移地址进行寻址呢?
答案是当然不可以!首先来说寄存器的资源是非常宝贵的,凭什么要占用一个段寄存器和一个通用寄存器来做这些事情,况且还是 bx 这种非常重要的通用寄存器。再者,实现逻辑是非常复杂的,设计上很麻烦,况且这还是在汇编上编程实现,复杂度可想而知。最后,最重要的就是,你能保证程序所用到的临时变量占用的空间不会超过你定义好的内存空间吗?内存空间开小了,那就程序崩溃了;内存空间开大了,那就是极大的浪费,降低运行效率。
思来想去,所以这活,还是栈才能干!
栈的运用
这里我们就不分析栈的优势了,因为栈本身就是为了解决这类问题而设计的。此前我们讲解了栈在方法跳转间的运用,那么接下来我们将学习到的是栈在临时变量上的处理运用。
上述我们已经分析过,主要就两个问题需要解决:1、开辟新空间用来存放临时变量;2、释放掉开辟的新空间。那么栈是如何完美解决这两个问题呢?接下来就是揭秘时刻。
开辟空间
我们先来看如何开辟空间,正如上面的【栈帧】小节中提到的,通过修改 sp 的值来开辟一个新的空间。
指令:sub sp,idata,表示将 sp寄存器内的值减去一个自然数 idata,计算结果放到 sp 寄存器内。
猛的一看,这只是一条SUB 指令,并无稀奇。但是,不要忽略该指令的操作对象 SP,那么该指令实际的意义为:开辟一个 idata 大小的临时空间。
例如 sub sp,10H,意义为:开辟一个 10H 字节大小的临时空间
现在我们知道通过 SUB 指令操作 SP 寄存器,来开辟临时空间,但是心里还是很迷茫,将 SP 寄存器的值减上一个数,怎么就开辟出空间了呢?为了解决疑惑,下面通过一个示例,来具体分析一下其中缘由:
现有有一个方法 a(),如下:
int a(int x) {
int y = 2;
return y+x;
}
假设现在程序开始调用方法 a(),那么此时栈状态应该如下所示:
1、开始执行
调用前,需要传入一个参数:即参数入栈,然后调用跳转指令,该指令将会把当前 IP 指令偏移地址的下一条指令地址入栈,再将当前 CS 寄存器入栈。然后修改 CS:IP 指向方法所在地址,接下来便进入方法内,CPU开始执行方法逻辑。假设栈空间大小为AH,假设传入参数值为:1,此时栈内数据状态如下表所示:
地址 | 数据 |
0H | |
1H | |
2H | |
3H | |
4H | CS 低8位 (SP指向) |
5H | CS 高8位 |
6H | IP 低8位 |
7H | IP 高8位 |
8H | 01H |
9H | 00H |
我们分析可知, 此时 SP 指向地址为 0004H。
下面方法开始执行,其内部定义了一个临时变量 y,那么如何开辟出一个临时空间存放变量 y呢?很显而易见,现在栈内只有 0H~3H 之间的空间是空余的,临时空间肯定要占用这里才行。
变量 y 占用一个字空间,所以临时空间大小为一个字,执行指令:sub sp,2H。那么此时 SP 指向地址为 0002H,地址 2H~3H 为存放变量 y 的临时空间。此时栈内数据状态如下表所示:
地址 | 数据 |
0H | |
1H | |
2H | 临时变量 低8位 (SP指向) |
3H | 临时变量 高8位 |
4H | CS 低8位 |
5H | CS 高8位 |
6H | IP 低8位 |
7H | IP 高8位 |
8H | 01H |
9H | 00H |
这里为了加深理解,我们对比其高级语言内的代码就是:int y。
看到这里有小伙伴可能要有问题了:怎么访问临时变量 y 呢?答案很简单,当然是使用 bp 啦~接下来,我们就说一下 bp 产生了什么变化,成为了什么样的角色,要做什么样的工作。
2、访问临时变量
在我们开辟临时空间之前,有一个非常重要的动作,那就是 mov bp,sp。
这一步的重要性关乎两点:
1:访问临时变量
2:方法执行完毕后恢复此前栈顶位置,即 SP
bp 的变化是:此时 bp 为当前栈顶
bp 的角色是:做为栈帧,保留 SP 现场
bp 的工作是:访问临时变量和方法参数数据,对其进行处理
我们先说访问临时变量,还是以上面的栈示例为例。此时 bp 指向地址为:0004H,这也是未开辟临时空间时,当前的栈顶位置。
使用 [bp+idata] 形式进行寻址,那么临时变量所在的偏移地址为:[bp-2],idata 为 -2。
我们看该方法的高级语言代码,语句:int y = 2;int 是开辟临时空间操作,对应汇编语句为:sub sp,2。接下来的 y=2,则是为临时变量赋值操作,对应的汇编语句为:mov [bp-2],2H,执行完毕后,此时栈内数据状态如下表所示:
地址 | 数据 |
0H | |
1H | |
2H | 02H (SP指向) |
3H | 00H |
4H | CS 低8位 (BP指向) |
5H | CS 高8位 |
6H | IP 低8位 |
7H | IP 高8位 |
8H | 01H |
9H | 00H |
我们继续下面的逻辑。方法 a() 逻辑是:计算传入参数加上临时变量的值,从表中所示,此时传入参数在栈中的偏移地址应为:[bp+4]。语句:y+x,对应的汇编语句如下:
1、mov ax,[bp+4],将传入参数数据 0001H 放入 AX 寄存器内,执行结束后,AX 寄存器值为:1
2、add ax,[bp-2],将临时变量 0002H 与 AX 寄存器的值相加,结果放到 AX 寄存器内,执行结束后,AX 寄存器值为:3
至此,完成了方法 a() 逻辑执行,执行结果在 AX 寄存器内。
3、执行结束
来到第三步,现在方法逻辑已经执行完毕,我们需要进行扫尾工作。主要有以下两个工作需要做:1、释放临时空间,高级语言中就是销毁临时变量;2、回到方法调用位置。
我们先说第一步:释放临时空间。
在前面的讲述中,有说到什么是释放空间:就是将空间寻址的偏移指针初始化归位即可。释放临时空间是同样的道理,SP 做为寻址的偏移指针,只需要将 SP 指向复位即可。那么 SP 复位到哪呢?当然是在开辟临时空间之前,SP 寄存器的值,也就是地址 0004H。这个时候,就体现出 bp 第二个重要作用了,也就是我们一直提到的【栈帧】。
因为此时 bp 寄存器的值就是此前 SP 的指向地址,所以恢复 SP 只需要执行:mov sp,bp。该条 MOV 指令执行完毕后,sp 重新归位:0004H,而临时空间:2H~3H 则会被放入栈中的新数据重新覆盖掉,也就是说该空间已被释放。
那么释放临时空间后,栈内数据状态如下表所示:
地址 | 数据 |
0H | |
1H | |
2H | 02H |
3H | 00H |
4H | CS 低8位 (SP、BP指向) |
5H | CS 高8位 |
6H | IP 低8位 |
7H | IP 高8位 |
8H | 01H |
9H | 00H |
我们可以看到此时栈中数据,sp、bp 同时指向了地址:0004H,地址 2H~3H 存放的还是此前临时变量 y 的值:0002H。但是由于 sp 的指向已经恢复归位,所以是程序内是无法再次访问到该数据,这对程序来说也就意味着这块空间已被销毁。这就是为什么一旦方法执行完毕后,其临时变量全都被销毁无法访问的原因。
那么接下来就是返回其调用地址。此时 sp 已经指向了 CS 所在的地址,那么直接执行 POP 执行返回即可。即指令:pop cs,pop ip,注意这里是需要两次 POP,首先 pop 出 cs,接着 pop 出 ip。pop 出的 IP 值是调用方法指令偏移地址的下一条指令偏移地址,这样重新设置 CS:IP ,CPU便从调用方法地址的下一条指令开始,继续向下执行。
继续探究
探究到这里,实际上我们已经把方法的调用、执行、结束,它的整个生命周期内的变动差不多分析完了。现在我们已经明白了一个临时变量,它所处的临时空间是如何被开辟的,它是如何被访问处理的,以及最后它所处的临时空间又是如何被销毁释放的。
现在貌似还剩下最后一个问题:bp 是如何处理呢?
在上面一节中【执行结束】,使用 bp 恢复 sp 后,此时 sp、bp 都指向了同一处地址:0004H。sp 指向 0004H 是很合理的,因为这本来就是栈顶位置所在,sp 指向当前栈顶是没任何问题的。
bp 之所以也指向了栈顶位置,是因为在 sp 开辟临时空间的时候,它需要记录栈顶,而释放临时空间恢复栈顶后,此时 bp 再指向栈顶,那就不太合理了。说白了就是,bp 的使命已经完成,工作已经做完了,它就不应该继续占用栈顶,而是恢复此前 bp 的数据!
那么,接下来咱们就开始探究 bp 在整个流程中的工作处理:
1、方法开始执行
在 bp 开始记录当前栈顶之前,即在指令:mov bp,sp 之前,需要保存当前 bp。如何保存?答:直接 PUSH 入栈即可。那么指令顺序如下:
pusu bp
mov bp,sp
....
这两条指令执行完毕后,栈内数据状态如下表所示:
地址 | 数据 |
0H | |
1H | |
2H | BP 低8位 (SP、BP指向) |
3H | BP 高8位 |
4H | CS 低8位 (SP、BP指向) |
5H | CS 高8位 |
6H | IP 低8位 |
7H | IP 高8位 |
8H | 01H |
9H | 00H |
2、执行方法逻辑
接下来就是 SUB SP 开辟临时空间,此时 bp 用来 [bp+idata] 寻址临时变量和传入参数。
3、执行结束
恢复 SP 后,则需要恢复 BP。 那么指令顺序如下:
mov sp.bp
pop bp
.....
这两条指令执行完毕后,栈内数据状态如下表所示:
地址 | 数据 |
0H | |
1H | |
2H | BP 低8位 |
3H | BP 高8位 |
4H | CS 低8位 (SP指向) |
5H | CS 高8位 |
6H | IP 低8位 |
7H | IP 高8位 |
8H | 01H |
9H | 00 |
此时 SP 指向 CS,接下来就是设置 CS:IP,返回方法调用处的下一条指令开始继续执行。
解疑
现在,我们可以对本篇博文开始的几处疑惑进行解疑。
为什么说 [bp] 无法进行寻址?因为在栈内空间寻址场景下,bp 是做为栈帧,保存记录当前栈顶位置的存在,其值是不能动态修改的。所以说 [bp] 无法进行寻址,必须使用 [bp+idata] 形式才能对栈内空间进行寻址。
为什么记录当前栈顶SP?因为通过 SUB 指令操作 SP 用来开辟临时空间,SP 的值就会发生变化,所以需要有人记录在开辟临时空间之前的SP值,这样方便方法执行后恢复当前栈顶。
为什么源程序中强指定 BP 语句,经过 MASM 工具编译后,其寻址格式默认为 [bp+idata] 形式呢?这是因为 bp 的使用场景主要就是栈内空间寻址,而栈内空间寻址必须使用 [bp+idata] 形式。所以按照开发规范和标准,MSM 工具便默认将 [bp] 语句编译成了 [bp+0] ,这是遵从规范的体现。
标准示例
我们还是以方法 a() 为示例,以汇编语言写出其整个生命周期内的逻辑处理:
int a(int x) {
int y = 2;
return y+x;
}
汇编代码如下:
push bp ; bp入栈,保存此时 bp 值
mov bp,sp ; 使用 bp 记录当前栈顶
sub sp,2 ; 开辟一个2个字节大小的临时空间
mov ax,2
mov [bp-2],ax ; 使用 [bp-2] 访问到临时空间,为其赋初始值 2
mov ax,[bp+6] ; 使用 [bp+6] 访问到传入参数,将其放入 AX 寄存器内
add ax,[bp-2] ; 执行逻辑,临时变量值与传入参数值相加,结果放到 AX 寄存器内
mov sp,bp ; 恢复当前栈顶,释放销毁临时空间
pop bp ; 恢复此前的 bp 值
; 下面就是设置 CS:IP,返回调用方法的下一条指令位置
pop cs
pop ip
那么观察汇编代码,我们能得到一个汇编中【方法逻辑格式模板】:
push bp
mov bp,sp ; 这两句表示方法开始
sub sp,xxH ; 如果方法内存在临时变量,则使用 sub 开辟临时空间。如果没有临时变量,则该条指令
; 就取消
...........
...........
.具体方法逻辑. ; 使用 [bp+idata] 寻址,访问临时变量数据和传入参数数据
...........
...........
mov sp,bp ; 恢复当前栈顶 SP
pop bp ; 恢复 BP
根据上述模板,我们能确定在汇编中,一个方法的开始标志为:
push bp
mov bp,sp
方法开辟临时空间的标志为:
sub sp,xxH
xxH 是临时空间的大小
方法的结束标志为:
mov sp,bp
pop bp
以上标志,在逆向分析的工作中,是非常重要的参照物。逆向工程师在使用调试分析软件查看目标程序的汇编代码时,通过这些标志位,便可确定方法的入口地址、结束地址、临时空间范围等关键信息,通过 [bp+idata] 便能分析得到方法传入的参数、临时变量、以及方法执行结果等。
结束语
本篇博文中,主要是通过细致分析汇编中方法如何开辟临时变量、如何访问临时变量、如何释放销毁临时空间,从而完整了分析了 BP 在其中发挥的关键作用。现在我们已经明白了,BP 寄存器不单单是为访问栈空间提供了方便,更是承担了恢复 SP 的核心角色。
此外,我们还学习了汇编中方法的模板样式,了解了方法的开始、结束等标志位,这些在我们日后的逆向分析工作中将发挥着重要作用!
好了,本篇到此结束~感谢围观,转发分享请标明出处,谢谢~