作者:王智通
堆栈基础篇:
1、堆栈结构
从广义上来讲,堆栈其实就是一种后进先出的数据结构,这跟队列的作用正好相反, 你可以定义一个数组或用malloc分配一块内存来模拟堆栈的作用, 比如openjdk的解释器就要用到堆栈结构来做计算。
我们在从c的角度来仔细审视下堆栈的结构,本文以intel体系结构为例。
intel处理器定义了跟堆栈有关的几个寄存器:
esp/rsp: 保存了当前堆栈栈顶指针的寄存器。
ebp/rbp: 保存了当前堆栈基地址指针的寄存器。
在通常情况下, 我们观察到的堆栈生长方向是向内存低地址生长的, 这是大多数操作系统的实现方式。但这不是固定的,intel给开发者定义了宽松的环境, 操作系统内核开发者可以让在内核进入保护模式前,通过给段描述符设置不同的属性,自由配置堆栈的生长方向,也就是说为了just for fun, 你可以写个内核让堆栈指针是做加法操作的。
0x0 0xc0000000 ---------------------------------------------- | stack | --------------------------------------------- <-----------------esp/rsp---------------------
当往堆栈压入一个数据的时候, esp自动减少一个数据的大小长度, 抽象为esp -= sizeof(type);
我们在c语言的函数里经常会定义一些变量, 看如下c代码:
test.c:
#include <stdio.h> #include <stdlib.h> void test(int a, int b) { char buff[32]; strcpy(buff, "hello, gdb"); } int main(void) { test(); }
编译后, 用gdb反汇编下test函数:
(gdb) disass test Dump of assembler code for function test: 0x0000000000400448 <test+0>: push %rbp 0x0000000000400449 <test+1>: mov %rsp,%rbp 0x000000000040044c <test+4>: lea -0x20(%rbp),%rax 0x0000000000400450 <test+8>: movl $0x6c6c6568,(%rax) 0x0000000000400456 <test+14>: movl $0x67202c6f,0x4(%rax) 0x000000000040045d <test+21>: movw $0x6264,0x8(%rax) 0x0000000000400463 <test+27>: movb $0x0,0xa(%rax) 0x0000000000400467 <test+31>: leaveq 0x0000000000400468 <test+32>: retq End of assembler dump.
留意下lea -0×20(%rbp),%rax 这条指令里的-0×20(%rbp), 也就是rbp – 0×20, 说明系统是用ebp减32个字节来给buff申请空间的。
我们在来画下test函数的堆栈结构:
----------- <------rsp 内存低址 | buff[0] | ----------- | buff[1] | ---------- | ... | ----------- | buff[63]| ----------- <------rbp | rbp | ----------- | ret_addr| <------test函数后面一条指令的地址 ----------- | a | <------参数a ----------- | b | <------参数b 内存高址 -----------
ret_addr保存的是当函数执行完后,要返回去执行的地址, 对这个例子, 用gdb或objdump都可以很轻松的看到:
objdump -d test 0000000000400469 <main>: 400469: 55 push %rbp 40046a: 48 89 e5 mov %rsp,%rbp 40046d: e8 d6 ff ff ff callq 400448 <test> 400472: c9 leaveq
c语言的函数参数是从右向左依次压入堆栈的, 所以函数调用之前,参数b先压入到test的栈帧里, 然后是参数a。从上面的堆栈结构, 我们可以看到rbp + 8就是参数a的地址, rbp + 12就是参数b的地址,为什么要是rbp + 8开始访问变量呢, 因为rbp + 4是ret_addr的地址。 对于变量的访问则是rbp – 4*n来进行的。
高级篇
在基础篇中, 我们认识了变量在堆栈中的分配方法, 下面我们来看看用这些知识都能来干什么事。
1、可变参数及printf的实现
在c code里, 经常会用到可变参数的函数,比如printf这是大家最熟悉的关于可变参数的示例, glibc里提供了stdarg.h给coder使用, 在掌握了堆栈结构的基础上, 我们可以自己来一个printf。
printf的基础用法可以这样:
printf("xxxx"); printf("%d", 4); printf("%d, %c", 4, 'a');
printf的第一个参数是格式化参数, 从第2个参数开始是变量的地址。
我们只要知道第一个参数的地址, 通过一个循环来解析%d, %c, %x这种类型, 没当这些类型时,就通过第一个参数地址加上这个类型对应的大小, 就能找到下一个参数的地址, 举个例子:
printf("%d, %c", 4, 'a');
“%d, %c”是printf的第一个参数, 我们用一个循环来解析它, 当它碰到%d时, 说明printf的第2个参数是
一个int类型的, 通过指针加sizeof(int), 就可以定位到第2个参数, 以此类推, 来解析所有的参数。
下面这些代码取自我自己写的一个操作系统内核, 实现了一个printf的部分功能。
printk.h: #define va_list char* #define va_start(arg, fortmat) (arg = (char *)&format + sizeof(format)) #define va_arg(arg, format) (*(format *)((arg += sizeof(format)) - sizeof(format))) #define va_end(arg) *(char *)arg = 0 int printk(char *format, ...) { va_list arg; va_start(arg, format); return vfprintf(format, arg); }
va_list就是一个char *指针的宏定义。
va_start用来取得第2个参数的地址, 注意第一个参数地址是format, 它是printf的格式化参数。
va_arg向后递归一个参数。
vfprintf是具体的解析函数, 大家可以仔细来阅读下。
int vfprintf(char *format, va_list arg) { int flag = 0, ret = 0; const char *p = format; while (*p) { switch (*p) { case '%': if (flag) { flag = 0; putc(*p); ret++; } else { flag = 1; } break; case 'd': if (flag) { char buf[32]; flag = 0; /* FIXME: can't print 0. */ itoa(va_arg(arg, int), buf, 10); puts(buf); ret += strlen(buf); } else { putc(*p); ret++; } break; case 'x': if (flag) { char buf[64]; flag = 0; itoa(va_arg(arg, int), buf, 16); puts(buf); ret += strlen(buf); } else { putc(*p); ret++; } break; case 'b': if (flag) { char buf[16]; flag = 0; itoa(va_arg(arg, int), buf, 2); puts(buf); ret += strlen(buf); } else { putc(*p); ret++; } break; case 's': if (flag) { char *str = va_arg(arg, char*); flag = 0; puts(str); ret += strlen(str); } else { putc(*p); ret++; } break; case 'c': if (flag) { char s = va_arg(arg, char); flag = 0; putc(s); ret++; } else { putc(*p); ret++; } break; default: putc(*p); ret++; break; } *p++; } va_end(arg); return ret; }
2、stacktrace的编写方法
根据堆栈的结构, 我们可以做例外一件非常有意义的事情, 打印stack trace。 各位亲, 通过前面的堆栈结构, 我们可以看到rbp后面保存的是ret_addr的地址。 只要知道rbp的地址, 就可以用rbp + 4来获得ret_addr的地址。 如果获得rbp的值呢, 可以用过gcc内嵌汇编来做到:
#define GET_BP(x) asm("movq %%rbp, %0":"=r"(x)) GET_BP(rbp); rip = *(unsigned long *)(rbp + 1);
这样我们就找到了这个函数的返回地址, 但是这个函数调用可能来自多个函数的嵌套调用, 各位亲,注意看test的反汇编代码:
0x0000000000400448 <test+0>: push %rbp 0x0000000000400449 <test+1>: mov %rsp,%rbp
一个函数在每次调用的时候,会把rbp压入到堆栈里去, 所以可以采用一个循环不断解析rbp的值, 就可以把ret_addr依次解析出来。
void calltrace(void) { unsigned long *rbp; unsigned long rip = 0; unsigned long func_ip = 0; char *symbol_name; printf("Call trace:nn"); GET_BP(rbp); while (rbp != top_rbp) { rip = *(unsigned long *)(rbp + 1); rbp = (unsigned long *)*rbp; if (search_symbol_by_addr(rip) == -1) return ; } rip = *(unsigned long *)(rbp + 1); if (search_symbol_by_addr(rip) == -1) return ; printf("n"); }
我们在这个函数里还实现了解析elf来获取函数的符号表, 是不是很cool。
root@localhost.localdomain # ./test hello, world. Call trace: [<0x400a0b>] test2 + 0x13/0x15 [<0x400a16>] test1 + 0x9/0xb [<0x400a21>] test + 0x9/0xb [<0x400a31>] main + 0xe/0x10
3、segfault的原因和调试方法
segfault是coder们经常碰到的, 要了解segfault的原因, 首先要看下linux进程的内存布局:
text代码段, 数据段, brk堆区(heap), stack堆栈区, 内核数据区。
对这里每个区的访问异常都会产生segfault。
a、 首先看第一种情况: 空指针引用
当程序里引用一个空指针的时候, 经常会出现segfault, 因为内存0处在这个进程里没有被用到,在内核里就是没有建立对应的页表, 这样无论是读, 还是写操作, 都会触发cpu的缺页异常中断, 内核在处理这个错误的时候就是直接将其杀死, 就是coder们看到的segfault。
b、访问text只读段
abcdef这个字符串在被编译器编译后, 是放在elf的text段后面, 这个段被设置成是只读的, 当我们的代码试图去写这个内存区域的时候, 同样会触发一次缺页中断, 内核的处理方法任然是将其杀死。
c、 访问brk区
代码里先用malloc分配了一段内存, 然后释放掉, 接着又去访问了它, 只是coder们经常出现的问题,glibc的free函数会把内存归还给操作系统, 这样之前内存对应的页表已经不存在, 同样会触发一次缺页中断, 内核毫不客气的把进程杀掉。
d、访问mmap区
我们用mmap分配了个1024字节大小的内存, 注意我们给这块内存设置的是PROT_READ, 也就是只读属性, 这样在访问这个内存就会出现segfault。
e、访问stack区
这也是coder们经常会出现的问题堆栈溢出, 我们会在后面的堆栈溢出攻击教学中详细纰漏这些技术。
这里看到的是一个测试例子, linux给每个进程都设置了最大的堆栈大小, 那是不是超出最大堆栈后, 程序马上就会crash掉, 其实不然, 在程序使用所有堆栈后, 继续访问堆栈的时候, 会触发一次缺页异常中断, 此时内核并没有马上将其杀死, 而是重新扩展了它的堆栈, 以便让这次堆栈操作顺利完成:
f、进程访问内核空间:
linux进程是属于cpu的ring3权限, 而内核则是在ring0权限, 从ring3是不能直接访问ring0内存的。
下面说说segfault的调试方法, 在多数情况下, coder们会通过coredump来分析程序。 但是线上的系统可能没有打开coredump环境, 出现segfault后, 大都没有了办法。下面介绍一个非常好用的快速debug segfault的方法:
看下面这个例子:
#include <stdio.h> #include <stdlib.h> #include "trace.h" void test2(void) { *(int *)0 = 1; } void test1(void) { test2(); } void test(void) { test1(); } int main(void) { init_calltrace(); test(); }
test2在执行后,会触发segfault, 此时没有coredump文件, 怎么办呢?
通过dmesg命令,看下内核给出的信息:
root@localhost.localdomain # dmesg|tail test[27792]: segfault at 0000000000000000 rip 0000000000400451 rsp 00007fffed136290 error 6
这段信息是内核在缺页异常处理时,打印出的debug信息, 这些信息却常常被coder们忽略, 这可是我们定位segfault的法宝, 注意看rip的值:0000000000400451, 这就是触发segfault时的代码地址。 接下来我们通过objdump反汇编看下test函数:
0000000000400448 <test2>: 400448: 55 push %rbp 400449: 48 89 e5 mov %rsp,%rbp 40044c: b8 00 00 00 00 mov $0x0,%eax 400451: c7 00 01 00 00 00 movl $0x1,(%rax) 400457: c9 leaveq 400458: c3 retq
我们可以看到程序是在0×400451处出现了错误, 这条指令的意思是把1赋值给了rax寄存器指向的内存地址。 继续往上看
mov $0x0,%eax
这下大家就明白了吧, 代码把0赋值给eax, 又在400451处将1赋值给了(rax), 这是一次空指针引用操作, 所以会触发segfault。
所以大家不妨试试在没有coredump的情况下,用这种方法来调试程序。
在高级一点我们可以自己在程序代码里捕获SIGEGV信号, 绕过内核自己来处理这种错误, 你可以打印日志等等, 方便以后的调试, oracle的openjdk就是这么来做的, 当然我自己写的代码库也会包含这类操作:
root@localhost.localdomain # ./test Pid: 27853 segfault at addr: (nil) Call trace: [<0x4009f8>] test2 + 0x0/0x11 [<0x400a1d>] test + 0x9/0xb [<0x400a37>] main + 0x18/0x1a
int init_signal(void) { struct sigaction sa; sa.sa_flags = SA_SIGINFO; sigemptyset(&sa.sa_mask); sa.sa_sigaction = signal_handler; if (sigaction(SIGSEGV, &sa, NULL) == -1) { perror("sigaction"); return -1; } return 0; } unsigned long compute_sigsegv_func_addr(unsigned long rip) { unsigned long func_addr = 0; unsigned long offset = 0; offset = *(unsigned long *)(rip - 4); func_addr = offset + rip; return func_addr; } void signal_handler(int sig_num, siginfo_t *sig_info, void *ptr) { unsigned long *rbp; unsigned long rip = 0; unsigned long func_ip = 0; int first_bp = 0; char *symbol_name; assert(sig_info != NULL); printf("nPid: %d segfault at addr: %pn", getpid(), sig_info->si_addr); printf("Call trace:nn"); GET_BP(rbp); while (rbp != top_rbp) { rip = *(unsigned long *)(rbp + 1); rbp = (unsigned long *)*rbp; if (first_bp == 1) { /* XXX: We can't get the ip addr that casue * the segfault, the signal handler will destroy * the ip value in the stack. To solve this problem * we can compute the eip from the prev callchain. * Exp: * 402b16: e8 62 ff ff ff callq 402a7d <test> * abstract the offset that callq used, than compute * the real function addr: * dst_addr = offset + src_addr + opcode_len * but with this fix, we just find the function addr * that casued the segfalt, still can't find the real * ip addr. Any better way? */ rip = compute_sigsegv_func_addr(rip); __search_symbol_by_addr(rip); } else { search_symbol_by_addr(rip); } first_bp++; } rip = *(unsigned long *)(rbp + 1); search_symbol_by_addr(rip); printf("n"); exit(-1); }
4、堆栈溢出的调试和利用
关于堆栈溢出, 又可以写好几篇paper了, 大家可以到我的个人站点: http://www.cloud-sec.org 去获取相关知识。
更新:
1、对于堆栈的结构, 我是按x86架构画的, x86_64结构大致相同, 只是函数参数是通过rdi, rsi etc来传递,没有压入堆栈里。
2、本文介绍了堆栈的结构;可变参数和printf的实现;stack call trace的编写方法;segfault的原因和调试方法以及无符号, 无coredump的调试方法。