整数溢出基础

1: 简介
1.1 什么是整数?
1.2 什么是整数溢出?
1.3 为什么整数溢出可能会很危险?

2: 整数溢出
2.1 宽度溢出
2.1.1 溢出利用
2.2 算数溢出
2.2.1 溢出利用

3: 整数符号处理不当的bug
3.1 它们看起来是什么样子?
3.1.1 漏洞利用
3.2 整数溢出造成的符号处理错误
4: 实例
4.1 整数溢出
4.2 符号处理错误 bugs

--[ 1.0 简介

在这篇文章中我将描述可被恶意用户用来改变程序执行流程的两种编程缺陷,它们不同于诸如缓冲区溢出或格式化字符串那样
直接修改内存,而是利用使程序变量等于非预期值而改变程序执行流程。本文中的所有例子都是用c语言写的,所以阅读本文
时您需要c语言的基础知识。整数如何在内存中存放对于您理解本文会有帮助但如果您不知道这方面的知识也无关紧要。

----[ 1.1 什么是整数?

在计算中,整数就是一个没有小数部分的实数。通常,整数具有同指针一样的宽度(在32位机器中,如i386,整数是32的,在
64位机器上,整数就是64位的。译者注:在32位的x86平台c语言采用的是ILP32模型,意即I(integer),L(long), P(point)都是
32位的,在AMD64平台c采用的是LP64,整数还是32位的,long和point是64位的)。有些编译器并不会让整数和指针具有相同
的宽度,但为了讨论的简单,文中所有的例子都假定目标系统采用ILP32模型。

整数和其他类型的变量一样都仅仅是在内存中的一块区域而已。当我们讨论整数时我们通常用人类习惯了的十进制表示法。
由于现在的计算机无法处理十进制的数,所以整数在计算机中用二进制表示。二进制只用两个数值0和1来表示数,它并不是
像十进制那样用0-9是个数字来表示数。除了二进制和十进制表示法,同二进制之间很容易转换的16进制也经常用来表示整数。

由于经常需要表示负数,因此需要一个机制只用二进制数字(0和1)来表示它们。现在通用的方法是用最高位来决定一个数的符号
(译者注:cpu其实根本不知道一个数是正数还是负数,也不需要知道,需要知道的仅仅是我们的程序):如果最高位是1就把它
当成负数,相反如果为0就把它当成正数(译者注:在这里我们并不像数学中那么严谨:0既不是正数也不是负数)。这可能会有
些混淆,比如在下文中将会描述的符号bug,因为并不是所有的数都带有符号,也就是说并不是在内存中的所有数都用最高位来
表示该数的正负,这些变量就是我们熟知的无符号数(unsigned),因此也只能赋予正数。那些既可正也可负的数我们称之为有符号
数(译者注:原文为:whereas variables which can be either positive or negative are called unsigned。应该是笔误?)

----[ 1.2 什么是整数溢出?

由于计算机中整数都有一个宽度(本文中为32位),因此它就有一个可以表示的最大值。当我们试图保存一个比它可以表示的最大值
还大的数时,就会发生整数溢出。ISO C99标准规定整数溢出将导致“不确定性行为”,意即遵循标准的编译器可以做它们想做的任何
事,比如完全忽略该溢出或终止进程。大多数编译器都会忽略这种溢出,这可能会导致不确定或错误的值保存在了整数变量中。

----[ 1.3 为什么整数溢出很危险?

在整数溢出确实发生之前我们是无法得知它会溢出的,因此程序是没有办法区分先前计算出的值是否正确。如果计算结果作为一个缓冲区
的大小或数组的下标时将会非常危险。当然,大多数整数溢出我们是没有办法利用的因为我们无法直接改写内存单元,但有时整数溢出
将会导致其它类型的缺陷,比如很容易发生的缓冲区溢出。整数溢出有时是很难发现的,也正因为如此,即使经过仔细审查的代码有时候
也不可避免。

--[ 2.0 整数溢出

当整数溢出时到底会发生什么呢?ISO C99中这么写道:

“当计算所涉及的操作数是无符号数时不会发生溢出,因为如果计算结果无法用无符号型整数表示
时,计算结果将会通过与无符号整数所能表示的最大值加一这个值取模运算截短。”

取模运算就是我们常说的除法中的求余。

例:
10 % 5 = 0
11 % 5 = 1
[译者:下面这段不太好表达,就原文放在这,意思还是很明了的。]
so reducing a large value modulo (MAXINT + 1) can be seen as discarding the
portion of the value which cannot fit into an integer and keeping the rest.
In C, the modulo operator is a % sign.

用一个例子来看看什么是“不确定性行为”:

有两个32位无符号整数,a和b,我们把32位整数的最大值赋给变量a, 给b赋值1。我们把a和b的和赋值给另一个32位整数r:

a = 0xffffffff
b = 0x1
r = a + b
现在由于a与b的和不能用32位来表示了,根据ISO标准,结果被与0x100000000取模截短了.

r = (0xffffffff + 0x1) % 0x100000000
r = (0x100000000) % 0x100000000 = 0
 通过取模的方法来截短可以保证只有最低的32位是有效的,因此整数溢出导致计算结果被截短致该变量可以表示的宽度。这通常被称为"wrap around",就像上例中的结果又回到了0。 
----[ 2.1 宽度溢出

因此整数溢出是给一个变量赋一个它不能容下的值的结果。这种溢出最简单的模拟就是给一个较小宽度的变量赋
一个较大宽度的值:

/* ex1.c - loss of precision */
#include <stdio.h>

int main(void){
int l;
short s;
char c;

l = 0xdeadbeef;
s = l;
c = l;

printf("l = 0x%x (%d bits)\n", l, sizeof(l) * 8);
printf("s = 0x%x (%d bits)\n", s, sizeof(s) * 8);
printf("c = 0x%x (%d bits)\n", c, sizeof(c) * 8);

return 0;
}
/* EOF */

结果:

nova:signed {48} ./ex1
l = 0xdeadbeef (32 bits)
s = 0xffffbeef (16 bits)
c = 0xffffffef (8 bits)

上例中因为每一个赋值都超出了左值能够表示的范围,所以它们都被截断到了左值的宽度。

在这里提一提整型提升是有必要的。当计算表达式中包括了不同宽度的操作数时,较小宽度的操作数被提升到了
跟较大操作数一样的宽度,然后再进行计算,如果计算结果保存在较小宽度的变量中,结果会被再次截短到较小
的宽度。例:

int i;
short s;

s = i;

这里两个操作数具有不同的宽度。因此s被提升到了32位,然后把i的值赋值给s,然后s又被截断回16位并保存下来。
如果结果比s所能表示的最大值还大的话,它将会被截短。

------[ 2.1.1 漏洞利用

整数溢出不同于一般的常见的bug,它不允许直接改写内存或直接控制程序流程,但它更难以捕获。问题的根源在于程序
无法在还没有发生整数溢出之前就检查出结果是否会发生溢出。因此,大多数整数溢出是无法利用的,尽管这样,在
某些条件下还是有可能让一些关键变量包含错误的值,进而引起其它bug.

正因整数溢出具有飘忽不定的性质,所以还是有很多情况下这种缺陷可以被利用,我不会一一列举所有的可以利用的条件,
但我会提供一些例子来展示如何利用这些漏洞,并希望能以此抛砖引玉 :) (译者注:实践才能出真知。我最佩服我们高中
的数学老师拿来教训我们的一句话:一看就会,一做就错。很多人一看就说,哎呀太简单了,可让它真的自己来搞又不会了
这是典型的浮躁。记得有位朋友在网上跟我说他对win32环境的病毒研究炉火纯青了,可是我一问他对PE文件格式吃透了没有
他居然问什么叫PE文件~~~狂晕!)

Example 1:

/* width1.c - exploiting a trivial widthness bug */
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]){
unsigned short s;
int i;
char buf[80];

if(argc < 3){
return -1;
}

i = atoi(argv[1]);
s = i;

if(s >= 80){ /* [w1] */
printf("Oh no you don't!\n");
return -1;
}

printf("s = %d\n", s);

memcpy(buf, argv[2], i);
buf[i] = '\0';
printf("%s\n", buf);

return 0;
}

像上面这种构造出来的代码也许永远不会出现在真实的代码中,但作为一个例子他可以很好的起到示范作用。看看
下面的输入:

nova:signed {100} ./width1 5 hello
s = 5
hello
nova:signed {101} ./width1 80 hello
Oh no you don't!
nova:signed {102} ./width1 65536 hello
s = 0
Segmentation fault (core dumped)

长度参数取自于命令行并保存在变量i中,当把它赋值给短整型变量s时,如果值大于s能够表示的范围就会发生截短,
(如i的值大于65535)。因此,我们是可能绕过长度限制的。这样,标准的缓冲区溢出攻击法就可以用来攻击这个
程序了。

----[ 2.2 算数溢出
从前面的内容可以看出,当把一个整数保存在一个宽度不足以保存该数时可能发生溢出。如果保存值是由算数运算而得,
程序在以后用到这个值时就会出错,我们看看下面这个例子:

/* ex2.c - an integer overflow */
#include <stdio.h>

int main(void){
unsigned int num = 0xffffffff;

printf("num is %d bits long\n", sizeof(num) * 8);
printf("num = 0x%x\n", num);
printf("num + 1 = 0x%x\n", num + 1);

return 0;
}
/* EOF */

程序运行结果:

nova:signed {4} ./ex2
num is 32 bits long
num = 0xffffffff
num + 1 = 0x0

聪明的你可能已经注意到0xffffffff就是十进制的-1,所以看起来好像我们是在做
1 + (-1) = 0
然而这只是一个表象,他可能会让我们感到糊涂,因为这些变量都是无符号的,那么所有的运算都应该是无符号
的。碰巧,很多有符号算数运算取决于整数溢出,如下例所示(假定所有操作数都是32的):

-700 + 800 = 100
0xfffffd44 + 0x320 = 0x100000064

由于计算结果超出了变量的表示范围,计算结果的最低32位被当成了结果而被保留了下来。上例中的最低32位
就是0x64,即十进制的100。

由于整数默认的是有符号的,溢出有时会导致改变正负,这将会导致一些列的非常有趣的运行行为。考虑下面
这个例子:

/* ex3.c - change of signedness */
#include <stdio.h>

int main(void){
int l;

l = 0x7fffffff;

printf("l = %d (0x%x)\n", l, l);
printf("l + 1 = %d (0x%x)\n", l + 1 , l + 1);

return 0;
}
/* EOF */

运行结果:

nova:signed {38} ./ex3
l = 2147483647 (0x7fffffff)
l + 1 = -2147483648 (0x80000000)

上例中变量l被赋予了32位整型数的最大正整数值,当给它加一时结果却变成了负数。

不光算数运算可能造成溢出,任何改变该整整型变量的值的操作都可能造成溢出,如下例:

/* ex4.c - various arithmetic overflows */
#include <stdio.h>

int main(void){
int l, x;

l = 0x40000000;

printf("l = %d (0x%x)\n", l, l);

x = l + 0xc0000000;
printf("l + 0xc0000000 = %d (0x%x)\n", x, x);

x = l * 0x4;
printf("l * 0x4 = %d (0x%x)\n", x, x);

x = l - 0xffffffff;
printf("l - 0xffffffff = %d (0x%x)\n", x, x);

return 0;
}
/* EOF */

结果:

nova:signed {55} ./ex4
l = 1073741824 (0x40000000)
l + 0xc0000000 = 0 (0x0)
l * 0x4 = 0 (0x0)
l - 0xffffffff = 1073741825 (0x40000001)

本例中的加法运算造成了溢出,它跟前一例情况一样,紧接着下来的乘法也造成了溢出,这两个运算其本质都是
运算结果太大而一个32位的整数无法表示而造成的溢出。下面的减法稍有不同,它造成了一个下溢而非上溢:
给一个整数保存一个比它能够表示的最小整数还小的数,也造成了回绕。利用这种方法,我们可以强制一个加法
变成减法,乘法变成除法,或则减法变成加法。

------[ 2.2.1 漏洞利用

算数溢出最可能被利用的情况之一是利用计算结果来决定将要分配缓冲区的大小。通常程序需要为一组对象分配
内存空间,因此它将把对象个数乘上单个对象大小的结果作为参数调用malloc(3) 或 calloc(3)来分配内存。
如果我们能够控制这两个操作数之一,我们就有可能让程序分配错误大小的缓冲区,如下程序片断所示:

int myfunction(int *array, int len){
int *myarray, i;

myarray = malloc(len * sizeof(int)); /* [1] */
if(myarray == NULL){
return -1;
}

for(i = 0; i < len; i++){ /* [2] */
myarray[i] = array[i];
}

return myarray;
}

这个实现错误的函数可能因为没有检查参数len而失败。在[1]处的乘法可能由于len太大而造成溢出,所以我们
可以利用它来分配一个任意长度的缓冲区。通过选择合适的len值,我们可以让程序在[2]处的循环改写超出myarray
范围的内存,这可以造成一个堆溢出,进而通过覆盖malloc的控制结构来执行任意代码,但对堆溢出的攻击超出
了本文的范围,因此在此并不会详细叙述如何进行堆溢出攻击。

再来一个例子:

int catvars(char *buf1, char *buf2, unsigned int len1,
unsigned int len2){
char mybuf[256];

if((len1 + len2) > 256){ /* [3] */
return -1;
}

memcpy(mybuf, buf1, len1); /* [4] */
memcpy(mybuf + len1, buf2, len2);

do_some_stuff(mybuf);

return 0;
}

本例中在[3]处对参数的检查可以通过给这两个参数提供合适的值从而让计算结果溢出而绕过。如:

len1 = 0x104
len2 = 0xfffffffc

这两个数相加结果是0x100(十进制256),于是在[3]处的检查可以通过,于是调用memcpy(3)就会造成
缓冲区溢出。

--[ 3 符号bug

无符号数被解释成有符号数或有符号数被解释成无符号数都可能造成符号bug。之所以会出现这种解释错误,原因
在于在计算机内部无符号和有符号数的存储方法是一样的。最近,FreeBSD和OpenBSD内核中就出现了好几个符号
处理不当的bug。

----[ 3.1 符号bug的表现形式?

符号bug具有大量的表现形式, 常见的有:

* 有符号整数用于比较
* 有符号整数用于算数运算
* 无符号整数和有符号整数比较

来一个经典的含有符号bug的程序:

int copy_something(char *buf, int len){
char kbuf[800];

if(len > sizeof(kbuf)){ /* [1] */
return -1;
}

return memcpy(kbuf, buf, len); /* [2] */
}

这段程序的问题在于[1]处的检查是有符号整数的比较,而[2]出调用memcpy时参数确是无符号数,这样通过传递
一个负的value,就有可能通过[1]处的检查,然后[2]处会被解释成无符号数,于是就会造成对kbuf的溢出。

再看一个例子:

int table[800];

int insert_in_table(int val, int pos){
if(pos > sizeof(table) / sizeof(int)){
return -1;
}

table[pos] = val;

return 0;
}

因为
table[pos] = val;
等价于
*(table + (pos * sizeof(int))) = val;
我们可以看出这里的问题在于程序并没有意识到可能有负数会参与到这个加法运算中:它主观认为(table + pos)
会比table大,所以如果给pos传递一个负数就会导致一些问题。

------[ 3.1.1 漏洞利用

这类bug易于被攻击,原因归结于当一个有符号数被解释成一个无符号数时,它可能很大。比如,-1被当成无符号数
时将会是十进制的(4,294,967,295),它是32位整数的最大值,加入这个值被用作memcpy的参数,memcpy就会试图
拷贝4G字节。很明显这可能导致断错误或破坏堆栈。Sometimes it is possible to get around this problem by passing a very low
value for the source address and hope, but this is not always possible.

----[ 3.2 整数溢出导致的符号bug

有时可能可以通过溢出一个整数使其变成一个负数。由于程序可能不会意识到这种情况,所以这样可以触发一个
符号bug.

示例

int get_two_vars(int sock, char *out, int len){
char buf1[512], buf2[512];
unsigned int size1, size2;
int size;

if(recv(sock, buf1, sizeof(buf1), 0) < 0){
return -1;
}
if(recv(sock, buf2, sizeof(buf2), 0) < 0){
return -1;
}

/* packet begins with length information */
memcpy(&size1, buf1, sizeof(int));
memcpy(&size2, buf2, sizeof(int));

size = size1 + size2; /* [1] */

if(size > len){ /* [2] */
return -1;
}

memcpy(out, buf1, size1);
memcpy(out + size1, buf2, size2);

return size;
}

这种代码可能出现在网络守护进程当中,特别是当数据长度信息也来自于网络时(换句话说就是来自于不可信任
的客户端)这段程序更容易被利用。在[1]处的假发用来检查数据长度是否会超过缓冲区的大小,但如果客户端
提供了一个合适的值就会导致加法的结果变成一个负数,比如:
size1 = 0x7fffffff
size2 = 0x7fffffff
(0x7fffffff + 0x7fffffff = 0xfffffffe (-2)).
这样在[2]处的检查就顺利地通过了,这样缓冲区out很可能被溢出。(事实上,(out + size1)可能被非法控制
指向任意内存,这段程序可能被用来修改任意内存中的值)

由整数溢出造成的这类bug利用方法与普通的符号bug一样。

--[ 4 真实代码

现实产品中有很多程序存在整数溢出和符号bug,特别是网络守护进程或操作系统内核。

----[ 4.1 整数溢出

下面这个例子(但无法被利用)摘自linux内核的一个安全模块,它运行在内核级:

int rsbac_acl_sys_group(enum rsbac_acl_group_syscall_type_t call,
union rsbac_acl_group_syscall_arg_t arg)
{
...
switch(call)
{
case ACLGS_get_group_members:
if( (arg.get_group_members.maxnum <= 0) /* [A] */
|| !arg.get_group_members.group
)
{
...
rsbac_uid_t * user_array;
rsbac_time_t * ttl_array;

user_array = vmalloc(sizeof(*user_array) *
arg.get_group_members.maxnum); /* [B] */
if(!user_array)
return -RSBAC_ENOMEM;
ttl_array = vmalloc(sizeof(*ttl_array) *
arg.get_group_members.maxnum); /* [C] */
if(!ttl_array)
{
vfree(user_array);
return -RSBAC_ENOMEM;
}

err =
rsbac_acl_get_group_members(arg.get_group_members.group,
user_array,
ttl_array,

arg.get_group_members.max
num);
...
}

此例中在[A]处的检查不能有效地防范在[B]和[C]处的整数溢出。通过传递一个足够大的arg.get_group_members.maxnum
(大于0xffffffff / 4)将会导致[B][C]处溢出,这样就可以强迫使ttl_array and user_array这个缓冲区比程序
预期的小。由于rsbac_acl_get_group_members拷贝用户控制的数据到这些缓冲区,所以有可能造成这些缓冲区溢出。
在这个例子中,程序利用vmalloc()来分配缓冲区,所以当我们意图溢出这些缓冲区时,只会导致一个错误,因此
它是不可被利用的。尽管这样,它还是提供了一个很好的例子来展示在真实的代码中整数溢出到底是个什么样子。

猜你喜欢

转载自blog.csdn.net/u012133341/article/details/79957377