陣列越界及其避免方法,C語言陣列越界詳解

2020-07-16 10:04:22
所謂的陣列越界,簡單地講就是指陣列下標變數的取值超過了初始定義時的大小,導致對陣列元素的存取出現在陣列的範圍之外,這類錯誤也是 C 語言程式中最常見的錯誤之一。

在 C 語言中,陣列必須是靜態的。換而言之,陣列的大小必須在程式執行前就確定下來。由於 C 語言並不具有類似 Java 等語言中現有的靜態分析工具的功能,可以對程式中陣列下標取值範圍進行嚴格檢查,一旦發現陣列上溢或下溢,都會因丟擲異常而終止程式。也就是說,C 語言並不檢驗陣列邊界,陣列的兩端都有可能越界,從而使其他變數的資料甚至程式程式碼被破壞。

因此,陣列下標的取值範圍只能預先推斷一個值來確定陣列的維數,而檢驗陣列的邊界是程式設計師的職責。

一般情況下,陣列的越界錯誤主要包括兩種:陣列下標取值越界指向陣列的指標的指向範圍越界

陣列下標取值越界

陣列下標取值越界主要是指存取陣列的時候,下標的取值不在已定義好的陣列的取值範圍內,而存取的是無法獲取的記憶體地址。例如,對於陣列 int a[3],它的下標取值範圍是 [0,2](即 a[0]、a[1] 與 a[2])。如果我們的取值不在這個範圍內(如 a[3]),就會發生越界錯誤。範例程式碼如下所示:
int a[3];
int i=0;
for(i=0;i<4;i++)
{
    a[i] = i;
}
for(i=0;i<4;i++)
{
    printf("a[%d]=%dn",i,a[i]);
}
很顯然,在上面的範例程式中,存取 a[3] 是非法的,將會發生越界錯誤。因此,我們應該將上面的程式碼修改成如下形式:
int a[3];
int i=0;
for(i=0;i<3;i++)
{
    a[i] = i;
}
for(i=0;i<3;i++)
{
    printf("a[%d]=%dn",i,a[i]);
}

指向陣列的指標的指向範圍越界

指向陣列的指標的指向範圍越界是指定義陣列時會返回一個指向第一個變數的頭指標,對這個指標進行加減運算可以向前或向後移動這個指標,進而存取陣列中所有的變數。但在移動指標時,如果不注意移動的次數和位置,會使指標指向陣列以外的位置,導致陣列發生越界錯誤。下面的範例程式碼就是移動指標時沒有考慮到移動的次數和陣列的範圍,從而使程式存取了陣列以外的儲存單元。
int i;
int *p;
int a[5];
/*陣列a的頭指標賦值給指標p*/
p=a;
for(i=0;i<10;i++)
{
    /*指標p指向的變數*/
    *p=i+10;
    /*指標p下一個變數*/
    p++;
}
在上面的範例程式碼中,for 迴圈會使指標 p 向後移動 10 次,並且每次向指標指向的單元賦值。但是,這裡陣列 a 的下標取值範圍是 [0,4](即 a[0]、a[1]、a[2]、a[3] 與 a[4])。因此,後 5 次的操作會對未知的記憶體區域賦值,而這種向記憶體未知區域賦值的操作會使系統發生錯誤。正確的操作應該是指標移動的次數與陣列中的變數個數相同,如下面的程式碼所示:
int i;
int *p;
int a[5];
/*陣列a的頭指標賦值給指標p*/
p=a;
for(i=0;i<5;i++)
{
    /*指標p指向的變數*/
    *p=i+10;
    /*指標p下一個變數*/
    p++;
}
為了加深大家對陣列越界的了解,下面通過一段完整的陣列越界範例來演示程式設計中陣列越界將會導致哪些問題。
#define PASSWORD "123456"
int Test(char *str)
{
    int flag;
    char buffer[7];
    flag=strcmp(str,PASSWORD);
    strcpy(buffer,str);
    return flag;
}
int main(void)
{
    int flag=0;
    char str[1024];
    while(1)
    {
        printf("請輸入密碼:  ");
        scanf("%s",str);
        flag = Test(str);
        if(flag)
        {
            printf("密碼錯誤!n");
        }
            else
            {
                printf("密碼正確!n");
            }
    }
    return 0;
}
上面的範例程式碼模擬了一個密碼驗證的例子,它將使用者輸入的密碼與宏定義中的密碼“123456”進行比較。很顯然,本範例中最大的設計漏洞就在於 Test() 函數中的 strcpy(buffer,str) 呼叫。

由於程式將使用者輸入的字串原封不動地複製到 Test() 函數的陣列 char buffer[7] 中。因此,當使用者的輸入大於 7 個字元的緩衝區尺寸時,就會發生陣列越界錯誤,這也就是大家所謂的緩衝區溢位(Buffer overflow)漏洞。但是要注意,如果這個時候我們根據緩衝區溢位發生的具體情況填充緩衝區,不但可以避免程式崩潰,還會影響到程式的執行流程,甚至會讓程式去執行緩衝區裡的程式碼。範例執行結果為:

請輸入密碼:12345
密碼錯誤!
請輸入密碼:123456
密碼正確!
請輸入密碼:1234567
密碼正確!
請輸入密碼:aaaaaaa
密碼正確!
請輸入密碼:0123456
密碼錯誤!
請輸入密碼:

在範例程式碼中,flag 變數實際上是一個標誌變數,其值將決定著程式是進入“密碼錯誤”的流程(非 0)還是“密碼正確”的流程(0)。當我們輸入錯誤的字串“1234567”或者“aaaaaaa”,程式也都會輸出“密碼正確”。但在輸入“0123456”的時候,程式卻輸出“密碼錯誤”,這究竟是為什麼呢?

其實,原因很簡單。當呼叫 Test() 函數時,系統將會給它分配一片連續的記憶體空間,而變數 char buffer[7] 與 int flag 將會緊挨著進行儲存,使用者輸入的字串將會被複製進 buffer[7] 中。如果這個時候,我們輸入的字串數量超過 6 個(注意,有字串截斷符也算一個),那麼超出的部分將破壞掉與它緊鄰著的 flag 變數的內容。

當輸入的密碼不是宏定義的“123456”時,字串比較將返回 1 或 -1。我們都知道,記憶體中的資料按照 4 位元組(DWORD)逆序儲存,所以當 flag 為 1 時,在記憶體中儲存的是 0x01000000。如果我們輸入包含 7 個字元的錯誤密碼,如“aaaaaaa”,那麼字串截斷符 0x00 將寫入 flag 變數,這樣溢位陣列的一個位元組 0x00 將恰好把逆序存放的 flag 變數改為 0x00000000。在函數返回後,一旦 main 函數的 flag 為 0,就會輸出“密碼正確”。這樣,我們就用錯誤的密碼得到了正確密碼的執行效果。

而對於“0123456”,因為在進行字串的大小比較時,它小於“123456”,flag的值是 -1,在記憶體中將按照二補數存放負數,所以實際儲存的不是 0x01000000 而是 0xffffffff。那麼字串截斷後符 0x00 淹沒後,變成 0x00ffffff,還是非 0,所以沒有進入正確分支。

其實,本範例只是用一個位元組淹沒了鄰接變數,導致程式進入密碼正確的處理流程,使設計的驗證功能失效。

盡量顯式地指定陣列的邊界

在 C 語言中,為了提高執行效率,給程式設計師更大的空間,為指標操作帶來更多的方便,C 語言內部本身不檢查陣列下標表示式的取值是否在合法範圍內,也不檢查指向陣列元素的指標是不是移出了陣列的合法區域。因此,在程式設計中使用陣列時就必須格外謹慎,在對陣列進行讀寫操作時都應當進行相應的檢查,以免對陣列的操作超過陣列的邊界,從而發生緩衝區溢位漏洞。

要避免程式因陣列越界所發生的錯誤,首先就需要從陣列的邊界定義開始。儘量顯式地指定陣列的邊界,即使它已經由初始化值列表隱式指定。範例程式碼如下所示:
int a[]={1,2,3,4,5,6,7,8,9,10};
很顯然,對於上面的陣列 a[],雖然編譯器可以根據始化值列表來計算出陣列的長度。但是,如果我們顯式地指定該陣列的長度,例如:
int a[10]={1,2,3,4,5,6,7,8,9,10};
它不僅使程式具有更好的可讀性,並且大多數編譯器在陣列長度小於初始化值列表的長度時還會發生相應警告。

當然,也可以使用宏的形式來顯式指定陣列的邊界(實際上,這也是最常用的指定方法),如下面的程式碼所示:
#define MAX 10
…
int a[MAX]={1,2,3,4,5,6,7,8,9,10};
除此之外,在 C99 標準中,還允許我們使用單個指示符為陣列的兩段“分配”空間,如下面的程式碼所示:
int a[MAX]={1,2,3,4,5,[MAX-5]=6,7,8,9,10};
在上面的 a[MAX] 陣列中,如果 MAX 大於 10,陣列中間將用 0 值元素進行填充(填充的個數為 MAX-10,並從 a[5] 開始進行 0 值填充);如果 MAX 小於 10,“[MAX-5]”之前的 5 個元素(1,2,3,4,5)中將有幾個被“[MAX-5]”之後的 5 個元素(6,7,8,9,10)所覆蓋,範例程式碼如下所示:
#define MAX 10
#define MAX1 15
#define MAX2 6
int main(void)
{
    int a[MAX]={1,2,3,4,5,[MAX-5]=6,7,8,9,10};
    int b[MAX1]={1,2,3,4,5,[MAX1-5]=6,7,8,9,10};
    int c[MAX2]={1,2,3,4,5,[MAX2-5]=6,7,8,9,10};
    int i=0;
    int j=0;
    int z=0;
    printf("a[MAX]:n");
    for(i=0;i<MAX;i++)
    {
        printf("a[%d]=%d ",i,a[i]);
    }
    printf("nb[MAX1]:n");
    for(j=0;j<MAX1;j++)
    {
        printf("b[%d]=%d ",j,b[j]);
    }
    printf("nc[MAX2]:n");
    for(z=0;z<MAX2;z++)
    {
        printf("c[%d]=%d ",z,c[z]);
    }
    printf("n");
    return 0;
}
執行結果為:
a[MAX]:
a[0]=1 a[1]=2 a[2]=3 a[3]=4 a[4]=5 a[5]=6 a[6]=7 a[7]=8 a[8]=9 a[9]=10
b[MAX1]:
b[0]=1 b[1]=2 b[2]=3 b[3]=4 b[4]=5 b[5]=0 b[6]=0 b[7]=0 b[8]=0 b[9]=0 b[10]=6 b[11]=7 b[12]=8 b[13]=9 b[14]=10
c[MAX2]:
c[0]=1 c[1]=6 c[2]=7 c[3]=8 c[4]=9 c[5]=10

對陣列做越界檢查,確保索引值位於合法的範圍之內

要避免陣列越界,除了上面所闡述的顯式指定陣列的邊界之外,還可以在陣列使用之前進行越界檢查,檢查陣列的界限和字串(也以陣列的方式存放)的結束,以保證陣列索引值位於合法的範圍之內。例如,在寫處理陣列的函數時,一般應該有一個範圍引數;在處理字串時總檢查是否遇到空字元‘’。

來看下面一段程式碼範例:
#define ARRAY_NUM 10
int *TestArray(int num,int value)
{
    int *arr=NULL;
    arr=(int *)malloc(sizeof(int)*ARRAY_NUM);
    if(arr!=NULL)
    {
        arr[num]=value;
    }
    else
    {
        /*處理arr==NULL*/
    }
    return arr;
}
從上面的“int*TestArray(int num,int value)”函數中不難看出,其中存在著一個很明顯的問題,那就是無法保證 num 引數是否越界(即當 num>=ARRAY_NUM 的情況)。因此,應該對 num 引數進行越界檢查,範例程式碼如下所示:
int *TestArray(int num,int value)
{
    int *arr=NULL;
    /*越界檢查(越上界)*/
    if(num<ARRAY_NUM)
    {
        arr=(int *)malloc(sizeof(int)*ARRAY_NUM);
        if(arr!=NULL)
        {
            arr[num]=value;
        }
        else
        {
            /*處理arr==NULL*/
        }
    }
    return arr;
}
這樣通過“if(num<ARRAY_NUM)”語句進行越界檢查,從而保證 num 引數沒有越過這個陣列的上界。現在看起來,TestArray() 函數應該沒什麼問題,也不會發生什麼越界錯誤。

但是,如果仔細檢查,TestArray() 函數仍然還存在一個致命的問題,那就是沒有檢查陣列的下界。由於這裡的 num 引數型別是 int 型別,因此可能為負數。如果 num 引數所傳遞的值為負數,將導致在 arr 所參照的記憶體邊界之外進行寫入。

當然,你可以通過向“if(num<ARRAY_NUM)”語句裡面再加一個條件進行測試,如下面的程式碼所示:
if(num>=0&&num<ARRAY_NUM)
{
}
但是,這樣的函數形式對呼叫者來說是不友好的(由於 int 型別的原因,對呼叫者來說仍然可以傳遞負數,至於在函數中怎麼處理那是另外一件事情),因此,最佳的解決方案是將 num 引數宣告為 size_t 型別,從根本上防止它傳遞負數,範例程式碼如下所示:
int *TestArray(size_t num,int value)
{
    int *arr=NULL;
    /*越界檢查(越上界)*/
    if(num<ARRAY_NUM)
    {
        arr=(int *)malloc(sizeof(int)*ARRAY_NUM);
        if(arr!=NULL)
        {
            arr[num]=value;
        }
        else
        {
            /*處理arr==NULL*/
        }
    }
    return arr;
}

獲取陣列的長度時不要對指標應用 sizeof 操作符

在 C 語言中,sizeof 這個其貌不揚的傢伙經常會讓無數程式設計師叫苦連連。同時,它也是各大公司爭相選用的面試必備題目。簡單地講,sizeof 是一個單目操作符,不是函數。其作用就是返回一個運算元所佔的記憶體位元組數。其中,運算元可以是一個表示式或括在括號內的型別名,運算元的儲存大小由運算元的型別來決定。例如,對於陣列 int a[5],可以使用“sizeof(a)”來獲取陣列的長度,使用“sizeof(a[0])”來獲取陣列元素的長度。

但需要注意的是,sizeof 操作符不能用於函數型別、不完全型別(指具有未知儲存大小的資料型別,如未知儲存大小的陣列型別、未知內容的結構或聯合型別、void 型別等)與位欄位。例如,以下都是不正確形式:
/*若此時max定義為intmax();*/
sizeof(max)
/*若此時arr定義為char arr[MAX],且MAX未知*/
sizeof(arr)
/*不能夠用於void型別*/
sizeof(void)
/*不能夠用於位欄位*/
struct S
{
    unsigned int f1 : 1;
    unsigned int f2 : 5;
    unsigned int f3 : 12;
};
sizeof(S.f1);
了解 sizeof 操作符之後,現在來看下面的範例程式碼:
void Init(int arr[])
{
    size_t i=0;
    for(i=0;i<sizeof(arr)/sizeof(arr[0]);i++)
    {
        arr[i]=i;
    }
}
int main(void)
{
    int i=0;
    int a[10];
    Init(a);
    for(i=0;i<10;i++)
    {
        printf("%dn",a[i]);
    }
    return 0;
}
從表面看,上面程式碼的輸出結果應該是“0,1,2,3,4,5,6,7,8,9”,但實際結果卻出乎我們的意料,如圖 1 所示。


圖 1 範例程式碼在 VC++2010 中的執行結果