realpath函數的參數,必須使用PATH_MAX大小的內存空間,否則是非常不安全,在某些設備上經過驗證,哪怕是PATH_MAX-1都會崩潰。正如使用如下:
char file_path[PATH_MAX];
memset(file_path, 0, sizeof(file_path));
realpath("/proc/self/exe", file_path);
那麼可能有人會問什麼場景下會出現崩潰呢,其實很簡單的不安全使用很難出現,如下代碼,怎麼運行都不會崩潰
#include <limits.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <cstdlib>
main()
{
char resolved_path[80];
realpath("/usr/X11R6/lib/modules/../../include/../",resolved_path);
//realpath("/usr/X11R6/lib/modules/../../include/../",resolved_path);
realpath("/proc/self/exe",resolved_path);
printf("resolved_path: %s,%d\n", resolved_path, PATH_MAX); //MAXPATHLEN
}
但是,在大型的項目中,不安全的使用一旦出現崩潰,就很容易連續發生,變成必現的現場,但是也有僥倖不崩潰的,我做的一箇中型項目,有個典型的例證,程序已經發布好多版本,突然在某一次運行時必現了,所以在此記錄。必現的驗證過程如下
首先原始代碼是這樣的
char file_path[1024];
memset(file_path, 0, sizeof(file_path));
realpath("/proc/self/exe", file_path);
一直崩潰,堆棧信息也顯示是realpath這行。
後來我將路徑長度寫成
char file_path[PATH_MAX];
memset(file_path, 0, sizeof(file_path));
realpath("/proc/self/exe", file_path);
恢復正常。
此刻還舉得可能是其他地方影響到了此處,有做了一個驗證,使用了PATH_MAX-1的長度,只少一個字節
char file_path[PATH_MAX-1];
memset(file_path, 0, sizeof(file_path));
realpath("/proc/self/exe", file_path);
現象是一直崩潰,堆棧信息也還是顯示realpath這行。
擴展閱讀:
函數 | 嚴重性 | 解決方案 |
---|---|---|
gets | 最危險 | 使用 fgets(buf, size, stdin)。這幾乎總是一個大問題! |
strcpy | 很危險 | 改爲使用 strncpy。 |
strcat | 很危險 | 改爲使用 strncat。 |
sprintf | 很危險 | 改爲使用 snprintf,或者使用精度說明符。 |
scanf | 很危險 | 使用精度說明符,或自己進行解析。 |
sscanf | 很危險 | 使用精度說明符,或自己進行解析。 |
fscanf | 很危險 | 使用精度說明符,或自己進行解析。 |
vfscanf | 很危險 | 使用精度說明符,或自己進行解析。 |
vsprintf | 很危險 | 改爲使用 vsnprintf,或者使用精度說明符。 |
vscanf | 很危險 | 使用精度說明符,或自己進行解析。 |
vsscanf | 很危險 | 使用精度說明符,或自己進行解析。 |
streadd | 很危險 | 確保分配的目的地參數大小是源參數大小的四倍。 |
strecpy | 很危險 | 確保分配的目的地參數大小是源參數大小的四倍。 |
strtrns | 危險 | 手工檢查來查看目的地大小是否至少與源字符串相等。 |
realpath | 很危險(或稍小,取決於實現) | 分配緩衝區大小爲 MAXPATHLEN。同樣,手工檢查參數以確保輸入參數不超過 MAXPATHLEN。 |
syslog | 很危險(或稍小,取決於實現) | 在將字符串輸入傳遞給該函數之前,將所有字符串輸入截成合理的大小。 |
getopt | 很危險(或稍小,取決於實現) | 在將字符串輸入傳遞給該函數之前,將所有字符串輸入截成合理的大小。 |
getopt_long | 很危險(或稍小,取決於實現) | 在將字符串輸入傳遞給該函數之前,將所有字符串輸入截成合理的大小。 |
getpass | 很危險(或稍小,取決於實現) | 在將字符串輸入傳遞給該函數之前,將所有字符串輸入截成合理的大小。 |
getchar | 中等危險 | 如果在循環中使用該函數,確保檢查緩衝區邊界。 |
fgetc | 中等危險 | 如果在循環中使用該函數,確保檢查緩衝區邊界。 |
getc | 中等危險 | 如果在循環中使用該函數,確保檢查緩衝區邊界。 |
read | 中等危險 | 如果在循環中使用該函數,確保檢查緩衝區邊界。 |
bcopy | 低危險 | 確保緩衝區大小與它所說的一樣大。 |
fgets | 低危險 | 確保緩衝區大小與它所說的一樣大。 |
memcpy | 低危險 | 確保緩衝區大小與它所說的一樣大。 |
snprintf | 低危險 | 確保緩衝區大小與它所說的一樣大。 |
strccpy | 低危險 | 確保緩衝區大小與它所說的一樣大。 |
strcadd | 低危險 | 確保緩衝區大小與它所說的一樣大。 |
strncpy | 低危險 | 確保緩衝區大小與它所說的一樣大。 |
vsnprintf | 低危險 | 確保緩衝區大小與它所說的一樣大。 |
C 中大多數緩衝區溢出問題可以直接追溯到標準 C 庫。最有害的罪魁禍首是不進行自變量檢查的、有問題的字符串操作(strcpy、strcat、sprintf 和 gets)。一般來講,象“避免使用 strcpy()”和“永遠不使用 gets()”這樣嚴格的規則接近於這個要求。
今天,編寫的程序仍然利用這些調用,因爲從來沒有人教開發人員避免使用它們。某些人從各處獲得某個提示,但即使是優秀的開發人員也會被這弄糟。他們也許在危險函數的自變量上使用自己總結編寫的檢查,或者錯誤地推論出使用潛在危險的函數在某些特殊情況下是“安全”的。
第一位公共敵人是 gets()。永遠不要使用 gets()。該函數從標準輸入讀入用戶輸入的一行文本,它在遇到 EOF 字符或換行字符之前,不會停止讀入文本。也就是:gets() 根本不執行邊界檢查。因此,使用 gets() 總是有可能使任何緩衝區溢出。作爲一個替代方法,可以使用方法 fgets()。它可以做與 gets() 所做的同樣的事情,但它接受用來限制讀入字符數目的大小參數,因此,提供了一種防止緩衝區溢出的方法。例如,不要使用以下代碼:
void main()
{
char buf[1024];
gets(buf);
}
而使用以下代碼:
#define BUFSIZE 1024
void main()
{
char buf[BUFSIZE];
fgets(buf, BUFSIZE, stdin);
}
C 編程中的主要陷阱
C 語言中一些標準函數很有可能使您陷入困境。但不是所有函數使用都不好。通常,利用這些函數之一需要任意輸入傳遞給該函數。這個列表包括:
- strcpy()
- strcat()
- sprintf()
- scanf()
- sscanf()
- fscanf()
- vfscanf()
- vsprintf
- vscanf()
- vsscanf()
- streadd()
- strecpy()
- strtrns()
壞消息是我們推薦,如果有任何可能,避免使用這些函數。好消息是,在大多數情況下,都有合理的替代方法。我們將仔細檢查它們中的每一個,所以可以看到什麼構成了它們的誤用,以及如何避免它。
strcpy()函數將源字符串複製到緩衝區。沒有指定要複製字符的具體數目。複製字符的數目直接取決於源字符串中的數目。如果源字符串碰巧來自用戶輸入,且沒有專門限制其大小,則有可能會陷入大的麻煩中!
如果知道目的地緩衝區的大小,則可以添加明確的檢查:
if(strlen(src) >= dst_size) {
/* Do something appropriate, such as throw an error. */
}
else {
strcpy(dst, src);
完成同樣目的的更容易方式是使用 strncpy() 庫例程:
strncpy(dst, src, dst_size-1);
dst[dst_size-1] = '\0'; /* Always do this to be safe! */
如果 src 比 dst 大,則該函數不會拋出一個錯誤;當達到最大尺寸時,它只是停止複製字符。注意上面調用 strncpy() 中的 -1。如果 src 比 dst 長,則那給我們留有空間,將一個空字符放在 dst 數組的末尾。
當然,可能使用 strcpy() 不會帶來任何潛在的安全性問題,正如在以下示例中所見:
strcpy(buf, "Hello!");
即使這個操作造成 buf 的溢出,但它只是對幾個字符這樣而已。由於我們靜態地知道那些字符是什麼,並且很明顯,由於沒有危害,所以這裏無須擔心 ― 當然,除非可以用其它方式覆蓋字符串“Hello”所在的靜態存儲器。
確保 strcpy() 不會溢出的另一種方式是,在需要它時就分配空間,確保通過在源字符串上調用 strlen() 來分配足夠的空間。例如:
dst = (char *)malloc(strlen(src));
strcpy(dst, src);
strcat()函數非常類似於 strcpy(),除了它可以將一個字符串合併到緩衝區末尾。它也有一個類似的、更安全的替代方法 strncat()。如果可能,使用 strncat() 而不要使用 strcat()。
函數 sprintf()和 vsprintf()是用來格式化文本和將其存入緩衝區的通用函數。它們可以用直接的方式模仿 strcpy() 行爲。換句話說,使用 sprintf() 和 vsprintf() 與使用 strcpy() 一樣,都很容易對程序造成緩衝區溢出。例如,考慮以下代碼:
void main(int argc, char **argv)
{
char usage[1024];
sprintf(usage, "USAGE: %s -f flag [arg1]\n", argv[0]);
}
我們經常會看到類似上面的代碼。它看起來沒有什麼危害。它創建一個知道如何調用該程序字符串。那樣,可以更改二進制的名稱,該程序的輸出將自動反映這個更改。 雖然如此, 該代碼有嚴重的問題。文件系統傾向於將任何文件的名稱限制於特定數目的字符。那麼,您應該認爲如果您的緩衝區足夠大,可以處理可能的最長名稱,您的程序會安全,對嗎?只要將 1024 改爲對我們的操作系統適合的任何數目,就好了嗎?但是,不是這樣的。通過編寫我們自己的小程序來推翻上面所說的,可能容易地推翻這個限制:
void main()
{
execl("/path/to/above/program",
<<insert really long string here>>,
NULL);
}
函數 execl() 啓動第一個參數中命名的程序。第二個參數作爲 argv[0] 傳遞給被調用的程序。我們可以使那個字符串要多長有多長!
那麼如何解決 {v}sprintf() 帶來得問題呢?遺憾的是,沒有完全可移植的方法。某些體系結構提供了 snprintf() 方法,即允許程序員指定將多少字符從每個源複製到緩衝區中。例如,如果我們的系統上有 snprintf,則可以修正一個示例成爲:
void main(int argc, char **argv)
{
char usage[1024];
char format_string = "USAGE: %s -f flag [arg1]\n";
snprintf(usage, format_string, argv[0],
1024-strlen(format_string) + 1);
}
注意,在第四個變量之前,snprintf() 與 sprintf() 是一樣的。第四個變量指定了從第三個變量中應被複制到緩衝區的字符最大數目。注意,1024 是錯誤的數目!我們必須確保要複製到緩衝區使用的字符串總長不超過緩衝區的大小。所以,必須考慮一個空字符,加上所有格式字符串中的這些字符,再減去格式說明符 %s。該數字結果爲 1000, 但上面的代碼是更具有可維護性,因爲如果格式字符串偶然發生變化,它不會出錯。
{v}sprintf() 的許多(但不是全部)版本帶有使用這兩個函數的更安全的方法。可以指定格式字符串本身每個自變量的精度。例如,另一種修正上面有問題的 sprintf() 的方法是:
void main(int argc, char **argv)
{
char usage[1024];
sprintf(usage, "USAGE: %.1000s -f flag [arg1]\n", argv[0]);
}
注意,百分號後與 s 前的 .1000。該語法表明,從相關變量(本例中是 argv[0])複製的字符不超過 1000 個。
如果任一解決方案在您的程序必須運行的系統上行不通,則最佳的解決方案是將 snprintf() 的工作版本與您的代碼放置在一個包中。可以找到以 sh 歸檔格式的、自由使用的版本;請參閱 參考資料。
繼續, scanf系列的函數也設計得很差。在這種情況下,目的地緩衝區會發生溢出。考慮以下代碼:
void main(int argc, char **argv)
{
char buf[256];
sscanf(argv[0], "%s", &buf);
}
如果輸入的字大於 buf 的大小,則有溢出的情況。幸運的是,有一種簡便的方法可以解決這個問題。考慮以下代碼,它沒有安全性方面的薄弱環節:
void main(int argc, char **argv)
{
char buf[256];
sscanf(argv[0], "%255s", &buf);
}
百分號和 s 之間的 255 指定了實際存儲在變量 buf 中來自 argv[0] 的字符不會超過 255 個。其餘匹配的字符將不會被複制。
接下來,我們討論 streadd()和 strecpy()。由於,不是每臺機器開始就有這些調用,那些有這些函數的程序員,在使用它們時,應該小心。這些函數可以將那些含有不可讀字符的字符串轉換成可打印的表示。例如,考慮以下程序:
#include <libgen.h>
void main(int argc, char **argv)
{
char buf[20];
streadd(buf, "\t\n", "");
printf(%s\n", buf);
}
該程序打印:
\t\n
而不是打印所有空白。如果程序員沒有預料到需要多大的輸出緩衝區來處理輸入緩衝區(不發生緩衝區溢出),則 streadd() 和 strecpy() 函數可能有問題。如果輸入緩衝區包含單一字符 ― 假設是 ASCII 001(control-A)― 則它將打印成四個字符“\001”。這是字符串增長的最壞情況。如果沒有分配足夠的空間,以至於輸出緩衝區的大小總是輸入緩衝區大小的四倍,則可能發生緩衝區溢出。
另一個較少使用的函數是 strtrns(),因爲許多機器上沒有該函數。函數 strtrns() 取三個字符串和結果字符串應該放在其內的一個緩衝區,作爲其自變量。第一個字符串必須複製到該緩衝區。一個字符被從第一個字符串中複製到緩衝區,除非那個字符出現在第二個字符串中。如果出現的話,那麼會替換掉第三個字符串中同一索引中的字符。這聽上去有點令人迷惑。讓我們看一下,將所有小寫字符轉換成大寫字符的示例:
#include <libgen.h>
void main(int argc, char **argv)
{
char lower[] = "abcdefghijklmnopqrstuvwxyz";
char upper[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
char *buf;
if(argc < 2) {
printf("USAGE: %s arg\n", argv[0]);
exit(0);
} buf = (char *)malloc(strlen(argv[1]));
strtrns(argv[1], lower, upper, buf);
printf("%s\n", buf);
}
以上代碼實際上不包含緩衝區溢出。但如果我們使用了固定大小的靜態緩衝區,而不是用 malloc() 分配足夠空間來複制 argv[1],則可能會引起緩衝區溢出情況。
避免內部緩衝區溢出
realpath() 函數接受可能包含相對路徑的字符串,並將它轉換成指同一文件的字符串,但是通過絕對路徑。在做這件事時,它展開了所有符號鏈接。
該函數取兩個自變量,第一個作爲要規範化的字符串,第二個作爲將存儲結果的緩衝區。當然,需要確保結果緩衝區足夠大,以處理任何大小的路徑。分配的 MAXPATHLEN 緩衝區應該足夠大。然而,使用 realpath() 有另一個問題。如果傳遞給它的、要規範化的路徑大小大於 MAXPATHLEN,則 realpath() 實現內部的靜態緩衝區會溢出!雖然實際上沒有訪問溢出的緩衝區,但無論如何它會傷害您的。結果是,應該明確不使用 realpath(),除非確保檢查您試圖規範化的路徑長度不超過 MAXPATHLEN。
其它廣泛可用的調用也有類似的問題。經常使用的 syslog() 調用也有類似的問題,直到不久前,才注意到這個問題並修正了它。大多數機器上已經糾正了這個問題,但您不應該依賴正確的行爲。最好總是假定代碼正運行在可能最不友好的環境中,只是萬一在哪天它真的這樣。getopt() 系列調用的各種實現,以及 getpass() 函數,都可能產生內部靜態緩衝區溢出問題。如果您不得不使用這些函數,最佳解決方案是設置傳遞給這些函數的輸入長度的閾值。
自己模擬 gets() 的安全性問題以及所有問題是非常容易的。 例如,下面這段代碼:
char buf[1024];
int i = 0;
char ch;
while((ch = getchar()) != '\n')
{
if(ch == -1) break;
buf[i++] = ch;
}
哎呀!可以用來讀入字符的任何函數都存在這個問題,包括 getchar()、fgetc()、getc() 和 read()。
緩衝區溢出問題的準則是:總是確保做邊界檢查。
C 和 C++ 不能夠自動地做邊界檢查,這實在不好,但確實有很好的原因,來解釋不這樣做的理由。邊界檢查的代價是效率。一般來講,C 在大多數情況下注重效率。然而,獲得效率的代價是,C 程序員必須十分警覺,並且有極強的安全意識,才能防止他們的程序出現問題,而且即使這些,使代碼不出問題也不容易。
在現在,變量檢查不會嚴重影響程序的效率。大多數應用程序不會注意到這點差異。所以,應該總是進行邊界檢查。在將數據複製到您自己的緩衝區之前,檢查數據長度。同樣,檢查以確保不要將過大的數據傳遞給另一個庫,因爲您也不能相信其他人的代碼!(回憶一下前面所討論的內部緩衝區溢出。)
其它危險是什麼?
遺憾的是,即使是系統調用的“安全”版本 ― 譬如,相對於 strcpy() 的 strncpy() ― 也不完全安全。也有可能把事情搞糟。即使“安全”的調用有時會留下未終止的字符串,或者會發生微妙的相差一位錯誤。當然,如果您偶然使用比源緩衝區小的結果緩衝區,則您可能發現自己處於非常困難的境地。
與我們目前所討論的相比,往往很難犯這些錯誤,但您應該仍然意識到它們。當使用這類調用時,要仔細考慮。如果不仔細留意緩衝區大小,包括 bcopy()、fgets()、memcpy()、snprintf()、strccpy()、strcadd()、strncpy() 和 vsnprintf(),許多函數會行爲失常。
另一個要避免的系統調用是 getenv()。使用 getenv() 的最大問題是您從來不能假定特殊環境變量是任何特定長度的。我們將在後續的專欄文章中討論環境變量帶來的種種問題。
到目前爲止,我們已經給出了一大堆常見 C 函數,這些函數容易引起緩衝區溢出問題。當然,還有許多函數有相同的問題。特別是,注意第三方 COTS 軟件。不要設想關於其他人軟件行爲的任何事情。還要意識到我們沒有仔細檢查每個平臺上的每個常見庫(我們不想做那一工作),並且還可能存在其它有問題的調用。
即使我們檢查了每個常見庫的各個地方,如果我們試圖聲稱已經列出了將在任何時候遇到的所有問題,則您應該持非常非常懷疑的態度。我們只是想給您起一個頭。其餘全靠您了。
靜態和動態測試工具
我們將在以後的專欄文章中更加詳細地介紹一些脆弱性檢測的工具,但現在值得一提的是兩種已被證明能有效幫助找到和去除緩衝區溢出問題的掃描工具。 這兩個主要類別的分析工具是靜態工具(考慮代碼但永不運行)和動態工具(執行代碼以確定行爲)。
可以使用一些靜態工具來查找潛在的緩衝區溢出問題。很糟糕的是,沒有一個工具對一般公衆是可用的!許多工具做得一點也不比自動化 grep 命令多,可以運行它以找到源代碼中每個有問題函數的實例。由於存在更好的技術,這仍然是高效的方式將幾萬行或幾十萬行的大程序縮減到只有數百個“潛在的問題”。(在以後的專欄文章中,將演示一個基於這種方法的、草草了事的掃描工具,並告訴您有關如何構建它的想法。)
較好的靜態工具利用以某些方式表示的數據流信息來斷定哪個變量會影響到其它哪個變量。用這種方法,可以丟棄來自基於 grep 的分析的某些“假肯定”。David Wagner 在他的工作中已經實現了這樣的方法(在“Learning the basics of buffer overflows”中描述;請參閱 參考資料),在 Reliable Software Technologies 的研究人員也已實現。當前,數據流相關方法的問題是它當前引入了假否定(即,它沒有標誌可能是真正問題的某些調用)。
第二類方法涉及動態分析的使用。動態工具通常把注意力放在代碼運行時的情況,查找潛在的問題。一種已在實驗室使用的方法是故障注入。這個想法是以這樣一種方式來檢測程序:對它進行實驗,運行“假設”遊戲,看它會發生什麼。有一種故障注入工具 ― FIST(請參閱 參考資料)已被用來查找可能的緩衝區溢出脆弱性。
最終,動態和靜態方法的某些組合將會給您的投資帶來回報。但在確定最佳組合方面,仍然有許多工作要做。
Java 和堆棧保護可以提供幫助
如上一篇專欄文章中所提到的(請參閱 參考資料),堆棧搗毀是最惡劣的一種緩衝區溢出攻擊,特別是,當在特權模式下搗毀了堆棧。這種問題的優秀解決方案是非可執行堆棧。 通常,利用代碼是在程序堆棧上編寫,並在那裏執行的。(我們將在下一篇專欄文章中解釋這是如何做到的。)獲取許多操作系統(包括 Linux 和 Solaris)的非可執行堆棧補丁是可能的。(某些操作系統甚至不需要這樣的補丁;它們本身就帶有。)
非可執行堆棧涉及到一些性能問題。(沒有免費的午餐。)此外,在既有堆棧溢出又有堆溢出的程序中,它們易出問題。可以利用堆棧溢出使程序跳轉至利用代碼,該代碼被放置在堆上。 沒有實際執行堆棧中的代碼,只有堆中的代碼。這些基本問題非常重要,我們將在下一篇專欄文章中專門刊載。
當然,另一種選項是使用類型安全的語言,譬如 Java。較溫和的措施是獲取對 C 程序中進行數組邊界檢查的編譯器。對於 gcc 存在這樣的工具。這種技術可以防止所有緩衝區溢出,堆和堆棧。不利的一面是,對於那些大量使用指針、速度是至關重要的程序,這種技術可能會影響性能。但是在大多數情況下,該技術運行得非常好。
Stackguard 工具實現了比一般性邊界檢查更爲有效的技術。它將一些數據放在已分配數據堆棧的末尾,並且以後會在緩衝區溢出可能發生前,查看這些數據是否仍然在那裏。這種模式被稱之爲“金絲雀”。(威爾士的礦工將 金絲雀放在礦井內來顯示危險的狀況。當空氣開始變得有毒時,金絲雀會昏倒,使礦工有足夠時間注意到並逃離。)
Stackguard 方法不如一般性邊界檢查安全,但仍然相當有用。Stackguard 的主要缺點是,與一般性邊界檢查相比,它不能防止堆溢出攻擊。一般來講,最好用這樣一個工具來保護整個操作系統,否則,由程序調用的不受保護庫(譬如,標準庫)可以仍然爲基於堆棧的利用代碼攻擊打開了大門。
類似於 Stackguard 的工具是內存完整性檢查軟件包,譬如,Rational 的 Purify。這類工具甚至可以保護程序防止堆溢出,但由於性能開銷,這些工具一般不在產品代碼中使用。