返回介绍

第3讲  初级栈溢出 B 系统栈原理

发布于 2023-07-21 20:09:42 字数 8692 浏览 0 评论 0 收藏 0

扫盲班第三讲开课啦!

上节课我们用越过数组边界的一个字节把邻接的标志变量修改成 0,从而突破了密码验证程序。您实验成功了吗?没有的话回去做完实验再来听今天的课!

有几个同学反映编译器的问题,我还是建议用 VC6.0,因为它 build 出来的 PE 最适合初学者领会概念。而且这门课动手很重要,基本上我的实验指导都是按 VC6.0 来写的,用别的 build 出来要是有点出入,实验不成功的话会损失学习积极性滴——实验获得的成就感是学习最好的动力。

另外在回帖中已经看到不少同学问了一些不错的问题:
如果变量之间没有相邻怎么办?
如果有一个编译器楞要把 authenticated 变量放在 buffer[8]数组前边咋办?

今天的课程将部分回答这些问题。

今天基本没有程序和调试(下一讲将重新回归实践),主要是一些理论知识的补充。听课的对象是只用 C 语言编过水仙花数的同学。如果你不是这样的同学,可以飘过本讲,否则你会说我罗嗦滴像唐僧~~~~我的目标就是一定要让你弄明白,不管多罗嗦,多俗气,多傻瓜的方法,呵呵

找工作滴同学也可以看看这部分,很可能会对面试有帮助呦。根据我个人无数次的面试经验,会有很多考官饶有兴趣的问你学校课本上从来不讲的东东,比如堆和栈的区别,什么样的变量在栈里,函数调用是怎么实现的,参数入栈顺序,函数调用时参数的值传递、地址传递的原理之类。学完本节内容,您将对高级语言的执行原理有一个比较深入的认识。

此外,这节课会对后面将反复用到的一些寄存器,指令进行扫盲。不要怕,就几个,保管你能弄懂。

最后,上次提意见说图少的同学注意了,这节课的配套图示那叫一个多啊。

所以还是那句话,不许不学,不许学不会,不许说难,呵呵

我们开始吧!

根据不同的操作系统,一个进程可能被分配到不同的内存区域去执行。但是不管什么样的操作系统、什么样的计算机架构,进程使用的内存都可以按照功能大致分成以下四个部分:

代码区:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域来取指并执行。
数据区:用于存储全局变量等。
堆区:进程可以在堆区动态的请求一定大小的内存,并在用完之后归还给堆区。动态分配和回收是堆区的特点
栈区:用于动态的存储函数之间的调用关系,以保证被调用函数在返回时恢复到母函数中继续执行

注意:这种简单的内存划分方式是为了让您能够更容易地理解程序的运行机制。《深入理解计算机系统》一书中有更详细的关于内存使用的论述,如果您对这部分知识有兴趣,可以参考之

在 windows 平台下,高级语言写出的程序经过编译链接,最终会变成各位同学最熟悉不过的 PE 文件。当 PE 文件被装载运行后,就成了所谓的进程。

图 1

如果把计算机看成一个有条不紊的工厂的话,那么可以简单的看成是这样组织起来的:

CPU 是完成工作的工人;
数据区,堆区,栈区等则是用来存放原料,半成品,成品等各种东西的场所;
存在代码区的指令则告诉 CPU 要做什么,怎么做,到哪里去领原材料,用什么工具来做,做完以后把成品放到哪个货舱去;
值得一提的是,栈除了扮演存放原料,半成品的仓库之外,它还是车间调度主任的办公室。

程序中所使用的缓冲区可以是堆区、栈区、甚至存放静态变量的数据区。缓冲区溢出的利用方法和缓冲区到底属于上面哪个内存区域密不可分,本讲座主要介绍在系统栈中发生溢出的情形。堆中的溢出稍微复杂点,我会考虑在中级班中给予介绍

以下内容针对正常情况下的大学本科二年级计算机水平或者计算机二级水平的读者,明白栈的飘过即可。

从计算机科学的角度来看,栈指的是一种数据结构,是一种先进后出的数据表。栈的最常见操作有两种:压栈(PUSH),弹栈(POP);用于标识栈的属性也有两个:栈顶(TOP),栈底(BASE)

可以把栈想象成一摞扑克牌:

PUSH:为栈增加一个元素的操作叫做 PUSH,相当于给这摞扑克牌的最上面再放上一张;
POP:从栈中取出一个元素的操作叫做 POP,相当于从这摞扑克牌取出最上面的一张;

TOP:标识栈顶位置,并且是动态变化的。每做一次 PUSH 操作,它都会自增 1;相反每做一次 POP 操作,它会自减 1。栈顶元素相当于扑克牌最上面一张,只有这张牌的花色是当前可以看到的。
BASE:标识栈底位置,它记录着扑克牌最下面一张的位置。BASE 用于防止栈空后继续弹栈,(牌发完时就不能再去揭牌了)。很明显,一般情况下 BASE 是不会变动的。

内存的栈区实际上指的就是系统栈。系统栈由系统自动维护,它用于实现高级语言中函数的调用。对于类似 C 语言这样的高级语言,系统栈的 PUSH,POP 等堆栈平衡细节是透明的。一般说来,只有在使用汇编语言开发程序的时候,才需要和它直接打交道。

注意:系统栈在其他文献中可能曾被叫做运行栈,调用栈等。如果不加特别说明,我们这里说的栈都是指系统栈这个概念,请您注意与求解“八皇后”问题时在自己在程序中实现的数据结构区分开来。

我们下面就来探究一下高级语言中函数的调用和递归等性质是怎样通过系统栈巧妙实现的。请看如下代码:

int  func_B(int arg_B1, int arg_B2){
  int var_B1, var_B2;
  var_B1=arg_B1+arg_B2;
  var_B2=arg_B1-arg_B2;
  return var_B1*var_B2;
}
int  func_A(int arg_A1, int arg_A2){
  int var_A;
  var_A = func_B(arg_A1,arg_A2) + arg_A1 ;
  return var_A;
}
int main(int argc, char **argv, char **envp){
  int var_main;
  var_main=func_A(4,3);
  return var_main;
}

这段代码经过编译器编译后,各个函数对应的机器指令在代码区中可能是这样分布的:

图 2

根据操作系统的不同、编译器和编译选项的不同,同一文件不同函数的代码在内存代码区中的分布可能相邻也可能相离甚远;可能先后有序也可能无序;但他们都在同一个 PE 文件的代码所映射的一个“区”里。这里可以简单的把它们在内存代码区中的分布位置理解成是散乱无关的。

当 CPU 在执行调用 func_A 函数的时候,会从代码区中 main 函数对应的机器指令的区域跳转到 func_A 函数对应的机器指令区域,在那里取指并执行;当 func_A 函数执行完闭,需要返回的时候,又会跳回到 main 函数对应的指令区域,紧接着调用 func_A 后面的指令继续执行 main 函数的代码。在这个过程中,CPU 的取指轨迹如下图所示:

图 3

那么 CPU 是怎么知道要去 func_A 的代码区取指,在执行完 func_A 后又是怎么知道跳回到 main 函数(而不是 func_B 的代码区)的呢?这些跳转地址我们在 C 语言中并没有直接说明,CPU 是从哪里获得这些函数的调用及返回的信息的呢?

原来,这些代码区中精确的跳转都是在与系统栈巧妙地配合过程中完成的。当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,并把它压入栈中。这个栈帧中的内存空间被它所属的函数独占,正常情况下是不会和别的函数共享的。当函数返回时,系统栈会弹出该函数所对应的栈帧。

图 4

如图所示,在函数调用的过程中,伴随的系统栈中的操作如下:

在 main 函数调用 func_A 的时候,首先在自己的栈帧中压入函数返回地址,然后为 func_A 创建新栈帧并压入系统栈
在 func_A 调用 func_B 的时候,同样先在自己的栈帧中压入函数返回地址,然后为 func_B 创建新栈帧并压入系统栈
在 func_B 返回时,func_B 的栈帧被弹出系统栈,func_A 栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址重新跳到 func_A 代码区中执行
在 func_A 返回时,func_A 的栈帧被弹出系统栈,main 函数栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址跳到 main 函数代码区中执行

注意:在实际运行中,main 函数并不是第一个被调用的函数,程序被装入内存前还有一些其他操作,上图只是栈在函数调用过程中所起作用的示意图

每一个函数独占自己的栈帧空间。当前正在运行的函数的栈帧总是在栈顶。WIN32 系统提供两个特殊的寄存器用于标识位于系统栈栈顶的栈帧:

ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶
EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部

寄存器对栈帧的标识作用如下图所示:

图 5


函数栈帧:ESP 和 EBP 之间的内存空间为当前栈帧,EBP 标识了当前栈帧的底部,ESP 标识了当前栈帧的顶部。

在函数栈帧中一般包含以下几类重要信息:

局部变量:为函数局部变量开辟内存空间。
栈帧状态值:保存前栈帧的顶部和底部(实际上只保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本帧被弹出后,恢复出上一个栈帧。
函数返回地址:保存当前函数调用前的“断点”信息,也就是函数调用前的指令位置,以便函数返回时能够恢复到函数被调用前的代码区中继续执行指令。

注意:函数栈帧的大小并不固定,一般与其对应函数的局部变量多少有关。在以后几讲的调试实验中您会发现,函数运行过程中,其栈帧大小也是在不停变化的。

除了与栈相关的寄存器外,您还需要记住另一个至关重要的寄存器:

EIP:指令寄存器(extended instruction pointer), 其内存放着一个指针,该指针永远指向下一条待执行的指令地址

图 6

可以说如果控制了 EIP 寄存器的内容,就控制了进程——我们让 EIP 指向哪里,CPU 就会去执行哪里的指令。下面的讲座我们就会逐步介绍如何控制 EIP,劫持进程的原理及实验。

函数调用约定与相关指令

函数调用约定描述了函数传递参数方式和栈协同工作的技术细节。不同的操作系统、不同的语言、不同的编译器在实现函数调用时的原理虽然基本类同,但具体的调用约定还是有差别的。这包括参数传递方式,参数入栈顺序是从右向左还是从左向右,函数返回时恢复堆栈平衡的操作在子函数中进行还是在母函数中进行。下面列出了几种调用方式之间的差异。

参数入栈顺序

C SysCall StdCall BASIC FORTRAN PASCAL
右->左 右->左 右->左 左->右 左->右 左->右

恢复栈平衡操作的位置 母函数 子函数 子函数 子函数 子函数 子函数

具体的,对于 Visual C++来说可支持以下三种函数调用约定
调用约定的声明 参数入栈顺序 恢复栈平衡的位置
__cdecl 右->左 母函数
__fastcall 右->左 子函数
__stdcall 右->左 子函数

要明确使用某一种调用约定的话只需要在函数前加上调用约定的声明就行,否则默认情况下 VC 会使用__stdcall 的调用方式。本篇中所讨论的技术,在不加额外说明的情况下,都是指这种默认的__stdcall 调用方式。

除了上边的参数入栈方向和恢复栈平衡操作位置的不同之外,参数传递有时也会有所不同。例如每一个 C++类成员函数都有一个 this 指针,在 windows 平台中这个指针一般是用 ECX 寄存器来传递的,但如果用 GCC 编译器编译的话,这个指针会做为最后一个参数压入栈中。

同一段代码用不同的编译选项、不同的编译器编译链接后,得到的可执行文件会有很多不同。

函数调用大致包括以下几个步骤:

参数入栈:将参数从右向左依次压入系统栈中
返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行
代码区跳转:处理器从当前代码区跳转到被调用函数的入口处
栈帧调整:具体包括
保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP 入栈)
将当前栈帧切换到新栈帧。(将 ESP 值装入 EBP,更新栈帧底部)
给新栈帧分配空间。(把 ESP 减去所需空间的大小,抬高栈顶)

对于__stdcall 调用约定,函数调用时用到的指令序列大致如下:

;调用前
push 参数 3 ; 假设该函数有 3 个参数,将从右向左依次入栈
push 参数 2
push 参数 1
call 函数地址 ; call 指令将同时完成两项工作:a)向栈中压入当前指令在内存中的位置, ; 即保存返回地址;b)跳转到所调用函数的入口地址

;函数入口处
push ebp ; 保存旧栈帧的底部
mov ebp,esp ; 设置新栈帧的底部(栈帧切换)
sub esp,xxx ; 设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间)

上面这段用于函数调用的指令在栈中引起的变化如下图所示:

注意:关于栈帧的划分不同参考书中有不同的约定。有的参考文献中把返回地址和前栈帧 EBP 值做为一个栈帧的顶部元素,而有的则将其做为栈帧的底部进行划分。在后面的调试中,您会发现 OllyDbg 在栈区标示出的栈帧是按照前栈帧 EBP 值进行分界的,也就是说前栈帧 EBP 值即属于上一个栈帧,也属于下一个栈帧,这样划分栈帧后返回地址就成为了栈帧顶部的数据。我们这里将坚持按照 EBP 与 ESP 之间的位置做为一个栈帧的原则进行划分。这样划分出的栈帧如上面最后一幅图所示,栈帧的底部存放着前栈帧 EBP,栈帧的顶部存放着返回地址。划分栈帧只是为了更清晰的了解系统栈的运作过程,并不会影响它实际的工作。

类似的,函数返回的步骤如下:

保存返回值:通常将函数的返回值保存在寄存器 EAX 中
弹出当前栈帧,恢复上一个栈帧:
具体包括
在堆栈平衡的基础上,给 ESP 加上栈帧的大小,降低栈顶,回收当前栈帧的空间
将当前栈帧底部保存的前栈帧 EBP 值弹入 EBP 寄存器,恢复出上一个栈帧
将函数返回地址弹给 EIP 寄存器
跳转:按照函数返回地址跳回母函数中继续执行

还是以 C 语言和 WIN32 平台为例,函数返回时的相关的指令序列如下:

add xxx, esp ;降低栈顶,回收当前的栈帧
pop ebp ;将上一个栈帧底部位置恢复到 ebp,
retn ;这条指令有两个功能:a)弹出当前栈顶元素,即弹出栈帧中的返回地址。至此 ;栈帧恢复工作完成。b)让处理器跳转到弹出的返回地址,恢复调用前的代码区

按照这样的函数调用约定组织起来的系统栈结构如下:

喂!醒醒!说你呐!还睡!呵呵

不要怪我罗嗦,要彻底的掌握,真正的掌握,完全的掌握缓冲区溢出攻击,这些知识是必须的!讲到这里,如果你思维够敏捷的话,应该已经可以看出我不是无中生有的花这么多篇幅来浪费版面的。

回忆上一讲的那个例子,buffer 后面是 authenticated 变量,authenticated 变量后面是谁呢?就是我废了好多口水讲到的当前的正在执行的函数对应的栈帧变量 EBP 与 EIP(函数返回地址)的值!

verify_password 函数返回之后,程序就会按照这个返回地址(EIP)所指示的内存地址去取指令并执行。

如果我们在多给几个输入的字符,让输入的数据跃过 authenticated 变量,一直淹没到返回地址的位置,把它淹没成我们想要执行的指令的内存地址,那么 verify_password 函数返回后,就会乖乖滴去执行我们想让它执行的东东了(例如直接返回到密码正确的处理流程)。

哎呀,拖堂了,我平生最恨拖堂滴老师,今天就到这里吧。

下节课我会带着大家一步一步的完成这节课的分析,让跃过数组的字符串继续跃过 authenticated 变量,直到把函数返回地址修改成我们想要的值,从而改变程序流程。

每天坚持用 20 分钟读帖一篇,两周后会惊奇的发现世界真奇妙,呵呵。再见

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文