C語言的整型溢出問題


http://coolshell.cn/articles/11466.html/comment-page-1#comments

 

整型溢出有點老生常談了,bla, bla, bla… 但似乎沒有引起多少人的重視。整型溢出會有可能導致緩沖區溢出,緩沖區溢出會導致各種黑客攻擊,比如最近OpenSSL的heartbleed事件,就是一個buffer overread的事件。在這里寫下這篇文章,希望大家都了解一下整型溢出,編譯器的行為,以及如何防范,以寫出更安全的代碼。

什么是整型溢出

C語言的整型問題相信大家並不陌生了。對於整型溢出,分為無符號整型溢出和有符號整型溢出。

對於unsigned整型溢出,C的規范是有定義的——“溢出后的數會以2^(8*sizeof(type))作模運算”,也就是說,如果一個unsigned char(1字符,8bits)溢出了,會把溢出的值與256求模。例如:

1
2
unsigned char x = 0xff;
printf ( "%d\n" , ++x);

上面的代碼會輸出:0 (因為0xff + 1是256,與2^8求模后就是0)

對於signed整型的溢出,C的規范定義是“undefined behavior”,也就是說,編譯器愛怎么實現就怎么實現。對於大多數編譯器來說,算得啥就是啥。比如:

1
2
signed char x =0x7f; //注:0xff就是-1了,因為最高位是1也就是負數了
printf ( "%d\n" , ++x);

上面的代碼會輸出:-128,因為0x7f + 0x01得到0x80,也就是二進制的1000 0000,符號位為1,負數,后面為全0,就是負的最小數,即-128。

 

另外,千萬別以為signed整型溢出就是負數,這個是不定的。比如:

1
2
3
4
signed char x = 0x7f;
signed char y = 0x05;
signed char r = x * y;
printf ( "%d\n" , r);

上面的代碼會輸出:123

相信對於這些大家不會陌生了。

整型溢出的危害

下面說一下,整型溢出的危害。

示例一:整形溢出導致死循環
1
2
3
4
5
6
7
8
... ...
... ...
short len = 0;
... ...
while (len< MAX_LEN) {
     len += readFromInput(fd, buf);
     buf += len;
}

上面這段代碼可能是很多程序員都喜歡寫的代碼(我在很多代碼里看到過多次),其中的MAX_LEN 可能會是個比較大的整型,比如32767,我們知道short是16bits,取值范圍是-32768 到 32767 之間。但是,上面的while循環代碼有可能會造成整型溢出,而len又是個有符號的整型,所以可能會成負數,導致不斷地死循環。

示例二:整形轉型時的溢出
1
2
3
4
5
6
7
8
9
10
11
12
13
int copy_something( char *buf, int len)
{
     #define MAX_LEN 256
     char mybuf[MAX_LEN];
      ... ...
      ... ...
 
      if (len > MAX_LEN){ // <---- [1]
          return -1;
      }
 
      return memcpy (mybuf, buf, len);
}

上面這個例子中,還是[1]處的if語句,看上去沒有會問題,但是len是個signed int,而memcpy則需一個size_t的len,也就是一個unsigned 類型。於是,len會被提升為unsigned,此時,如果我們給len傳一個負數,會通過了if的檢查,但在memcpy里會被提升為一個正數,於是我們的mybuf就是overflow了。這個會導致mybuf緩沖區后面的數據被重寫。

示例三:分配內存

關於整數溢出導致堆溢出的很典型的例子是,OpenSSH Challenge-Response SKEY/BSD_AUTH 遠程緩沖區溢出漏洞。下面這段有問題的代碼摘自OpenSSH的代碼中的auth2-chall.c中的input_userauth_info_response() 函數:

1
2
3
4
5
6
nresp = packet_get_int();
if (nresp > 0) {
     response = xmalloc(nresp* sizeof ( char *));
     for (i = 0; i < nresp; i++)
         response[i] = packet_get_string(NULL);
}

上面這個代碼中,nresp是size_t類型(size_t一般就是unsigned int/long int),這個示例是一個解數據包的示例,一般來說,數據包中都會有一個len,然后后面是data。如果我們精心准備一個len,比如:1073741825(在32位系統上,指針占4個字節,unsigned int的最大值是0xffffffff,我們只要提供0xffffffff/4 的值——0x40000000,這里我們設置了0x4000000 + 1), nresp就會讀到這個值,然后nresp*sizeof(char*)就成了 1073741825 * 4,於是溢出,結果成為了 0x100000004,然后求模,得到4。於是,malloc(4),於是后面的for循環1073741825 次,就可以干環事了(經過0x40000001的循環,用戶的數據早已覆蓋了xmalloc原先分配的4字節的空間以及后面的數據,包括程序代碼,函數指針,於是就可以改寫程序邏輯。關於更多的東西,你可以看一下這篇文章《Survey of Protections from Buffer-Overflow Attacks》)。

示例四:緩沖區溢出導致安全問題
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int func( char *buf1, unsigned int len1,
          char *buf2, unsigned int len2 )
{
    char mybuf[256];
 
    if ((len1 + len2) > 256){    //<--- [1]
        return -1;
    }
 
    memcpy (mybuf, buf1, len1);
    memcpy (mybuf + len1, buf2, len2);
 
    do_some_stuff(mybuf);
 
    return 0;
}

上面這個例子本來是想把buf1和buf2的內容copy到mybuf里,其中怕len1 + len2超過256 還做了判斷,但是,如果len1+len2溢出了,根據unsigned的特性,其會與2^32求模,所以,基本上來說,上面代碼中的[1]處有可能為假的。(注:通常來說,在這種情況下,如果你開啟-O代碼優化選項,那個if語句塊就全部被和諧掉了——被編譯器給刪除了)比如,你可以測試一下 len1=0x104, len2 = 0xfffffffc 的情況。

示例五:size_t 的溢出
1
for ( int i= strlen (s)-1;  i>=0; i--)  { ... }
1
for ( int i=v.size()-1; i>=0; i--)  { ... }

上面這兩個示例是我們經常用的從尾部遍歷一個數組的for循環。第一個是字符串,第二個是C++中的vector容器。strlen()和vector::size()返回的都是 size_t,size_t在32位系統下就是一個unsigned int。你想想,如果strlen(s)和v.size() 都是0呢?這個循環會成為個什么情況?於是strlen(s) – 1 和 v.size() – 1 都不會成為 -1,而是成為了 (unsigned int)(-1),一個正的最大數。導致你的程序越界訪問。

[經測試,該例子不會溢出,v.size() - 1是unsigned int,但其又被賦值給了int,所以不會溢出]

這樣的例子有很多很多,這些整型溢出的問題如果在關鍵的地方,尤其是在搭配有用戶輸入的地方,如果被黑客利用了,就會導致很嚴重的安全問題。

關於編譯器的行為

在談一下如何正確的檢查整型溢出之前,我們還要來學習一下編譯器的一些東西。請別怪我羅嗦。

編譯器優化

如何檢查整型溢出或是整型變量是否合法有時候是一件很麻煩的事情,就像上面的第四個例子一樣,編譯的優化參數-O/-O2/-O3基本上會假設你的程序不會有整形溢出。會把你的代碼中檢查溢出的代碼給優化掉。

關於編譯器的優化,在這里再舉個例子,假設我們有下面的代碼(又是一個相當相當常見的代碼):

1
2
3
4
5
6
7
int len;
char * data;
 
if (data + len < data){
     printf ( "invalid len\n" );
     exit (-1);
}

上面這段代碼中,len 和 data 配套使用,我們害怕len的值是非法的,或是len溢出了,於是我們寫下了if語句來檢查。這段代碼在-O的參數下正常。但是在-O2的編譯選項下,整個if語句塊被優化掉了。

你可以寫個小程序,在gcc下編譯(我的版本是4.4.7,記得加上-O2和-g參數),然后用gdb調試時,用disass /m命信輸出匯編,你會看到下面的結果(你可以看到整個if語句塊沒有任何的匯編代碼——直接被編譯器和諧掉了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
7           int len = 10;
8           char* data = (char *)malloc(len);
    0x00000000004004d4 <+4>:     mov    $0xa,%edi
    0x00000000004004d9 <+9>:     callq  0x4003b8 <malloc@plt>
 
9
10          if (data + len < data){
11              printf ( "invalid len\n" );
12              exit (-1);
13          }
14
15      }
    0x00000000004004de <+14>:    add    $0x8,%rsp
    0x00000000004004e2 <+18>:    retq

對此,你需要把上面 char* 轉型成 uintptr_t 或是 size_t,說白了也就是把char*轉成unsigned的數據結構,if語句塊就無法被優化了。如下所示:

1
2
3
if (( uintptr_t )data + len < ( uintptr_t )data){
     ... ...
}

關於這個事,你可以看一下C99的規范說明《 ISO/IEC 9899:1999 C specification 》第 §6.5.6 頁,第8點,我截個圖如下:(這段話的意思是定義了指針+/-一個整型的行為,如果越界了,則行為是undefined)

注意上面標紅線的地方,說如果指針指在數組范圍內沒事,如果越界了就是undefined,也就是說這事交給編譯器實現了,編譯器想咋干咋干,那怕你想把其優化掉也可以。在這里要重點說一下,C語言中的一個大惡魔—— Undefined! 這里都是“野獸出沒”的地方,你一定要小心小心再小心

花絮:編譯器的彩蛋

上面說了所謂的undefined行為就全權交給編譯器實現,gcc在1.17版本下對於undefined的行為還玩了個彩蛋(參看Wikipedia)。

下面gcc 1.17版本下的遭遇undefined行為時,gcc在unix發行版下玩的彩蛋的源代碼。我們可以看到,它會去嘗試去執行一些游戲NetHack, Rogue 或是Emacs的 Towers of Hanoi,如果找不到,就輸出一條NB的報錯。

1
2
3
4
5
6
execl( "/usr/games/hack" , "#pragma" , 0); // try to run the game NetHack
execl( "/usr/games/rogue" , "#pragma" , 0); // try to run the game Rogue
// try to run the Tower's of Hanoi simulation in Emacs.
execl( "/usr/new/emacs" , "-f" , "hanoi" , "9" , "-kill" ,0);
execl( "/usr/local/emacs" , "-f" , "hanoi" , "9" , "-kill" ,0); // same as above
fatal( "You are in a maze of twisty compiler features, all different" );

正確檢測整型溢出

在看過編譯器的這些行為后,你應該會明白——“在整型溢出之前,一定要做檢查,不然,就太晚了”。

我們來看一段代碼:

1
2
3
4
5
void foo( int m, int n)
{
     size_t s = m + n;
     .......
}

上面這段代碼有兩個風險:1)有符號轉無符號2)整型溢出。這兩個情況在前面的那些示例中你都應該看到了。所以,你千萬不要把任何檢查的代碼寫在 s = m + n 這條語名后面,不然就太晚了。undefined行為就會出現了——用句純正的英文表達就是——“Dragon is here”——你什么也控制不住了。(注意:有些初學者也許會以為size_t是無符號的,而根據優先級 m 和 n 會被提升到unsigned int。其實不是這樣的,m 和 n 還是signed int,m + n 的結果也是signed int,然后再把這個結果轉成unsigned int 賦值給s)

比如,下面的代碼是錯的:

1
2
3
4
5
6
7
void foo( int m, int n)
{
    size_t s = m + n;
    if ( m>0 && n>0 && (SIZE_MAX - m < n) ){
         //error handling...
     }
}

上面的代碼中,大家要注意 (SIZE_MAX – m < n) 這個判斷,為什么不用m + n > SIZE_MAX呢?因為,如果 m + n 溢出后,就被截斷了,所以表達式恆真,也就檢測不出來了。另外,這個表達式中,m和n分別會被提升為unsigned。

但是上面的代碼是錯的,因為:

1)檢查的太晚了,if之前編譯器的undefined行為就已經出來了(你不知道什么會發生)。

2)就像前面說的一樣,(SIZE_MAX – m < n) 可能會被編譯器優化掉。

3)另外,SIZE_MAX是size_t的最大值,size_t在64位系統下是64位的,嚴謹點應該用INT_MAX或是UINT_MAX

 所以,正確的代碼應該是下面這樣:

1
2
3
4
5
6
7
8
9
void foo( int m, int n)
{
    size_t s = 0;
    if ( m>0 && n>0 && ( UINT_MAX - m < n ) ){
         //error handling...
         return ;
     }
     s = ( size_t )m + ( size_t )n;
}

在《蘋果安全編碼規范》(PDF)中,第28頁的代碼中:

如果n和m都是signed int,那么這段代碼是錯的。正確的應該像上面的那個例子一樣,至少要在n*m時要把 n 和 m 給 cast 成 size_t。因為,n*m可能已經溢出了,已經undefined了,undefined的代碼轉成size_t已經沒什么意義了。(如果m和n是unsigned int,也會溢出),上面的代碼僅在m和n是size_t的時候才有效。

不管怎么說,《蘋果安全編碼規范》絕對值得你去讀一讀。

二分取中搜索算法中的溢出

我們再來看一個二分取中搜索算法(binary search),大多數人都會寫成下面這個樣子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int binary_search( int a[], int len, int key)
{
     int low = 0;
     int high = len - 1;
 
     while ( low<=high ) {
         int mid = (low + high)/2;
         if (a[mid] == key) {
             return mid;
         }
         if (key < a[mid]) {
             high = mid - 1;
         } else {
             low = mid + 1;
         }
     }
     return -1;
}

上面這個代碼中,你可能會有這樣的想法:

1) 我們應該用size_t來做len, low, high, mid這些變量的類型。沒錯,應該是這樣的。但是如果這樣,你要小心第四行 int high = len -1; 如果len為0,那么就“high大發了”。

2) 無論你用不用size_t。我們在計算mid = (low+high)/2; 的時候,(low + high) 都可以溢出。正確的寫法應該是:

1
int mid = low + (high - low)/2;
上溢出和下溢出的檢查

前面的代碼只判斷了正數的上溢出overflow,沒有判斷負數的下溢出underflow。讓們來看看怎么判斷:

對於加法,還好。

1
2
3
4
5
6
7
8
9
10
11
#include <limits.h>
 
void f( signed int si_a, signed int si_b) {
     signed int sum;
     if (((si_b > 0) && (si_a > (INT_MAX - si_b))) ||
         ((si_b < 0) && (si_a < (INT_MIN - si_b)))) {
         /* Handle error */
         return ;
     }
     sum = si_a + si_b;
}

對於乘法,就會很復雜(下面的代碼太誇張了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void func( signed int si_a, signed int si_b)
{
   signed int result;
   if (si_a > 0) {  /* si_a is positive */
     if (si_b > 0) {  /* si_a and si_b are positive */
       if (si_a > (INT_MAX / si_b)) {
         /* Handle error */
       }
     } else { /* si_a positive, si_b nonpositive */
       if (si_b < (INT_MIN / si_a)) {
         /* Handle error */
       }
     } /* si_a positive, si_b nonpositive */
   } else { /* si_a is nonpositive */
     if (si_b > 0) { /* si_a is nonpositive, si_b is positive */
       if (si_a < (INT_MIN / si_b)) {
         /* Handle error */
       }
     } else { /* si_a and si_b are nonpositive */
       if ( (si_a != 0) && (si_b < (INT_MAX / si_a))) {
         /* Handle error */
       }
     } /* End if si_a and si_b are nonpositive */
   } /* End if si_a is nonpositive */
 
   result = si_a * si_b;
}

更多的防止在操作中整型溢出的安全代碼可以參看《INT32-C. Ensure that operations on signed integers do not result in overflow

其它

對於C++來說,你應該使用STL中的numeric_limits::max() 來檢查溢出

另外,微軟的SafeInt類是一個可以幫你遠理上面這些很tricky的類,下載地址:http://safeint.codeplex.com/

對於Java 來說,一種是用JDK 1.7中Math庫下的safe打頭的函數,如safeAdd()和safeMultiply(),另一種用更大尺寸的數據類型,最大可以到BigInteger。

可見,寫一個安全的代碼並不容易,尤其對於C/C++來說。對於黑客來說,他們只需要搜一下開源軟件中代碼有memcpy/strcpy之類的地方,然后看一看其周邊的代碼,是否可以通過用戶的輸入來影響,如果有的話,你就慘了。

參考

最后, 不好意思,這篇文章可能羅嗦了一些,大家見諒。

(全文完)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM