C语言函数调用栈

  • 函数调用栈是指程序运行时内存一段连续的区域
  • 用来保存函数运行时的状态信息,包括函数参数与局部变量等
  • 称之为“栈”是因为发生函数调用时,调用函数(caller)的状态被保存在栈内,被调用函数(callee)的状态被压入调用栈的栈顶,一般32位的栈顶寄存器是esp,栈底是ebp,64位的栈顶是rsp,栈底是rbp
  • 在函数调用结束时,栈顶的函数(callee)状态被弹出,栈顶恢复到调用函数(caller)的状态
  • 函数调用栈在内存中从高地址向低地址生长,所以栈顶对应的内存地址在压栈时变小,退栈时变大

image-20250824132041565

  • 栈帧结构:

image-20250824132330792

  • 以32位系统函数栈的使用过程为例。

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

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

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

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

  • 在压栈的过程中,esp寄存器的值不断减小(对应于栈从内存高地址向低地址生长)。压入栈内的数据包括调用参数、返回地址、调用函数的基地址,以及局部变量,其中调用参数以外的数据共同构成了被调用函数(callee)的状态。在发生调用时,程序还会将被调用函数(callee)的指令地址存到eip寄存器内,这样程序就可以依次执行被调用函数的指令了。
  • 恢复则是丢弃被调用函数(callee)的状态,将栈顶恢复为调用函数(caller)的状态。
  • 首先被调用函数的局部变量会从栈内直接弹出,栈顶会指向被调用函数(callee)的基地址。
  • 然后将基地址内存储的调用函数(caller)的基地址从栈内弹出,并存到ebp寄存器内。这样调用函数(caller)的ebp(基地址)信息得以恢复,此时栈顶会指向返回地址。
  • 再将返回地址从栈内弹出,并存到eip寄存器内。
  • 至此调用函数(caller)的函数状态就全部恢复了,之后就是继续执行调用函数的指令了。
image-20250824141135791
  • 用IDA分析一个ELF文件。先查看文件是32位还是64位,然后用对应的IDA打开,就可以看到main函数。

image-20250824162300635

image-20250824163747921
  • 可以F5反汇编:

image-20250824163917806

  • 汇编分析:
image-20250824165201597 image-20250824165226066 image-20250824165247868 image-20250824165309381 image-20250824165327407 image-20250824165342833 image-20250824165427536 image-20250824165513449 image-20250824165550858 image-20250824165620009 image-20250824165648288 image-20250824165856710 image-20250824165910493 image-20250824165946144
  • 当函数正在执行内部指令的过程中我们无法拿到程序的控制权,只有在发生函数调用或者结束函数调用时,程序的控制器会在函数状态之间发生跳转,这时才可以通过修改函数状态来实现攻击。而控制程序执行指令最关键的寄存器就是eip,所以我们的目标是让eip载入攻击指令的地址。
  • 回复函数调用结束的过程。首先,在退栈过程中,返回地址会被传给eip,所以我们只需要让溢出数据用攻击指令的地址来覆盖返回地址就可以了。其次,我们可以在溢出数据内包含一段攻击指令,也可以在内存其他位置寻找可用的攻击指令。

缓冲区溢出

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

  • 栈溢出(Stack Overflow)
    • 最常见、漏洞比例最高、危害最大的二进制漏洞
    • 在CTF PWN中往往是漏洞利用的基础
    • 由莫里斯蠕虫开始
  • 堆溢出(Heap Overflow)
    • 堆管理器复杂,利用花样繁多
    • CTF PWN中的常见题型
  • BSS溢出(BSS Overflow)
    • 攻击效果依赖于BSS上存放了何种控制数据

PWN工具

  • IDA Pro:可以反汇编、反编译,静态工具
  • pwntools:python模块
  • checksec:随着pwntools安装的命令行工具,可以看到程序采取的防护措施,往往是pwn的第一步
  • gdb:C语言动态调试工具
  • pwndbg:gdb插件,安装之后运行gdb前面就是pwndbg

image-20250824211006521

  • ROPgadget
  • one_gadget:自动获取shell

IDA

  • IDA反汇编之后,可以双击其中可能有漏洞的函数查看其内容

image-20250824211639728

  • gets是一个经典的漏洞函数
  • 可以在Linux开发者手册第三章查看。由于gets会向缓冲区写入数据而且不检查长度,所以不推荐使用。

image-20250824212210214

  • 左侧函数列表白色的区域,是编写程序中的函数,以及已经添加进程序中的函数,粉色是运行时需要在操作系统中找的函数,在elf文件中只留下表项。
  • 如果程序把函数名去除了,直接没有main函数,可以从输入输出入手,可以按F12/Shift+F12打开字符串界面,找到输出的字符串,双击找到字符串在程序中的位置,应该是在rodata节,并会标出其在程序中的引用位置,双击跳转反编译,很大可能是主函数。

image-20250825083539016

image-20250825083814425

Pwntools

  • 可以创建程序进程进行交互,比如输入输出数据:
image-20250825085159350

ret2text

  • 篡改栈帧上的返回地址为程序中已有的后门函数。
  • 代码图片在上面的IDA部分。先使用checksec查看程序使用的包含措施,该程序关闭了许多保护措施,保留了栈保护NX enabled。后进行反编译,看到局部变量buffer位于ebp-10h,可以通过buffer的溢出覆盖函数返回地址。但二进制文件不一定完全按照静态分析的位置运行,因此需结合动态调试进行更改。
  • gdb可以运行程序:

image-20250828185348466

  • 给 *地址 或者函数打断点:

image-20250828185930938

  • 运行后可以看到寄存器信息、反编译代码、栈信息、栈函数调用关系。

image-20250828190037083

  • 步过到有漏洞的函数并进入:

image-20250828190400239

image-20250828190432357

  • 先运行输入符合要求的数据:

image-20250828212852997

  • 查看正常的栈内容。ebp处存放上一个栈帧栈底,数据大小也是非常大的,下一个地址存放的就是函数返回地址,eax处的数据如果有16+4+4字节,即ebp - eax,[ebp]和4字节覆盖返回地址的内容,如果是后门代码的位置,则可跳转执行。

image-20250828213008356

  • 比如程序中的函数get_shell,用于打开命令提示符,运行该函数则可以使攻击者使用目标电脑的cmd,从而进行非法操作。

image-20250828213600165

  • 攻击过程如下。get_shell的内存地址为0x0804 8522:

image-20250828215302005

  • 在本机打开该程序的进程,以字节格式覆盖返回地址,以交互方式运行即可使用命令提示符,如果远程机器运行了该程序,就可以用remote连接后以相同的方式进行漏洞利用。

image-20250828215650225

ret2shellcode

  • 篡改栈帧上的返回地址为攻击者手动传入的shellcode所在缓冲区地址
  • 初期往往将shellcode直接写入栈缓冲区
  • 目前由于the NX bits保护措施的开启,栈缓冲区不可执行,故当下的常用手段变为向bss缓冲区写入shellcode或向堆缓冲区写入shellcode并使用mprotect赋予其可执行权限

The NX bits(the No-eXecute bits)

  • 程序和操作系统配合起来的防护措施,编译时决定是否生效,由操作系统实现
  • 通过在内存页的标识中增加“执行”位,可以表示该内存页是否可以执行,若程序代码的EIP执行至不可运行的内存页,则CPU将直接拒绝执行“指令”造成程序崩溃

ASLR(Address Space Layout Randomization)

  • 系统的防护措施,程序装载时生效
    • /proc/sys/kernel/randomize_va_space = 0:没有随机化,即关闭ASLR
    • /proc/sys/kernel/randomize_va_space = 1:保留的随机化,共享库、栈、mmap()以及VDSO将被随机化
    • /proc/sys/kernel/randomize_va_space = 2:完全随机化,在randomize_va_space = 1的基础上,通过brk()分配的内存空间也将被随机化
  • 在bss插入恶意代码简单来说,比如下面的的全局变量shellcode有足够大的空间,如果可以输入数据的话就可以插入shellcode。
  • 填入可执行代码需要填机器码才能运行,pwntools里的工具shellcraft里有大量shellcode,可以直接使用。比如sh(),括上print可以以具有可读性的形式查看内容,汇编代码没有问题后,可以阔上asm(),转换成机器码的形式,这里以ASCII码的形式打印,但其实是机器码,填入发送即可。

image-20250829135645420

image-20250829135851905

  • 在设计shellcode之前,需要与目标机器的系统位数对应,上面都是32位的,如果需要转成64位,可以用amd64括起来。或提前转换成64位的模式。

image-20250829140029118

  • 再来看看程序ret2shellcode。先看保护措施,开启了NX,但没有开PIE,而且同时有段是可读可写可执行的,非常危险。可以查看虚拟内存分布查看各部分的权限,一般有w和x不能同时在一个段中才是基本安全的。

image-20250830083941898

image-20250830085245555

  • 反汇编该程序,可以发现可以利用函数gets,并且拷贝到了未声明变量buf2,则有可能是全局变量,双击查看变量,发现buf2在bss段中,则可以将shellcode通过buf2写入内存,获取地址后,地址为0x0804 A080。

image-20250830085449897

image-20250830083714265

  • 静态分析中,s的值距离ebp为64字节,所以需要64+4+4覆盖返回地址。可以先在程序中输入一点数据进行验证。ebp-eax = 0xffff d148 - 0xffff d0dc = 0x6c =108,加上ebp为112字节,和静态分析结果不同以动态调试为准。
image-20250830091906296
  • 组合shellcode、填充字符、shellcode地址,编写攻击脚本:

ret2stack

  • 先关闭栈的保护措施看看最初的执行过程。如果修改randomize_va_space的值为0,就可以使栈里的值存放的位置确定。

image-20250830111107149

  • 关闭canary,打开栈执行权限,关闭pie,带上调试信息(需要带源代码)。脚本文件前可以指定脚本文件解释器,附可执行权限。此时对应的保护措施已经关闭。

image-20250830112720565

  • 断点还是打在main,因为-g参数,此时输出了源代码:

image-20250830113033646

  • 打印一下数组地址。此时ASLR已经关闭,地址都是一样的。在超级管理员模式下打开ASLR,此时相同的参数,每次运行地址都不一样。如果打开了PIE,text、data、bss也会每次运行地址都不一样。

image-20250830114349921

  • 使用gdb进行分析。rbp - rsp = 112,进行攻击,发现失败了。

image-20250830151127126

image-20250830151143763

image-20250830151947028

  • 64位时gdb的位置信息不可靠,这里直接输出变量地址,更改攻击脚本:
  • 然后卡住了。因为缓冲区没有关,卡在了io.recv(),因为程序里虽然有printf,但会先输出到缓冲区,满了才会打印,此时recv接收不到打印的数据就会卡住,可以关闭缓冲区。

image-20250830153806985

  • 重新编译输出地址,更改目标地址即可:

返回导向编程

ret2syscall

  • 什么是系统调用?

    • 操作系统提供给用户的编程接口
    • 是提供访问操作系统所管理的底层硬件的接口
    • 本质上是一些内核函数代码,以规范的方式驱动硬件
    • x86通过int 0x80指令进行系统调用、amd64通过syscall指令进行系统调用
  • 先编写一段代码并运行。此时输出是hello world。write函数在执行时,会mov eax,04h;(系统调用号是4) mov ebx,01h; mov ecx,&“hello world”; mov edx,12h填入参数之后,运行中断程序int 0x80,调用系统函数sys_write(),运行内核代码进行输出。

  • 下面学习部分动态链接库的知识。可以用以下指令查看函数用到的动态链接库:

image-20250830160422258

  • 这里关注libc.so.6,是C语言动态链接库的软链接。软链接的一个例子是快捷方式,如果给一个文件创建快捷方式,因为快捷方式指向的是文件的实际地址,快捷方式被移动到任何地方都可以打开指向的文件。libc.so.6是指向动态链接库的软链接,如果动态链接库的信息有变化,一起更新libc就行了,而不用把需要的内容写死在程序里。
  • 现在来看一下这个动态链接库。可以看到libc.so.6是符号链接,指向libc-2.31.so,运行结果与其一致,所以动态链接库是存放在pwd目录下的可执行文件。

image-20250830162303616

image-20250830162349616

image-20250830162431478

  • 拷贝到桌面用IDA反编译一下。在函数窗口中可以找到printf和system。这些函数会被装入shared library,程序运行时从text跳转到内存共享空间运行这些功能。这些函数也调用了更底层、更复杂的函数。
  • 还有execve函数,它会执行参数里第一个字符串的命令,比如execve("/bin/sh",NULL,NULL)和在命令提示符里直接输入/bin/sh或者执行system(/bin/sh)结果是一样的,其实system就是execve的包装。用汇编表示大概就是:mov eax,0xb mov ebx,["/bin/sh"] mov ecx,0 mov edx,0 int 0x80

动态链接过程

image-20260414222936132

image-20260414222957140

image-20260414223009370

image-20260414223030455

image-20260414223041270

image-20260414223052147

image-20260414223105356

image-20260414223117640

image-20260414223127363

image-20260414223159292

ret2libc

篡改栈帧上自返回地址开始的一段区域为一系列 gadget 的地址,最终调用 libc 中的函数获取 shell:

image-20260414223417437

image-20260414223509961 image-20260414223528805 image-20260414223542889 image-20260414223559990 image-20260414223615934