关于栈的一些基础知识
Wednesday, October 26, 2022
本文共3110字
7分钟阅读时长
⚠️本文是作者P3troL1er原创,首发于https://peterliuzhi.top/principle/%E5%85%B3%E4%BA%8E%E6%A0%88%E7%9A%84%E4%B8%80%E4%BA%9B%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/。商业转载请联系作者获得授权,非商业转载请注明出处!
When we feel love and kindness toward others, it not only makes others feel loved and cared for, but it helps us also to develop inner happiness and peace.
— Dalai Lama
pwndbg中显示的栈
✨pwndbg中默认只显示8行stack,这在很多时候是不够用的->输入stact 10显示10行,stack 20显示20行,以此类推
最上方的rsp
rsp就是栈顶指针,时刻指向当前的栈顶,pop和push后就会变化
✨pwndbg中的栈更符合栈的概念,栈越往上则地址越小,因此低位在上,高位在下;而ida中双击变量后显示的栈则显示的是该变量的地址距离栈顶的地址差,因此有正负之分
❔pwndbg中的栈还有很多小箭头,这是什么意思?
这些小箭头可以理解为指针的意思,如果是'→',表示该地址的这个位置存储的是一个地址,如果是'←',表示存储的是数据,
黄色表示是栈上地址,红色上色表示代码段,黑色为数据,
有些数据还会有箭头指向别的数据,这些是“该位置存储的地址的位置存储的数据”
for example:
表示0x7fffffffde50这个位置存储的数据是0x7fffffffdf78,同时0x7fffffffdf78这个位置存储的数据是0x7fffffffe2da,0x7fffffffe2da这个位置存储的数据是"/home/linux/0/pwn/30"
而这种寄存器符号在左边的标识,表示当前r12的值为0x7fffffffdf78,每次pop就会从栈顶弹出一个数据到对应的寄存器中,同时rsp会+8,也就是显示为往下移一行。
rbp栈帧指针
rbp保存的是子函数栈帧的起始地址,是它下面那一块栈(调用者的栈)的rsp,也就是相当于在最开始的时候给rsp做了一个备份(可以把rbp中的b记为backup)
32位程序中会有esp、ebp,实际上就是rsp、rbp的32位低级版本,是他们的低四位,只有rsp、rbp的一半
栈的高低区别
栈是从高地址向低地址增长的(栈的增长发生在调用子函数和push的时候),向栈中写时是从低地址到高地址写
而我们常说的大小端序是仅针对整数而言的(对字符串无效),假设现在有一个整数,它是0x123456789ABCDEF0,如果按最常见的小端序存储,在内存中:从低往高:F0 DE BC 9A 78 56 34 12 (注意,以一个字节为一个单元!) ;若使用不常见的大端序,则在内存中,从高往低:12 34 56 78 9A BC DE F0 (可以看作大端序正着读,符合人类直觉,而小端序反着读,符合计算机逻辑)
✨另外提及一下在这方面很蠢的ida,例如一个整型数:0x41424344,由于是小端序,内存中存储的是 44 43 42 41,翻译成字符串就是"DCBA",但是当你在ida中右键标注这个整型数据为字符串时,ida会显示:“ABCD”,所以在这种情况下脑子里自己给它倒过来。
CALL函数
完整的函数调用过程要有几个语句实现的,
在这里,main函数中call了一个用户自定义函数vuln,此时将vuln所在地址传给rip寄存器(rip寄存器是所有寄存器中最神奇的寄存器,神奇之处在于rip保存的值是什么,程序就会执行哪个地址的指令),传了以后,程序转到vuln函数执行,这个时候rbp和rsp还未发生变化。
接着,看到vuln函数的指令第一句:push rbp,将rbp当前的值压入栈中,在栈中保存rbp原来的值(用C语言去理解就是新建了一个变量来保存rbp原来的值),
紧接着下一句:mov rbp, rsp,将rsp当前的值传入rbp,用rbp保存rsp原有的值,所以说rbp是给rsp做备份用的。
而有趣的是,当前的rbp所存储的地址,它这里的数据是什么呢,就是原rbp。
call除了改变rip,还将call所在的位置的下一条指令的地址压入了栈中,也就是:返回地址。 也就是说,函数调用第一个压入返回地址,第二个压入原rbp的值。当函数返回的时候,执行leave和ret两条指令,(leave等同于:mov rsp, rbp; pop rbp),两条语句把之前的rsp和rbp的值都恢复了,接着执行ret,作用是:pop rip(可能只是因为用得多,而且都用于返回,才写成ret,实际上与pop rip完全等价。),那么pop rip,就是将函数调用前保存的返回地址送进rip寄存器,下一句指令就会到那里执行,于是可以看到在函数返回的时候,栈结构被恢复成函数被调用前的样子了。
在leave(mov rsp, rbp; pop rbp)时,这里在执行pop rbp时,rsp因为已经被赋值为rbp当前值,而之前就提到过rbp所指位置存储的数据时原rbp,所以pop rbp就会将当前rsp所指,也是rbp所指的数据弹到rbp,恢复rbp原有值,然后rsp指向了rbp原有的值,紧接着又pop了一次,那么在上次pop了栈顶上保存的原rbp后,新的栈顶数据是什么呢?没错,比原rbp早一步被压入的:返回地址
一个关于函数调用的比喻
调用子函数的全过程由以下几条语句组成:
(调用者函数 caller)
call xxx
(跳转到被调用函数 callee)
push rbp
mov rbp, rsp
sub rsp,
以上四个语句就是整个标准的函数调用流程,当完成这个流程时,栈空间往低地址增长。
如果用一个很奇怪的比喻来解释栈的增长,和栈中数据的rwx,就是你面前有一辆火车,火车内的空间代表栈空间,火车内的乘客代表数据,左边是低地址,右边是高地址,乘客在火车里面永远是先坐满最左边的那一排,再逐渐往右坐,这代表write的顺序,乘务员点人的时候也永远是从左边往右边点,这代表read的顺序
而当发生了调用子函数的事件时,列车组人员会再拉来一列车厢,接在火车的最左边,这是栈往低地址增长。然后这个时候乘客要上车,是在那个新加上去的那节车厢从左往右坐。火车是从右往左加车厢的,栈空间增长就是在低地址那边加了节车厢,乘客永远先坐整辆火车的最左边。
push是在最左边加一排座位并且从寄存器那里揪一排人摁进去,pop是把那排的人推进寄存器然后拆掉那排座位。
push的时候,寄存器的值被压入栈,但是寄存器的值没有改变。pop的时候,rsp所指(栈顶)数据进入寄存器,并且rsp下移,此时表现为栈顶数据从栈中消失,但是那个地址只是不再位于栈的范围了,里面存储的数据没有改变。
继续修改上面的奇怪比喻的话,那就是,push的时候加了排座位,并且从指定的寄存器那复制了一排人摁到了座位上;pop的时候先把原来最左边座位上的人复制一遍拖到指定的寄存器那里,再把座位拆掉,然后原本在那坐的人就站着了,但是他们“不属于火车上的人了”。
函数返回的时候就是把那节现在不知道多长的车厢拔掉。
然后当拔掉一节车厢时,只是车厢被拔掉了,人还站在原来的位置,站在铁轨上,如果那里又加了一节车厢,他们就到车厢里继续坐原来的位置(加车厢对应call函数,加座位是push)
除非出现例外情况:例如拆座位拆到最左边整节车厢都拆空了,这时候如果继续拆会拆到下一节,这时要是再返回程序一般就出错了。
在这个比喻的基础上解释栈溢出
那就是乘客在最左边的一节车厢上车,却因为车厢坐满了直接坐到了车厢之间的连通处,连通处存着的返回地址直接给坐没了(
在这个比喻的基础上解释局部变量
乘客不是永远从“最左边”开始坐,而且基本不会从最左边(rsp)开始,真正开始坐的位置是局部变量的位置
- 当函数中声明局部变量时,编译器就确定了该变量相对于rbp的偏移量,相当于在车厢某个位置画了条线,写上那个变量的名字
- 当有gets一类读取输入的时候,编译器会为其预留“座位”
- 不管怎样,变量(乘客)总是从低到高(从左到右)写入(入座)的
总结
扫码阅读此文章
点击按钮复制分享信息
点击订阅