内容纲要

简介

本文是根据长亭科技大佬 jwizard 的教程总结而成的 . 之前学习过一些基础课程 , 现在正式开始学习PWN


开始

缓冲区溢出是个非常古老的话题 , 计算机程序的运行依赖于函数调用栈 . 栈溢出是指在栈内写入超出长度限制的数据 , 从而破坏程序运行甚至获得系统控制权的手段 , 这里以 32位x86架构下的程序为例 , 学习什么是栈溢出

如何实现栈溢出? 需要满足以下两个条件

  • 程序要有向栈内写入数据的行为

  • 程序不限制写入数据的长度

史上第一例被广泛注意的 " 莫里斯蠕虫 " 就是利用C语言标准库的 gets() 函数没有限制数据长度的漏洞 , 从而实现了栈溢出

如果想要使用栈溢出来执行攻击指令 , 就要在溢出数据内包含攻击指令的内容或者地址 , 并且要将程序的控制权交给攻击指令

攻击指令可以是自定义的指令片段 , 也可以是利用系统内已有的函数及指令


背景

首先需要知道函数调用栈的相关知识~

  • 什么是函数调用栈
    函数调用栈是指程序运行时内存一段连续的区域 , 用来保存函数运行时的状态信息( 包含函数参数 , 局部变量 , 返回地址 等等 )

  • 为何被称为栈?
    因为发生函数调用时 , 调用函数( caller )的状态被保存在栈内 . 被调用函数( callee )的状态被压入调用栈的栈顶 .

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

    函数调用栈在内存中从高地址向低地址生长( 栈是由高地址向低地址增长 , 堆是从低地址向高地址增长 ) , 所以栈顶对应的内存地址在压栈时变小 , 退栈时增大 .

    如下图所示

    png


  • 函数调用发生和结束时函数调用栈的变化

    函数状态主要涉及到三个寄存器 : ESP , EIP , EBP

    • ESP( 扩展栈指针寄存器 , 也叫栈指针 ) : 是指针寄存器的一种 , 该指针永远指向系统栈最上方一个栈帧的栈顶( 也就是用于存储函数调用栈的栈顶地址 ) , 该指针在压栈和退栈时会发生变化

    • EBP( 扩展基址指针寄存器 , 也叫帧指针 ) : 也是指针寄存器的一种 , 该指针永远指向系统栈最上方一个栈帧的栈底( 也就是用于存储当前函数状态的基地址 ) . 该指针在函数运行时不变 , 用于索引确定函数参数或局部变量的位置

       EBP 和 BP , 它们的关系就像 AX 与 AL 和 AH 一样 
      
       BP : 基址指针寄存器 , 它可以直接存取堆栈中的数据 . 主要用于在调用函数时保存EBP使得函数结束时可以正确返回 . 
      
       E 是 Extension( 扩展型寄存器 ) , 用于32位的数据处理
      
    • EIP( 扩展指令寄存器 ) , 存储即将执行的程序指令地址 , CPU按照EIP存储的内容读取指令并执行 . 循环读取EIP存储的值 , 实现程序指令的顺序执行

  • 发生函数调用时 , 栈顶函数状态以及上述寄存器的变化

    变化的核心任务 : 将 caller 的状态保存并创建 callee 的状态

    1. callee 的参数按照逆序依次压入栈中 , 如果 callee 没有参数 , 则没有这一步

    png

     这些参数是逆序压入到栈中的 , 先压入最后一个参数 , 然后以此向前 , 最后压入第一个参数
    
    1. 然后将 caller 进行调用之后的下一条指令地址作为返回地址压入到栈内 , 这样 caller 的 EIP 信息得以保存

    png

     EIP寄存器存储将要执行的指令地址 , 返回地址就是函数返回后将要执行的下一条指令
    
     该步进行完毕后 , 下面就会跳转到 callee 的栈帧
    
    1. 将 caller 的栈帧起始地址( EBP )压入栈中

    png

     将当前EBP寄存器的值( caller 的基地址 )压入到栈内 , 这样 caller 的基地址信息得以保存
    
     再将 EBP 的值设置为当前 ESP 的值 , 即将 EBP 指向 callee 栈帧的起始地址
    
     因此可以认为 : EBP标志着当前栈帧的开始
    
    1. 将 callee 的局部变量等数据压入到栈内

    png

     在将局部变量压入栈内的过程中 , ESP寄存器的值不断减小( 因为ESP始终指向栈顶 , 而栈是从内存高地址向低地址生长的 ) 
    

    总的来说 , 函数调用过程中 , caller 先将 callee 的参数从后向前压入栈中 , 再将返回地址压入栈中保存 , 然后就进入了 callee 的栈帧 , callee 会将 caller 的栈帧( caller's EBP )起始地址压入栈中 , 再将 EBP 指向 callee 函数栈帧的起始位置( ESP ) , 最后压入 callee 内定义的局部变量等信息 , 在调用函数后 , caller 和 callee 的栈帧如下所示

    png

    发生函数调用时 , 程序还会将 callee 的指令依次存入到EIP寄存器内 , 这样程序就可以依次执行被调用函数的指令了


  • 结束函数调用时 , 栈顶函数状态以及上述寄存器的变化

    变化的核心任务 : 丢弃 callee 的状态 , 将栈顶恢复为 caller 的状态

    1. callee 的局部变量会从栈顶直接弹出 , 此时栈顶指向调用函数的基地址( caller's ebp )

    png

    1. 将 caller 的基地址从栈内弹出 , 存入到EBP寄存器内 . 此时 caller 的基地址得以恢复 , 栈顶指向返回地址

    png

    1. 再将返回地址从栈内弹出 , 存入到EIP寄存器中 , 这样 caller 的EIP指令得以恢复

    png

现在 caller 的函数状态就全部恢复了 , 之后就是继续执行 caller 的指令了

     如果看不懂上面的过程 , 可以看一看下面这个例子

函数调用实例

仅仅看原理还是有些头晕 , 不如直接分析具体的例子~

因为现在我只有64位的主机 , 所以暂时只能生成 x64 的汇编代码 , 因此这里先记录一下 x86( 32位 ) 和 x64( 64位 )的区别

  • 首先要知道 x86( 32位 ) 下常用的几个寄存器

    png

     专用寄存器 : 只能被特定的汇编指令使用 , 不能用来存储任意数据
    
     通用寄存器 : 虽然有些指令规定了某些寄存器的特殊用途 , 但通用寄存器在大部分汇编指令下可以任意使用
    
      一般寄存器 : 用来存储运行时的数据 , 是最常用的寄存器 . 除了存放一般性的数据 , 每个一般寄存器都有较为固定的独特用途
    
      索引寄存器 : 常用于字符串操作
    
      堆栈指针寄存器 : 保存函数在调用栈中的状态
    
      EAX : 累加寄存器 , 用于算术运算和存放函数结果
    
      EBX : 基址寄存器 , 在内存寻址时( 比如数组运算 )存放基地址
    
      ECX : 计数寄存器 : 用于在循环过程中计数
    
      EDX : 数据寄存器 , 常配合 EAX 一起存放运算结果等( 比如返回值是64位的情况 )
    

    专用寄存器比较特殊 , x86 下的专用寄存器主要由下面三个部分组成

     段地址寄存器( ss , cs , ds , es , fs , gs )
     标志位寄存器( EFLAGS )
     指令指针寄存器( EIP )
    

    现代操作系统内存通常是以 " 分段 " 的形式存放不同类型的信息 , 本章的重点 " 函数调用栈 " 就是分段的一个部分( Stack Segment , 栈段 ) . 内存分段还包括 堆段( Heap Segment ) , 数据段( Data Segment ) , BSS段( Block Started By Symbol Segment ) , 代码段( Code Segment ) , 各段在内存中的排序如下

    png

     Code Segment : 存储可执行代码和只读常量( 比如常量字符串 ) , 属性可读可执行不可写
    
     Data Segment : 存储已初始化或者初值不为 0 的全局变量或静态局部变量 , 属性可读可写可执行
    
     BSS Segment : 存储未初始化或者初值为 0 的全局变量或静态局部变量 , 属性可读可写可执行
    
     Heap Segment : 存放程序运行中的各种动态分配 , 比如C语言中的 malloc() 或 free() 等函数就是在堆上分配或者释放内存的
    
     Stack Segment : 存放程序临时创建的局部变量 
    
    • 段寄存器用来存储内存分段地址
      寄存器 ss 存储函数调用栈( Stack Segment )的地址
      寄存器 cs 存储代码段( Code Segment )的地址
      寄存器 ds 存储数据段( Data Segment )的地址 , es , fs , gs 是附加存储数据段地址的寄存器

    • 标志位寄存器( EFLAGS )的大部分字段用于标志数据或者程序的状态
      OF( OverFlow Flag ) : 数值溢出
      IF( Interrupt Flag ) : 中断
      ZF( Zero Flag ) : 运算结果为 0
      CF( Carry Flag ) : 运算产生进位

    • 指令寄存器( EIP )用来存储下一条运行指令的地址

  • x86 和 x64 的区别

    • 寄存器名称不同 , x86 系统下 EBP , ESP 等寄存器在 x64 系统中都成为了 RBP , RSP

    • 函数传参不同 , x86 系统下调用函数的参数都是保存在栈上的 , 但是在 x64系统中调用函数的前六个参数依次保存在 RDI , RSI , RDX , RCX , R8 , R9 中 , 如果还有更多的参数才会保存到栈中 .

    • 内存地址大小不同 , x64 系统中内存地址不能大于 0x00007ffffffffff , 否则会抛出(0x7fffffffffff = 01111111111111111111111111111111111111111111111)这个异常

    • ... ... ... ...

    可以看到 x86 和 x64 的汇编代码在原理上没有太大区别 , 下面会以 x64 汇编代码为例进行分析 .

  • 对 x64 架构 , 一共有16个64位的寄存器 , 各寄存器及用途如下所示

    png

     每个寄存器的用途并不是唯一的
    
     %rax 通常用于存储函数的返回结果 , 同时也用于乘法和除法指令
    
     %rsp 是栈指针寄存器 , 通常会指向栈顶位置 , 堆栈的 pop 和 push 操作就是通过改变 %rsp 的值来移动栈指针的位置
    
     %rbp 是桢指针寄存器 , 用于标识当前栈帧的起始位置 , %rbp 的值是不会变动的
    
     %rdi , %rsi , %rdx , %rcx , %r8 , %r9 这六个寄存器用于存储调用函数的6个参数 , 如果还有其他参数 , 则它们会被保存到栈中
    
     上述被标记为 miscellaneous register 的寄存器属于通用型寄存器 , 编译器和汇编程序可以根据需要存储任意数据
    
     Callee Save 这个字段表示寄存器的值是由被调用者保存 . 在产生函数调用时 , 子函数通常也会使用到通用寄存器 . 那么这些寄存器中保存的父函数的值就会被覆盖 , 为了避免数据覆盖而导致子函数返回时寄存器中的数据不可恢复 , 所以需要有规定来确定通用寄存器值的保存方式
    
     如果一个寄存器被标识为 Caller Save , 那么在进行子函数调用前 , 需要由调用者保存这些寄存器的值 , 保存的方法是将寄存器中的值压入栈中 , 待保存完毕后 , 被调用者( 也就是子函数 )就可以随意使用这些通用寄存器了 . 
    
     如果一个寄存器被标识为 Callee Save , 那么在调用子函数时 , 调用函数不需要保存这些寄存器中的值 , 被调用函数( 子函数 )会在覆盖这些通用寄存器之前保存这些寄存器的值 . 即这些寄存器的值是由被调用者来保存和恢复的
    

    下面直接举一个例子来学习

  • 一个C语言程序

    png

  • 反汇编

    png

     -g  : 使目标文件包含程序的调试信息
    
     start : 用于拉起被调试的程序 , 并执行至 main 函数的开始位置 , 程序被执行后与一个用户态的调用栈关联
    
  • 分析 main 函数

    现在进程跑在main函数中了 , 可以使用 disassemble 命令显示当前函数的汇编信息

    png

     /m : 在显示汇编指令的同时 , 显示其相应的源代码 . 输出信息每行由4部分组成
    
     1. int a=0x1; : 程序源码
    
     2. 0x000055555555513f : 当前指令对应的虚拟内存地址
    
     3. <+x> : 当前指令的虚拟内存地址偏移量
    
     4. push %rbp : 汇编指令
    

    下面分析这些汇编代码的含义

    1. 代码块1
      png

      push %rbp : 将上一级函数栈帧的栈底指针压入栈中 , 可以看出 main 函数也是一个 " 被调函数 " , 它被 _start 函数调用( 可以参考这里 ) . 在 main 函数返回后 , _start 函数需要用到寄存器 rbp 中的值 , 根据上图 rbp 又是个 Callee Save 寄存器 , 因此这里需要由 callee 压栈保存 . 简单的说 , 这句代码保存了旧的帧指针 , 创建了新的栈帧

      mov %rsp,%rbp : 因为创建了新的栈帧 , 所以需要让 %rbp 指向新栈帧的起始位置( rsp )

      sub $0x10,%rsp : 在新栈帧中预留一些空位 , 供子程序使用 . 因为栈指针( %rsp )会变化而帧指针( %rbp )是固定的 , 所以大多数的信息访问都是通过帧指针(%rbp )实现的 . 访问栈中的元素可以通过偏移量的方式来访问 %rbp 上方或者下方的元素 , 比如 -0x4(%rbp) 或者 +0x8(%rbp) 这种形式 .

      保存上一栈帧的 %rbp 是为了函数返回时恢复 caller 的栈帧结构 !

    2. 代码块2
      png

      movl $0x1,-0x4(%rbp) : 将 main 函数中的局部变量等属性压入栈中 , 因此这里连续压入了三个立即数( $ 开头的为立即数 )到栈中 .

      movl : " l " 是 AT&T 汇编中表示操作数属性的限定符 , 表示长字( 4个字节 ) . 事实上GCC编译器下 INT 型变量就是占4个字节 , 如果操作数是32位( 4个字节 ) , 则指令以" l "结尾 . 如果操作数是64位 , 则指令以 " q " 结尾 , 比如 "movq , callq"

      现在可以理解 sub $0x10,%rsp$0x10 这个立即数是怎么来的了 .

      png

      地址大小刚好16个字节( 0x10 )

    3. 代码块3
      png

      该代码块用于调用 add(a,b) 的子函数

      首先将之前压入栈中的数据提出放入暂存寄存器( edx , eax )中

      对于 x64 架构 , 调用函数的前6个参数不会压入栈中 , 而是放入固定的6个寄存器中 . 比如 第一个参数放入 %rdi , 第二个参数放入 %rsi . 并且这里仅需要用到32位存储 , 所以直接使用 %edi 和 %esi

      参数准备完毕后 , 就将程序控制权转交给 add 函数了 , 通过 callq 指令来完成任务交接

       call / callq 指令会完成两个任务
      
       1. 将 caller 的下一条指令( 这里为 : 0x000055555555516b )作为返回地址压入到栈中 , 待函数返回后将取该指令继续执行程序
      
       2. 修改指令指针寄存器( RIP )的值 , 使其指向 callee 的执行位置( 这里为 : 0x555555555125 )
      

      最后 , 因为这里返回值是32位的 , 所以函数的返回值保存在 %eax 中( 如果返回的数据是64位的 , 那么 %edx 保存高32位数据 , %eax 保存低32位数据 . 这是公认的 , 你可以在之后分析函数时看到 ) . 但因为之后的 add(sum,c) 函数肯定会覆盖 %eax 寄存器 , 所以需要保存当前函数的返回值 . 所以将 %eax 中的值压入栈中保存( mov %eax,-0x10(%rbp) )

  • 分析 add 函数

    上面 callq 将程序控制权交给了 add 函数 . 为了分析 add 函数 , 通过 gdb 命令 si 执行指定数目的汇编代码 , 使其跳转到 add 函数

    之前 start 命令执行到 main 函数 , 根据汇编代码可以看到 , 执行11条指令后到达 add 函数 , 然后通过 disassemble /m 输出汇编信息

    png

    下面分析这些汇编代码的含义

    1. 代码块1
      png

      将 caller 的栈帧栈底压入栈内 , 建立新的栈帧 , 让 %rbp 指向新栈帧的起始位置

      然后在新栈帧中预留一些空位 , 供子程序使用

    2. 代码块2
      png

      将之前压入栈中的局部变量取出 , 进行add运算 , 将结果放入 %eax 寄存器中并压入栈中保存

      返回计算的结果 , 从栈中取出被保存的值并将值传递给 %eax 寄存器

    3. 代码块3
      png

      从栈中弹出 caller 的 %rbp , 之前提到过保存上一栈帧的 %rbp 是为了函数返回时恢复 caller 的栈帧结构 , 就在这里用到的

      retq是 64 位架构的过程返回指令 , 将之前保存的函数返回地址作为下一条将要执行的指令地址 , 实现程序的返回

  • 接下来又调用了一遍 add 函数 , 过程与上述提到的完全相同 , 故不再过多阐述

  • 最后就是结束 main 函数调用的代码

    png

    函数返回 0 , 所以这里将立即数 " 0 " 赋值给 %eax

     leave / leaveq 会完成两个任务
    
     1. mov %rbp,%rsp
    
     2. pop %rbp
    

    将 %rbp 和 %rsp 寄存器中的值还原为函数调用前的值 , 是开始函数调用指令的逆向过程 .

    retq 指令将原栈顶的数据弹出至RIP寄存器 , 执行原函数栈帧中将要执行的指令

最终 , 所有的函数执行完毕 , 调用栈被摧毁.

从上面这个例子 , 你应该能较容易的理解函数调用的具体过程了 ~


攻击技术学习

当函数正在执行内部指令的过程中 , 我们是无法拿到程序控制权的

当发生函数调用或者结束函数调用时 , 程序的控制权会在函数状态之间发生跳转 , 这时可以通过修改函数状态来实现攻击

  • 在函数调用结束时有哪些利用点?
    在退栈过程中 , 返回地址会被传给EIP寄存器 , 只需要让溢出数据用攻击指令的地址来覆盖函数的返回地址就可以了 ! 或是在溢出数据内包含一段攻击指令 , 亦或是在内存的其他位置寻找可用的攻击指令

  • 在函数调用发生时有哪些利用点?
    在函数调用发生时 , EIP会指向原程序中某个指定的函数 . 此时虽然无法通过修改返回地址来拿到程序控制权 , 但是可以将原本指定的函数在调用时替换为其它函数

因此 , 控制程序执行指令最关键的寄存器就是 EIP( 扩展指令寄存器 ) 寄存器 ! 攻击的核心目的就是让 EIP 载入攻击指令的地址

本文覆盖到的四个核心技术分别如下所示

  • ShellCode
    修改返回地址 , 让其指向溢出数据的一段指令

  • Return2libc
    修改返回地址 , 让其指向内存中某个已有的函数

  • ROP
    修改返回地址 , 让其指向内存中已有的一段指令

  • Hijack GOT
    修改某个被调用函数的地址 , 让其指向另一个函数

最后修改日期:2019年9月17日

作者

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。