C和C++安全編碼筆記:格式化輸出

C標準中定義了一些可以接受可變數量參數的格式化輸出參數,參數中包括一個格式字符串。printf()和sprintf()都是格式化輸出函數的例子。格式化輸出函數是由一個格式字符串和可變數目的參數構成的。在效果上,格式化字符串提供了一組可以由格式化輸出函數解釋執行的指令。因此,用戶可以通過控制格式字符串的內容來控制格式化輸出函數的執行。格式化輸出函數是一個變參函數,也就是說它接受的參數個數是可變的。變參函數在C語言中實現的侷限性導致格式化輸出函數的使用中容易產生漏洞。

6.1 變參函數:<stdarg.h>頭文件聲明瞭一種類型並定義了四個宏,用於傳遞一組參數列表,在編譯時被調用的函數對這些參數的數量和類型是不瞭解的。變參函數是通過使用一個部分參數列表後跟一個省略號進行聲明的省略號必須出現在參數列表的最後。參數列表的終止條件是函數的實現者和使用者之間的一個契約

int average(int first, ...)
{
	va_list marker;
	// 在使用變量marker之前,首先必須調用va_start()對參數列表進行初始化
	// 定參first允許vs_start()決定第一個變參的位置
	va_start(marker, first);

	int count = 0, sum = 0, i = first;
	while (i != -1) {
		sum += i;
		count++;
		// va_arg()需要一個已初始化的va_list和下一個參數的類型.
		// 這個宏可以根據類型的大小返回下一個參數,並且相應地遞增參數指針
		i = va_arg(marker, int);
	}

	// 在函數返回之前,調用va_end()來執行任何必要的清理工作
	// 若在返回前未調用va_end()宏,則行爲是未定義的
	va_end(marker);
	return (sum ? (sum / count) : 0);
}

void test_format_output_variable_parameter_function()
{
	int ret = average(3, 5, 8, -1);
	fprintf(stdout, "average: %d\n", ret);
}

更多變參函數的介紹可參考:https://blog.csdn.net/fengbingchun/article/details/78483471

6.2 格式化輸出函數:C標準中定義的格式化輸出函數如下所示:

(1).fprintf():按照格式字符串的內容將輸出寫入流中。流、格式字符串和變參列表一起作爲參數提供給函數。

(2).printf():等同於fprintf(),除了前者假定輸出流爲stdout外。

(3).sprintf():等同於fprintf(),但是輸出不是寫入流而是寫入數組中。C標準規定在寫入的字符末尾必須添加一個空字符。

(4).snprintf():等同於spirntf(),但是它指定了可寫入字符的最大值n。當n非零時,輸出的字符超過第n-1個的部分會被捨棄而不會寫入數組中。並且,在寫入數組的字符末尾會添加一個空字符。

(5).vfprintf()、vprintf()、vsprintf()、vsnprintf():分別對應於fprintf()、printf()、sprintf()、snprintf(),只是它們將後者的變參列表換成了va_list類型的參數。當參數列表是在運行時決定時,這些函數非常有用。

格式字符串:是由普通字符(ordinary character)(包括%)和轉換規範(conversion specification)構成的字符序列。普通字符被原封不動地複製到輸出流中。轉換規範根據與實參對應的轉換指示符對其進行轉換,然後將結果寫入輸出流中。轉換規範通常以”%”開始按照從左向右的順序解釋。大多數轉換規範都需要單個參數,但有時也可能需要多個或者完全不需要。程序員必須根據指定的格式提供相應個數的參數。當參數多於轉換規範時,多餘的將被忽略,而當參數不足時,則結果是未定義的

一個轉換規範是由可選域(標誌、寬度、精度以及長度修飾符)和必須域(轉換指示符)按照下面的格式組成的:

%[標誌][寬度][.精度][{長度修飾符}] 轉換指示符

例如,對轉換規範%-10.8ld來說,-是標誌位,10代表寬度,8代表精度,字面l是長度修飾符,d是轉換指示符。這個轉換規範將一個long int型的參數按照十進制格式打印,在一個最小寬度爲10個字符的域中保持最少8位左對齊。每一個域都是代表特定格式選項的單個字符或數字。最簡單的轉換規範僅僅包含一個”%”和一個轉換指示符(例如%s)。

轉換指示符:用來指示所應用的轉換類型。它是唯一必須的格式域,出現在任意可選格式域之後。下表中列舉了C標準中的一些轉換指示符:

標誌:標誌位用來調整輸出和打印的符號、空白、小數點、八進制和十六進制前綴等。一個格式規範中可能包含一個或多個標誌。

寬度:是一個用來指定輸出字符的最小個數的十進制非負整數。如果輸出的字符個數比指定的寬度小,就用空白字符補足。如果指定的寬度較小也不會引起輸出域的截斷。如果轉換的結果比域寬大,則域會被擴展以容納轉換結果。如果使用星號(*)來指定寬度,則寬度將由參數列表中的一個int型的值提供。在參數列表中,寬度參數必須置於被格式化的值之前。

精度:是用來指示打印字符個數、小數位或者有效數字個數的非負十進制整數。與寬度域不同,精度域可能會引起輸出的截斷或浮點值的舍入。如果精度值被設爲0並且被轉換值也爲0,則不會輸出任何字符。如果精度域是一個星號(*),那麼它的值就由參數列表中的一個int參數提供。在參數列表中,精度參數必須置於被格式化的值之前。

長度修飾符:指定了參數的大小。下表列舉了長度修飾符及其對應的含義。如果使用了表中未出現的長度修飾符和轉換指示符的組合,則會導致未定義的行爲:

6.3 對格式化輸出函數的漏洞利用:當使用的格式字符串(或部分字符串)是由用戶或其他非信任來源提供的時候,就有可能出現格式字符串漏洞。

緩衝區溢出:向字符數組中寫入數據的格式化輸出函數(如sprintf())會假定存在任意長度的緩衝區,從而導致它們易於造成緩衝區溢出。

void test_format_output_buffer_overflow()
{
{
	char* user = "abcd"; // 用戶提供的字符串(可能是惡意的數據)
	char buffer[512];
	// 用戶提供的字符串寫入一個固定長度的緩衝區
	// 任何長度大於495字節的字符串都會導致越界寫(512字節-16個字符字節-1個空字節)
	sprintf(buffer, "Wrong command: %s\n", user);
	fprintf(stdout, "buffer: %s\n", buffer);
}

{
	char* user = "%497d\x3c\xd3\xff\xbf<nops><shellcode>";
	fprintf(stdout, "user: %s\n", user);
	char outbuf[512], buffer[512];

	sprintf(buffer, "ERR Wrong command: %.400s", user);
	fprintf(stdout, "buffer: %s\n", buffer);
	// 格式規範%497d指示函數sprintf()從棧中讀出一個假的參數並向緩衝區中寫入497個字符,包括格式字符串中的普通字符
	// 在內,現在寫入的字符總數已經超過了outbuf的長度4個字節
	// 用戶輸入可被操縱用於覆寫返回地址,也就是拿惡意格式字符串參數中提供的利用代碼的地址(0xbfffd33c)去覆寫該
	// 地址.在當前函數退出時,控制權將以與棧溢出攻擊相同的方式轉移給漏洞利用代碼
	sprintf(outbuf, buffer);
	fprintf(stdout, "outbuf: %s\n", outbuf);
}
}

輸出流:將結果輸出到流而不是輸出到文件中的格式化輸出函數(例如printf())也可能會導致格式字符串漏洞。

使程序崩潰:格式字符串漏洞通常是在程序崩潰的時候才被發現。在大多數UNIX系統中,存取無效的指針會引起進程收到SIGSEGV信號。除非能夠捕捉並處理它,否則程序將會非正常終止並導致核心轉儲(dump core)。與之類似,在Windows中讀取一個未映射的地址將會導致系統的一般保護錯誤(general protection fault)並導致程序非正常終止。

查看棧內容:攻擊者還可以利用格式化輸出函數來檢查內存的內容。這類信息往往被用於進一步的漏洞利用。

查看內存內容:攻擊者可以使用一個”顯示指定地址的內存”的格式規範來查看任意地址的內存。例如,轉換指示符%s顯示參數指針所指定的地址的內存,將它作爲一個ASCII字符串處理,直至遇到一個空字符。如果攻擊者能夠通過操作這個參數指針來”引用”一個特定的地址,那麼轉換指示符%s將會輸出該位置的內存內容。

覆寫內存:

void test_format_output_overwrite_memory()
{
{ // 向各種類型和大小的整數變量寫入輸出的字符數
	char c;
	short s;
	int i;
	long l;
	long long ll;

	// 最初轉換指示符%n是用來幫助排列格式化輸出字符串的. 它將字符數目成功地輸出到以參數的形式
	// 提供的整數地址中
	printf("hello %hhn.", &c);
	printf("hello %hn.", &s);
	printf("hello %n.", &i);
	printf("hello %ln.", &l);
	printf("hello %lln.", &ll);
	fprintf(stdout, "c: %d, s: %d, i: %d, l: %ld, ll: %lld\n", c, s, i, l, ll); // 6
}

{
	// 格式化輸出函數寫入的字符個數是由格式字符串決定的.如果攻擊者能夠控制格式字符串,那麼他就能通過使用
	// 具有具體的寬度或精度的轉換規範來控制寫入的字符個數
	// 每一個格式字符串都耗用兩個參數,第一個參數是轉換指示符%u所使用的整數值,輸出的字符個數(一個整數值)
	// 則被寫入由第二個參數指定的地址中
	int i;
	printf("%10u%n", 1, &i); fprintf(stdout, "i: %d\n", i); // 10
	printf("%100u%n", 1, &i); fprintf(stdout, "i: %d\n", i); // 100
}

{
	// 在對格式化輸出函數的單次調用中,還可以執行多次寫
	int i, j, m, n;
	// 第一個%16u%n字符序列向指定地址中寫入的值是16,但第二個%16u%n則寫32字節,因爲計數器沒有被重置
	printf("%16u%n%16u%n%32u%n%64u%n", 1, &i, 1, &j, 1, &m, 1, &n);
	fprintf(stdout, "i: %d, j: %d, m: %d, n: %d\n", i, j, m, n); // 16, 32, 64, 128
}
}

國際化:出於國際化的考慮,格式字符串和消息文本通常被移動到由程序在運行時打開的外部目錄或文件中。格式字符串是必要的,因爲不同的區域設置之間,參數的順序可能會有所不同。這也意味着使用目錄的程序必須傳遞格式字符串的變量。因爲這是格式化輸出函數合法和必要的使用,所以在格式字符串不是文本的情況下診斷可能會導致過度誤報。攻擊者可以通過修改這些文件的內容從而改動程序的格式和字符串的值。因此,應該對這些文件加以保護,以防止其內容被非法改變。同時,我們還必須防止攻擊者使用自己的消息文件來替換正常的文件。這可以通過設置查找路徑、環境變量或邏輯名字來限制存取。

寬字符格式字符串漏洞:寬字符格式化輸出函數易招致格式字符串和緩衝區溢出漏洞,它的利用方式與窄字符格式化輸出函數類似,即使在從ASCII轉換爲Unicode字符串的特殊情況下。

6.4 棧隨機化:許多Linux變體(例如Red Hat、Debian和OpenBSD)中包含某種棧隨機化機制。這種機制使得很難預測棧上信息的位置,包括返回地址和自動變量的位置,這是通過向棧中插入隨機的間隙實現的。

阻礙棧隨機化:儘管棧隨機化加大了漏洞利用的難道,但它並不能完全阻止漏洞利用的發生。

直接參數訪問:C標準不支持直接參數訪問。轉換規範%n$中的參數數字n必須是這樣的一個整數值:其值必須介於1和提供給函數調用的參數的最大數目之間。

void test_format_output_direct_parameter_access()
{
	// 在包含%n$形式的轉換規範的格式字符串中,參數列表中的數字式參數可視需要被從格式字符串中引用多次, 其中n是一個
	// 1~{NL_ARGMAX}範圍內的十進制整數,它指定了參數的位置
	// 展示了%n$形式的規範轉換是如果被用於格式字符串漏洞利用的
	int i, j, k;
	// 第一個轉換規範%4$5u獲得第四個參數(即常量5),並將輸出格式爲無符號的十進制整數,寬度爲5.第二個轉換規範%3$n,
	// 將當前輸出計數器的值(5)寫到第三個參數(&i)所指定的地址
	printf("%4$5u%3$n%5$5u%2$n%6$5u%1$n\n", &k, &j, &i, 5, 6, 7);
	fprintf(stdout, "i = %d, j = %d, k = %d\n", i, j, k); // i=5, j=10, k=15
}

6.5 緩解策略:

排除用戶輸入的格式字符串。

靜態內容的動態使用:有一個消除格式字符串漏洞的常見建議是禁止動態格式字符串的使用。如果所有格式字符串都是靜態的,那麼格式字符串漏洞將不復存在(除非目標字符數組沒有得到足夠的限制而造成緩衝區溢出)。然而,這種方案不切實際,因爲動態格式字符串已被現有代碼廣泛使用。動態格式策略的一個可選替代品是對靜態內容的動態使用。

void test_format_output_dynamic_format_string()
{
	int x = 2, y = 3;
	static char format[256] = "%d * %d = ";

	strcat(format, "%d\n");
	printf(format, x, y, x * y); // 2 * 3 = 6
}

限制字節寫入:當被誤用的時候,格式化輸出函數容易造成格式字符串漏洞和緩衝區溢出漏洞。緩衝區溢出可以通過嚴格控制這些函數寫入的字節數來避免。寫入的字節數可以通過指定一個精度域作爲%s轉換規範的一部分進行控制。

void test_format_output_limit_bytes_number()
{
	char buffer[512];
	char* user = "abc";
	sprintf(buffer, "Wrong command: %s\n", user); // 不推薦
	// 精度域指定了針對%s轉換所要寫入的最大字節數
	sprintf(buffer, "Wrong command: %.495s\n", user); // 推薦
}

另一種方式是使用更安全版本的格式化輸出庫函數,它們不容易產生緩衝區溢出問題(例如,採用snprintf()和vsnprintf()代替sprintf()和vsprintf())。這些函數指定了寫入的最大字節數(包括末尾的空字節在內)。

C11附錄K邊界檢查接口:C11標準添加了一個新的可選的規範性附錄,它包括更安全版本的格式化輸出函數。這些具有增強的安全性的函數包括fprintf_s()、printf_s()、snprintf_s()、sprintf_s()、vfprintf_s()、vprintf_s()、vsnprintf_s()、vsprintf_s()以及它們的寬字符版本。這些格式化輸出函數有着不帶_s後綴的原型對應物,但sprintf_s()和vsprintf_s()除外,二者對應的原型是snprintf()和vsnprintf()。這些安全性函數與不帶_s後綴的對應物的區別在於,如果格式字符串爲空指針,或者如果格式字符串中包含指示符%n(無論是否被標誌、域、寬度或精度修改),或者如果在這些函數中對應一個%s指示符的參數是空指針的話,那麼它們會將其視作運行時約束錯誤(runtime constraint error)。如果在格式字符串中後續出現的字符%n不被解釋爲一個%n指示符,例如,如果整個格式字符串是%%n時,就不構成運行時約束違例(runtime constraint violation)。儘管這些函數是對現有C標準函數的改進,即可以防止寫內存,但它們無法防止格式字符串漏洞,這些漏洞使程序崩潰,或被用於查看內存內容。因此,當使用這些函數時,有必要像在使用不帶_s後綴的格式化輸出函數一樣保持警惕。

iostream與stdio:iostream庫提供了通過流來實現輸入、輸出的功能。格式化輸出使用iostream依照中綴二元插入操作符”<<”進行實現。左操作數是待插入數據的流,右操作數則是要插入的值。格式化和標記化(tokenized)輸入是通過提取操作符”>>”實現的。標準的C I/O流stdin、stdout和stderr被cin、cout和cerr所取代。除了提供類型安全和可擴展性之外,iostream庫在安全性方面做得要比stdio好得多。

編譯器檢查:GNU C編譯器(GCC)提供了對格式化輸出函數調用進行附加檢測的標誌。Visual C++中沒有這樣的選項。GCC的標誌包括-Wformat、-Wformat-nonliteral以及-Wformat-security。

FormatGuard:是一個編譯器修改器,通過插入代碼實現動態檢測,並且拒絕那些參數個數與轉換規範所指定個數不匹配的格式化輸出函數調用。

靜態二進制分析:按照如下標準,可以通過分析二進制映像來發現格式化字符串漏洞:棧修正是否比最小值還小?格式化字符串是靜態的還是可變的?

爲了消除格式字符串漏洞,推薦在可能的情況下使用iostream代替stdio,在沒有條件的情況下則要儘量使用靜態格式字符串。當需要動態字符串的時候,最關鍵的是不要將來自非信任源的輸入合併到格式字符串中。儘量使用C11附錄K”邊界檢查接口”中定義的格式化輸出函數來代替不帶_s後綴的格式化輸出函數,如果你的實現支持它們的話。

以上代碼段的完整code見:GitHub/Messy_Test

GitHubhttps://github.com/fengbingchun/Messy_Test

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