C和C++安全编码笔记:指针诡计

旧巷老猫 提交于 2020-04-12 10:21:12

指针诡计(pointer subterfuge)是通过修改指针值来利用程序漏洞的方法的统称

可以通过覆盖函数指针将程序的控制权转移到攻击者提供的外壳代码(shellcode)。当程序通过函数指针执行一个函数调用时,攻击者提供的代码将会取代原本希望执行的代码而得到执行。

对象指针也可以被修改,从而执行任意代码。如果一个对象指针用作后继赋值操作的目的地址,那么攻击者就可以通过控制该地址从而修改内存其它位置中的地址。

3.1 数据位置:

static int GLOBAL_INIT = 1; // 数据段,全局
static int global_uninit; // BSS段,全局
int test_secure_coding_3_1() // 栈,局部
{
	int local_init = 1; // 栈,局部
	int local_uninit; // 栈,局部
	static int local_static_init = 1; // 数据段,局部
	static int local_static_uninit; // BSS段,局部
	// buff_ptr的存储空间是栈,局部;分配的内存是堆,局部
	int* buff_ptr = (int*)malloc(32);
	free(buff_ptr);

	return 0;
}

UNIX可执行文件包含data段和BSS段。data段包含了所有已初始化的全局变量和常数。BSS段包含了所有未初始化的全局变量。将已初始化和未初始化变量分开是为了让汇编器不将未初始化的变量内容(BSS段)写入目标文件中。

3.2 函数指针:

void good_function(const char* str) {} // 栈
// 一个有漏洞的程序,其BSS段中的函数指针可以被覆写
void test_secure_coding_3_2(int argc, char* argv[]) // 栈
{
	const int BUFFSIZE = 10; // 栈
	static char buff[BUFFSIZE]; // BSS段
	static void(*funPtr)(const char* str); // BSS段
	funPtr = &good_function;
	// 当argv[1]的长度大于BUFFSIZE的时候,就会发生缓冲区溢出,这个缓冲区溢出漏洞
	// 可以被利用来将函数指针值覆写为外壳代码的地址,从而将程序的控制权转移到任意的代码
	// 当执行由funPtr标识的函数时,外壳代码将会取代good_function()得以执行
	strncpy(buff, argv[1], strlen(argv[1])); 
	(void)(*funPtr)(argv[2]);
}

虽然栈溢出(连同很多基于堆的攻击)不可能发生于数据段(data segment)中,但是覆写函数指针在任何内存段中都会发生。

3.3 对象指针:

// 一个有漏洞的程序,可以被利用来实现任意内存写,修改对象指针
void test_secure_coding_3_3(void* arg, size_t len)
{
	char buff[100];
	long val = 1;
	long* ptr = &val;
	// 一个无界内存复制,在溢出缓冲区后,攻击者可以覆写ptr和val
	// 当执行*ptr=val时,就会发生任意内存写
	memcpy(buff, arg, len);
	*ptr = val;
}

C和C++中的对象指针用于指向动态分配的结构、函数的引用参数、数组以及其它对象。这些对象指针可能会被攻击者修改,比如当利用一个缓冲区溢出漏洞的时候。如果一个指针接下来被用作一个赋值操作的目的地址,那么攻击者就可以通过控制地址达到修改其它内存位置内容的目的,这种技术也称为”任意内存写”(arbitrary memory write)。

3.4 修改指令指针:攻击者要想在x86-32架构上成功地执行任意代码,必须利用某种方式修改指令指针,使其指向外壳代码。指令指针寄存器(eip)存储了将要执行的下一条指令在当前代码段内的偏移量。eip寄存器不能被软件直接访问。它在顺序执行代码时由一个指令边界步进到下一条指令,也可以由控制转移指令(例如jmp、jcc、call和ret等)、中断以及异常间接修改。

以call指令为例,它首先将返回信息存储于栈中,然后将控制权转移到由目标操作数指定的被调用函数处。目标操作数指定了被调用函数中的第一条指令的地址。该操作数可以是一个立即数(immediate value)、一个通用寄存器或一个内存位置。

3.5 全局偏移表:Windows和Linux在库函数的链接和控制转移方面使用了类似的机制。从安全的角度来看,二者主要的区别在于Linux使用的方法是可被利用的,而Windows则不然。

Linux使用的默认二进制格式称为可执行和链接格式(Executable and Linking Format, ELF)。ELF最初由UNIX系统实验室(UNIX System Laboratories, USL)作为二进制应用程序接口(Application Binary Interface, ABI)的一个部分开发并发布。后来将ELF标准作为多种x86-32操作系统上的可移植目标文件格式。

任何ELF的二进制文件的进程空间中,都包含一个称为全局偏移表(Global Offset Table, GOT)的区。GOT存放绝对地址,从而使得地址可用,并且不会影响位置独立性和程序代码的可共享性。要使得动态链接的进程能够工作,这个表是必不可少的。该表的实际内容和形式取决于处理器的型号。

程序使用的每一个库函数在GOT中都拥有一个入口项,GOT中包含有实际函数的地址。这使得很容易在进程内存中对库函数进行重定位。在程序首次使用一个函数之前,该入口项包含有运行时链接器(RunTime Linker, RTL)的地址。如果该函数被程序调用,则程序的控制权被转移到RTL,然后函数的实际地址被确定且被插入到GOT中。接下来就可以通过GOT中的入口项直接调用函数,而跟RTL就无关了。

在ELF可执行文件中GOT入口项的地址是固定的。这就导致对任何可执行进程映像而言GOT入口项都位于相同的地址。可以利用objdump命令查看某一个函数的GOT入口项的位置,如下图所示:为每一个R_X86_64_JUMP_SLOT重定位记录指定的偏移量,包含了指定函数(或RTL链接函数)的地址。

攻击者可以利用任意内存写将一个函数的GOT入口项覆写为外壳代码的地址。这样,当程序调用对应于被改写的GOT入口项的函数时,程序的控制权就被转移到外壳代码。例如,每一个编写良好的C程序最后都会调用exit()函数,因此,只要覆写了exit()的GOT入口项,就可以在exit()被调用时将程序的控制权转移到指定的地址。ELF过程链接表(Procedure Linkage Table, PLT)具有类似的问题。

Windows PE(Portable Executable, 可移植的可执行)文件格式扮演着与ELF格式相似的角色。PE文件中包含一个数据结构数组,每一项对应一个导入的DLL。每一项都包含有导入的DLL的名称以及一个指向函数指针数组的指针(即导入地址表,Import Address Table, IAT)。每一个被导入的API在IAT中都有自己的保留槽,由Windows载入器为其填充导入函数的地址。一旦一个模块被载入,IAT就保存了需要调用的导入函数的地址。IAT的入口项是写保护的,因此它们在运行时无需修改。

3.6 .dtors区:

#ifndef _MSC_VER
static void create(void) __attribute__((constructor));
static void destroy(void) __attribute__((destructor));

static void create(void)
{
	fprintf(stdout, "create called.\n");
}

static void destroy(void)
{
	fprintf(stdout, "destructor called.\n");
}
#endif

void test_secure_coding_3_6()
{
#ifndef _MSC_VER
	fprintf(stdout, "create: %p.\n", create);
	fprintf(stdout, "destroy: %p.\n", destroy);
	exit(0);
#endif
}

任意内存写攻击的另外一个目标是覆写由GCC生成的可执行文件的.dtors区中的函数指针。GNU C允许程序员利用__attribute__关键字后跟一个包含于双括号中的属性修饰符来声明函数的属性。属性修饰符包括constructor和destructor。constructor属性指示函数在main()之前被调用,destructor属性则表示函数将在main()执行完成后或exit()被调用后进行调用。

构造函数和析构函数分布存储于生成的ELF可执行映像的.ctors和.dtors区中。.ctors和.dtors区映射到进程地址空间后,默认属性为可写。漏洞利用程序从未利用过构造函数,因为它们都在main()函数之前执行。结果,攻击者的兴趣都集中到了析构函数和.dtors区上。攻击者可以通过覆写.dtors区中的函数指针的地址从而将程序控制权转移到任意的代码。如果攻击者能够读取到目标二进制文件,那么通过分析ELF映像,很容易就能确定要覆写的确切位置。

注:在GCC高版本中好像用.init_array、.fini_array取代了.ctors、.dtors。如下图所示:

3.7 虚指针:在C++中可以定义虚函数(virtual function)。虚函数就是用virtual关键字声明的类成员函数。该函数可以由派生类中的同名函数重写。一个指向派生类对象的指针可以被赋给基类指针,并且通过该指针来调用函数。如果没有虚函数,则调用的是基类的函数,因为它和指针的静态类型相关联。当使用虚函数时,调用的则是派生类的函数,因为该函数和对象的动态类型相关联。

大多数C++编译器使用虚函数表(Virtual Function Table, VTBL)实现虚函数。VTBL是一个函数指针数组,用于在运行时派发虚函数调用。在每一个对象的头部,都包含一个指向VTBL的虚指针(Virtual Pointer, VPTR)。VTBL含有指向虚函数的每一个实现的指针。

覆写VTBL中的函数指针或者改变VPTR使其指向其它任意的VTBL都是可能的,可以通过任意内存写或者利用缓冲区溢出直接写入对象实现这一操作。通过对对象的VTBL和VPTR的覆写,攻击者可以使函数指针执行任意的代码。

3.8 atexit()和on_exit()函数:

char* glob;

void test(void)
{
	fprintf(stdout, "%s", glob);
}

int test_secure_coding_3_8()
{
	atexit(test);
	glob = "Exiting.\n";

	return 0;
}

atexit()是C标准定义的一个通用工具函数。atexit()可以注册无参函数,并在程序正常结束后调用该函数。C要求实现支持至少32个函数的注册。SunOS上的on_exit()函数具有类似的功能。libc4、libc5和glibc也提供了这样的函数。

atexit()通过向一个退出时将被调用的已有函数的数组中添加指定的函数完成工作。当exit()被调用时,数组中的每一个函数都以”后进先出”(Last-in, First-out, LIFO)的顺序被调用。由于atexit()和exit()都要访问该数组,因此它被分配为一个全局性的符号(在Linux操作系统中是__exit_funcs)。可以通过对__exit_funcs结构采用任意内存写或缓冲区溢出手段将程序的控制权转移到任意的代码。

3.9 longjump()函数:

int test_secure_coding_3_9()
{
	jmp_buf env;
	int val;

	val = setjmp(env);

	fprintf(stdout, "val is %d\n", val);

	if (!val) longjmp(env, 1);

	return 0;
}

C标准定义了setjmp()宏、longjmp()函数,以及jmp_buf类型,它们可以用来绕过正常的函数调用和返回规则。

setjump()宏为稍后将会调用的longjmp()函数保存其调用环境。longjmp()则恢复最后一次由setjmp()宏保存的调用环境。可以通过将jmp_buf缓冲区中PC(Program Counter, 程序计数器)的值覆写为外壳代码的起始地址的方法来利用longjmp()函数。任意内存写或者直接针对jmp_buf结构的缓冲区溢出都能达到这个目的。

3.10 异常处理:

int test_secure_coding_3_10()
{
	try {
		//throw 10;
		throw "overflow";
	}
	catch(int x) {
		fprintf(stderr, "exception value: %d\n", x);
	}
	catch (const char* str) {
		fprintf(stderr, "exception value: %s\n", str);
	}

	return 0;
}

异常就是函数操作中发生的意外情况。例如,被除0将会产生一个异常。很多程序员采取实现异常处理程序的方式来处理这些特殊情况,以避免非预期的程序中止。另外,异常处理程序被串在一起并以一定的顺序被调用,直到其中一个能够处理异常为止。

Microsoft Windows操作系统提供了三种形式的异常处理程序。操作系统按给定的顺序调用它们,直到其中某一个被成功执行:(1).向量化异常处理(Vectored Exception Handling, VEH):首先调用以重写结构化异常处理程序。(2).结构化异常处理(Structured Exception Handling, SEH):这种方式被实现为每函数(per-function)或每线程(per-thread)的异常处理程序,即每一个函数或每一个线程都有自己的异常处理程序。(3).系统默认异常处理:这是一个全局异常过滤器和处理器,用于处理整个进程的异常情况。如果上面两个异常处理程序都无法处理异常,那么它将会被调用。

结构化异常处理:SHE通常在编译器级别通过try…catch语句实现。try块中引发的任何异常都将被匹配的catch块处理。如果catch块无法处理异常,那么它将被传回之前的范围块。__finally关键字是微软对C/C++语言的扩展,用于表示一个代码块,该代码块被调用来清理由try块说明的任何东西。不管try块如何退出,该关键字都被调用。

对结构化异常处理而言,Windows为每线程的异常处理程序提供了特殊支持。编译器产生的代码将一个指向EXCEPTION_REGISTRATION结构的指针的地址,写入fs段寄存器所引用的地址。因为异常处理程序地址紧跟在局部变量之后,因此,如果一个栈变量发生缓冲区溢出,那么异常处理程序地址就可以被覆写为任意值。除了覆写单独的函数指针外,还可以替换线程环境块(Thread Environment Block, TEB)中的指针,已注册的异常处理程序的列表就是由该指针所引用的。

系统默认异常处理:未处理异常过滤器函数利用SetUnhandledExceptionFilter()函数进行设置。该函数作为进程的最后一级异常处理程序而被调用。然而,如果攻击者利用任意内存写技术覆写了某特定内存地址,则未处理异常过滤器可以被重定向去执行任意代码。

3.11 缓解策略:防止指针诡计的最佳方式就是消除允许内存被不正确地覆写”的漏洞。覆写对象指针、常见的动态内存管理错误、字符串格式化漏洞都可能导致指针诡计的发生。消除这些漏洞来源是消除指针诡计的最佳方式。

栈探测仪:仅对那些预通过溢出栈缓冲区来覆写栈指针或者其它受保护区域的漏洞利用有效。栈探测仪并不能防止对变量、对象指针或者函数指针进行修改的漏洞利用。栈探测仪不能阻止包括栈段在内的任何位置发生缓冲区溢出。

W^X:此策略意思是说一段内存区域要么可写要么可执行,但不可同时两者兼备。这种策略不能防止类似于atexit()这样的同时需要运行时写入和可执行的目标覆写。

对函数指针编码和解码:程序可以存储一个指针的加密版本,而不是存储该指针。攻击者需要破解加密的指针才能重定向到其它代码。提议在C11标准中加入encode_pointer()和decode_pointer()函数,后未被采纳。这两个函数与Microsoft Windows的两个函数(EncodePointer()和DecodePointer())的目的类似,但细节略有不同,后者被Visual C++的C运行时库所使用。

3.12 小结:就像栈溢出攻击被用于覆写返回地址一样,缓冲区溢出可被用于覆写对象指针或函数指针。覆写函数指针或对象指针的能力取决于缓冲区溢出发生的地址和目标指针之间距离的远近,不过一般而言,同一个内存段内都存在这样的机会。

攻击函数指针使得攻击者能够直接将程序的控制权转移到由其提供的任意代码。对对象指针进行修改并赋值的能力创建了任意内存写技术。不管环境如何,任意内存写技术都有很多机会将程序的控制权转移给任意的用于任意内存写技术的代码。其中一些目标是C标准特性的结果,另外一些则特定于编译器或操作系统。

GitHubhttps://github.com/fengbingchun/Messy_Test

发布了725 篇原创文章 · 获赞 1232 · 访问量 632万+
标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!