linux下进程堆栈下溢出判断及扩展实现
一、堆栈扩展
在进程创建的时候,内核并没有为进程分配太多的堆栈,即使是逻辑地址空间也没有,这样做的好处就是如果说用户态的程序堆栈向下溢出(对386来说,就是访问了更低地址的内存空间),这样内核可以比较容易的检测出这种错误,尽管这种错误出现的可能性要比向上溢出的概率小的多。记得在之前使用VS编译器的时候,编译器还有一个堆栈探测过程,就是对于局部变量大小超过一个页面的函数,编译器会生成额外的probe指令来预先来踩一脚这些页面,可能是windows内核中只允许一次向下扩展一个页面的堆栈空间?不管如何,这个我们就不验证了,因为我现在没有装windows的编译器啊,所以只能看这个Linux下的这种实现了。
二、内核中判断
linux-2.6.21\mm\mmap.c文件中的
int expand_stack(struct vm_area_struct *vma, unsigned long address)
函数负责对堆栈进行扩展,这个名字贴切而拉轰,以至于我一眼就就找到了它。在其中访问地址和堆栈的判断,其中对于限制的代码并不多,大致来说是集中在
acct_stack_growth
函数中。这个函数中并没有检测一个页面限制,所以在这个里面是没有对向下溢出多少发生错误提供信息。
三、真正判断位置
事实上,这个是一个硬件相关的一个特殊判断
1、386体系结构实现
对于我们常见的386来说,其实现位于linux-2.6.21\arch\i386\mm\fault.c
fastcall void __kprobes do_page_fault(struct pt_regs *regs,
unsigned long error_code)
if (error_code & 4) {
/*
* Accessing the stack below %esp is always a bug.
* The large cushion allows instructions like enter
* and pusha to work. ("enter $65535,$31" pushes
* 32 pointers and then decrements %esp by 65535.)
*/
if (address + 65536 + 32 * sizeof(unsigned long) < regs->esp)
goto bad_area;
}
也就是这里判断是如果访问地址在386栈顶位置向下32 * sizeof(unsigned long),则认为是越界访问,当然,这里处理也是有些简单粗暴了,因为这里更加详细的做法应该是判断一下指令,这里所说的指令应该在大部分时间都是不用的,所以这个判断应该相对是一个过于宽泛的限制,这个65536相当于16个大小为4KB的页面,所以还是比较宽泛的。
2、powerpc实现
我们再看一下powepc的处理
/*
* N.B. The POWER/Open ABI allows programs to access up to
* 288 bytes below the stack pointer.
* The kernel signal delivery code writes up to about 1.5kB
* below the stack pointer (r1) before decrementing it.
* The exec code can write slightly over 640kB to the stack
* before setting the user r1. Thus we allow the stack to
* expand to 1MB without further checks.
*/
if (address + 0x100000 < vma->vm_end) {对于堆栈空间刚开始的1M空间之下的内容,可以随意扩展而不加检测。
/* get user regs even if this fault is in kernel mode */
struct pt_regs *uregs = current->thread.regs;
if (uregs == NULL)
goto bad_area;
/*
* A user-mode access to an address a long way below
* the stack pointer is only valid if the instruction
* is one which would update the stack pointer to the
* address accessed if the instruction completed,
* i.e. either stwu rs,n(r1) or stwux rs,r1,rb
* (or the byte, halfword, float or double forms).
*
* If we don't check this then any write to the area
* between the last mapped region and the stack will
* expand the stack rather than segfaulting.
*/
if (address + 2048 < uregs->gpr[1]
&& (!user_mode(regs) || !store_updates_sp(regs)))如果堆栈已经扩展到1M一下,这里检测开始加强,只能访问2048字节之下范围,否则这里真的判断了特殊指令,所以这个应该比较准确,可能是由于RISC机型的指令操作比较简单?
goto bad_area;
}
四、386系统调用时寄存器使用情况和这个判断的关系
386处理器有8个通用寄存器 E[ABCD]X,E[SD]I,E[SB]P,这八个寄存器,但是现在的系统调用使用寄存器传递的话,可以看到386系统中最多只是用了7个寄存器,这里唯一没有使用的就是ESP寄存器,通过这里我们可以猜测,如果用户态把ESP也作为寄存器传递入内核的话,那么内核在栈顶判断的时候这里可能就不准确,因为用户态的代码是可能缺页的。
五、验证一下
[tsecer@Harry stackflow]$ cat stackflow.c
#include <stdio.h>
int overflower()
{
//char placeholder[0x1000*19];
char localvar,*localvaraddr=&localvar,page;
int myesp;
__asm__ (
"movl %%esp,%0"
:"=r"(myesp)); //这里使用汇编语言获得栈顶指针ESP。
printf("myesp is %#x,most acc %#x\n",myesp,myesp-65536-32*sizeof(unsigned long));//模拟内核的计算规则,算出可以访问的最低位置
for(page =0 ; ;page++)//以页面为单位访问ESP之下内存,看何时触发内核段错误。
{
printf("probing %#x\n",localvaraddr-(page<<12));
localvaraddr[-(page<<12)] = 0;
}
return 0;
}
int main()
{
return overflower();
}
[tsecer@Harry stackflow]$ gcc stackflow.c -g -o stackflow.c.exe -static
[tsecer@Harry stackflow]$ ./stackflow.c.exe
myesp is 0xbfb45c10,most acc 0xbfb35b90
probing 0xbfb45c23
probing 0xbfb44c23
probing 0xbfb43c23
probing 0xbfb42c23
probing 0xbfb41c23
probing 0xbfb40c23
probing 0xbfb3fc23
probing 0xbfb3ec23
probing 0xbfb3dc23
probing 0xbfb3cc23
probing 0xbfb3bc23
probing 0xbfb3ac23
probing 0xbfb39c23
probing 0xbfb38c23
probing 0xbfb37c23
probing 0xbfb36c23
probing 0xbfb35c23
probing 0xbfb34c23根据计算,这个地址是不能访问的,但是这里并没有触发异常。
probing 0xbfb33c23
probing 0xbfb32c23
probing 0xbfb31c23这里总共访问了大致21个页面,与我们假设内容不同。
Segmentation fault (core dumped)
[tsecer@Harry stackflow]$
六、现象分析(进程初始化堆栈空间多大)
这一点要说到内核为一个可执行文件建立一个堆栈的时候,这个堆栈空间是多大,也就是内核为一个进程启动的过程中一次性分配了多少vma区间。这个问题在内核的
linux-2.6.21\fs\exec.c:setup_arg_pages
函数中有决定性影响:
arg_size += EXTRA_STACK_VM_PAGES * PAGE_SIZE;
……
#ifdef CONFIG_STACK_GROWSUP
mpnt->vm_start = stack_base;
mpnt->vm_end = stack_base + arg_size;
#else
mpnt->vm_end = stack_top;
mpnt->vm_start = mpnt->vm_end - arg_size;
#endif
其中
#define EXTRA_STACK_VM_PAGES 20 /* random */
也就是说,内核在为一个进程分配堆栈的时候,将会在实际使用的堆栈基础之上在额外增加20个页面的虚拟地址空间。由于实际上一个进程真正使用的参数空间一般小于一个页面(4KB),所以通常一个进程最开始堆栈分配的空间为21个页面,我们随便找一个程序看一下:
[tsecer@Harry stackflow]$ cat /proc/self/maps
0017b000-0017c000 r-xp 00000000 00:00 0 [vdso]
001e8000-00206000 r-xp 00000000 fd:00 1280 /lib/ld-2.11.2.so
00206000-00207000 r--p 0001d000 fd:00 1280 /lib/ld-2.11.2.so
00207000-00208000 rw-p 0001e000 fd:00 1280 /lib/ld-2.11.2.so
0020a000-0037c000 r-xp 00000000 fd:00 1282 /lib/libc-2.11.2.so
0037c000-0037d000 ---p 00172000 fd:00 1282 /lib/libc-2.11.2.so
0037d000-0037f000 r--p 00172000 fd:00 1282 /lib/libc-2.11.2.so
0037f000-00380000 rw-p 00174000 fd:00 1282 /lib/libc-2.11.2.so
00380000-00383000 rw-p 00000000 00:00 0
08048000-08053000 r-xp 00000000 fd:00 68967 /bin/cat
08053000-08055000 rw-p 0000a000 fd:00 68967 /bin/cat
09af0000-09b11000 rw-p 00000000 00:00 0 [heap]
b75b8000-b77b8000 r--p 00000000 fd:00 100518 /usr/lib/locale/locale-archive
b77b8000-b77b9000 rw-p 00000000 00:00 0
b77ce000-b77cf000 rw-p 00000000 00:00 0
bfc1f000-bfc34000 rw-p 00000000 00:00 0 [stack]这个空间也是21个页面,同一个系统中多次执行这个命令,栈顶的位置并不确定,这是因为load_elf_binary在调用setup_arg_pages的时候执行了randomize_stack_top,所以这个堆栈区间不确定,但是大小确定。
回到我们刚才说的那个问题,由于堆栈向下的20个页面都是在初始堆栈的虚拟地址空间中的,所以它不会触发堆栈扩展检测,因为这个本来已经在堆栈区间中了。
七、再次模拟
把overflower函数中的
//char placeholder[0x1000*19];
注释打开,从而让堆栈尽可能多的占用更多的堆栈空间,也就是迫使esp濒临原始堆栈vma区间下边界,然后开始逐步探测,此时计算结果会达到预期结果
[tsecer@Harry stackflow]$ ./stackflow.c.exe
myesp is 0xbf9f4b40,most acc 0xbf9e4ac0
probing 0xbf9f4b53
probing 0xbf9f3b53
probing 0xbf9f2b53
probing 0xbf9f1b53
probing 0xbf9f0b53
probing 0xbf9efb53
probing 0xbf9eeb53
probing 0xbf9edb53
probing 0xbf9ecb53
probing 0xbf9ebb53
probing 0xbf9eab53
probing 0xbf9e9b53
probing 0xbf9e8b53
probing 0xbf9e7b53
probing 0xbf9e6b53
probing 0xbf9e5b53
probing 0xbf9e4b53
probing 0xbf9e3b53访问出错位置在预测位置之下。
Segmentation fault (core dumped)