前言
笔者在阅读《深入理解计算机系统》时,理解了为何C语言被称为不安全语言,C语言除了指针非常灵活可能会导致大量漏洞之外,C语言的无符号数据也可能带来致命性危害。
扩展一个数据的表示
将一个无符号数转换为更大的数据类型,我们只需要在表示的开头添加0,这种运算称之为0扩展(zero extension),将一个补码数字转换为一个更大的数据类型可以执行符号扩展(sign extension)。
因此我们可以看到,无符号表示的数和补码表示的数扩展方式不一样,这就会导致下面这种情况:
sx = -12345: cf c7
usx = 53191: cf c7
x = -12345: ff ff cf c7
ux = 53191: 00 00 cf c7
尽管-12345的补码表示和53191的无符号表示在16位字长时是相同的,但是在32位字长时却是不同的。前者使用的是符号拓展——最开头加了16位,都是最高有效位1,后者开头使用了16个0来扩展。
在书中有符号扩展,数值不变的数学证明过程,不感兴趣的小伙伴可以跳过这一部分。
$B2T_{w+k}([x_{w-1},…,x_{w-1},x_{w-1},x_{w-2},…x_0])$ = $B2T_w([x_{w-1},x_{w-2},…,x_0])$
上面这一条表达式左边增加了K个$x_{w-1}$,下面的证明是对k进行的归纳,即,我们只需证明拓展1次,即可通过数学归纳法证明上式正确
要证明:
$B2T_{w+1}([x_{w-1},x_{w-1},x_{w-2},…,x_0])$ = $B2T_w([x_{w-1},x_{w-2},…,x_0])$
展开左边得到:
$$ \begin{aligned} B2T_{w+1}([x_{w-1},x_{w-1},x_{w-2},...,x_0]) &=\ -x_{w-1}2^{w}+\sum\limits^{w-1}_{i=0}x_{i}2^{i}\\ &=\ -x_{w-1}2^{w} + x_{w-1}2^{w-1} + \sum\limits^{w-2}_{i=0}x_{i}2^{i}\\ &=\ -x_{w-1}(2^{w} - 2^{w-1}) + \sum\limits^{w-2}_{i=0}x_{i}2^{i}\\ &=\ B2T_w([x_{w-1},x_{w-2},...,x_0])\\ \end{aligned} $$不同大小的有无符号转变
当把short
转换成unsigned
时,我们先要改变大小,之后再完成从有符号到无符号的转变。
unsigned sx
= unsigned int sx
因为是先改变大小,因此是先按照有符号的规则进行符号扩展,然后再从有符号转变成无符号。
截断数字
当我们强制类型转换将int
修改为short
,是直接将数字高位的相应部分直接截断,因此截断一个数字可能会改变它的值(溢出的部分没了)。截断它到k位的结果就相当于计算 $x mode 2^k$。
关于有符号数与无符号数
有符号数到无符号数的隐式强制类型转换导致了某些非直观的行为。而这些非直观的特性经常导致程序错误,并且这种包含隐式强制类型转换细微差别的错误很难被发现。
下面给出两个例题:
根据上一篇文章得知,在无符号数与符号数之间的比较时,通通转换成无符号数比较。
可以看出,当usigned length
被设置为0时,在length - 1
中,无符号数的减法是通过模运算实现的,因此不会得到负数,该表达式会变成UINT_MAX - 1
(在32位系统中为0xFFFFFFFE,即4294967294),这么大的数进行遍历时,a数组的长度肯定是不够的,因此会遇到存储器错误,修改只需要将unsigned length
改为int length
即可
在此例题中,因为strlen
函数返回值的数据类型为size_t
即unsigned int
,因此涉及到无符号数据类型是要小心是否会转变成有符号类型,如果有,在转变时是否会发生与预期的数值产生偏差。
在此处,strlen(s)
与strlen(t)
都是无符号类型,且进行大小比较,因此整个表达式都会转换成无符号类型进行比较。
在无符号类型中,0
就是最小值,无论strlen(s)
与strlen(t)
谁大谁小,相减也不会小于0,因此这个表达式只有在两者相等时为false
,否则皆为true
。
函数getpreername
的安全漏洞
2002 年,从事 FreeBSD 开源操作系统项目的程序员意识到,他们对 getpeername 函数的实现存在安全漏洞。代码的简化版本如下 :
在这段代码里,第 7 行给出的是库函数 memcpy 的原型,这个函数是要将一段指定长度为 n的字节从存储器的一个区域复制到另一个区域。
从第 14 行开始的函数 copy_from_kernel 是要将一些操作系统内核维护的数据复制到指定的用户可以访问的存储器区域。对用户来说,大多数内核维护的数据结构应该是不可读的,因为这些数据结构可能包含其他用户和系统上运行的其他作业的敏感信息,但是显示为 kbuf 的区域是用户可以读的。参数 maxlen 给出的是分配给用户的缓冲区的长度,这个缓冲区是用参数user_dest 指示的。然后,第 16 行的计算确保复制的字节数据不会超出源或者目标缓冲区可用的范围。
不过,假设有些怀有恶意的程序员在调用 copy_from_kernel 的代码中对 maxlen 使用了负数值,那么,第 16 行的最小值计算会把这个值赋给 len,然后 len 会作为参数 n 被传递给 memcpy。不过,请注意参数 n 是被声明为数据类型 size_t 的。这个数据类型是在库文件stdio.h 中(通过 typedef)被声明的。典型地,在 32 位机器上被定义为 unsigned int。既然参数 n 是无符号的,那么 memcpy 会把它当作一个非常大的正整数,并且试图将这样多字节的数据从内核区域复制到用户的缓冲区。虽然复制这么多字节(至少 231 个)实际上不会完成,因为程序会遇到进程中非法地址的错误,但是程序还是能读到没有被授权的内核存储器区域。
我们可以看到,这个问题是由于数据类型的不匹配造成的 :在一个地方,长度参数是有符号数 ;而另一个地方,它又是无符号数。正如这个例子表明的那样,这样的不匹配会成为缺陷的原因,甚至会导致安全漏洞。幸运的是,还没有案例报告有程序员在 FreeBSD 上利用了这个漏洞。他们发布了一个安全建议,“FreeBSD-SA-02:38.signed-error”,建议系统管理员如何应用补丁消除这个漏洞。要修正这个缺陷,只要将 copy_from_kernel 的参数 maxlen 声明为类型 size_t,也就是与 memcpy 的参数 n 一致。同时,我们也应该将本地变量 len 和返回值声明为 size_t。
实际上,除了C语言,很少有语言支持无符号整数,很明显,设计者们认为它们带来的麻烦要比益处多得多。
但当我们想把字仅仅看作时位的集合,并且没有任何数字意义时,无符号数值是非常有用的。例如,往一个字中放入描述各种布尔条件的标记时,就是这样。地址自然地就是无符号的,所以系统程序员发现无符号类型是很有帮助的。当实现模运算和多精度运算的数学包时,数字是由字的数组来表示的,无符号值也会非常有用。