1 概念

栈是我们做PWN时的一个十分重要的数据结构,栈区也是一个需要注意的内存区域

栈是一种数据结构,存放局部变量,从感性上说,可以想象成羽毛球筒,球只能从筒口插入,从筒口拿出

栈是由高地址向低地址生长

2 PUSH指令(字入栈)

格式:PUSH 源操作数(字)

1
PUSH AX   ;AX进栈

PUSH 指令首先减少 ESP 的值,再将源操作数复制到堆栈。操作数是 16 位的,则 ESP 减 2,操作数是 32 位的,则 ESP 减 4。PUSH 指令有 3 种格式:

1
2
3
PUSH reg/mem16  
PUSH reg/mem32
PUSH imm32

注意:push指令不允许8位寄存器

高字节先进栈,低字节在栈顶
图片.png


2.1 PUSHAD指令

图片.png

在实际应用中,pushad 和 popad 常用于函数调用或中断处理程序的开头和结尾,以此来保存和恢复寄存器状态,确保这些操作不会对调用者的寄存器值产生影响。不过要留意,这两条指令会对栈进行大量操作,所以在栈空间有限的情形下要谨慎使用。

3 POP指令(字出栈)

格式:POP 目的操作数

1
POP AX   ;恢复AX

图片.png
POP 指令首先将当前栈顶(ESP所指向的内存单元)的数据复制到目的操作数中,然后再增加 ESP 的值(因为出栈意味着栈顶指针向高地址回退)。如果操作数是 16 位的,则 ESP 加 2;如果操作数是 32 位的,则 ESP 加 4。

POP 指令主要有以下 2 种格式(注意:目的操作数不能是立即数):

1
2
1 POP reg/mem16   ;弹出到 16 位寄存器或内存单元
2 POP reg/mem32 ;弹出到 32 位寄存器或内存单元

注意:

  1. 与 PUSH 相同,POP 指令不允许使用 8 位寄存器(如 AL, AH 等)。
  2. 目的操作数不能是立即数(你不能把栈里的数据弹到一个固定常数里)。
  3. POP CS 是非法的(代码段寄存器 CS 不能通过 POP 指令直接修改,这会在你下方的“堆栈操作说明”中提到)。

3.1 POPAD指令

4 栈操作说明

1、栈顶SP指向数据
 因为堆栈指针SP总是指向已经存入数据的栈顶(不是空单元),所以PUSH指令时先将(SP)减2,后将内容压栈(即先修改SP使之指向空单元,后压入数),而POP是先从栈顶弹出一个字,后将堆栈指针SP加2.
2、对代码段寄存器
 PUSH CS是合法的,但POP CS是不合法的。
3、堆栈特点—–FILO
 因为SP总是指向栈顶,而用PUSH和POP指令存取数时都是在栈顶进行的,所以堆栈是“先进后出”或叫“后进先出”的。栈底在高地址,堆栈是从高地址向低地址延伸的,所以栈底就是最初的栈顶。
4、按字访问
 用PUSH指令和POP指令时只能按字访问堆栈,不能按字节访问堆栈。
5、不影响标志
 PUSH和POP都不影响标志。
6、不能用立即寻址方式
PUSH 1234H ;错误!!


这里就是对AX清零。

1
2
3
4
5
6
PUSH    DS
SUB AX,AX
PUSH AX
...
...
RET

下面就是一个保护现场的代码

1
2
3
4
5
6
7
PUSH    AX
PUSH BX
PUSH CX
......;期间用到了AX、BX、CX
POP CX
POP BX
POP AX

从键盘上键入10个字符,然后与键入字符的先后相反的顺序显示出来。( 使用 堆栈的办法)
分析: 因为堆栈是“后进先出”的,因此,利用堆栈作为输出缓冲区极易实现按逆序输出。
 分配一个256个字的堆栈缓冲区,在其低字节中存放从键盘上键入的字符。我们将从键盘上接受来的10个字符依次进栈,存放在这片堆栈区里,然后再从最后一个字单元开始,弹出堆栈,即可逆序把它们显示出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
STACKS SEGMENT PARA STACK 'STACK'
DW 256 DUP(?) ;堆栈只能是一个字一个字的操作,多预留点栈空间
STACKS ENDS

CODES SEGMENT
ASSUME CS:CODES,SS:STACKS
START:
MOV AX,STACKS ;这里就用STACKS,以前都是DATAS
MOV SS,AX ;这里就用SS了,以前都是用DS
MOV CX,10 ;输入10个字符=>堆栈
READ:
MOV AH,01H
INT 21H
PUSH AX ;堆栈操作都是‘字’,不能用AL,浪费了AH的空间
LOOP READ ;读10个字符

MOV DL,0AH ;显示回车
MOV AH,02H
INT 21H
MOV DL,0DH ;显示换行
INT 21H

MOV CX,10 ;从栈顶依次弹出10个字符输出
DISP:
POP DX ;显示
MOV AH,02H
INT 21H
LOOP DISP ;显示10个字符

MOV AH,4CH
INT 21H
CODES ENDS
END START

5 栈帧

进程中每个函数在运过程中都要保存自己的临时数据,这些数据就会被保存在栈上 但是不同的函数不能把数据放在一块。每个函数都要有自己的栈空间。系统给出的方案就是栈帧 栈帧指的就是一个函数在运行时使用的栈空间,它由rsp和rbp表示边界 rsp与rbp是CPU中的两个特殊寄存器,rsp叫栈顶指针寄存器,rbp叫栈底指针寄存器。两个寄存器专门用来表示stack范围

5.1 栈顶指针

作用:永远指向当前栈的“物理最顶部”(也就是栈的最后一个压入数据的地址)

栈的操作都会直接影响rsp的值

push操作:rsp会减少(因为栈地址是从高地址指向低地址,压入数据后,会向低地址移动)
pop操作:rsp会增加

5.2 栈底指针

作用:永远指着当前函数栈帧的底部 核心作用就是定位——程序找局部变量,找函数参数时,都是以rbp为参考的,比如代码buf[16],它在栈中的地址可能是rbp-0x10(rbp减去16字节),程序通过rbp的值,就能精确找到buf的地址

另外,rbp还会保存上一个函数栈帧的底部地址,这样函数执行结束后,程序能通过Saved RBP,找到上一个函数的栈帧,进而回到上一个函数继续执行

6 函数调用

函数调用本质上解决几个问题:

  1. 程序如何跳转到另一个函数执行;
  2. 函数执行完后如何回到原来的位置;
  3. 参数如何传递;
  4. 返回值如何传递;
  5. 栈由谁来恢复。

6.1 函数调用约定

调用约定规定了函数之间调用的基本规则,包括:

  • 参数如何传递;
  • 参数入栈顺序;
  • 返回值放在哪里;
  • 栈空间由调用者清理还是被调用者清理;
  • 哪些寄存器需要保存。

比较常见的调用约定有:

6.1.1 __cdecl

__cdecl 又称 C 调用约定,是 C/C++ 中常见的调用约定。

特点:

  • 参数按照从右到左的顺序入栈;
  • 函数本身不清理栈;
  • 栈由调用者清理;
  • 返回值通常放在 EAX/RAX 中;
  • 支持可变参数函数。

例如:

1
printf("%d %d", a, b);

因为参数数量不固定,所以只能由调用者清理栈。


6.1.2 __stdcall

__stdcall 是 Windows API 中常见的调用约定。

特点:

  • 参数按照从右到左的顺序入栈;
  • 被调用函数自己清理栈;
  • 返回值通常放在 EAX/RAX 中;
  • 不适合可变参数函数。

6.1.3 __fastcall

__fastcall 的特点是尽量使用寄存器传参,从而减少栈操作,提高调用速度。

在 32 位环境下,常见规则是:

  • 前几个参数通过寄存器传递;
  • 剩余参数再通过栈传递;
  • 返回值通常放在 EAX/RAX 中。

需要注意:不同编译器、不同平台下,fastcall 的具体规则可能不同。


6.1.4 x86-64 下的注意点

在 64 位程序中,参数通常不再全部通过栈传递,而是优先通过寄存器传递。

Linux x86-64 普通函数调用常见规则:

参数位置 寄存器
第 1 个参数 RDI
第 2 个参数 RSI
第 3 个参数 RDX
第 4 个参数 RCX
第 5 个参数 R8
第 6 个参数 R9
返回值 RAX

Windows x64 普通函数调用常见规则:

参数位置 寄存器
第 1 个参数 RCX
第 2 个参数 RDX
第 3 个参数 R8
第 4 个参数 R9
返回值 RAX

因此,分析 64 位程序时不能简单认为参数一定都在栈上。


6.2 CALL / RET 调用与返回

函数调用最核心的两条指令是:

1
2
call
ret

call 用来调用函数。

ret 用来从函数返回。


6.2.1 call 指令

call 不是单纯跳转,它会先保存返回地址,再跳转到目标函数。

例如:

1
call func

可以理解为:

1
2
push 下一条指令的地址
jmp func

也就是说,call 做了两件事:

  1. call 后面那条指令的地址压入栈中;

  2. 跳转到目标函数执行。

这个被压入栈中的地址,叫做:

1
返回地址 Return Address

它表示函数执行完之后,程序应该回到哪里继续执行。


6.2.2 ret 指令

ret 用来从函数返回。

1
ret

可以理解为:

1
pop rip

也就是说,ret 会从栈顶取出一个地址,然后放入 RIP

RIP 是当前程序正在执行的指令地址,所以一旦 RIP 被修改,程序就会跳到新的地址继续执行。

因此,ret 的作用是:

1
从栈顶取出返回地址,然后跳回调用函数之后的位置。

6.2.3 pushjmpcallret 的区别

指令 作用
push 只负责把数据压入栈,不跳转
jmp 只负责跳转,不保存返回地址
call 先压入返回地址,再跳转到函数
ret 从栈顶弹出返回地址,跳回去

简单理解:

1
2
call = push 返回地址 + jmp 函数
ret = pop 返回地址到 RIP

6.2.4 CALL / RET 示例

1
2
3
4
5
6
7
8
9
_start:
call func
mov rax, 60
mov rdi, 0
syscall

func:
mov rbx, 123
ret

执行流程:

1
2
3
4
5
6
7
1. 执行 call func
2. CPU 把 call 后面的地址压入栈
3. CPU 跳转到 func
4. func 执行 mov rbx, 123
5. 执行 ret
6. ret 从栈顶取出返回地址
7. 程序回到 call 后面的 mov rax, 60 继续执行

6.2.5 CALL / RET 和 PWN 的关系

call 会把返回地址压入栈中。

ret 会从栈中取出返回地址并跳转过去。

所以如果我们能通过栈溢出覆盖返回地址,就可以控制程序执行流。

典型目标就是覆盖:

1
[rbp + 0x08]

因为这里通常保存着函数的返回地址。

如果返回地址被改成攻击者指定的地址,那么函数执行 ret 时,程序就会跳到攻击者控制的位置。

这就是很多栈溢出、ROP 攻击的核心基础。


6.3 栈帧核心寄存器:RSP 和 RBP

在 x86-64 函数调用体系中,有两个非常重要的栈相关寄存器:

寄存器 名称 作用
RSP Stack Pointer,栈顶指针 指向当前栈顶
RBP Base Pointer,栈基址指针 指向当前函数栈帧的固定基准位置

6.3.1 RSP:栈顶指针

RSP 永远指向当前栈顶。

当执行:

1
push rax

栈会向低地址增长:

1
2
rsp = rsp - 8
[rsp] = rax

当执行:

1
pop rax

栈会向高地址恢复:

1
2
rax = [rsp]
rsp = rsp + 8

因此,RSP 是一个动态变化的指针。

只要发生 pushpopcallretsub rsp, xxxadd rsp, xxxRSP 都可能变化。


6.3.2 RBP:栈帧基准指针

RBP 通常用来作为当前函数栈帧的固定参考点。

函数开始时,程序会把当前 RSP 保存到 RBP 中。

之后即使 RSP 因为压栈、出栈、分配局部变量而变化,RBP 仍然保持不变。

这样就可以通过:

1
2
[rbp + 偏移]
[rbp - 偏移]

稳定地访问参数、返回地址、Saved RBP 和局部变量。


6.4 为什么不能只用 RSP?

在函数执行过程中,程序经常需要临时压栈和出栈。

例如:

1
2
3
4
push rax
push rbx
pop rbx
pop rax

每一次 pushpop 都会改变 RSP

如果只用 RSP 定位参数和局部变量,那么同一个变量相对于 RSP 的偏移会不断变化。

这会导致分析和编译都变得很麻烦。

所以函数通常会使用 RBP 作为稳定基准。

简单理解:

1
2
RSP:实时变化的栈顶
RBP:固定不动的栈帧基准线

6.5 标准汇编范式

理解函数序言和函数结语,是分析栈帧结构的基础。


6.5.1 函数序言:建立当前函数的栈帧

函数刚开始时,常见代码如下:

1
2
3
push rbp
mov rbp, rsp
sub rsp, 0x40

含义:

1
2
3
push rbp        ; 保存调用者的 RBP,也就是 Saved RBP
mov rbp, rsp ; 建立当前函数自己的 RBP 基准线
sub rsp, 0x40 ; 给局部变量分配栈空间

执行完之后,当前函数就拥有了自己的栈帧。


6.5.2 函数结语:恢复现场并返回

函数执行完毕后,常见代码如下:

1
2
3
mov rsp, rbp
pop rbp
ret

含义:

1
2
3
mov rsp, rbp    ; 释放局部变量空间
pop rbp ; 恢复调用者的 RBP
ret ; 弹出返回地址到 RIP,跳回调用者

也可以看到这种写法:

1
2
leave
ret

其中:

1
leave

等价于:

1
2
mov rsp, rbp
pop rbp

所以:

1
2
leave
ret

等价于:

1
2
3
mov rsp, rbp
pop rbp
ret

6.6 RBP 静态坐标系速查表

在经典栈帧结构中,执行完函数序言之后:

1
2
3
push rbp
mov rbp, rsp
sub rsp, 0x40

栈帧大致如下:

内存位置 存放内容 说明 危险性
[rbp + 0x18] 参数 2 栈上传递的第二个参数 正常读取
[rbp + 0x10] 参数 1 栈上传递的第一个参数 正常读取
[rbp + 0x08] 返回地址 Return Address 函数执行完后,下一条要执行的指令地址 极度危险,PWN 核心目标
[rbp + 0x00] Saved RBP 调用者的 RBP 备份 危险,覆盖后可能导致栈帧崩溃
[rbp - 0x08] 局部变量 1 当前函数内部变量 正常读写,常见溢出源头
[rbp - 0x10] 局部变量 2 当前函数内部变量 正常读写

经典结构示意:

1
2
3
4
5
6
7
8
9
10
高地址

│ [rbp + 0x18] 参数 2
│ [rbp + 0x10] 参数 1
│ [rbp + 0x08] 返回地址
│ [rbp + 0x00] Saved RBP
│ [rbp - 0x08] 局部变量 1
│ [rbp - 0x10] 局部变量 2

低地址

注意:

在 x86-64 程序中,前几个参数通常通过寄存器传递,不一定出现在 [rbp + 0x10][rbp + 0x18] 这些位置。

但是返回地址和 Saved RBP 的位置非常关键:

1
2
[rbp + 0x08]  返回地址
[rbp + 0x00] Saved RBP

在栈溢出分析中,最核心的目标通常就是覆盖返回地址。


6.7 小结

函数调用可以简单理解为:

1
2
call:保存返回地址,然后跳转到函数
ret :取出返回地址,然后跳回去

函数栈帧可以简单理解为:

1
2
RSP:当前栈顶,会频繁变化
RBP:当前函数的固定基准线

经典函数结构:

1
2
3
4
5
6
7
8
9
push rbp
mov rbp, rsp
sub rsp, xxx

; 函数主体

mov rsp, rbp
pop rbp
ret

PWN 中最重要的位置:

1
[rbp + 0x08]

因为这里保存的是返回地址。

一旦返回地址被覆盖,ret 就会跳到攻击者控制的位置。