文章封面图

Essay2026 / 03 / 20

汇编进阶-缓冲区溢出与栈帧防御

由于C语言对于数组引用,指针操作等内存访问缺乏内置的边界检查,这使得其被贴上”不安全语言”的标签,也催生了后续的语言对于安全性的更好支持。而本文将围绕这一特性,剖析常见的内存攻击手段及其防御策略。

1-缓冲区溢出

缓冲区(buffer) 是程序在内存中分配的一段连续空间,用以存放数据。而 缓冲区溢出(buffer overflow) 就是往这个缓冲区写入的数据超出原本的容量,使得数据溢出到相邻的内存区域。 其中,以运行时栈的溢出最为经典。因为运行时栈上不仅存数据,还有返回地址,调用者的栈帧信息。 精心构造的溢出可以篡改返回地址,劫持程序的控制流。 下面是一段示例的C代码,这是C最为经典的漏洞。gets函数从标准输入流中读取字符,直到遇上换行或错误信息停止。

char* gets(char *s){
    int c;
    char *dest = s;
    while((c == getchar()) != '\n' && c != EOF)
        *dest++ =c;
    if(c == EOF && dest == s){
        return NULL;
    }
    *dest++ = '\0';
    return s;
}

void echo(){
    char buf[8];
    gets(buf);
    puts(buf);
}

汇编之后,我们关注echo产生的代码

echo:
    subq $24,%rsp;
    movq %rsp,%rdi;
    call gets;
    movq %rsp,&rdi;
    call puts;
    addq $24,%rsp
    ret

我们可以看到,echo将栈指针减去24(分配了24字节的内存空间)。按照规划,栈顶为字符数组buffer的第一个元素。

stack overflow 而gets的设计存在缺陷,其会无视数组的长度进行读取输入流,写入数据。在写入字符长度为0-7时,还在正常工作。但是写入字符为9-23时,字符数据将会被写入echo栈帧内的未被使用空间。而在写入字符为24-31时,其将破坏调用者栈帧内的返回地址,继续增加还会破坏调用者栈帧的其他部分。如果攻击者别有用心,构造精妙的输入,就可以更改其他函数的返回地址,数据等,劫持程序的控制流。 所以C语言在C11标准发布时,将gets()标记为”不推荐使用”,并在C14标准中移除了该函数,并推荐使用fgets()替代。 历史上利用缓冲区溢出而产生的漏洞破解不尽其数。如1988年的 moris蠕虫病毒,是第一个在互联网上大范围传播的蠕虫病毒 ;2014年的 心脏出血漏洞(Heartbleeding bug) ,由于OpenSSL内新增的心跳协议拓展对输入缺少边界检查,使得可以伪造精妙的请求内容让服务器返回超出缓冲区的内存内容,从而获取到ssh密钥等敏感内容,并且漏洞存在了两年,堪称互联网商用以来的最大漏洞。 moris蠕虫病毒

2-对抗缓冲区溢出攻击

这一段内容非常精彩,我十分建议你看看CSAPP上的原文

由于缓冲区溢出攻击会带来大量未知的bug与漏洞。所以现代编译器与操作系统通过众多机制来防止这一攻击。

2-1 栈随机化

对于攻击者,通过栈溢出完成攻击,需要插入攻击代码与指向代码的指针。在过去的系统中,程序的栈地址是容易预测的,也就是说运行同一套程序与操作系统版本的系统而言,只要一台可以被攻击,那么其他同样的设备也就可以类似的方式实施攻击。 栈随机化 的思想就是让程序每次在栈上运行的位置随机不确定。实现方式也很简单,就是在程序开始执行前,在栈上分配一段长度1到n之间的空间,程序不使用它。 其中,栈随机化属于 ASLR(Address-Space Layout Randomization,地址空间布局随机化) 这一大类技术下的一个。ASLR让程序每次运行,程序代码,栈,全局变量等加载到不同内存区域,使得程序每次运行,所在的内存地址存在巨大差异。

但同样的,攻击者也有一些破解手段,虽然无法得知插入的恶意代码所在准确位置,但是通过在在恶意代码前插入一段很长一段的nop指令(no operation,除了让程序计数器加一并到下一条指令外,没有任何用处),挤占掉原程序可能会返回到的内存地址,那么程序一旦返回到相应的内存地址,就会一条一条执行nop指令一路到达恶意代码。由于这个过程像是滑雪橇一般一路滑至恶意代码,也叫做”空操作雪橇”

2-2 栈破坏检测

计算机的第二道防线就是检测栈何时被破坏了。 现代编译器会给你生成的代码中,增加一个栈保护着机制(stack protector)。思想就是在栈帧内的任何一个局部缓冲区与栈状态之间插入一个特殊的值(随机产生)。在恢复寄存器状态与返回之前,检查该值是否被更改,如果有,则程序异常中止。 由于这个值类似历史上煤矿内用以察觉毒气的金丝雀,故得名 canary value(金丝雀值) 具体些的策略为,在内存中将金丝雀值放在一个特殊的段内(只读),然后同步加载到栈中。最后检查的时候把栈的金丝雀值与内存的金丝雀值对比,如果不同,就报错中断执行。

2-3 限制可执行代码区域

顾名思义,就是限制内存的区域,划定那些地方能放可执行代码,哪些不可执行。消除攻击者插入可执行代码的能力。 在硬件上,过去CPU将读写与执行访问控制合并为1位的标志,这使得内存区域要么不可读,妖梦就是可读又可执行。现在的系统已经支持单独控制读,写,执行这三种访问形式了。