购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.1 系统栈的工作原理

2.1.1 内存的不同用途

如果您关注网络安全问题,那么一定听过缓冲区溢出这个术语。简单说来,缓冲区溢出就是在大缓冲区中的数据向小缓冲区复制的过程中,由于没有注意小缓冲区的边界,“撑爆”了较小的缓冲区,从而冲掉了和小缓冲区相邻内存区域的其他数据而引起的内存问题。缓冲溢出是最常见的内存错误之一,也是攻击者入侵系统时所用到的最强大、最经典的一类漏洞利用方式。

成功地利用缓冲区溢出漏洞可以修改内存中变量的值,甚至可以劫持进程,执行恶意代码,最终获得主机的控制权。要透彻地理解这种攻击方式,我们需要回顾一些计算机体系架构方面的基础知识,搞清楚CPU、寄存器、内存是怎样协同工作而让程序流畅执行的。

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

(1)代码区:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域取指并执行。

(2)数据区:用于存储全局变量等。

(3)堆区:进程可以在堆区动态地请求一定大小的内存,并在用完之后归还给堆区。动态分配和回收是堆区的特点。

(4)栈区:用于动态地存储函数之间的调用关系,以保证被调用函数在返回时恢复到母函数中继续执行。

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

在Windows平台下,高级语言写出的程序经过编译链接,最终会变成第1章介绍过的PE文件。当PE文件被装载运行后,就成了所谓的进程。

PE文件代码段中包含的二进制级别的机器代码会被装入内存的代码区(.text),处理器将到内存的这个区域一条一条地取出指令和操作数,并送入算术逻辑单元进行运算;如果代码中请求开辟动态内存,则会在内存的堆区分配一块大小合适的区域返回给代码区的代码使用;当函数调用发生时,函数的调用关系等信息会动态地保存在内存的栈区,以供处理器在执行完被调用函数的代码时,返回母函数。这个协作过程如图2.1.1所示。

图2.1.1 进程的内存使用示意图

如果把计算机看成一个有条不紊的工厂,我们可以得到如下类比。

程序中所使用的缓冲区可以是堆区、栈区和存放静态变量的数据区。缓冲区溢出的利用方法和缓冲区到底属于上面哪个内存区域密不可分,本章主要介绍在系统栈中发生溢出的情形。

2.1.2 栈与系统栈

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

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

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

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

2.1.3 函数调用时发生了什么

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

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

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

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

图2.1.2 函数代码在代码区中的分布示意图

图2.1.3 CPU在代码区中的取指轨迹示意图

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

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

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

图2.1.4 系统栈在函数调用时的变化

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

2.1.4 寄存器与函数栈帧

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

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

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

注意: EBP指向当前位于系统栈最上边一个栈帧的底部,而不是系统栈的底部。严格 说来,“栈帧底部”和“栈底”是不同的概念,本书在叙述中将坚持使用“栈帧底部” 这一提法以示区别; ESP所指的栈帧顶部和系统栈的顶部是同一个位置,所以后面叙 述中并不严格区分“栈帧顶部”和“栈顶”的概念。请您注意这里的差异,不要产生 概念混淆。

寄存器对栈帧的标识作用如图2.1.5所示。

图2.1.5 栈帧寄存器ESP与EBP的作用

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

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

(1)局部变量:为函数局部变量开辟的内存空间。

(2)栈帧状态值:保存前栈帧的顶部和底部(实际上只保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本帧被弹出后恢复出上一个栈帧。

(3)函数返回地址:保存当前函数调用前的“断点”信息,也就是函数调用前的指令位置,以便在函数返回时能够恢复到函数被调用前的代码区中继续执行指令。

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

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

EIP:指令寄存器(Extended Instruction Pointer),其内存放着一个指针,该指针永远指向下一条等待执行的指令地址,其作用如图2.1.6所示。

可以说如果控制了EIP寄存器的内容,就控制了进程——我们让EIP指向哪里,CPU就会去执行哪里的指令。在本章第4节中我们会介绍控制EIP劫持进程的原理及实验。

2.1.5 函数调用约定与相关指令

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

表2-1-1 调用方式之间的差异

具体的,对于Visual C++来说,可支持以下3种函数调用约定,如表2-1-2所示。

表2-1-2 函数调用约定

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

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

注意: 同一段代码用不同的编译选项、不同的编译器编译链接后,得到的可执行文件 会有很多不同。因此,请您在进行后续实验前务必注意实验环境的描述,否则所得结 果可能会与实验指导有所差异。

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

(1)参数入栈:将参数从右向左依次压入系统栈中。

(2)返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行。

(3)代码区跳转:处理器从当前代码区跳转到被调用函数的入口处。

(4)栈帧调整:具体包括。

保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP入栈);

将当前栈帧切换到新栈帧(将ESP值装入EBP,更新栈帧底部);

给新栈帧分配空间(把ESP减去所需空间的大小,抬高栈顶);

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

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

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

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

(1)保存返回值:通常将函数的返回值保存在寄存器EAX中。

(2)弹出当前栈帧,恢复上一个栈帧。

具体包括:

图2.1.7 函数调用时系统栈的变化过程

(3)跳转:按照函数返回地址跳回母函数中继续执行。

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

按照这样的函数调用约定组织起来的系统栈结构如图2.1.8所示。

题外话: Win32平台下有很多寄存器, Intel指令集中的指令也有很多,现在立刻逐 一介绍它们无疑相当于给已经满头雾水的您再浇一桶冷水。虽然这里仅仅列出了 3个寄存器和几条指令的作用,但只要您完全理解它们,就一定能顺利理解本书的后 续章节,因为它们是栈溢出利用的关键,也是计算机架构的核心所在。当然,入门 以后要想提高到一个新的层次,用《 IBM X86汇编》或者《 Win32汇编》恶补一下 汇编知识是非常必要的。

图2.1.8 函数调用的实现 Jpm05qHWo+SJ6s4L26fp23pr3PWW22gL6OpmvnNedfPwsr6IrqdV09OlGsnDykRm

点击中间区域
呼出菜单
上一章
目录
下一章
×