前置知识
磁盘中的ELF与内存中的ELF
比较重要的是stack,shared libraries和heap三项
其中stack是栈,他的元素保存方法是从高地址到低地址保存
heap是堆,他的元素保存方法是从低地址到高地址保存
shared libraries是共享函数库,好像pwn手用的比较多,我这个re手就直接把他跳过吧
大端序与小端序
小端序存储即数据的高位字节保存在内存的高地址中,低位字节则保存在内存的低地址中。大端序反之。
现在我们主要使用的格式是小端序存储,此处重点分析小端序存储。
这是个很直观的小端序存储方式。我们再举一个在IDA里经常能观察到的例子。
比如我们看到这样的初始化
v
7
[
0
]
=
1734437990
v7[0] = 1734437990
v7[0]=1734437990;
v
7
[
1
]
=
1801545339
v7[1] = 1801545339
v7[1]=1801545339;
v
7
[
2
]
=
1818648421
v7[2] = 1818648421
v7[2]=1818648421;
v
7
[
3
]
=
2099341153
v7[3] = 2099341153
v7[3]=2099341153;
以v7[0]为例,该数字在内存中会表示为十六进制储存,即
0
x
67616
c
66
0x67616c66
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是寄存器低位的那一半
然后是一些常见寄存器的功能
- e i p eip eip: 存放当前执行的指令的地址
- e s p esp esp: 存放当前栈帧的栈顶地址
- e b p ebp ebp: 存放当前栈帧的栈底地址
- e a x eax 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的状态。
这里需要注意:由于函数调用栈在内存中从高地址向低地址变化,所以栈顶对应的内存地址在压栈时变小,退栈时变大。
操作流程
首先明确一点,函数状态主要涉及到三个寄存器: e s p esp esp, e b p ebp ebp, e i p eip eip。上面提到过, e s p esp esp 用来存储函数调用栈的栈顶地址,在压栈和退栈时发生变化。 e b p ebp ebp 用来存储当前函数状态的栈底(或者说基地址),在函数运行时不变,可以用来索引确定函数参数或局部变量的位置。 e i p eip eip 用来存储即将执行的程序指令的地址,cpu 依照 e i p eip eip 的存储内容读取指令并执行, e i p eip eip 随之指向相邻的下一条指令,如此反复,程序就得以连续执行指令。
下面来看看函数调用时,栈的状态以及寄存器的变化。记住一点,这个过程的核心任务是将Caller的状态保存,并创建Callee的状态。
- 首先将Callee的参数按照逆序依次入栈(还记得小端序吗?),当然,如果Callee没有参数,则忽略这一步骤。注意一点,这些参数是作为Caller而非Callee的函数状态而保存起来的。之后入栈的数据则会作为Callee的函数状态保存。
- 然后,将Caller进行调用的下一条指令地址作为返回地址入栈。这样,Caller的 e i p eip eip信息得以保存。
-
再将当前 e b p ebp ebp的值入栈,并将 e b p ebp ebp的值更新为当前栈顶地址。这样,Caller的 e b p ebp ebp信息得以保存(以便后续找到Caller的栈底从而恢复Caller的函数状态),同时,对 e b p ebp ebp的更新相当于为Callee开辟了新的栈空间( e s p esp esp与 e b p ebp ebp指向同一地址,可以理解为当前栈(指Callee的栈)为空)
-
之后将Callee的局部变量等数据入栈
-
这之后的入栈过程中, e s p esp esp的值不断减小,对应着栈从高地址向低地址变化。入栈的数据包括调用参数、返回地址、Caller的栈底以及局部变量。之前提到过,除调用参数外的数据共同组成Callee的函数状态。在发生调用时,程序还会将Callee的指令地址存到 eip 寄存器内,这样程序就可以依次执行被调用函数的指令了。
现在,我们应该很好理解函数调用结束后的变化了。这个阶段的任务是丢弃Callee的状态并复原Caller的状态。
-
首先,Callee的局部变量被弹出,栈顶指向Callee的栈底
-
然后,将储存的Caller的 e b p ebp ebp(栈底地址)弹出,并存储到当前的 e b p ebp ebp内,这样,Caller的栈底信息得以恢复,然后栈顶指向返回地址
-
将返回地址弹出,并存储到 e i p eip 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函数的函数的 e b p ebp ebp入栈
- 把当前的 e b p ebp ebp的值置为 e s p esp esp,相当于开辟一个新的栈
- 把局部变量入栈,并且 e s p esp esp地址偏移16位,剩下12位空间可能编译器拿去干其他事情了?此处我们就认为他是未使用的空间吧(反正编译出来就是这样的。。有无懂哥教一下这个未使用空间具体是干啥用的。。)
- 把传参入栈
- 调用Callee,注意call在执行时会把返回地址直接入栈
- 同caller
- 这些指令可以对照源代码理解,需要注意的是,此处的0x8 0xc 0x10是偏移量,按照此偏移量可以找到以前的传参,eax edx均为通用寄存器,此处eax保存了和的值
- 弹栈, e b p ebp ebp恢复
- 执行ret,恢复 e i p eip eip(还记得ret可以看做pop eip吗)
- 注意,现在已经返回到Caller了,继续跟着 e i p eip eip执行,此处让 e s p esp esp偏移12位,相当于在栈中清除了传递的参数
- 此处是源码中的
ret += 4;
,很好理解
-
leave
可以理解为两句汇编的整合:MOV ESP EBP
,POP EBP