理解堆栈及其利用方法

来源:互联网 发布:sql 注入式攻击 编辑:程序博客网 时间:2024/06/05 06:36

作者:王智通

 

堆栈基础篇:

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 testDump of assembler code for function test:0x0000000000400448 <test+0>:    push   %rbp0x0000000000400449 <test+1>:    mov    %rsp,%rbp0x000000000040044c <test+4>:    lea    -0x20(%rbp),%rax0x0000000000400450 <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>:   leaveq0x0000000000400468 <test+32>:   retqEnd 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 test0000000000400469 <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 = 0int 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   %rbp0x0000000000400449 <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 # ./testhello, 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进程的内存布局:

一个进程从内存低地址开始到内存高址, 它是这样布局的:
1

text代码段, 数据段, brk堆区(heap), stack堆栈区, 内核数据区。

对这里每个区的访问异常都会产生segfault。
a、 首先看第一种情况:  空指针引用

2

当程序里引用一个空指针的时候, 经常会出现segfault, 因为内存0处在这个进程里没有被用到,在内核里就是没有建立对应的页表, 这样无论是读, 还是写操作, 都会触发cpu的缺页异常中断, 内核在处理这个错误的时候就是直接将其杀死, 就是coder们看到的segfault。

 

 

b、访问text只读段

3

abcdef这个字符串在被编译器编译后, 是放在elf的text段后面, 这个段被设置成是只读的, 当我们的代码试图去写这个内存区域的时候, 同样会触发一次缺页中断, 内核的处理方法任然是将其杀死。

 

c、 访问brk区

4

代码里先用malloc分配了一段内存, 然后释放掉, 接着又去访问了它, 只是coder们经常出现的问题,glibc的free函数会把内存归还给操作系统, 这样之前内存对应的页表已经不存在, 同样会触发一次缺页中断, 内核毫不客气的把进程杀掉。

 

d、访问mmap区

5

我们用mmap分配了个1024字节大小的内存, 注意我们给这块内存设置的是PROT_READ, 也就是只读属性, 这样在访问这个内存就会出现segfault。

 

e、访问stack区

6

这也是coder们经常会出现的问题堆栈溢出, 我们会在后面的堆栈溢出攻击教学中详细纰漏这些技术。

这里看到的是一个测试例子, linux给每个进程都设置了最大的堆栈大小, 那是不是超出最大堆栈后, 程序马上就会crash掉, 其实不然, 在程序使用所有堆栈后, 继续访问堆栈的时候, 会触发一次缺页异常中断, 此时内核并没有马上将其杀死, 而是重新扩展了它的堆栈, 以便让这次堆栈操作顺利完成:

7

 

f、进程访问内核空间:

8 

 

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|tailtest[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 # ./testPid: 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的调试方法。



原文地址: https://yq.aliyun.com/articles/1741

0 0
原创粉丝点击