首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >C语言,利用gdb 加载符号表后怼着栈内存、反汇编分析程序行为的方法论

C语言,利用gdb 加载符号表后怼着栈内存、反汇编分析程序行为的方法论

原创
作者头像
oscizk
发布2025-09-14 21:50:19
发布2025-09-14 21:50:19
24700
代码可运行
举报
运行总次数:0
代码可运行

注:本文内容不涉及浮点数相关内容

前言

不同处理器架构有各自特性,但是在C语言通过编译器生成可执行文件后其背后的运行逻辑基本一致:

  1. 程序一条一条执行指令,这个背后的逻辑为cpu从程序计数器指向的地址拿到机器代码,解码后执行指令对应的操作,执行完后程序计数器自动指向下一条指令的地址,这样实现了程序的顺序执行;
  2. 处理器能在内存、不同寄存器间移动数据;
  3. 处理器能够进行算数运算,将算数运算结果保存在寄存器中;
  4. 处理器能够进行逻辑运算,并将逻辑运算结果保存在标志寄存器中;
  5. 程序计数器能够跳转到另一处代码执行,通过类似jmp这样的指令实现了循环、分支逻辑;
  6. 在运行过程中调用另一个子流程,并在子流程结束后继续执行,在这个过程中涉及到了通过栈内存保留现场与恢复现场,这就是C语言里函数调用的过程,函数调用时参数传递不同架构都约定了一套自己的寄存器规则,函数调用流程均遵守这套规则保证了ABI(Application Binary Interface,应用程序二进制接口) 的一致性。

理解上述流程,对相关处理器汇编有所了解,即可开启底层的程序行为分析

分析栈内存中关键的指令与寄存器

  1. 程序计数器:通常所说的PC指针,该寄存器指向了下一条要执行的指令所在的地址
  2. 栈指针:通常所说的SP指针,该指针指向当前栈顶元素的地址
  3. 栈帧基址指针:这个指针指向当前栈帧的基址,这个指针不一定在所有程序中都有效的,在有的处理器架构中这个寄存器被用来做其他的事,但是有的处理器架构中这个指针一定是有效的。
  4. 入栈出栈指令:push、pop两个指令,执行push指令时栈深度增加,执行pop指令栈深度减小
  5. 函数调用与返回指令:类似call 与 ret 的两个指令,执行call指令时硬件自动将程序计数器压入栈,并将调用的函数地址放入PC寄存器中开启子流程,执行ret指令时,硬件自动从栈上取出执行call指令时候保存的程序计数器,放入pc寄存器中继续之前的流程执行。
  6. 有时候我们会看到针对栈指针 sp 的加减操作,这其实是在开辟/回收栈上局部变量的空间。

函数调用 ABI 介绍

在学习c语言过程中,学习函数章节时我们就需要认清形参与实参的区别,当我们用熟了C语言后我们就知道,在函数中形参仅仅起到了参数传递的作用,修改形参根本就不会影响到实参的值。

那么为什么是这样的呢?我们先从表面现象开看,修改形参的值不会反过来影响实参的值,这个现象其实表明了形参和实参在计算机中是两个不同的实体,那么我们再进一步分析,在刚进入被调用的函数未对形参做任何修改时形参和实参的值是相同的,因此我们还可以推测出,在函数调用过程中计算机将实参的值复制了一份,从而有了形参。

问题来了,将实参复制后的形参保存在什么地方呢,这里给出结论:当函数的入参个数小于等于一个临界值时,形参放在寄存器里面,当函数入参个数大于这个临界值时,前几个形参放在寄存器里面,后续的形参放在栈内存里面。

在被调用的函数执行完后,函数的返回值其实也放在某个寄存器中,方便被调用者快速获取返回值。

接下来有几个问题以及答案帮助读者理解这部分内容:

  1. 为什么会有这个入参个数临界值?由于cpu里面寄存器个数是有限且宝贵的,不会无限制地支持存放形参。
  2. 这个临界值又是怎么确定的呢?临界值其实是处理器架构、操作系统、编译器三方面层层递进所约束的,处理器架构设计者会在一定程度规定函数调用相关寄存器,但是不做过多约束,操作系统再次基础上进一步作出规定约束,编译器又进一步约束,形成了最后的寄存器、内存的传参的规则。应用程序运行过程中,调用者遵守这个规则,前几个形参放入寄存器,后几个形参放入栈上哪块儿内存;被调用者也遵守这个规则,从哪个寄存器里面找哪个形参,从栈内存中哪个地址找哪个形参。这样函数调用过程就不会乱掉。
  3. 既然没办法都放到寄存器里面,为什么不将形参都放到栈内存里面,保持统一不好吗?我们要知道计算机的存储体系是分等级的,访问速度最快的就是寄存器了,访问内存里面的数据就是要比读寄存器里面的值慢(寄存器和内存里面其实还隔好几个等级的缓存,访问速度一级比一级慢,缓存与内存之间的数据同步是由硬件来完成,作者是个程序员,对硬件细节也不是太了解,该过程与本文主题相关性也不大,不做过多赘述),为了保证函数调用时速度足够快,尽量减少访问内存所带来的时间消耗,将形参放入寄存器是最优解了。
  4. 既然寄存器访问速度快,那把内存做成像访问寄存器那么快不就好了吗?寄存器成本太高了,真做成那样现在就没人能买的起PC了。不排除后续技术发展,真出现了又便宜速度又快的材料,那就又能推动计算机科学一步向前迈进了(这个问题扯远了)。

接下来再说明两个概念:

  1. 调用者保留寄存器(caller-saved):函数调用完成后需要调用者来将这些寄存器里面的值恢复到函数调用前的值,也就是被调用者可以任意修改这些寄存器里的值而不必担心函数返回后出什么问题。为了实现这一点调用者在调用函数前需要将这些寄存器里面的值压入栈,然后再函数返回后从栈中取出保存的值重新放入寄存器中恢复现场。其实用于传递形参、返回值的寄存器也都是调用者保留寄存器,为了将形参放入指定寄存器,调用者是需要先压入栈保存现场,函数调用完后再恢复,
  2. 被调用者保留寄存器(callee-saved): 函数调用前后由被调用者保证不变的寄存器。如果在函数运行过程中这些寄存器的值要修改,那么被调用者需要现将寄存器里面的值放到栈内存保存,在函数返回前在从栈内存中将原始的值取出,恢复进入函数时这些寄存器的值。

所以这就是不同处理器架构里面函数调用 ABI 规范性的由来,想要了解某种特定架构的 ABI 都可以上网查资料或者问问AI大模型找到的。

在进入到某个函数后汇编指令都是在干什么(方法论核心内容)

从c语言的视角来看某个函数的构成时,C语言内部带有局部变量,完成加减乘除算数运算以及各种逻辑运算,完成代码执行流程控制,可以继续调用其他函数。

到了汇编层面后,这些内容相较c语言更加晦涩难懂一些,初学的人往往单条指令能看懂,但是逻辑无法串联起来,c语言在顶层屏蔽了机器的一些细节上的操作,整体粗略的来看,汇编指令其实也不过在做下面这三种流程。

  1. 构建栈帧:分配栈空间通过对sp指针减操作为当前栈帧数据开辟空间;保护现场保护需要当前函数维护的 callee-saved 寄存器;栈保护(可选)初始化金丝雀值预防栈溢出。
  2. 函数主体实现:初始化局部变量将常亮或计算值移入栈帧预定的位置;执行运算执行算数、逻辑判断、内存访问等计算任务;控制流程通过跳转指令实现条件分支和循环;子函数调用如果需要调用其他函数,则根据 ABI 规则构建形参,初始化相关寄存器,参数较多时也要初始化相关栈内存,执行call指令、获取返回值,恢复 caller-saved 寄存器。
  3. 当前函数退出:设置返回值将函数结果放入约定好的寄存器;恢复现场根据ABI规则恢复callee-saved寄存器;栈检查(可选)验证金丝雀值未被修改;撤销栈帧对sp指针做过的减操作加回来,push的内容pop出来,恢复调用者的栈指针;返回执行ret命令,将控制权交还给调用者。 当我们看到汇编代码时就可以回头看这部分描述,看看这段代码在执行上述的那种流程,再结合c语言、gdb工具,确定每个局部变量放在内存中的什么地方,就能将汇编代码执行的逻辑链条分析清楚,注意由于编译器的优化,上述的部分操作可能在实际调试是中看不到对应的汇编代码。

x86_64 架构下栈内存分析

代码语言:txt
复制
x86_64 架构下各寄存器的名称

通用寄存器
64位寄存器名称      低32位寄存器名称        低16位寄存器名称        低8位寄存器名称     8~15位寄存器名称
rax                 eax                     ax                      al                  ah
rbx                 ebx                     bx                      bl                  bh
rcx                 ecx                     cx                      cl                  ch
rdx                 edx                     dx                      dl                  dh
rsi                 esi                     si                      sil                 --
rdi                 edi                     di                      dil                 --
rbp                 ebp                     bp                      bpl                 --
r8                  r8d                     r8w                     r8b                 --
r9                  r9d                     r9w                     r9b                 --
r10                 r10d                    r10w                    r10b                --
r11                 r11d                    r11w                    r11b                --
r12                 r12d                    r12w                    r12b                --
r13                 r13d                    r13w                    r13b                --
r14                 r14d                    r14w                    r14b                --
r15                 r15d                    r15w                    r16b                --

特殊寄存器
程序计数器:rip
栈顶指针:  rsp
标志寄存器: rflags

这里的寄存器并没有完整包含所有寄存器,仅挑重要的寄存器来介绍

在x86_64架构下 rbp 可以被用作栈帧基址寄存器,但是在实际编译程序时这个寄存器一般不用作此用途,直接用来当通用寄存器使用。

x86_64 linux ABI 规则约定

  1. 传参寄存器 函数调用时前1~6个整形参数放入的寄存器依次是:rdi rsi idx rcx r8 r9
  2. 返回值寄存器 在函数结束时,函数返回值放在 rax 中
  3. 剩余寄存器调用者保留寄存器 r10 r11
  4. 被调用者保留寄存器 rbx rbp r12 r13 r14 r15

简单的函数调用示例

代码语言:c
代码运行次数:0
运行
复制
#include <stdio.h>


int func(int a, short b, char c, long d, long long e, void *f)
{
    int ret = 0;
    ret = printf("a: %d, b: %hd, c: %c, d: %ld, e: %lld, f: %p\n",
        a, b, c, d, e, f);
    return ret;
}

int main()
{
    int a = 1;              // 4 bytes
    short b = 2;            // 2 bytes
    char c = 'r';           // 1 bytes
    long d = 3;             // 8 bytes
    long long e = 4;        // 8 bytes
    void *f = &c;           // 8 bytes

    int ret = func(a, b, c, d, e, f);
    return ret;
}

为了防止编译器优化,同时编译出来的可执行文件需要有符号表,使用gcc -O0 -g ./main.c ,将上述文件编译,之后使用gdb a.out 命令开启调试,首先看看函数的反汇编长什么样。

代码语言:txt
复制
(gdb) disassemble main
Dump of assembler code for function main:
   0x00000000000011d5 <+0>:     endbr64 
   0x00000000000011d9 <+4>:     push   %rbp
   0x00000000000011da <+5>:     mov    %rsp,%rbp
   0x00000000000011dd <+8>:     sub    $0x30,%rsp
   0x00000000000011e1 <+12>:    mov    %fs:0x28,%rax
   0x00000000000011ea <+21>:    mov    %rax,-0x8(%rbp)
   0x00000000000011ee <+25>:    xor    %eax,%eax
   0x00000000000011f0 <+27>:    movl   $0x1,-0x28(%rbp)
   0x00000000000011f7 <+34>:    movw   $0x2,-0x2a(%rbp)
   0x00000000000011fd <+40>:    movb   $0x72,-0x2b(%rbp)
   0x0000000000001201 <+44>:    movq   $0x3,-0x20(%rbp)
   0x0000000000001209 <+52>:    movq   $0x4,-0x18(%rbp)
   0x0000000000001211 <+60>:    lea    -0x2b(%rbp),%rax
   0x0000000000001215 <+64>:    mov    %rax,-0x10(%rbp)
   0x0000000000001219 <+68>:    movzbl -0x2b(%rbp),%eax
   0x000000000000121d <+72>:    movsbl %al,%edx
   0x0000000000001220 <+75>:    movswl -0x2a(%rbp),%esi
   0x0000000000001224 <+79>:    mov    -0x10(%rbp),%r8
   0x0000000000001228 <+83>:    mov    -0x18(%rbp),%rdi
   0x000000000000122c <+87>:    mov    -0x20(%rbp),%rcx
   0x0000000000001230 <+91>:    mov    -0x28(%rbp),%eax
   0x0000000000001233 <+94>:    mov    %r8,%r9
   0x0000000000001236 <+97>:    mov    %rdi,%r8
   0x0000000000001239 <+100>:   mov    %eax,%edi
   0x000000000000123b <+102>:   callq  0x1169 <func>
   0x0000000000001240 <+107>:   mov    %eax,-0x24(%rbp)
   0x0000000000001243 <+110>:   mov    -0x24(%rbp),%eax
   0x0000000000001246 <+113>:   mov    -0x8(%rbp),%rdx
--Type <RET> for more, q to quit, c to continue without paging--c
   0x000000000000124a <+117>:   xor    %fs:0x28,%rdx
   0x0000000000001253 <+126>:   je     0x125a <main+133>
   0x0000000000001255 <+128>:   callq  0x1060 <__stack_chk_fail@plt>
   0x000000000000125a <+133>:   leaveq 
   0x000000000000125b <+134>:   retq   
End of assembler dump.
(gdb) disassemble func
Dump of assembler code for function func:
   0x0000000000001169 <+0>:     endbr64 
   0x000000000000116d <+4>:     push   %rbp
   0x000000000000116e <+5>:     mov    %rsp,%rbp
   0x0000000000001171 <+8>:     sub    $0x40,%rsp
   0x0000000000001175 <+12>:    mov    %edi,-0x14(%rbp)
   0x0000000000001178 <+15>:    mov    %esi,%eax
   0x000000000000117a <+17>:    mov    %rcx,-0x28(%rbp)
   0x000000000000117e <+21>:    mov    %r8,-0x30(%rbp)
   0x0000000000001182 <+25>:    mov    %r9,-0x38(%rbp)
   0x0000000000001186 <+29>:    mov    %ax,-0x18(%rbp)
   0x000000000000118a <+33>:    mov    %edx,%eax
   0x000000000000118c <+35>:    mov    %al,-0x1c(%rbp)
   0x000000000000118f <+38>:    movl   $0x0,-0x4(%rbp)
   0x0000000000001196 <+45>:    movsbl -0x1c(%rbp),%ecx
   0x000000000000119a <+49>:    movswl -0x18(%rbp),%edx
   0x000000000000119e <+53>:    mov    -0x30(%rbp),%rdi
   0x00000000000011a2 <+57>:    mov    -0x28(%rbp),%rsi
   0x00000000000011a6 <+61>:    mov    -0x14(%rbp),%eax
   0x00000000000011a9 <+64>:    sub    $0x8,%rsp
   0x00000000000011ad <+68>:    pushq  -0x38(%rbp)
   0x00000000000011b0 <+71>:    mov    %rdi,%r9
   0x00000000000011b3 <+74>:    mov    %rsi,%r8
   0x00000000000011b6 <+77>:    mov    %eax,%esi
   0x00000000000011b8 <+79>:    lea    0xe49(%rip),%rdi        # 0x2008
   0x00000000000011bf <+86>:    mov    $0x0,%eax
   0x00000000000011c4 <+91>:    callq  0x1070 <printf@plt>
   0x00000000000011c9 <+96>:    add    $0x10,%rsp
   0x00000000000011cd <+100>:   mov    %eax,-0x4(%rbp)
--Type <RET> for more, q to quit, c to continue without paging--c
   0x00000000000011d0 <+103>:   mov    -0x4(%rbp),%eax
   0x00000000000011d3 <+106>:   leaveq 
   0x00000000000011d4 <+107>:   retq   
End of assembler dump.

函数入口通用特点:

先比较一下两个函数的开头,可以发现两个函数开头的八个字节都是一样的,都是三个相同的指令,endbr64 这个指令标识这是一个函数起始的位置,这个指令和本文主题不大,不做过多赘述。后面第二个指令与第三个指令值得我们关注一下,由于编译的时候设置了O0的优化等级,这就导致了rbp寄存器在处理器运行过程中充当了栈帧基址指针的作用,push %rbp,这条指令将上一个栈帧的基址压入栈,执行完这一步后需要构建当前栈帧,mov %rsp,%rbp 这条指令就设置了当前栈帧基址,继续看后续的第四条指令,这两条指令都是对rsp做了减操作,栈变得更深,腾出了局部变量所需的空间。

main函数静态分析:

为栈帧上的元素腾出空间:在main函数中函数开始的地方通过 sub $0x30,%rsp 命令腾出了48字节的栈空间空间,用于存放该栈帧里面一些局部的值,包括但不限于局部变量。

初始化金丝雀值:接下来,在 mov %fs:0x28,%rax 以及 mov %rax,-0x8(%rbp) 这两个命令是不是看起来感觉很奇怪,这一步其实在C代码里面根本就不存在,这是编译器做的一种保护,在栈空间 -0x8(%rbp) 这个地址放上一个“金丝雀值”,在函数返回的时候查看这个值是否被改变以确定栈空间是否被损坏,在后续会详细介绍这个机制。

一条没用的指令:继续向下看xor %eax,%eax 这条指令将eax寄存器清零了,从汇编代码继续向后看rax寄存器会写入新的值,覆盖掉这个操作,这个清空操作后也没有将eax值写入内存或mov入其他寄存器,所以这个指令可以直接忽略。

初始化局部变量:接下来0x00000000000011f0 ~ 0x0000000000001215 这段内容很有特点,都是往内存里面写入立即数,而且写入得地址都是相对栈帧基址向下某个地址上得值,这段其实就是在给局部变量赋值,配合c代码的操作,我们可以推测出 -0x28(%rbp) 这个地址存放的值就是 a,后续的可以用相同的方式推测出-0x2a(%rbp) 存放的是局部变量b, -0x2b(%rbp) 存放的是 c,-0x20(%rbp)存放的是 d,-0x18(%rbp) 存放 e, -0x10(%rbp) 存放 f。其中有两条指令值得我们研究 lea -0x2b(%rbp),%rax 以及 mov %rax,-0x10(%rbp) ,lea这个指令读者可以学习一下,其实就是将某个寻址计算的结果放入某个寄存器里面,在这条指令里就是将 -0x2b(%rbp) 计算得到地址放入 rax 寄存器里面,然后将这个地址再放入内存中,而-0x2b(%rbp) 这个地址放的是什么东西,通过我们之前的分析方法可以确定这个地址被初始化后放的是0x72,被初始化为0x72的是其实是局部变量c,也就是局部变量c的地址被放入了内存中。计算某个变量的地址,这就是c语言里面的取址符&的规则,这两个指令其实对应的就是c代码里面 void *f = &c 这一行。

根据ABI构建形参:至此,函数调用前的局部变量全部初始化完成,c代码里面接下来就是函数调用了,既然要函数调用,就要构造符合ABI规定的形参存储位置,因此我们整体上可以推测0x0000000000001219 ~ 0x0000000000001239这段指令是在构造形参。movzbl -0x2b(%rbp),%eax 与 movsbl %al,%edx 这两条指令将内存里面某个参数放入了edx寄存器里面,我们回头看X86_64 abi 规则 rdx 存放的应该是第三个参数,回头看c语言里面函数调用第三个参数是char类型变量c,通过我们之前分析 -0x2b(%rbp) 这个地址存放的就是变量c,第三个形参构建完成;之后将 -0x2a(%rbp) 的值放入 esi 寄存器里面,rsi 寄存器放的是第二个形参,而 -0x2a(%rbp) 就是第二个实参 b 的地址,第二个形参构建完成; 类似的,接下来几条指令依次是下面这个顺序,将 f 的值放入 r8 ,将 e 的值放入 rdi,将 d 的值放入 rcx ,将 a 的值放入eax里,将 f 的值由 r8 移到 r9,将 e 的值由 rdi 移动到 r8,将 a 的值由 eax 移动到 rdi。最终a 在 rdi,b 在 rsi,c 在 rdx, d 在 rcx,e 在r8,f 在 r9,对照 c 代码以及abi规则,完全能对应的上。

获取返回值:根据ABI规则,执行callq命令调用func函数执行完成后返回值放在rax上,但是我们的返回值类型是 int 只占4个字节,所以将mov的是四字节将eax放到-0x24(%rbp) 这个地址,对照c代码,这个地址放的其实就是ret变量。

构造main返回值: main函数返回值是ret,存放在-0x24(%rbp)这个地址,从这个地址取出值,根据ABI规则放到eax里面。

检查金丝雀值是否发生变化: 之前我们分析 -0x8(%rbp) 存放的就是金丝雀值,现在重新取出,和放入时候的值重新对比,如果发生了变化就跳转到 stack_chk_fail@plt 来执行相关后续操作了,一旦执行stack_chk_fail@plt这个函数,操作系统会向当前进程发出信号,使进程终止。

回收栈内存: leaveq这个指令直接执行了两步,首先将rbp栈帧基址指向的内存的值放入rsp里面,然后自动将rsp指向内存的值放入rbp。这个步骤其实就直接回收了当前函数开辟的栈内存,栈帧基址指针恢复到调用main函数前的状态,只有栈帧基址有效的前提下这个操作才有效,如果rbp没有被用作栈帧基址,还是需要通过对rsp减过的值加回来,push过的值pop出来才能恢复上一个栈帧。

返回上个函数继续执行: retq指令又从栈顶取出上个函数下一条指令的地址,放入 rip 里面,这就回到了上个函数的流程,继续向下执行。

看到这里读者可能有下面两个疑问

  1. r10 r11 两个寄存器是 caller-saved 寄存器,为什么main函数里面调用完func函数没有恢复呢?我们过一遍main函数的汇编代码,可以看出r10,r11两个寄存器在 main 函数里面完全没有用到,如果在这种情况下还进行r10, r11两个寄存器完全就是多余的额外操作,会影响程序的性能,这是编译器的一种优化策略。
  2. rbx rbp r12 r13 r14 r15 是 callee-saved 寄存器,为什么在 main 函数返回前没有恢复呢?原因和上一个问题是一样的,main函数里面上面几个寄存器的值从来就没有变过,也就没有必要恢复了。

func函数静态分析

在上一小节已经逐条汇编指令分析了程序的行为,本小节简单过一遍相关内容(指令地址 + 这段指令的含义)。

  1. 0x0000555555555169 表明是函数开头
  2. 0x000055555555516d ~ 0x000055555555516e 构建新栈帧
  3. 0x0000555555555171 为栈帧上的元素开辟空间
  4. 0x0000555555555175 ~ 0x000055555555518f 将寄存器里的形参放入栈内存以及函数里定义的局部变量初始化
  5. 0x0000555555555196 ~ 0x00005555555551bf 根据 abi 构建形参(注意在0x00005555555551a9与0x00005555555551ad 两行又向栈上放入新的东西,这就是超过6个入参,后续的参数在栈上传递),在0x00005555555551bf 这一行这个步骤其实表明了可变参数函数,入参里面没有浮点数,读者有兴趣可以自行了解。
  6. 0x00005555555551c4 调用函数
  7. 0x00005555555551c9 恢复栈平衡(针对0x00005555555551a9与0x00005555555551ad 两行的操作)
  8. 0x00005555555551cd 获取返回值,赋值给ret
  9. 0x00005555555551d0 设置函数返回值
  10. 0x00005555555551d3 撤销当前栈帧
  11. 0x00005555555551d4 函数返回。

值得注意的是printf函数入参的个数达到了7个,超过了6个入参,在这种情况下将第7个参数放入栈上,而栈是要求需要按照16字节对齐,所以在 0x00000000000011bf 这个地址出现了sub $0x8,%rsp 这个指令,目的就是对齐栈内存。

运行时分析栈内存

当我们理解了汇编函数的行为后,其实栈内存理解起来就变得信手拈来了,栈空间上存储的内容其实和汇编指令中涉及到操作rsp指针的指令都是能一一对应起来的,接下来我们分析一下停在func函数入口时栈内存分布情况。

代码语言:txt
复制
(gdb) b func
Breakpoint 1 at 0x555555555169: file abi.c, line 5.
(gdb) r
Starting program: /home/w/Desktop/usrProjs/stack_analise/abi 

Breakpoint 1, func (a=0, b=-18689, c=85 'U', d=0, e=0, f=0x0) at abi.c:5
warning: Source file is more recent than executable.
5       {
(gdb) x/100xb $rsp
0x7fffffffd7b8: 0x40    0x52    0x55    0x55    0x55    0x55    0x00    0x00
0x7fffffffd7c0: 0xe8    0x42    0xfb    0xf7    0xff    0x72    0x02    0x00
0x7fffffffd7c8: 0x01    0x00    0x00    0x00    0x55    0x55    0x00    0x00
0x7fffffffd7d0: 0x03    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x7fffffffd7d8: 0x04    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x7fffffffd7e0: 0xc5    0xd7    0xff    0xff    0xff    0x7f    0x00    0x00
0x7fffffffd7e8: 0x00    0xea    0x0d    0x13    0xe0    0x5d    0xda    0x5a
0x7fffffffd7f0: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x7fffffffd7f8: 0x83    0x70    0xde    0xf7    0xff    0x7f    0x00    0x00
0x7fffffffd800: 0x20    0xc6    0xff    0xf7    0xff    0x7f    0x00    0x00
0x7fffffffd808: 0xe8    0xd8    0xff    0xff    0xff    0x7f    0x00    0x00
0x7fffffffd810: 0x00    0x00    0x00    0x00    0x01    0x00    0x00    0x00
0x7fffffffd818: 0xd5    0x51    0x55    0x55
(gdb) disassemble 
Dump of assembler code for function func:
=> 0x0000555555555169 <+0>:     endbr64 
   0x000055555555516d <+4>:     push   %rbp
   0x000055555555516e <+5>:     mov    %rsp,%rbp
   0x0000555555555171 <+8>:     sub    $0x40,%rsp
   0x0000555555555175 <+12>:    mov    %edi,-0x14(%rbp)
   0x0000555555555178 <+15>:    mov    %esi,%eax
   0x000055555555517a <+17>:    mov    %rcx,-0x28(%rbp)
   0x000055555555517e <+21>:    mov    %r8,-0x30(%rbp)
   0x0000555555555182 <+25>:    mov    %r9,-0x38(%rbp)
   0x0000555555555186 <+29>:    mov    %ax,-0x18(%rbp)
   0x000055555555518a <+33>:    mov    %edx,%eax
   0x000055555555518c <+35>:    mov    %al,-0x1c(%rbp)
   0x000055555555518f <+38>:    movl   $0x0,-0x4(%rbp)
   0x0000555555555196 <+45>:    movsbl -0x1c(%rbp),%ecx
   0x000055555555519a <+49>:    movswl -0x18(%rbp),%edx
   0x000055555555519e <+53>:    mov    -0x30(%rbp),%rdi
   0x00005555555551a2 <+57>:    mov    -0x28(%rbp),%rsi
   0x00005555555551a6 <+61>:    mov    -0x14(%rbp),%eax
   0x00005555555551a9 <+64>:    sub    $0x8,%rsp
   0x00005555555551ad <+68>:    pushq  -0x38(%rbp)
   0x00005555555551b0 <+71>:    mov    %rdi,%r9
   0x00005555555551b3 <+74>:    mov    %rsi,%r8
   0x00005555555551b6 <+77>:    mov    %eax,%esi
   0x00005555555551b8 <+79>:    lea    0xe49(%rip),%rdi        # 0x555555556008
   0x00005555555551bf <+86>:    mov    $0x0,%eax
   0x00005555555551c4 <+91>:    callq  0x555555555070 <printf@plt>
   0x00005555555551c9 <+96>:    add    $0x10,%rsp
   0x00005555555551cd <+100>:   mov    %eax,-0x4(%rbp)
   0x00005555555551d0 <+103>:   mov    -0x4(%rbp),%eax
   0x00005555555551d3 <+106>:   leaveq 
   0x00005555555551d4 <+107>:   retq   
End of assembler dump.
(gdb) fr 1
#1  0x0000555555555240 in main () at abi.c:21
21          int ret = func(a, b, c, d, e, f);
(gdb) disassemble 
Dump of assembler code for function main:
   0x00005555555551d5 <+0>:     endbr64 
   0x00005555555551d9 <+4>:     push   %rbp
   0x00005555555551da <+5>:     mov    %rsp,%rbp
   0x00005555555551dd <+8>:     sub    $0x30,%rsp
   0x00005555555551e1 <+12>:    mov    %fs:0x28,%rax
   0x00005555555551ea <+21>:    mov    %rax,-0x8(%rbp)
   0x00005555555551ee <+25>:    xor    %eax,%eax
   0x00005555555551f0 <+27>:    movl   $0x1,-0x28(%rbp)
   0x00005555555551f7 <+34>:    movw   $0x2,-0x2a(%rbp)
   0x00005555555551fd <+40>:    movb   $0x72,-0x2b(%rbp)
   0x0000555555555201 <+44>:    movq   $0x3,-0x20(%rbp)
   0x0000555555555209 <+52>:    movq   $0x4,-0x18(%rbp)
   0x0000555555555211 <+60>:    lea    -0x2b(%rbp),%rax
   0x0000555555555215 <+64>:    mov    %rax,-0x10(%rbp)
   0x0000555555555219 <+68>:    movzbl -0x2b(%rbp),%eax
   0x000055555555521d <+72>:    movsbl %al,%edx
   0x0000555555555220 <+75>:    movswl -0x2a(%rbp),%esi
   0x0000555555555224 <+79>:    mov    -0x10(%rbp),%r8
   0x0000555555555228 <+83>:    mov    -0x18(%rbp),%rdi
   0x000055555555522c <+87>:    mov    -0x20(%rbp),%rcx
   0x0000555555555230 <+91>:    mov    -0x28(%rbp),%eax
   0x0000555555555233 <+94>:    mov    %r8,%r9
   0x0000555555555236 <+97>:    mov    %rdi,%r8
   0x0000555555555239 <+100>:   mov    %eax,%edi
   0x000055555555523b <+102>:   callq  0x555555555169 <func>
=> 0x0000555555555240 <+107>:   mov    %eax,-0x24(%rbp)
   0x0000555555555243 <+110>:   mov    -0x24(%rbp),%eax
   0x0000555555555246 <+113>:   mov    -0x8(%rbp),%rdx
   0x000055555555524a <+117>:   xor    %fs:0x28,%rdx
   0x0000555555555253 <+126>:   je     0x55555555525a <main+133>
   0x0000555555555255 <+128>:   callq  0x555555555060 <__stack_chk_fail@plt>
   0x000055555555525a <+133>:   leaveq 
   0x000055555555525b <+134>:   retq   
End of assembler dump.

(gdb) fr 1
#1  0x0000555555555240 in main () at abi.c:21
21          int ret = func(a, b, c, d, e, f);
(gdb) info locals
a = 1
b = 2
c = 114 'r'
d = 3
e = 4
f = 0x7fffffffd7c5
ret = 21845
(gdb) p /x &a
$1 = 0x7fffffffd7c8
(gdb) p /x &b
$2 = 0x7fffffffd7c6
(gdb) p /x &c
$3 = 0x7fffffffd7c5
(gdb) p /x &d
$4 = 0x7fffffffd7d0
(gdb) p /x &e
$5 = 0x7fffffffd7d8
(gdb) p /x &f
$6 = 0x7fffffffd7e0
(gdb) p /x &ret
$7 = 0x7fffffffd7cc

(gdb) p $rbp-0x8
$8 = (void *) 0x7fffffffd7e8

栈内存内容分析:

  1. 当前处理器停在了func函数的第一个指令处,func函数还没有对栈内存做任何操作,因此回到main函数分析,上条指令执行的是call指令,call指令会向栈内存上压入一个8字节的指针。因此 “0x7fffffffd7b8: 0x40 0x52 0x55 0x55 0x55 0x55 0x00 0x00” 这行整体上就是一个指针,组合起来和 fr 1里面rip指向的地址 0x0000555555555240 一致。
  2. 在往前看对main函数rsp相关的操作是 sub $0x30,%rsp 这个指令,这个指令表明0x7fffffffd7c0 ~ 0x7fffffffd7ef 的内容是栈帧上的局部内存,gdb可以直接帮助我们获取每个变量所在的内存位置,可以按照下面这种思路来进行分析:a是int类型变量占4个字节,0x7fffffffd7c8~0x7fffffffd7cb这段内存对应变量a,值为1;b是short类型变量占两个字节,0x7fffffffd7c6~0x7fffffffd7c7这段内存对应变量b,值为2;c是char类型变量占一个字节,0x7fffffffd7c5 这个地址存放的就是c,置为0x72对应 'r' 字符的ascii码;d是long 类型变量占8个字节,0x7fffffffd7d8~0x7fffffffd7df这段内存对应变量d,值为4,f为一个指针64位系统下占8个字节0x7fffffffd7e0~0x7fffffffd7e7这段内存对应变量f,值为变量c的地址0x7fffffffd7c5;ret为int类型变量占4个字节,0x7fffffffd7cc~0x7fffffffd7cf内存对应ret变量的值,由于当前func函数还没有返回结果,ret也没有被初始化,所以其内部的值是个随机值,目前是0x5555。
  3. 再往前看栈上放了个金丝雀值,这个金丝雀值放在-0x8(%rbp) 的位置,我们通过 p $rbp-0x8 命令可以拿到这个金丝雀值的地址0x7fffffffd7e8,由于采用了pushq因此是个8字节的值,对应0x7fffffffd7e8~0x7fffffffd7ef,其值为0x5ada5de0120dea00。
  4. main函数入口将上个函数的栈帧基址 rbp 压入栈,也就是0x7fffffffd7ef再往前8个字节,0x7fffffffd7f0~0x7fffffffd7f7都是上个函数的栈帧基址也就是0(main函数之前的加载流程作者也不熟悉,就只能分析到这了)。

对栈空间的分析到此为止读者有兴趣可以继续分析func函数的栈帧结构,或者自己写出一些c代码来进行分析。

提升编译器优化等级后汇编有什么变化

我们将这个相同的文件以O1的优化等级进行编译看看反汇编会发生什么样的变化。

代码语言:txt
复制
(gdb) disassemble main
Dump of assembler code for function main:
   0x000000000000119f <+0>:     endbr64 
   0x00000000000011a3 <+4>:     push   %rbx
   0x00000000000011a4 <+5>:     sub    $0x10,%rsp
   0x00000000000011a8 <+9>:     mov    $0x28,%ebx
   0x00000000000011ad <+14>:    mov    %fs:(%rbx),%rax
   0x00000000000011b1 <+18>:    mov    %rax,0x8(%rsp)
   0x00000000000011b6 <+23>:    xor    %eax,%eax
   0x00000000000011b8 <+25>:    movb   $0x72,0x7(%rsp)
   0x00000000000011bd <+30>:    lea    0x7(%rsp),%r9
   0x00000000000011c2 <+35>:    mov    $0x4,%r8d
   0x00000000000011c8 <+41>:    mov    $0x3,%ecx
   0x00000000000011cd <+46>:    mov    $0x72,%edx
   0x00000000000011d2 <+51>:    mov    $0x2,%esi
   0x00000000000011d7 <+56>:    mov    $0x1,%edi
   0x00000000000011dc <+61>:    callq  0x1169 <func>
   0x00000000000011e1 <+66>:    mov    0x8(%rsp),%rdx
   0x00000000000011e6 <+71>:    xor    %fs:(%rbx),%rdx
   0x00000000000011ea <+75>:    jne    0x11f2 <main+83>
   0x00000000000011ec <+77>:    add    $0x10,%rsp
   0x00000000000011f0 <+81>:    pop    %rbx
   0x00000000000011f1 <+82>:    retq   
   0x00000000000011f2 <+83>:    callq  0x1060 <__stack_chk_fail@plt>
End of assembler dump.
(gdb) disassemble func
Dump of assembler code for function func:
   0x0000000000001169 <+0>:     endbr64 
   0x000000000000116d <+4>:     sub    $0x8,%rsp
   0x0000000000001171 <+8>:     mov    %rcx,%rax
   0x0000000000001174 <+11>:    movswl %si,%ecx
   0x0000000000001177 <+14>:    push   %r9
   0x0000000000001179 <+16>:    push   %r8
   0x000000000000117b <+18>:    mov    %rax,%r9
   0x000000000000117e <+21>:    movsbl %dl,%r8d
   0x0000000000001182 <+25>:    mov    %edi,%edx
   0x0000000000001184 <+27>:    lea    0xe7d(%rip),%rsi        # 0x2008
   0x000000000000118b <+34>:    mov    $0x1,%edi
   0x0000000000001190 <+39>:    mov    $0x0,%eax
   0x0000000000001195 <+44>:    callq  0x1070 <__printf_chk@plt>
   0x000000000000119a <+49>:    add    $0x18,%rsp
   0x000000000000119e <+53>:    retq   
End of assembler dump.

通过反汇编是不是感觉函数变短了很多。细看我们可以看到,main函数和func函数开头都没有了对rbp指针的入栈操作,同时函数中对rsp指针减的值变少了很多,我们来大致分析一下。

  1. rbp寄存器的职责发生变化:在优化等级提升后rbp不再被用作栈帧基址指针了,所以函数返回前不能再使用 leaveq 指令了,如果在使用这个指令势必会指向一块未知的内存导致进程崩溃,rbp在这种情况下被用作了通用寄存器,原因还是很简单处理器内部的寄存器价值太高了,仅用作栈帧基址指针有点浪费,多一个通用寄存器能带来更高的使用效率。由于不能再使用 leaveq 指令自动实现栈帧回收,因此在函数内部就要自己实现栈空间的回收,对rsp sub操作后需要add回去,push的值还要pop出来,例如在main函数里面0x00000000000011a3 地址先 push,在 0x00000000000011f0 这个地址有对称的 pop 操作,在0x00000000000011a4 先执行sub操作,在0x00000000000011ec 这个地址有对称的 add 操作;在func函数里面对rsp的操作有下面这几步0x000000000000116d 的sub 0x8操作,在0x0000000000001177 以及0x0000000000001179 两个地址的push操作,最后在0x000000000000119a 这个地址的一个add 0x18操作将之前的所有操作撤销了。
  2. 尽量减少栈内存的访问: 提升优化等级后,栈内存使用明显减少,在优化等级为O0时所有的局部变量我们都能在栈内存上找到其对应的存储空间,对局部变量的计算操作的范式都是从内存中读出局部变量,对变量的值进行一些处理,如果变量值发生修改则重新写入内存中。但是在O1的情况下能单纯用寄存器来完成的事就不会再用栈空间了,在我们写的例子里面,基本上局部变量全都存储在寄存器里面。这么做的原因还是在于访问内存要比访问寄存器慢,用更简短的指令序列来完成相同的事比更长的指令序列耗时更短,程序性能更强,因此某些时候程编译器优化等级越高,程序就越难调试。

金丝雀值被破坏后会发生什么事

我们接下来尝试破坏一下金丝雀值来看看会有什么事发生,我们先就用当前的程序使用gdb强行破坏一下这个值看看现象。

代码语言:txt
复制
(gdb) b func
Breakpoint 1 at 0x1169: file abi.c, line 5.
(gdb) r
Starting program: /home/w/Desktop/usrProjs/stack_analise/abi 

Breakpoint 1, func (a=0, b=-18689, c=85 'U', d=0, e=0, f=0x0) at abi.c:5
5       {
(gdb) bt
#0  func (a=0, b=-18689, c=85 'U', d=0, e=0, f=0x0) at abi.c:5
#1  0x0000555555555240 in main () at abi.c:21
(gdb) fr 1
#1  0x0000555555555240 in main () at abi.c:21
21          int ret = func(a, b, c, d, e, f);
(gdb) disassemble 
Dump of assembler code for function main:
   0x00005555555551d5 <+0>:     endbr64 
   0x00005555555551d9 <+4>:     push   %rbp
   0x00005555555551da <+5>:     mov    %rsp,%rbp
   0x00005555555551dd <+8>:     sub    $0x30,%rsp
   0x00005555555551e1 <+12>:    mov    %fs:0x28,%rax
   0x00005555555551ea <+21>:    mov    %rax,-0x8(%rbp)
   0x00005555555551ee <+25>:    xor    %eax,%eax
   0x00005555555551f0 <+27>:    movl   $0x1,-0x28(%rbp)
   0x00005555555551f7 <+34>:    movw   $0x2,-0x2a(%rbp)
   0x00005555555551fd <+40>:    movb   $0x72,-0x2b(%rbp)
   0x0000555555555201 <+44>:    movq   $0x3,-0x20(%rbp)
   0x0000555555555209 <+52>:    movq   $0x4,-0x18(%rbp)
   0x0000555555555211 <+60>:    lea    -0x2b(%rbp),%rax
   0x0000555555555215 <+64>:    mov    %rax,-0x10(%rbp)
   0x0000555555555219 <+68>:    movzbl -0x2b(%rbp),%eax
   0x000055555555521d <+72>:    movsbl %al,%edx
   0x0000555555555220 <+75>:    movswl -0x2a(%rbp),%esi
   0x0000555555555224 <+79>:    mov    -0x10(%rbp),%r8
   0x0000555555555228 <+83>:    mov    -0x18(%rbp),%rdi
   0x000055555555522c <+87>:    mov    -0x20(%rbp),%rcx
   0x0000555555555230 <+91>:    mov    -0x28(%rbp),%eax
   0x0000555555555233 <+94>:    mov    %r8,%r9
   0x0000555555555236 <+97>:    mov    %rdi,%r8
   0x0000555555555239 <+100>:   mov    %eax,%edi
   0x000055555555523b <+102>:   callq  0x555555555169 <func>
=> 0x0000555555555240 <+107>:   mov    %eax,-0x24(%rbp)
   0x0000555555555243 <+110>:   mov    -0x24(%rbp),%eax
   0x0000555555555246 <+113>:   mov    -0x8(%rbp),%rdx
   0x000055555555524a <+117>:   xor    %fs:0x28,%rdx
   0x0000555555555253 <+126>:   je     0x55555555525a <main+133>
   0x0000555555555255 <+128>:   callq  0x555555555060 <__stack_chk_fail@plt>
   0x000055555555525a <+133>:   leaveq 
   0x000055555555525b <+134>:   retq   
End of assembler dump.
(gdb) x/gx $rbp-8
0x7fffffffd7e8: 0x5b5c38f668b36600
(gdb) set {long long}($rbp - 0x8) = 0x0 
(gdb) x/gx $rbp-8
0x7fffffffd7e8: 0x0000000000000000
(gdb) b __stack_chk_fail
__stack_chk_fail        __stack_chk_fail@plt    __stack_chk_fail_local  
(gdb) b __stack_chk_fail
Breakpoint 2 at 0x7ffff7ef2c90: file stack_chk_fail.c, line 23.
(gdb) c
Continuing.
a: 1, b: 2, c: r, d: 3, e: 4, f: 0x7fffffffd7c5

Breakpoint 2, __stack_chk_fail () at stack_chk_fail.c:23
23      stack_chk_fail.c: No such file or directory.
(gdb) c
Continuing.
*** stack smashing detected ***: terminated

Program received signal SIGABRT, Aborted.
__GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:50
50      ../sysdeps/unix/sysv/linux/raise.c: No such file or directory.

可以看出金丝雀值被破坏后程序确实跳转到 __stack_chk_fail 函数执行,运行到最后进程接收到了SIGABRT信号,进程终止。

什么情况下会出现金丝雀值被破坏的情况呢

缓冲区溢出

金丝雀值被破坏比较常见的情形源自于缓冲区溢出,栈上给定一个数组,往里面填入值,如果不注意就容易出现数组越界的情况,这是任何一个C/C++程序员不愿意看到的情况。我们来编写一个简单的函数来触发一下缓冲区溢出的场景。

代码语言:c
代码运行次数:0
运行
复制
#include <stdio.h>
#include <string.h>

int main() 
{
    char *str = "overflow";
    char buf[5];
    sprintf(buf, "%s", str);
    return 0;
}

使用gcc -O0 -g overflow.c -o overflow 命令编译该文件后,用gdb开始调试

代码语言:txt
复制
(gdb) b sprintf
Breakpoint 1 at 0x1070
(gdb) disassemble main
Dump of assembler code for function main:
   0x0000000000001169 <+0>:     endbr64 
   0x000000000000116d <+4>:     push   %rbp
   0x000000000000116e <+5>:     mov    %rsp,%rbp
   0x0000000000001171 <+8>:     sub    $0x20,%rsp
   0x0000000000001175 <+12>:    mov    %fs:0x28,%rax
   0x000000000000117e <+21>:    mov    %rax,-0x8(%rbp)
   0x0000000000001182 <+25>:    xor    %eax,%eax
   0x0000000000001184 <+27>:    lea    0xe79(%rip),%rax        # 0x2004
   0x000000000000118b <+34>:    mov    %rax,-0x18(%rbp)
   0x000000000000118f <+38>:    mov    -0x18(%rbp),%rdx
   0x0000000000001193 <+42>:    lea    -0xd(%rbp),%rax
   0x0000000000001197 <+46>:    lea    0xe6f(%rip),%rsi        # 0x200d
   0x000000000000119e <+53>:    mov    %rax,%rdi
   0x00000000000011a1 <+56>:    mov    $0x0,%eax
   0x00000000000011a6 <+61>:    callq  0x1070 <sprintf@plt>
   0x00000000000011ab <+66>:    mov    $0x0,%eax
   0x00000000000011b0 <+71>:    mov    -0x8(%rbp),%rcx
   0x00000000000011b4 <+75>:    xor    %fs:0x28,%rcx
   0x00000000000011bd <+84>:    je     0x11c4 <main+91>
   0x00000000000011bf <+86>:    callq  0x1060 <__stack_chk_fail@plt>
   0x00000000000011c4 <+91>:    leaveq 
   0x00000000000011c5 <+92>:    retq   
End of assembler dump.
(gdb) b __stack_chk_fail
Breakpoint 2 at 0x1060
(gdb) r
Starting program: /home/w/Desktop/usrProjs/stack_analise/overflow 

Breakpoint 1, __sprintf (s=0x7fffffffd7d3 "\377\377\177", format=0x55555555600d "%s") at sprintf.c:25
25      sprintf.c: No such file or directory.
(gdb) fr 1
#1  0x00005555555551ab in main () at overflow.c:8
8           sprintf(buf, "%s", str);
(gdb) x/50xb $rsp
0x7fffffffd7c0: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x7fffffffd7c8: 0x04    0x60    0x55    0x55    0x55    0x55    0x00    0x00
0x7fffffffd7d0: 0xd0    0xd8    0xff    0xff    0xff    0x7f    0x00    0x00
0x7fffffffd7d8: 0x00    0xea    0xdc    0x24    0x3f    0xda    0x0e    0xd5
0x7fffffffd7e0: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x7fffffffd7e8: 0x83    0x70    0xde    0xf7    0xff    0x7f    0x00    0x00
0x7fffffffd7f0: 0x20    0xc6
(gdb) x/gx $rbp-0x8
0x7fffffffd7d8: 0xd50eda3f24dcea00
(gdb) p buf
$1 = "\377\377\177\000"
(gdb) p &buf
$2 = (char (*)[5]) 0x7fffffffd7d3
(gdb) p &str
$3 = (char **) 0x7fffffffd7c8
(gdb) c
Continuing.

Breakpoint 2, __stack_chk_fail () at stack_chk_fail.c:23
23      stack_chk_fail.c: No such file or directory.
(gdb) bt
#0  __stack_chk_fail () at stack_chk_fail.c:23
#1  0x00005555555551c4 in main () at overflow.c:10
(gdb) fr 1
#1  0x00005555555551c4 in main () at overflow.c:10
10      }
(gdb) x/50xb $rsp
0x7fffffffd7c0: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x7fffffffd7c8: 0x04    0x60    0x55    0x55    0x55    0x55    0x00    0x00
0x7fffffffd7d0: 0xd0    0xd8    0xff    0x6f    0x76    0x65    0x72    0x66
0x7fffffffd7d8: 0x6c    0x6f    0x77    0x00    0x3f    0xda    0x0e    0xd5
0x7fffffffd7e0: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x7fffffffd7e8: 0x83    0x70    0xde    0xf7    0xff    0x7f    0x00    0x00
0x7fffffffd7f0: 0x20    0xc6
(gdb) p buf
$4 = "overf"
(gdb) x/gx $rbp-0x8
0x7fffffffd7d8: 0xd50eda3f00776f6c
(gdb) c
Continuing.
*** stack smashing detected ***: terminated

Program received signal SIGABRT, Aborted.
__GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:50
50      ../sysdeps/unix/sysv/linux/raise.c: No such file or directory.
(gdb) 

我们分别在sprintf 函数逻辑执行前和触发金丝雀值破坏后执行的 __stack_chk_fail 函数打上断点,在我们可以,清晰的观察到 sprintf 函数执行前后金丝雀值由 0xd50eda3f24dcea00 变为了 0xd50eda3f00776f6c。我们先画出栈内存布局

代码语言:txt
复制
0x7fffffffd7c8~0x7fffffffd7cf    str字符串指针对应地址(8个字节)
0x7fffffffd7d0~0x7fffffffd7d2    为了内存对齐填充的空内容,没用到的(3个字节)
0x7fffffffd7d3~0x7fffffffd7d7    buf数组在内存中的位置(5个字节)
0x7fffffffd7d8~0x7fffffffd7df    金丝雀值在内存中的位置 (8个字节)

当运行sprintf函数时,会从buf数组开始的位置,向高地址依次填入“overflow” 这个字符串,算上末尾的结束符需要9个字节,最后就会导致内存越界,刚好修改了金丝雀值的一部分。我们对比一下金丝雀前后发生了什么变化,刚好就是buf缓冲器放不下的东西就放在了金丝雀值上。

代码语言:txt
复制
地址              初始值   修改后的值
0x7fffffffd7d8    0x00     0x6c ('l' 的ascii码)
0x7fffffffd7d9    0xea     0x6f ('o' 的ascii码)
0x7fffffffd7da    0xdc     0x77 ('w' 的ascii码)
0x7fffffffd7db    0x24     0x00 ('\0' 的ascii码)
0x7fffffffd7dc    0x3f     0x3f
0x7fffffffd7dd    0xda     0xda
0x7fffffffd7de    0x0e     0x0e
0x7fffffffd7df    0xd5     0xd5

没有金丝雀值会发生什么事

金丝雀值放入栈上后其实再往高低址看就是栈帧基址以及为了保存上个函数的现场而放置的程序计数器的值,如果不对金丝雀值做判断,在函数返回过程中不会调用__stack_chk_fail 这个函数,此时如果程序计数器的值被修改,后续一个不确定的地址放到rip寄存器里,指向一个未知的内存区域,比较好的情况是这段区域的内存是不可执行的,程序后续直接崩溃,而不妙的情况是这段区域的内存正好是攻击者放入的攻击代码的区域,那么攻击者就直接将这个线程给劫持了,他可以做自己想做的事情。金丝雀值就是为了防止这种情况的出现。

总结

本文描述了一种分析汇编与栈内存的方法论,并以x86_64架构为例,以一个简单的程序为,以此方法论为基础,分析了程序行为以及栈空间中各种变量的分布。同时简要分析了一下编译器优化等级的提升对程序带来的影响。这套抽象出来的方法论在其他处理器架构中同样适用,这篇文章写的实在是太长了,后续作者可能新开一个博客,利用这个方法论来分析 arm / risc-v 的反汇编以及栈内存,(arm汇编很早之前了解过,作者已经忘光了,risc-v的汇编作者更是没接触过,希望这不是给自己挖了但是填不上的坑吧),后续一遍学习一边分析。

参考文献

《深入理解计算机系统》

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 分析栈内存中关键的指令与寄存器
  • 函数调用 ABI 介绍
  • 在进入到某个函数后汇编指令都是在干什么(方法论核心内容)
  • x86_64 架构下栈内存分析
    • x86_64 linux ABI 规则约定
    • 简单的函数调用示例
      • 函数入口通用特点:
      • main函数静态分析:
      • func函数静态分析
      • 运行时分析栈内存
    • 提升编译器优化等级后汇编有什么变化
    • 金丝雀值被破坏后会发生什么事
      • 什么情况下会出现金丝雀值被破坏的情况呢
  • 总结
  • 参考文献
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档