理解堆栈及其利用方法

作者:王智通

堆栈基础篇:

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

#include

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 : push %rbp

0x0000000000400449 : mov %rsp,%rbp

0x000000000040044c : lea -0x20(%rbp),%rax

0x0000000000400450 : movl $0x6c6c6568,(%rax)

0x0000000000400456 : movl $0x67202c6f,0x4(%rax)

0x000000000040045d : movw $0x6264,0x8(%rax)

0x0000000000400463 : movb $0x0,0xa(%rax)

0x0000000000400467 : leaveq

0x0000000000400468 : 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

:

400469: 55 push %rbp

40046a: 48 89 e5 mov %rsp,%rbp

40046d: e8 d6 ff ff ff callq 400448

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 : push %rbp

0x0000000000400449 : 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

#include

#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 :

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

* 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的调试方法。

Copyright © 2088 一键全脑游戏活动站 - 脑力挑战专属福利 All Rights Reserved.
友情链接