一个栈溢出的BUG
我的博客:http://blog.striveforfreedom.net
1 BUG描述
最近修改一C程序,在一个结构体里加入了几个新的字段,编译完一跑竟然出现段错误(segmentation fault)崩溃了。用gdb查看,引发崩溃的是一条这样的指令:mov register offset(%rsp)。
2 解决过程
从引发崩溃的指令可以看出,崩溃的原因是访问了栈上的内存,然而通常来说访问栈上内存是不会导致段错误的,因为栈上内存不需要程序员手动管理,一般来说很难出错。猜测有可能是栈溢出了,需要证实这个想法。发生崩溃的机器是X86_64+Linux,用ulimit -s得知进程栈默认的soft limit是10MB,因为程序代码并没有调用setrlimit调整过栈的soft limit,于是需要证明出现段错误的进程栈大于10MB了,导致崩溃的地址可以从gdb中查看,如果知道栈的起始地址(栈底),两者之差就是栈的大小。但如何才能知道栈的起始地址呢?我们知道Linux有个proc文件系统,系统里每个进程在/proc下都有一个以进程ID命名的文件夹,/proc/PID下包含的都是进程ID为PID的进程的相关信息,比如说该进程对应的可执行文件路径/当前目录/打开的文件等。其中/proc/PID/maps包含了该进程所有虚拟区域的起始和结束地址,包括栈(即文件maps里最后一个字段为[stack]对应的那一行)。拿到了栈的起始地址之后,用起始地址减去引发崩溃的那条指令中访问的内存地址(即rsp加一个偏移),得到的值果然大于10MB了。至于栈溢出的原因,原有代码在栈上定义了一个结构体的数组,而我在结构体里面加了几个size比较大的字段,因此溢出了。找到原因,BUG修改起来就很简单了,要么在shell里修改栈默认的soft limit,要么在代码里调用setrlimit,要么在堆上分配内存。
为了说明这个BUG,我写一段测试代码作为例子,代码如下:
int main(int argc, char* argv[]) { const unsigned len = 10 * (1U << 20); char data[len]; data[0] = 'a'; return 0; }
编译完一运行,出乎意料的是,进程竟然没崩溃!这就非常奇怪了,因为我在main函数里定义了10MB大小的数组(并且访问了第一个元素,即最地址最小的那个),且不说环境变量所占空间,单这个数组加上C运行库调用序列所占空间就超过10MB了,而栈soft limit是10MB,按理说必然崩溃。然而实际却没有崩溃,一开始我怀疑代码被优化掉了,用objdump一看并没有优化掉,接着想了很久都没有头绪,最后终于想起去查看内核代码,看看内核到底是怎么处理栈溢出的。这包含两个方面,一是栈的soft limit是怎么读取出来的,二是内核怎么检查栈大小是否超过soft limit了。发生崩溃的机器上装的是CentOS 5.7,用uname -r得到的内核版本是2.6.18-308.el5,这个版本号跟官方内核版本号对应不上,因为感觉应该很接近2.6.18,于是就查看了官方内核2.6.18的代码(推荐下 lxr.linux.no ,查看某个版本内核代码很方便,不用去下载几十M的源码包了)。
读取资源限制(soft limit & hard limit)是由系统调用getrlimit完成的,getrlimit在内核中的入口是sys_getrlimit,代码如下:
asmlinkage long sys_getrlimit(unsigned int resource, struct rlimit __user *rlim) { if (resource >= RLIM_NLIMITS) return -EINVAL; else { struct rlimit value; task_lock(current->group_leader); value = current->signal->rlim[resource]; task_unlock(current->group_leader); return copy_to_user(rlim, &value, sizeof(*rlim)) ? -EFAULT : 0; } }
这个函数很简单,就是把当前进程的某种资源限制读出来,并复制到用户空间,没有发现什么问题。
对栈的大小限制的检查是在页面异常(page fault)处理中完成的,从页面异常入口page_fault开始,查看调用序列page_fault > do_page_fault > expand_stack > acct_stack_growth,在函数acct_stack_growth中发现了对栈大小限制进行检查的代码,如下(省略了跟我们这个例子无关的代码):
static int acct_stack_growth(struct vm_area_struct * vma, unsigned long size, unsigned long grow) { //... struct rlimit *rlim = current->signal->rlim; //... /* Stack limit test */ if (size > rlim[RLIMIT_STACK].rlim_cur) return -ENOMEM; //... }
其中,参数size是栈的起始地址减去当前引发页面异常的地址并按页大小向上对齐的,很显然,这里如果发现栈大小大于soft limit就返回错误,最终会给当前进程发送SIGSEGV信号,导致进程出现段错误崩溃。上面的内核代码说明我的想法是正确的,然而进程并未和我预料的那样崩溃,一想可能是内核版本不对,于是又查看了官方2.6.19版的代码,发现这两处的代码并没有改过。这就很奇怪了,过了一会我突然想到,机器上装的是CentOS,CentOS可能修改了这处的官方内核代码,于是我下载了和我系统对应的源码包kernel-2.6.18-308.el5.src.rpm,安装之后,发现该版本对应的官方内核版本是2.6.18.4,CentOS的修改过的代码放在一个patch文件kernel-2.6.18-redhat.patch里,运行patch之后,果然发现CentOS修改了acct_stack_growth函数,修改如下(该函数有多处修改,这里只列出了跟我们这个BUG相关的修改):
static int acct_stack_growth(struct vm_area_struct * vma, unsigned long size, unsigned long grow) { //... struct rlimit *rlim = current->signal->rlim; //... /* Stack limit test */ if (over_stack_limit(size)) return -ENOMEM; //... }
一比较就可以发现官方内核代码是直接比较size和栈的soft limit,而CentOS把这个比较放进了函数over_stack_limit里,再来看函数over_stack_limit:
static int over_stack_limit(unsigned long sz) { if (sz < EXEC_STACK_BIAS) return 0; return (sz - EXEC_STACK_BIAS) > current->signal->rlim[RLIMIT_STACK].rlim_cur; }
其中EXEC_STACK_BIAS是一个整型常量,定义如下:
#define EXEC_STACK_BIAS (2*1024*1024)
很显然,CentOS把栈大小限制从soft limit往上提高了2MB,如果栈大小超过栈的soft limit+EXEC_STACK_BIAS(在我们这个例子中为12MB)则说明栈溢出。到此真相大白,把上面的测试代码修改一下(把数组大小改为12MB),再一运行进程果然崩溃。
3 小结
有时候接近问题的真相,但并没有发现问题的全部,就这个问题来说,如果我不写这篇文章,也就不会去写上面那段测试代码,就会想当然地认为在我的机器上栈的默认大小限制是10MB了。
posted on 2013-06-14 10:40 striveforfreedom 阅读(3172) 评论(2) 编辑 收藏 举报