理解堆栈及其利用方法

下午详细膜拜了一浪大牛的《无符号和栈破坏情况下coredump的分析方法》, 此刻惊魂未定,激动不已, 既然谈到了堆栈, 就跟大家分享下我认识和理解的堆栈, 希望对大家有所帮助。

 

 

更新:

 

1、对于堆栈的结构, 我是按x86架构画的, x86_64结构大致相同, 只是函数参数是通过rdi, rsi etc来传递,没有压入堆栈里。

2、本文介绍了堆栈的结构;可变参数和printf的实现;stack call trace的编写方法;segfault的原因和调试方法以及无符号, 无coredump的调试方法。


堆栈基础篇:

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    -0x20(%rbp),%rax 这条指令里的-0x20(%rbp), 也就是rbp - 0x20, 说明系统是用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进程的内存布局:

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

 

alt


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

   对这里每个区的访问异常都会产生segfault。


a、 首先看第一种情况:  空指针引用

 

alt

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

 

 

  b、访问text只读段

 

 

alt

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

 

c、 访问brk区

 

 

alt

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

 

 d、访问mmap区

 

 

alt

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

 

  e、访问stack区

 

alt

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

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

 

 

alt

 

 

 

  f、进程访问内核空间:

 

alt

 

 

  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

 

我们可以看到程序是在0x400451处出现了错误, 这条指令的意思是把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
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值