1 概念

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

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

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

2 PUSH指令(字入栈)

格式:PUSH 源操作数(字)

PUSH AX   ;AX进栈

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

PUSH reg/mem16  
PUSH reg/mem32  
PUSH imm32

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

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


2.1 PUSHAD指令

图片.png

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

3 POP指令(字出栈)

格式:POP 目的操作数

POP AX   ;恢复AX

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

POP 指令主要有以下 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清零。

PUSH    DS
SUB AX,AX
PUSH    AX
...
...
RET

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

PUSH    AX
PUSH    BX
PUSH    CX
......;期间用到了AX、BX、CX
POP CX
POP BX
POP AX

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

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 中;
  • 支持可变参数函数。

例如:

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 调用与返回

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

call
ret

call 用来调用函数。

ret 用来从函数返回。


6.2.1 call 指令

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

例如:

call func

可以理解为:

push 下一条指令的地址
jmp func

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

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

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

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

返回地址 Return Address

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


6.2.2 ret 指令

ret 用来从函数返回。

ret

可以理解为:

pop rip

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

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

因此,ret 的作用是:

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

6.2.3 pushjmpcallret 的区别

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

简单理解:

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

6.2.4 CALL / RET 示例

_start:
    call func
    mov rax, 60
    mov rdi, 0
    syscall
 
func:
    mov rbx, 123
    ret

执行流程:

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 会从栈中取出返回地址并跳转过去。

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

典型目标就是覆盖:

[rbp + 0x08]

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

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

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


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

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

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

6.3.1 RSP:栈顶指针

RSP 永远指向当前栈顶。

当执行:

push rax

栈会向低地址增长:

rsp = rsp - 8
[rsp] = rax

当执行:

pop rax

栈会向高地址恢复:

rax = [rsp]
rsp = rsp + 8

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

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


6.3.2 RBP:栈帧基准指针

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

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

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

这样就可以通过:

[rbp + 偏移]
[rbp - 偏移]

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


6.4 为什么不能只用 RSP?

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

例如:

push rax
push rbx
pop rbx
pop rax

每一次 pushpop 都会改变 RSP

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

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

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

简单理解:

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

6.5 标准汇编范式

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


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

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

push rbp
mov rbp, rsp
sub rsp, 0x40

含义:

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

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


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

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

mov rsp, rbp
pop rbp
ret

含义:

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

也可以看到这种写法:

leave
ret

其中:

leave

等价于:

mov rsp, rbp
pop rbp

所以:

leave
ret

等价于:

mov rsp, rbp
pop rbp
ret

6.6 RBP 静态坐标系速查表

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

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当前函数内部变量正常读写

经典结构示意:

高地址

│ [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 的位置非常关键:

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

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


6.7 小结

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

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

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

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

经典函数结构:

push rbp
mov rbp, rsp
sub rsp, xxx
 
; 函数主体
 
mov rsp, rbp
pop rbp
ret

PWN 中最重要的位置:

[rbp + 0x08]

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

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