【转载】C 语言的整型溢出问题

114 阅读7分钟

背景

之所以转载这篇文章是因为阅读了 Blender 有关 C/C++ 代码风格Blender 有关 C/C++ 最佳实践 的约定,其中对整型有如下的要求

整数类型

  • 只使用内置整数类型的 intchar 。不使用 shortlonglong long ,而是使用固定大小的整数类型,如 int16_t 。你可以假设 int 至少有 32 位。
  • 对于我们知道可以是 “大” 的整数,使用 int64_t
  • 使用带有 truefalsebool 来表示真值(而不是带有 01int)。
  • 如果您的代码是一个具有大小的容器,请确保其大小类型对于任何可能的用途都 足够大。如果有疑问,请使用较大的类型,如 int64_t
  • 位操作模运算 中使用 无符号整数。当使用模运算时,请在注释中提及。
  • 当使用无符号整数时,总是使用 uint8_tuint16_tuint32_t 或 uint64_t
  • 不要使用无符号整数来表示一个值是 非负的,而是使用 断言
  • 由于 位操作 是在标志上使用的,所以这些标志应该是 固定大小 的无符号整数。
  • 如果你的代码已经使用了 int尽量避免 对该类型的值进行任何算术运算。小的正常量的加法可能是可以的,但要避免对任何可能为负的值进行减法或算术运算。
  • 当不能避免将指针存储在整数中(例如进行 算术运算排序)时,请使用 intptr_tuintptr_t
  • 对于与外部库接口的代码,最好使用库使用的类型,以避免类型之间 不必要的转换

sizeof(…) 的运算顺序

当计算以字节为单位的值的大小时,首先排序 sizeof (即 将它位置提前),例如: sizeof(type) * length ,这可以避免 整数溢出 错误,在它是一个较小的类型(如 int)的情况下,将第二个值提升为 size_t

注意,Blender 中对于数组分配,有 MEM_malloc_arrayNmem_calloc_arrayn 两种函数

原文

C语言的整型溢出问题

为了方便阅读,我对原文的格式进行了重新整理和校正

正文

整型溢出有点老生常谈了,bla, bla, bla… 但似乎没有引起多少人的重视。整型溢出会有可能导致缓冲区溢出,缓冲区溢出会导致各种黑客攻击,比如最近 OpenSSL 的 HeartBleed 事件,就是一个 Buffer Overread 的事件。在这里写下这篇文章,希望大家都了解一下整型溢出,编译器的行为,以及如何防范,以写出更安全的代码。

什么是整型溢出

C语言的整型问题相信大家并不陌生了。对于整型溢出,分为无符号整型溢出和有符号整型溢出。

对于 unsigned 整型溢出,C 的规范定义

溢出后的数会以 2^(8*sizeof(type)) 作模运算

也就是说,如果一个 unsigned char(1字符,8 bits)溢出了,会把溢出的值与 256 求模。

例如:

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

上面的代码会输出:0 (因为 0xff + 1 是 256,与 2^8 求模后就是 0

对于 signed 整型的溢出,C 的规范定义

undefined behavior

也就是说,编译器爱怎么实现就怎么实现。对于大多数编译器来说,算得啥就是啥。比如:

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

上面的代码会输出:-128,因为 0x7f + 0x01 得到 0x80,也就是二进制的 1000 0000,符号位为 1,负数,后面为全 0,就是负的最小数,即 -128

另外,千万 别以为 signed 整型溢出就是负数,这个是不定的。比如:

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

上面的代码会输出:123

相信对于这些大家不会陌生了。

整型溢出的危害

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

示例一:整形溢出导致死循环

... ...
 
... ...
 
short len = 0;

... ...
 
while(len< MAX_LEN) {
 
    len += readFromInput(fd, buf);
    buf += len;
 
}

上面这段代码可能是很多程序员都喜欢写的代码(我在很多代码里看到过多次),其中的 MAX_LEN 可能会是个比较大的整型,比如 32767,我们知道 short16bits,取值范围是 -3276832767 之间。但是,上面的 while循 环代码有可能会造成整型溢出,而 len 又是个有符号的整型,所以可能会成负数,导致不断地死循环。

示例二:整型转型时的溢出

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_tlen,也就是一个 unsigned 类型。于是,len 会被提升为 unsigned,此时,如果我们给 len 传一个负数,会通过了 if 的检查,但在 memcpy 里会被提升为一个正数,于是我们的 mybuf 就是 OverFlow 了。这个会导致 mybuf 缓冲区后面的数据被重写。

示例三:分配内存

关于整数溢出导致堆溢出的很典型的例子是,OpenSSH Challenge-Response SKEY/BSD_AUTH 远程缓冲区溢出漏洞。下面这段有问题的代码摘自 OpenSSH 的代码中的 auth2-chall.c 中的 input_userauth_info_response() 函数:

nresp = packet_get_int();
 
if (nresp > 0) {
 
    response = xmalloc(nresp*sizeof(char*));

    for (i = 0; i < nresp; i++)

        response[i] = packet_get_string(NULL);
 
}

上面这个代码中,nrespsize_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》)。

示例四:缓冲区溢出导致安全问题

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;
 
}

上面这个例子本来是想把 buf1buf2 的内容 copy 到 mybuf 里,其中怕 len1 + len2 超过 256 还做了判断,但是,如果 len1+len2 溢出了,根据 unsigned 的特性,其会与 2^32 求模,所以,基本上来说,上面代码中的 [1] 处有可能为假的。

(注:通常来说,在这种情况下,如果你开启 -O 代码优化选项,那个 if 语句块就全部被和谐掉了 —— 被编译器给删除了)

比如,你可以测试一下 len1=0x104, len2 = 0xfffffffc 的情况。

示例五:size_t 的溢出

for (int i= strlen(s)-1; i>=0; i--) { ... }
 
for (int i=v.size()-1; i>=0; i--) { ... }

上面这两个示例是我们经常用的从尾部遍历一个数组的 for 循环。第一个是字符串,第二个是 C++ 中的vector 容器。strlen()vector::size() 返回的都是 size_tsize_t 在 32 位系统下就是一个 unsigned int。你想想,如果 strlen(s)v.size() 都是 0 呢?这个循环会成为个什么情况?于是 strlen(s) – 1v.size() – 1 都不会成为 -1,而是成为了 (unsigned int)(-1),一个 正的最大数。导致你的程序越界访问。

这样的例子有很多很多,这些整型溢出的问题如果在关键的地方,尤其是在搭配有用户输入的地方,如果被黑客利用了,就会导致 很严重的安全问题

关于编译器的行为

在谈一下如何正确的检查整型溢出之前,我们还要来学习一下编译器的一些东西。请别怪我罗嗦。

编译器优化

如何检查整型溢出或是整型变量是否合法有时候是一件很麻烦的事情,就像上面的第四个例子一样,编译的优化参数 -O/-O2/-O3 基本上会假设你的程序 不会 有整型溢出。会把你的代码中检查溢出的代码给优化掉。

关于编译器的优化,在这里再举个例子,假设我们有下面的代码(又是一个相当相当常见的代码):

int len;
 
char* data;
 
if (data + len < data){
 
    printf("invalid len\n");

    exit(-1);
 
}

上面这段代码中,lendata 配套使用,我们害怕 len 的值是非法的,或是 len 溢出了,于是我们写下了 if 语句来检查。这段代码在 -O 的参数下正常。但是在 -O2 的编译选项下,整个 if 语句块被优化掉了。

你可以写个小程序,在 gcc 下编译(我的版本是 4.4.7,记得加上 -O2-g 参数),然后用 gdb 调试时,用 disass /m 命信输出汇编,你会看到下面的结果(你可以看到整个 if 语句块没有任何的汇编代码 —— 直接被编译器和谐掉了):

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 语句块就无法被优化了。如下所示:

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的报错。

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");

正确检测整型溢出

在看过编译器的这些行为后,你应该会明白——“在整型溢出之前,一定要做检查,不然,就太晚了”。

我们来看一段代码:

void foo(int m, int n)
{
 
    size_t s = m + n;

    .......
 
}

上面这段代码有两个风险:

  1. 有符号转无符号
  2. 整型溢出

这两个情况在前面的那些示例中你都应该看到了。所以,你千万不要把任何检查的代码写在 s = m + n 这条语名后面,不然就太晚了

undefined 行为就会出现了 —— 用句纯正的英文表达就是——“Dragon is here”——你什么也控制不住了。

注意:有些初学者也许会以为 size_t 是无符号的,而根据优先级 mn 会被提升到 unsigned int。其实不是这样的,mn 还是 signed intm + n 的结果也是 signed int,然后再把这个结果转成 unsigned int 赋值给 s

比如,下面的代码是错的:

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 溢出后,就被截断了,所以表达式恒真,也就检测不出来了。另外,这个表达式中, mn 分别会被提升为 unsigned

但是上面的代码是错的,因为:

  1. 检查的太晚了,if 之前编译器的 undefined 行为就已经出来了(你不知道什么会发生)。
  2. 就像前面说的一样,(SIZE_MAX – m < n) 可能会被编译器优化掉。
  3. 另外,SIZE_MAXsize_t 的最大值,size_t 在 64 位系统下是 64 位的,严谨点应该用INT_MAX 或是 UINT_MAX

 所以,正确的代码应该是下面这样:

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 页的代码中:

如果 nm 都是 signed int,那么这段代码是错的。正确的应该像上面的那个例子一样,至少要在 n*m 时要把 nm 给 cast 成 size_t。因为,n*m 可能已经溢出了,已经 undefined 了, undefined 的代码转成 size_t 已经没什么意义了。(如果 mnunsigned int,也会溢出),上面的代码仅在 mnsize_t 的时候才有效。

不管怎么说,《苹果安全编码规范》绝对值得你去读一读。

二分取中(二分查找)搜索算法中的溢出

我们再来看一个二分取中搜索算法(binary search),大多数人都会写成下面这个样子:

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) 都可以溢出。正确的写法应该是:

int mid = low + (high - low)/2;

上溢出和下溢出的检查

前面的代码只判断了 正数 的上溢出 overflow,没有判断 负数 的下溢出 underflow。让们来看看怎么判断:

对于加法,还好。

#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;
 
}

对于乘法,就会很复杂(下面的代码太夸张了):

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 的类,下载地址:safeint.codeplex.com/

对于 Java 来说,一种是用 JDK 1.7 中 Math 库下的 safe 打头的函数,如 safeAdd()safeMultiply(),另一种用更大尺寸的数据类型,最大可以到 BigInteger

可见,写一个安全的代码并不容易,尤其对于 C/C++ 来说。对于黑客来说,他们只需要搜一下开源软件中代码有 memcpy/strcpy 之类的地方,然后看一看其周边的代码,是否可以通过用户的输入来影响,如果有的话,你就惨了。