引言
2014年,支持安全传输层协议的开源库 OpenSSL 曝出惊天漏洞,在其 TLS / DTLS Heartbeat 扩展中,存在一个缓冲区溢出漏洞。此即是“大名鼎鼎”的 Heartbleed(心脏出血)漏洞。
这个漏洞可以让攻击者获得经过加密的数据,还能盗取用于加密的密钥。这看起来确实有点讽刺,专注于安全防护的组件,本身却因为一个小小的漏洞而变得毫无安全感。
OpenSSL 是互联网服务的基础设施之一,Heartbleed 漏洞席卷了几乎全世界的互联网公司,由此造成的损失则不可计数。
heartbleed 漏洞
而造成这个漏洞的原因,却只是一段 C 代码中对memcpy方法的不慎使用造成的,即没有对缓存区进行正确的边界判断。
我不知道那位写出 Heartbleed 漏洞的 C 程序员,有没有为此后悔到拿头撞墙。如果他的案头有《C 陷阱与缺陷》这本书——也许已经有这本书,他能够仔细看上几个来回,这样可怕的漏洞或许就能避免了。
是的,C 程序员会掉的坑基本都写在《C 陷阱与缺陷》这本书里头了。它并不是揭示 C 语言的问题,而是要帮助程序员用好 C 语言,写出安全、稳定、可靠的程序。
写缓存数据越界是 C 代码中一种常见的问题,其他情况包括在语法、语义上会掉坑,指针的使用更是大雷区。不过这些问题都不是 C 语言自身的缺陷,大多是程序员使用不当造成。
接下来我们就从避免粗心犯错,注意使用指针,以及做好可移植三个方面,来探讨 C 程序员要如何避坑。
轻松赢了一顿饭
一般的语法错误,编译过程就能发现,看编译失败提示就好。但有一些语法问题是能通过编译的,却躲藏在隐秘的角落里。我想起自己曾经轻松赢得一顿饭的故事。
话说有一回,坐对面的小姐姐让我帮忙看个问题,她写了段百十来行的代码,但运行总是不达预期,却又找不到原因在哪里。
看她编译、运行演示一遍,好像真像她说的那样,代码看着都对,但运行结果就是不对。不能在小姐姐面前丢了份儿,我干脆拉过代码来一行一行地看。
忽然眼前似有一道金光闪过,我看到了问题所在,故意卖个关子:“我已经看出来哪里不对了,你再仔细看看吧。”
小姐姐有些急了:“不可能,我都看了一上午了,肯定不是我代码的问题,是系统环境的问题吧?”
这么一激,我脱口而出道:“要是我说出来,你请我吃饭怎么样?”
“没问题,一定请你。” 小姐姐倒也爽快。
我把光标拉到下面的代码上:
for (int i=0; i<N; i++);
do_something();小姐姐认真一看,捂脸认输,叹道:“我还以为是个多难的问题,太便宜你啦。”
问题就出在这条语句最后的分号上。单看这一句,问题很显眼,for 循环语句后的分号是多余的,它造成循环空转,do_something()方法得不到运行。
粗浅分析,小姐姐看不到这个问题,是因为存在心理盲区,当它混在一百多行代码中时,眼睛看到不等于脑子也看得到。通常这是程序员的手误,但不是自我暗示下次更小心就有用的。
更好的办法,可能还是改变一下代码风格。因为 for 循环语句的规则是不加花括号,则循环体执行的就是紧接 for 的下一条语句。程序员写代码时一般是手里写着当前的,脑子里想着下一句的。碰上循环体只有一条语句的,for 语句敲到最后,很容易随手带一个分号。
所以强制 for 循环后都使用花括号,那么就不用分心去想了,反正多打两个字符占不了多少时间,却可以形成肌肉记忆,避免这种看起来是粗心的错误。
for (int i=0; i<N; i++) { // 有了花括号,即使多打分号,也容易看得出来
do_something();
}除了语法、语义问题,结合书中的内容,现给出下面几条良心建议:
- 包括if, switch, while等条件判断或者循环语句时,也建议都给加上花括号。这样通过统一代码风格的方式,尽量消除分心所造成的手误。
- 做比较判断时,将字面量写在变量之前。例如if(100 == x),这可以避免写成if(x = 100)这样的手误仍然通过编译。
- 使用边界明确的内存复制方法,避开缓冲区溢出问题。例如禁止使用sprintf(),strcpy()这类方法,而使用snprintf(),strncpy(),strncat()。
做好两方面的事,不再被指针虐
一个 C 程序员成长起来的标志,就是他真切懂得了被指针虐过的痛。话说指针这个概念在 C 语言中并不难理解,但用起来的时候,就是各种问题满天飞。
指针使用相关的问题有这么三类:
- 野指针:指针未初始化就使用,或者指向未知区域。
- 悬垂指针:指针指向的内存被释放,但却未置空。
- 越界访问:遍历数组或者链表时,超出了边界,造成非法访问。
上述问题导致的后果,要么数据内容异常,要么直接报Segmentation fault (core dumped)崩溃退出,这个让人闻风丧胆的提示,就曾是我心底的痛。
要在编程中避开上面这些坑,我们从两个方面来说明,一是指针与数组的操作,二是指针操作动态内存的注意事项。
指针经常会用来对数组进行访问,以获得操作上的便利性。如果对数组的特性不熟悉,就会出错却不自知。我们先了解一下数组的基本特点:
- C 语言中只有一维数组,数组元素可以是任意类型的对象。
- 对于一个数组,只能做两件事:确定该数组的大小,以及获得指向该数组下标为0的元素的指针。需要注意的是,数组首元素指针是个指针常量。
这给我们的启示就是,C 语言中的多维数组,是通过一维数组中包含另一个数组元素的方式给模拟出来的;对于数组的任何下标式操作,都能转化为同等功能的指针操作。
下面通过代码说明指针对数组的操作:
int array[100]; // 声明有100个元素的整型数组,下标是0~99
int *p = array; // 声明整型指针p,指向数组首元素array
*p = 123; // 等同于array[0] = 123
p++; // 指针移动到数组下一个元素,即a[1]的位置
*p = 456; // 等同于array[1] 456使用指针操作数组易出错的地方,是p++这一步可能会被误解为是只偏移一个字节,有同学会错误地使用p = p + sizeof(int)来实现。
指针对动态内存的操作引发的问题,是 C 语言中最为幽深晦暗的深坑,结合书中的内容,我总结出以下几条代码编写的原则:
- 内存在哪里分配,就在哪里释放。即将malloc()与free()尽量置于同一个模块或者抽象层之内,而不要分散于不同层级中。
- 调用malloc()之后立即对内存初始化。即不要假设内存分配函数会有初始化动作,小心驶得万年船。
- 调用free()之后,立即给指针变量赋值NULL。不管后面的代码用不用这个变量,简单的一个操作,就能有效避免野指针的问题。
- 要检测内存分配错误。即调用malloc()之后,要判断指针变量是否为NULL。不要假设内存分配永远会成功,发生内存泄漏时,它的调用就可能会失败。
做好可移植,要小心的几件事
C 程序员要说服自己相信一件事,就是自己写的程序如果在一种平台上运行良好,那它移植到另一个平台上,肯定会有问题。
这就是可移植工作的特点,完美地符合墨菲定律。最诡异的现象就是“明明在我这儿运行得很正常,跑你那去怎么就出错了呢?”
我在工作中就掉过一次坑。那是我将一个功能库移植到嵌入式平台上去,这个库在服务端已经稳定运行一段时间了。我想应该没什么问题,就直接交叉编译,然后测试。
结果它总是跑不稳定,有时候正常,有时候出错,飘忽不定,不可捉摸。没办法,问题要解决,嵌入式环境受限,gdb无法运行,只得在代码里塞满了打印语句。最后发现是个整型变量的值不对劲。
这个声明为unsigned long类型的变量,值超过4294967295就不对。结果用sizeof一探测,才发现服务器环境是64位,sizeof(long) = 8,嵌入式环境是32位的,sizeof(long) = 4。(这是个很low的问题,早期懵懂无知,各位见笑了)
尽管 ANSI C 竭力保证在不同平台上,相同的类型和方法,预期使用结果是一致的。但架不住 C 编译器厂商的各自为战,总在一些很小的细节上存在差别。这样的差别,就是名副其实的陷阱。
一个螺栓没对准,宇宙飞船都会爆炸。幸好《C 陷阱与缺陷》中将可移植会遇到的暗坑列举出来,我挑选比较有代表性的进行说明。
标识符名称不要与库函数重名
书中以malloc()方法为例,即为了追踪内存分配而自定义Malloc()方法又封装了一次。虽然大小写有差别,但在某些特殊平台上可能真就不区分大小写,这就不是个好主意。
解决办法是在自定义方法名前,添加独特不易重复的前缀名。例如工程名全称是cactus_project,那么取其缩写再和方法名拼接,即可为cac_malloc()。
统一预定义整型数类型
这就是为了解决前文中我所犯过的错误,在不同平台上long的类型是不一样的问题。可以使用#define进行宏定义,更建议使用typedef进行类型预定义。
以下示例则可以确保在所有平台上,int64就是64位有符号整型数,而uint64就是64位无符号整型数:
typedef long long int int64;
typedef unsigned long long int uint64;小心处理NULL
如果字符型指针置为NULL,表示空指针状态,对其进行读访问时存在不确定性行为。这种情况一般出现在打印调试信息的语句里。
简单示例如下:
char *p = NULL;
printf("result: %s\n", p);有的平台能正常工作,只是显示result: (null),而有的平台则会直接提示段错误并退出。
最好的应对方法还是在使用指针之前,要进行一次非空判断。当然,我也能理解在处理打印语句时,直接显示null也是有意义的,这时候又要增加判断分支,还要额外处理,C 程序员们不一定会乐意。
不过从程序可移植性来考虑,写代码时谨慎一些,就不用掉到这样无谓的坑里,节约了大量的修改调试成本,还是相当值得的。
结语
《C 陷阱与缺陷》的作者是 Andrew Koenig ,他在1977年加入贝尔实验室,之后开始从事 C 语言研究。在 C/C++ 领域,Andrew 大神星光闪耀,他编写的数百篇研究论文,给多少程序员带来启示,也帮助他们避开一个又一个深坑。
Andrew Koenig
他将自己在使用 C 语言时遇到的问题,进行整理之后在1985年通过论文发表出来。出乎意料的是,随后共有2000多人,向贝尔实验室的图书馆索取该论文的副本。Andrew 大神便将这篇论文扩充,于是就有了历经37年而长盛不衰的《C 陷阱与缺陷》。
刚入行的 C 程序员,如果你正掉在坑里苦苦挣扎,那么赶紧拿起这本书帮自己爬出来吧;入行多年还没看过这本书的老鸟们,那更不要等了,相信拿起来随便一翻都能想起一段血泪往事,要以此书警醒自己不要再掉到更幽暗的坑里了。
案头常备此书,bug 再无影踪!
<script type="text/javascript" src="//mp.toutiao.com/mp/agw/mass_profit/pc_product_promotions_js?item_id=7113752682619306507"></script>