您的位置:首页 > 其它

理解堆栈及其利用方法

2015-09-26 23:12 316 查看
堆栈基础篇:

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的调试方法。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: