我是大家的老朋友CPU阿甘, 每天你一开机,我就忙得不亦乐乎,从内存中读取一条条的指令,挨个执行。
最早的时候我认为程序都是顺序执行的,后来发现并不是这样,经常会出现一条跳转指令,让我到另外一个内存地址处去下一条指令去执行。
时间久了我就明白这是人类代码中的if ... else ,或者for ,while等循环导致的。
这样跳来跳去,让我觉得有点头晕,不过没有办法,这是人类做出的规定。
后来我发现,有些指令经常会出现重复,尤其是下面这几个:
- pushl %ebp
- movl %esp %ebp
- call xxxx
- ret
正当我疑惑的时候,内存炫耀地说:这些指令是为了函数调用,建立栈帧所所必需的啊。
“函数调用?这是什么鬼?”
“函数调用你都不知道? 我告诉你吧,现在的计算机语言,甭管你是面向对象还是函数式、动态还是静态、解释还是编译,只要想在我们冯诺依曼体系结构下运行,最终都得变成顺序、循环、分支,以及函数调用!”
内存说着给我举了一个例子:
这个例子非常简单,一看就明白。
“但是栈帧是什么?”
“阿甘你知道栈是什么意思吧?”
“不就是一个先进后出的数据结构吗?”
“对,通俗来说:一个栈帧就是这个栈中的一个元素,表示了一个函数在运行时的结构。” 内存继续给我科普:
“你这种画法好古怪,怎么倒过来了,栈底在上方,栈顶反而在下方!”
“这也是人类规定的,一个进程的虚拟内存中有个区域,就是栈,这个栈就是从高地址向低地址发展的啊。”
“奥,原来我执行的代码在一个叫做代码区的地方存放着啊,执行的时候会操作你的栈,对不对?”
“没错,我再给你看看那个栈帧的内部结构吧!”
这张图看起来很复杂,但是和代码一对应,还是比较清楚的。
我心中模拟了一下这个执行过程,hello()函数正在被执行,当要调用add函数的时候,需要准备参数,即x = 10, y=20 。
还要记录下返回地址,即printf(....)这个指令在内存的地址。当add函数调用完成以后,就可以返回到这里执行了。
真正开始执行add函数的时候,也需要给它建立一个栈帧(其中要记录下上个函数栈帧的开始地址),还有这个函数的参数,在栈帧也会分配内存空间,例如sum, buf等。
等到执行结束,add函数的栈帧就废弃了(相当于从栈中弹出),找到返回地址,继续执行printf指令。
hello函数执行完毕,也会废弃掉,回到上一个函数的栈帧,继续执行,如此持续下去....
我对内存说:“明白了,我已经迫不及待地想执行一下这个函数,看看效果了。”
内存说:“真的明白了?正好,操作系统老大已经发出指令,让我们运行了,开始吧!”
建立hello函数的栈帧,调用add函数,建立add栈帧,执行add函数的代码, 一切都很顺利。
add函数中调用了scanf ,要求用户输入一些数据,人类是超级慢的,我耐心等待。
用户输入了8个字符A,我把他们都放到了buf所在的内存中:
但是人类还在输入,接下里是一些很奇怪的数据,其长度远远超过了char buf[8]中的8个字节。
可是我还得把数据给放到内存中啊,于是函数栈帧就变成了这个样子。
(注:用户输入的数据是从低地址向高地址存放的。)
我觉得特别古怪的是,这个返回地址也被冲掉了,被改写了。
这个用户到底要干啥?
add函数执行完毕,要返回到hello函数了, 我明明知道返回地址已经被改掉, 可是我没有选择,还得把那个新的(用户输入的)返回地址给取出来, 老老实实地去那个地址取出下一条指令去执行。
完了,这根本就不是原来的prinf函数,而是一段恶意代码的入口!
分割线
与此同时....
黑客三兄弟中的老三大叫: 大哥二哥,我的这次缓冲区溢出攻击实验成功了!
“不错啊,你是怎么搞的?” 老大问道。
“正如二哥说的,那个scanf函数没有边界检查,我成功地把代码注入到了栈帧中,并且修改了返回地址!于是程序就跳到我指定的地方执行了。”
【本文为51CTO专栏作者“刘欣”的原创稿件,转载请通过作者微信公众号coderising获取授权】