C和C++安全編碼筆記:指針詭計

指針詭計(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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章