理解堆栈及其利用方法

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介:

作者:王智通

 

堆栈基础篇:

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进程的内存布局:

一个进程从内存低地址开始到内存高址, 它是这样布局的:
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|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的调试方法。

相关实践学习
阿里云图数据库GDB入门与应用
图数据库(Graph Database,简称GDB)是一种支持Property Graph图模型、用于处理高度连接数据查询与存储的实时、可靠的在线数据库服务。它支持Apache TinkerPop Gremlin查询语言,可以帮您快速构建基于高度连接的数据集的应用程序。GDB非常适合社交网络、欺诈检测、推荐引擎、实时图谱、网络/IT运营这类高度互连数据集的场景。 GDB由阿里云自主研发,具备如下优势: 标准图查询语言:支持属性图,高度兼容Gremlin图查询语言。 高度优化的自研引擎:高度优化的自研图计算层和存储层,云盘多副本保障数据超高可靠,支持ACID事务。 服务高可用:支持高可用实例,节点故障迅速转移,保障业务连续性。 易运维:提供备份恢复、自动升级、监控告警、故障切换等丰富的运维功能,大幅降低运维成本。 产品主页:https://www.aliyun.com/product/gdb
相关文章
|
3月前
|
Python
自定义异常堆栈信息
自定义异常堆栈信息
45 0
|
Java
强制要求JVM始终抛出含堆栈的异常(-XX:-OmitStackTraceInFastThrow)
强制要求JVM始终抛出含堆栈的异常(-XX:-OmitStackTraceInFastThrow)
174 0
|
存储 算法 C语言
5.堆栈算法
5.堆栈算法
|
存储 Java
堆栈的区别是什么
堆和栈是计算机内存中两种不同的数据结构,它们用来存储程序运行时所需的数据。虽然堆和栈都是用于存储数据的,但它们在内存管理和数据访问方面有着明显的区别。下面我将详细解释堆和栈的区别。
233 0
顺序堆栈和链式堆栈的实现,用一个数组实现两个堆栈的例子
顺序堆栈和链式堆栈的实现,用一个数组实现两个堆栈的例子
堆栈/Stack的常见方法调用(含详细注释)
堆栈/Stack的常见方法调用(含详细注释)
124 0
堆栈/Stack的常见方法调用(含详细注释)
|
Java 中间件 Unix
JVM:如何分析线程堆栈
英文原文:JVM: How to analyze Thread Dump 在这篇文章里我将教会你如何分析JVM的线程堆栈以及如何从堆栈信息中找出问题的根因。在我看来线程堆栈分析技术是Java EE产品支持工程师所必须掌握的一门技术。
1718 0
特殊堆栈
数据结构栈的使用
|
C++ API 数据建模
Windbg查看调用堆栈(k*)
https://www.52pojie.cn/thread-664189-1-1.html       无论是分析程序崩溃原因,还是解决程序hang问题,我们最常查看的就是程序调用堆栈。
1890 0