浸淫二进制已久,几年来学习途径上也颇为坎坷,每每苦于没有一套完整的纲要,尤在入门开荒期间,上下而求索。一直以来都想自己规划出一套二进制安全系列的教纲,从上古时期到现代的技术细节,汇集整理所学所想,承百家之技艺,但遗后来人乘凉。

栈溢出漏洞概述

二进制漏洞中最为著名的当属栈溢出,源于其数量庞大,早期的利用较为简单且高效,往往成为帽子们最受宠的漏洞类型。各种各样的商用、小型软件可以说是栈溢出漏洞的重灾区。本节我们来探讨的就是上古时期的栈溢出漏洞,其漏洞成因,以及该漏洞在调试过程中的表现。

栈溢出的成因

在讨论栈溢出前,我们首先要明确一个概念——栈,在英文中称为stack。国内的叫法一般有两种,堆栈或栈,而实际上对于应用程序来说,堆和栈是两个东西,所以这里的堆栈往往会引起初学者的误会,最后堆栈傻傻分不清楚。我猜测这个堆栈的叫法源于stack的直译,而堆在英文中称呼为heap,由此可见二者是截然不同的东西。关于堆和栈的概念,你可以在《程序员的自我修养:装载、链接与库》中看到详细的讲解。

在这里,我假设你已经对应用程序的栈有所了解,所以我不会繁琐的重新讲解栈这种结构以及在应用程序中的使用。如果你对此不是很了解,请参考failwest的《0day:软件漏洞分析技术》第二章。

而栈溢出的成因则较为简单,其中最为常见的就是用户可控的局部变量,在程序逻辑中并未进行长度的检查,导致用户的输入越过了相应变量在栈上的范围,而覆盖掉了其他关键的结构。而我们知道,根据栈帧的布局,如果输入的值长度越过了当前的栈帧(即子函数内EBP-ESP地址区间),覆盖掉了子函数待返回的地址,那么一旦子函数执行ret时,就会将这个错误的值当成待返回的地址而填充给EIP寄存器,如果该值是一个异常的值,比如说不可执行地址或未初始化的地址那么程序就会崩溃。

从一个Demo说起

千言万语,不如一个实际案例,对于初学者而言,最简单的方法就是自己构造一个demo案例进行研究。这里,我们以《现代Exploit编写指南》中第七节的程序为例子:

#include <stdio.h>

int main()
{
	char name[32];
	long bytes = 0;
	FILE *f = NULL;

	printf("Reading name from file...\n");

	f = fopen("name.dat", "rb");
	if (!f)
		return -1;

	fseek(f, 0L, SEEK_END);
	bytes = ftell(f);
	fseek(f, 0L, SEEK_SET);
	fread(name, 1, bytes, f);
	name[bytes] = '\0';
	fclose(f);

	printf("Hi, %s!\n", name);
	return 0;
}

程序非常简单,从当前目录读取name.dat文件的内容,填充到name数组中。而在这一过程中,程序没有对读取的字节长度bytes进行判断(bytes为该文件长度),导致栈溢出。

因为现代的IDE在编译程序时会默认设置一些安全选项(比如DEP,ASLR,GS等),所以,为了我们能够顺利的研究一个较为原生的案例,我们需要修改这些工程属性:

  • 配置属性
    • C/C++
      • 代码生成
        • 安全检查:禁用安全检查(/GS-)
    • 链接器
      • 高级
        • 数据执行保护(DEP):否 (/NXCOMPAT:NO)

将编译好的二进制程序以及构造的name.dat(写入32个a)拖到测试环境中(这里就先选用Win7的虚拟机)。

一切正常!

当我们将name.dat的内容填充超过32字节后,我们看看是什么样的效果,这里多填充一些a,比如40个,此时程序就因为异常而崩溃了:

我们用Immunity Debugger加载,轻松的找到main的入口在0x01371000地址处,在入口和fread调用处下两个断点:

我们先执行到main的入口:

通过堆栈窗口可以看到main的正常逻辑返回地址应该是0x01371288,此时ESP也就是栈顶为0x0019F968。我们follow过去看一下:

0x01371283处就是对main的CALL调用。

由于我们已经看过了C源码,所以这里的反汇编就不多解释了,值得注意的是经典的EBP,ESP栈帧设置操作以及该函数在返回前对堆栈的平衡(包含局部空间的使用以及调用参数平衡)。

我们直接执行到fread的调用前,看看此时的栈布局:

此时在main内部,可以看到栈由于局部变量、寄存器的使用,已经被抬高,但在fread之前返回地址依然是正确的(dump窗口标蓝色的块)。

我们接下来单步步过这个fread(如果你有兴趣可以跟一下看看fread内部是如何处理的:>)。

此时我们发现原有的ret地址被覆盖掉了,继续单步,在平衡了堆栈后,当我们执行到retn时,此时栈顶就是这个异常的被覆盖的0x61616161:

而这个0x61刚好是a的ASCII码,0x61616161不是一个可执行的地址,于是在retn程序抛出异常。

这里可以看看各个模块的虚拟地址分配区间:

Immunity Debugger无法定位这里的指令(显然是没有的,根据上图看这段地址尚未初始化):

栈溢出的利用猜想

目前我们通过调试,已经完全理解了栈溢出的原因以及其在执行时,机器码的具体表现。下一步,我们就该思考如何去利用这样的一个漏洞。我们既然可以设置返回地址为任意的值,那么也就是说我们可以填充一个精心构造的地址,而该地址处的指令就是我们预先设置好的恶意代码。

那么接踵而至的就是两个问题:

  1. 这个地址在哪儿找?
  2. 我们定制的代码如何放置在该地址处?

实际上,对于栈溢出来说,我们想要的地址远在天边,近在眼前——放在栈上就行。对于本章的demo来说,我们可以控制name起始的栈空间,原则上来讲,如果我们将预先设置好的恶意代码指令放置在栈上,比如放在ret地址后面,然后将ret地址处0x0019F968对应的name.dat偏移的字节设置为放置恶意代码的起始位置,也就是0x0019F96C,那么retn返回时就会直接跳转到栈上,执行我们的恶意指令。

如果放置在ret地址后,就会破坏函数栈帧,程序虽会崩溃,但崩溃前可以执行我们的代码,关于此我们以后会详细说明。

留白

那么这种方法是否可行呢?关于此,我希望你在阅读这篇文章后,能够自己去试一试,如果你不知道如何去找恶意代码字节序列(实际上以后我们叫他shellcode),那么可以简单的设置该地址为栈上的其他地址,甚至是你找到的其他任意模块的某个可执行地址。然后,你需要跟踪一下代码,是否会正确的跳转到你所覆盖的地址处,至于执行的代码暂时不用关心,我们后面会着重讲shellcode。

参考: