[toc]
关键词
过程,数组,内存结构,缓冲区溢出
数组和缓冲区溢出一定去看看汇编代码,然后做练习题,就算懂了原理,也要看一下数组是怎么操作的。
因为汇编语言里面的操作和你写的C代码可能很不一样。比如你写了一个for循环,求和一个一维整型数组里面的所有数。那么你在C代码里面可能会写for (int i = 0; i < N; i++)
,但是汇编里面很可能不会出现一个寄存器来保存整数N来判断,而是判断指针会不会超过(指针名的地址 + N * size)。
过程
过程是种抽象,一个函数调用另一个函数就是个过程。抽象是什么我也不懂,没学过面向对象,不过这章讲的东西我基本上都搞懂了。
为啥要用程序栈
首先说下为啥要用程序栈,为啥不是程序队列程序树啥的呢?因为我们调用函数的思想和栈正好相符。如果P函数调用Q函数,那么我们肯定希望P的必要信息被保留,Q的必要信息被暂时生成,在Q运行结束后,Q里面的所有auto自动变量都应该被释放,这就是函数的思想,就算没学计算机原理也大概都知道。
这个思想和栈的数据结构是完全相符的。内存在计算机看来就是个大数组,而且栈完全可以由数组实现。而且我们用数组实现栈的时候,pop出栈的时候需要擦除掉pop的内容吗?不用,移动栈顶的位置就行。
当主函数调用P函数时,挂起主函数,在栈中向栈顶开辟一片新的空间给P用,栈顶指针移动开辟空间,当P调用Q的时候同理。当Q运行结束的时候,计算机根本不用去擦除Q的信息,只要移动栈顶指针回到P函数给Q函数释放空间前的位置就行了。
这也就解释了为什么递归容易造成栈溢出,因为计算机会规定,这些函数占的地方不能超出某个阈值,但是如果递归无限循环,那会导致程序栈一直在变大,无法被释放(想释放得等函数return,但是如果递归一直不结束就不会触发任何一个终止的return),最后就gg了。
程序栈细节
首先,程序栈是很反人类的倒着的。你可以想象成一个反重力水桶,它倒扣在地上,但是水桶里的水出人意料的贴在水桶底部,在高处悬浮着。这就是我们的程序栈了。
这个水桶的地址是正常思维,离地面越高,地址越大,反之亦然。程序栈寄存器%rsp永远指向水的最低点。每次我们调用程序栈,那么栈指针地址会减小(地址减小反而是开辟空间)。
每次调用函数多出来的地方,我们叫做栈帧。这里重点要说下call和ret这两个汇编代码的作用。
当我们遇到 call的时候,程序栈会干三件事情:
栈指针%rsp减小,给返回地址开辟空间。
将call这一条代码的下一条代码的地址(即返回地址),push压栈,作为将来Q结束时回来的路标。
将程序寄存器PC(它指哪cpu运行哪里)里面的地址,换成Q的起始地址,让cpu开始运行Q的代码。
当程序遇到 ret的时候,程序栈会干三件事情:
把返回地址出栈
栈指针%rsp增加,释放空间
将程序寄存器PC里面的地址,换成刚被pop出栈的返回地址。让cpu继续运行P函数的后续代码。
数据储存
之前说过,有6个寄存器来保存6个函数参数,但是如果函数参数大于6个咋办?
答案是让程序栈开辟空间,给多余的参数腾地方。但其实很多函数根本就用不到这么多参数,所以参数都在寄存器里面存着,程序栈上面仅仅开辟出来8个字节保存返回地址而已。
有时候数据必须存在内存中:
寄存器不足,上面提了
如果对一个局部变量使用了取地址符 &,那么必须得生成一个地址在内存上面。
数组
课上有人提问,如果被调用的Q在栈上开辟了16个字节,那么最后计算机能记住Q开辟了多少字节然后一键还原吗?
它就是能。首先如果Q没有不确定的变长数组,那么它的空间在运行前就被确定了(各种类型的大小都是确定的)。如果Q有变长数组,那么计算机会使用特殊寄存器%rbp来保存开辟前的地址,最后也能还原。
被调用者保存
这个词我第一次见是看16个寄存器列表的时候,有个编译器就被注释--被调用者保存。%rbp 和 %rbx 都是被调用者保存类。
啥意思?很简单,就是假设P有个局部变量 num 保存在%rbx中,当P调用Q,调用结束后,我立马去访问 %rbx,那么一定能获得 num 的值,计算机亲口保证,在被调用的函数Q在结束的时候会让%rbx和以前保持一致。
我为啥没说让 %rbx 不变呢?因为它是可能变的,在Q的运行过程中,它如果一直不动 %rbx 就算了,如果它动了,那么它一定会在Q栈帧中开辟出一个地方保存原 %rbx 的值,最后再把这个值赋值回去。
为什么要这样子?这个东西叫约定俗成,就跟化学课为什么 1 摩尔等于XXXX,IEEE的浮点数要搞成三部分一样。大家的cpu都这样设计,就算换成不同的编译器去编译代码,得到的结果都是一样的。
调用者保存
这又是啥意思?就是 调用者你自己负责的意思, Q和P说,这个寄存器的值你自己保存啊,到时候我可能会把它改了,你要是没存我可不负泽任。所以P一般会把这些值保存在自己开辟出的栈帧上面。
图3.32
这个例子强烈建议看下,如果能秒懂那你很无敌。太长了就不敲了。
练习题 3.35
我加大了一个下难度,如果你能做出来那说明你真正理解了,做不出来就看书去吧老铁。
- 请还原原函数,变量名你随便起
- 为什么第一条要pushq %rbx?
long rfun(unsigned long x)
x in %rdi
rfun:
pushq %rbx
movq %rdi, %rbx
movl $0, %eax
testq %rdi, %rdi
je .L2
shrq $2, %rdi
call rfun
addq %rbx, %rax
.L2:
popq %rbx
ret
答案:
long rfun(unsigned long x) {
if (x <= 0) {
return 0;
}
unsigned long nx = x >> 2;
long rv = rfun(nx);
return x + rv;
}
因为要被调用者保存,这个%rbx是保存x的,x会参与到最后的return中,那么这个递归必须要保证,经过递归之前的 x 能够被保存下来。怎么保存的你不用管,但是只要放在 被调用者保存 寄存器,它就一定会被保存。
数组和结构体
数组的东西其实和C Primer Plus 讲的重合度挺高的,讲了很多关于数组的基本操作。
数组在内存
数组在内存里是一个整体,里面的元素肯定都是挨着的。不过假设我们连续创建两个数组,并不能保证这两个数组是挨着的。二维数组是放完一排再放一排,整体上每排之间都是紧挨着的。
感觉看了数组的视频,只要把
(%, %, i)
这个东西搞懂了,数组的事情就完全明白了。二维数组的话 就是
数组头 + size_t*宽*index_i + size_t*index_j
结构体
重心就是字节对齐,其他的和数组没什么太大区别。因为汇编代码不求会写,只要看懂就行了,所以不用记太多东西。
对齐的原因是硬件的问题,计算机每次大概取64字节的数据来读,如果没有字节对齐,而且有个double类型的只被截取了一半,那么计算机需要花点时间去读另一半,速度会变慢。这个字节对齐过程是编译器自动给你干的,程序员能做的就是尽量的注意定义顺序,来让被浪费的空间最小化。
顺便提下,就算不是字节对齐,x86也能读也能正常运行,就是慢。
内存结构
之前说了程序栈像一个反重力水桶,而在内存的结构中,程序栈正好处于最上层。注意下图每个模块之间可能是有空白的,不是上来大家就是紧挨着的。
首先说下47位这个概念,因为目前我们的内存几乎不可能填满64位,因为这特么实在太大了,所以系统就规定,最大的地方就是47位,不会再大了。所以最大的地址是多少?
就是0x00007FFFFFFFFFFF,11个F就是44个1,再加上7对应的3个1,正好47位。那我知道这个有什么用?在看机器代码的时候,如果你看到地址是0x00007FFFFXXXXXX打头的,那么你就知道这个地址多半就是程序栈所在的地方。
内存还有一个很有意思的设定,那就是栈是完全从上到下连贯的,但是堆的内存分配不是连续的。假设我初始化两个指针 p1 p2
:
int *p1 = malloc(1<<20);
int *p2 = malloc(4);
很明显,p1
的空间贼大,但是p2
就很小,只能存一个int
,那么x86会把偏大的内存p1
给放在很高的地方(贴近栈),p2
放在很低的地方(贴近Data)。如果你再申请内存,新生成的内存p3
会在它们中间的空白挑个地方落草。如果你一直申请内存而且不释放,最后内存都被用完了,malloc
失败,会malloc(0)
。
为什么?写书的教授也不知道,规定。
缓冲区溢出怎么攻击
C语言不设置边界检查,最容易导致的就是缓冲区溢出被攻击。
我们看到下面这个代码有个长度为4的char型数组,但是在栈上,并不会因为这里的长度是4,就只分配4个字节。
int main(void)
{
char buf[4];
gets(buf);
puts(buf);
}
事实上这里分配了30个字节,而且buf对应的空间在30个字节的最低端(我也不知道为什么是最低端,公开课上的ppt就是这样,也没解释),我是在我的电脑的汇编代码中看到了这句话:
401554: 48 83 ec 30 sub $0x30,%rsp
结尾还有一句对应:
40157a: 48 83 c4 30 add $0x30,%rsp
很明显这是初始化主函数的时候,给分配了30个字节的空间,而且,我在上一篇博客,第三章上里面说了,当PC读到call时,它会把返回地址push压栈进入程序栈,再进入被调用函数,在读到ret的时候,会把这个地址pop出来,然后把这个地址的值放在%rip里面,这就是PC要运行的下一条指令。
什么意思呢?就是 返回地址那8个字节,紧紧挨着下面被分配的30个字节。
注入攻击
如果我们不止在gets环节中输入4个,而是输入30个以上,那么就会填充到之前被push的 返回地址,返回地址被破坏,如果我们是黑客,那就可以用输入把返回地址刻意改成了我们想要的地方,然后在那个地方加入一些code(咋加的我也不懂),就可以运行我们想要的代码。
我自己认为,想完成这个操作,黑客必须知道源码或者要猜出来,输入多少个字节后能改变返回地址。
返回攻击
这一段我也没太听懂,我理解的是,把一段代码,我们叫做gadget,和return连接起来,还是利用PC读到return的原理,如果我们可以把gadget想办法插入到程序栈的末尾(别管怎么插进去的,反正就插进去了),那么通过让程序一直读取到恶意的return,从而pop出黑客设计好的 gadget 返回地址,以达到执行特定命令的效果,等做完了Lab再详细解释。
防御机制
有三种方法,基本上现在可以防住绝大多数缓冲区攻击。
栈随机
正常的栈开始是从47位顶端开始下降的,但是这样子如果别人有你的源代码,那么就能精确的算出来,你的return的返回地址将会出现在哪个对应的地址,并且每次运行这个地址都是固定的。那只要通过注入攻击就可以精确修改return地址。
所以编译器可以,每次在最高内存地址的地方上,随机减个一到两兆,每次都是不一样的,自然就没办法那么准确的找到return 返回地址了。
限制可执行代码区域
硬件上的优化,将内存标记成三种形式:可写,可读,可执行。也就是黑客放进来的代码可能处在不可执行区,或者有的地方无法修改,只是可读,就能让攻击没那么容易。
栈破坏检测(金丝雀canary)
煤矿地下可能有瓦斯,金丝雀对这玩意特别敏感,如果它不叫了或者死了,工人就可以逃命了。
同样的原理,回到上面栈开辟30字节的例子,编译器可能随机在栈的备用区域中选8个字节作为canary(意思就是这个canary在30个字节里选但不影响buf),然后它会检查这个值有没有被改变,不管是注入还是返回攻击,都会恶意地改变canary的值,那么编译器会认定这个代码有问题,会强制终止程序的执行。
这个是最强的方法,几乎可以杜绝这种攻击。