汇编初上手 - 栈帧学习笔记
感谢@Flandre25大大用pwn手的方法教re手怎么看汇编🙇♂️🙇♂️🙇♂️
前置知识
磁盘中的ELF与内存中的ELF

比较重要的是stack,shared libraries和heap三项
其中stack是栈,他的元素保存方法是从高地址到低地址保存
heap是堆,他的元素保存方法是从低地址到高地址保存
shared libraries是共享函数库,好像pwn手用的比较多,我这个re手就直接把他跳过吧
大端序与小端序
小端序存储即数据的高位字节保存在内存的高地址中,低位字节则保存在内存的低地址中。大端序反之。
现在我们主要使用的格式是小端序存储,此处重点分析小端序存储。

这是个很直观的小端序存储方式。我们再举一个在IDA里经常能观察到的例子。
比如我们看到这样的初始化
\(v7[0] = 1734437990\);
\(v7[1] = 1801545339\);
\(v7[2] = 1818648421\);
\(v7[3] = 2099341153\);
以v7[0]为例,该数字在内存中会表示为十六进制储存,即\(0x67616c66\)
在地址中应2个字节2个字节存储,即 67 61 6c 66
那么由于是小端序储存,现在倒过来看这4个数字,即66 6c 61 67
转换成10进制即102 108 97 103
再转化为ascii码,即f l a g
以此类推,v7所储存的整数在转化为字符型后,所得的字符串为flag{fake_flag}
基本寄存器类型
首先是寄存器们的结构
rax: 8Bytes // r开头是64位的
eax: 4Bytes // e开头是32位的
ax: 2Bytes // 没有开头的是16位的
ah: 1Bytes // h是寄存器高位的那一半
al: 1Bytes // l是寄存器低位的那一半
然后是一些常见寄存器的功能
- \(eip\): 存放当前执行的指令的地址
- \(esp\): 存放当前栈帧的栈顶地址
- \(ebp\): 存放当前栈帧的栈底地址
- \(eax\):通用寄存器,存放函数的返回值
和栈相关的重要汇编指令
LEA REG, SRC:把源操作数的有效地址存放到指定的寄存器中
LEA EBX, ASC:把ASC的地址存放到EBX寄存器中LEA EAX, 6[ESI]:把ESI+6的32位地址存放到EAX寄存器中
PUSH VALUE:把目标值压栈,同时SP指针-1字长POP DEST: 将栈顶的值弹出至目的存储位置,同时SP指针+1字长LEAVE:在函数调用的末尾(即函数return的时候)恢复父函数(上一个函数)的栈帧指令
- 可以理解为两句汇编的整合:
MOV ESP EBP// 相当于把栈空间初始化了,虽然保存的数据没删除,但是后面可以直接对其覆盖操作,没有影响POP EBP// 恢复栈帧
RET:在函数返回时,控制程序执行流返回父函数的指令
*可以理解为POP EIP(这条指令只是帮助理解,实际上并不能对EIP进行直接的操作)
函数调用栈
重点来了
基本概念
函数调用栈(stack)是指程序运行时内存中一段连续的区域,这段区域用来保存函数运行时的状态信息,包括函数的参数和局部变量等。
根据栈的特性,在发生函数调用时,调用函数(我们之后统称为"Caller")的状态被保存在调用栈内,被调用函数(我们之后统称为"Callee")的状态被压入调用栈的栈顶。
在函数调用结束,即Callee函数return时,栈顶函数(Callee)的状态被弹出,栈顶恢复到Caller的状态。
这里需要注意:由于函数调用栈在内存中从高地址向低地址变化,所以栈顶对应的内存地址在压栈时变小,退栈时变大。

操作流程
首先明确一点,函数状态主要涉及到三个寄存器:\(esp\),\(ebp\),\(eip\)。上面提到过,\(esp\) 用来存储函数调用栈的栈顶地址,在压栈和退栈时发生变化。\(ebp\) 用来存储当前函数状态的栈底(或者说基地址),在函数运行时不变,可以用来索引确定函数参数或局部变量的位置。\(eip\) 用来存储即将执行的程序指令的地址,cpu 依照 \(eip\) 的存储内容读取指令并执行,\(eip\) 随之指向相邻的下一条指令,如此反复,程序就得以连续执行指令。
下面来看看函数调用时,栈的状态以及寄存器的变化。记住一点,这个过程的核心任务是将Caller的状态保存,并创建Callee的状态。
- 首先将Callee的参数按照逆序依次入栈(还记得小端序吗?),当然,如果Callee没有参数,则忽略这一步骤。注意一点,这些参数是作为Caller而非Callee的函数状态而保存起来的。之后入栈的数据则会作为Callee的函数状态保存。

- 然后,将Caller进行调用的下一条指令地址作为返回地址入栈。这样,Caller的\(eip\)信息得以保存。

-
再将当前\(ebp\)的值入栈,并将\(ebp\)的值更新为当前栈顶地址。这样,Caller的\(ebp\)信息得以保存(以便后续找到Caller的栈底从而恢复Caller的函数状态),同时,对\(ebp\)的更新相当于为Callee开辟了新的栈空间(\(esp\)与\(ebp\)指向同一地址,可以理解为当前栈(指Callee的栈)为空)

-
之后将Callee的局部变量等数据入栈

-
这之后的入栈过程中,\(esp\)的值不断减小,对应着栈从高地址向低地址变化。入栈的数据包括调用参数、返回地址、Caller的栈底以及局部变量。之前提到过,除调用参数外的数据共同组成Callee的函数状态。在发生调用时,程序还会将Callee的指令地址存到 eip 寄存器内,这样程序就可以依次执行被调用函数的指令了。
现在,我们应该很好理解函数调用结束后的变化了。这个阶段的任务是丢弃Callee的状态并复原Caller的状态。
-
首先,Callee的局部变量被弹出,栈顶指向Callee的栈底

-
然后,将储存的Caller的\(ebp\)(栈底地址)弹出,并存储到当前的\(ebp\)内,这样,Caller的栈底信息得以恢复,然后栈顶指向返回地址

-
将返回地址弹出,并存储到\(eip\)中,这样Caller的指令信息得以恢复。

至此,Caller的函数状态已全部恢复。
一次形象的实践
这个是我们的源代码
int callee(int a, int b, int c) {
return a + b + c;
}
int caller(void) {
int ret;
ret = callee(1, 2, 3);
ret += 4;
return ret;
}
这个是对应的汇编(尝试下amd64的格式)
00000012 <caller>:
12: 55 push %ebp
13: 89 e5 mov %esp,%ebp
15: 83 ec 10 sub $0x10,%esp
18: 6a 03 push $0x3
1a: 6a 02 push $0x2
1c: 6a 01 push $0x1
1e: e8 fc ff ff ff call 1f <caller+0xd>
23: 83 c4 0c add $0xc,%esp
26: 89 45 fc mov %eax,-0x4(%ebp)
29: 83 45 fc 04 addl $0x4,-0x4(%ebp)
2d: 8b 45 fc mov -0x4(%ebp),%eax
30: c9 leave
31: c3 ret
00000000 <callee>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 8b 55 08 mov 0x8(%ebp),%edx
6: 8b 45 0c mov 0xc(%ebp),%eax
9: 01 c2 add %eax,%edx
b: 8b 45 10 mov 0x10(%ebp),%eax
e: 01 d0 add %edx,%eax
10: 5d pop %ebp
11: c3 ret
我们一步一步执行上述汇编,看看栈是怎么变化的。
注意,以下步骤是遵循_cdecl调用约定的
- 把调用Caller函数的函数的\(ebp\)入栈

- 把当前的\(ebp\)的值置为\(esp\),相当于开辟一个新的栈

- 把局部变量入栈,并且\(esp\)地址偏移16位,剩下12位空间可能编译器拿去干其他事情了?此处我们就认为他是未使用的空间吧(反正编译出来就是这样的。。有无懂哥教一下这个未使用空间具体是干啥用的。。)

- 把传参入栈

- 调用Callee,注意call在执行时会把返回地址直接入栈

- 同caller

- 这些指令可以对照源代码理解,需要注意的是,此处的0x8 0xc 0x10是偏移量,按照此偏移量可以找到以前的传参,eax edx均为通用寄存器,此处eax保存了和的值

- 弹栈,\(ebp\)恢复

- 执行ret,恢复\(eip\)(还记得ret可以看做pop eip吗)

- 注意,现在已经返回到Caller了,继续跟着\(eip\)执行,此处让\(esp\)偏移12位,相当于在栈中清除了传递的参数

- 此处是源码中的
ret += 4;,很好理解

-
leave可以理解为两句汇编的整合:MOV ESP EBP,POP EBP


结束辣!下次补一篇栈帧平衡的笔记和例题,感觉已经快掌握了呢(bushi)

浙公网安备 33010602011771号