表达实践:从缓冲区溢出说起

类别:软件工程 点击:0 评论:0 推荐:

 

表达实践:从缓冲区溢出说起

Spacesoft【暗夜狂沙】

    目前流行软件最出名的安全漏洞大多来自于缓冲区溢出。1999年,缓冲区溢出就占使 CERT/CC 提出建议的所有重大安全性错误的百分之五十以上。这个问题似乎由来已久又绵延不绝,大家想了各式各样的方式来避免,但是又防不胜防。解决缓冲区溢出同样没有银子弹,它需要的是更多的细致与认真。

    本文希望以这个棘手的问题为例子,探讨一些关于函数表达与函数间参数传递相关的问题。

    缓冲区溢出的原理相当的复杂。一个典型的溢出是这样出现的:程序在调用一个函数时,把函数参数、局部变量和返回指针一起压到函数堆栈里,假如运行的时候,向某个局部变量数组写入超过这个数组容量的数据,那么多出来的数据就在堆栈里越过这个数组的边界,覆盖其它变量,甚至覆盖函数的返回指针。假如我们用指向另外一个有效的函数的指针覆盖原来的返回指针,我们就改变了原来程序的运行过程。可是,原来的函数是被编译好了的啊,怎么改变它的局部变量数组的大小呢?当然是在参数上做文章了,于是对参数的设计和处理就成为了防止缓冲区溢出的第一道防线、这就是我们这篇讨论的由来。

假如希望进一步了解缓冲区溢出的原理,可以参考IBM 的DeveloperWorks 中国网站上的文章《让您的软件运行起来:实质问题和摧毁攻击》。

    为了更形象的说明我们在对付缓冲区溢出时遇到的问题,我们来描述一个需求的例子:我们要实现一个函数,从指定的ini文件中读取里面的Sections, 使用C ++语言实现,为了更加典型一点,我们规定:用字符串(char *)做参数,不能用string 类。

    好了,我们来仔细分析一下,这个需求下我们遇到的问题:我们获得的信息是指定的文件名,这是一个字符串,我们要输出的是一系列的Sections,我们也把它做成字符串输出。嗯,熟悉API 的朋友们应该想到了,这个就是传说中那个API ——GetPrivateProfileSectionNames 嘛。没错,就是它,为了专注于我们关心的函数参数问题,我们就不用别的麻烦事来影响我们的思路,这样我们要讨论的就是如何把它好好的封装起来。现在我们看到,这个函数输入的是字符串数组,输出的也是字符串数组,里面的工作就是在字符串处理中打转,已经满足典型的容易发生缓冲区溢出的条件了:与字符串数组反复打交道。

    讨论传递内存缓冲区时,最基本的问题就是谁来申请的问题,当然,不考虑垃圾回收和引用计数等运行期资源自动管理手段的话,我们只有两种选择:调用者申请或者函数本身来申请。前者的典型例子就是GetPrivateProfileSectionNames API 本身,这样的情况适用于调用者事先可以知道或者估计到缓冲区大小的情况下。后者的典型例子是API 函数NetShareGetInfo ,调用者事先不知道需要分配多大的缓冲区,只好由被调用的函数来分配。

    由被调用者进行分配的好处是不需事先了解或者估计需要多大的缓冲区,分配的缓冲区大小总是合适的,不会造成溢出(当然不是绝对的)。缺点在于调用者未必了解资源的申请方式,是LocalAlloc 还是GlobalAlloc ?不一定知道。所以未必知道如何释放这些资源。于是写被调用的函数的程序员只好另外写一个函数来供调用者释放资源,比如前面提到的NetShareGetInfo 就对应了 NetApiBufferFree 函数来释放资源。所以,在平时编程中,我们更多的是看到大家使用前面的方式:调用者申请缓冲区的资源,调用者决定何时释放它。于是大家就只好小心翼翼的处理参数,保证被调用的函数不会把大于缓冲区长度的数据写入缓冲区,导致溢出。

    前面我们说了,使用被调用者分配缓冲区的方式不会导致缓冲区溢出,既然没有溢出了,那我们这篇文章还有什么搞头?所以我们还是把我们的函数参数设计成前面一种形式。于是,我们就得到了我们的函数参数的雏形:

int MyGetPrivateProfileSectionNames(const char *pszPileName, char *pMyBuffer);

    且慢!我们讨论的是缓冲区溢出耶!在缓冲区溢出这个题目面前,这个参数表表现如何呢?

    不好,非常不好。从函数本身的角度来看,这个参数表有没有告诉我这个缓冲区有多大呢?有人说了,可以用sizeof 一个缓冲区来取得缓冲区的大小嘛。可以的吗?你去试试看?sizeof 取得的可是char * 这个指针的大小,不是缓冲区的大小!要知道,sizeof 得到的是编译期信息,不能帮助我们确定一个指针指向的缓冲区的长度和有效性。

    从调用者的角度来看呢?单看这个这个参数表的话,它事实上表达了和我们要表达的相反的含义:没有表述Buffer 的长度要求,就是说“缓冲区的长度你就不用管了”,那不就是说由被调用者管吗?假如我看代码看到这里,我就要开始找释放资源的MyBufferFree() 了:)

    语言的表达更多的含义是通过约定成俗的习惯表达出来的,假如一个表达方式为众多的人所采用,那么你使用同样的表达方式,就更容易被人理解。

    OK,那么我们再加一个参数,表示传入的缓冲区的大小。因为缓冲区大小不能为负,所以这个大小应该使用unsign 类型的数字来描述,这样别人就不会传错了。于是现在我们的函数变成了这样:

int MyGetPrivateProfileSectionNames(const char *pszPileName, char *pszMyBuffer,     DWORD dwBufferLen);

是不是变得和GetPrivateProfileSectionNames 一样了啊?嘿嘿,看来API 真是千锤百炼的好东西啊:) 何况,别忘了,使用大家都在用的表达方式,表达效果最好。这样缓冲区指针 + 缓冲区长度的参数组合,大家看得很多,也就比较容易接受。

    好了,现在要取得的信息都取得的,我们防止缓冲区溢出的表达就这样结束了吗?当然没有。

    你能保证函数的调用者每次调用都如你所愿吗?假如你不能相信你的用户,小心翼翼的防止他们的胡乱操作把你的软件弄崩溃,你又怎么能保证你的函数的每个调用者在调用的时候都不出错?而你平时写程序的时候,是怎么进行参数检查的呢?

    一个很常见的写法是这样的:

int MyGetPrivateProfileSectionNames(const char *pszPileName, char *pszMyBuffer,     DWORD dwBufferLen)
{
    if ((!pszPileName) || (!pszMyBuffer))
        return false;
       
    // do something you need
}

    不错,很结实的写法,但是,我们关心的是:它是一个良好的表达吗?

    我给出的答案是否定的。在一次失败的调用之后,调用者得到的信息是:调用失败了,返回了一个false,那么错在哪里有没有更进一步的说明,于是调用者只好一步步跟进函数里面,才能发现“哦,我传了一个空指针进来”。如果这个函数本身做的就是一些很可能失败的操作,比如调用 gethostbyname之类,那么这么一个返回错误就不易分清具体的错误原因,并且使开发人员忽略一些重要情况。

    这是一个比较典型的“过于严密的防护”的例子,这样的程序很结实,很难崩溃,同时也容易隐藏很多不易发觉的错误,藏污纳垢。比如,调用者可能在某种特殊情况下出现错误,传了一个空指针进来,假如没有做这样的防御,那么这样的错误很可能在测试阶段就以异常的形式提醒开发人员错误的所在,而加上这样严密的防御以后,那个错误很可能被一直掩盖下去,直到有一天爆发出来,让用户发疯为止。

    问题在哪里呢?问题在于:这段代码没有在关键的时候旗帜鲜明的声明自己的基本需求:“要我干活,你不能拿空指针糊弄我”。暧昧不明的返回一个false,并不能伸张自己的主张。防御代码应该尽量不要掩盖那些可能由逻辑问题带来的错误。同时,我们应该用代码把那些可能的逻辑错误尽量在编译阶段和debug 阶段显示出来。比如在这里,两个断言就可以帮助我们在debug 阶段发现参数指针为空的错误。并且清晰的表达出了这样的意思:“这两个指针绝对不能为空,不然这个函数不能运行”。不需要文档,代码自己表达了自己的需要。

int MyGetPrivateProfileSectionNames(const char *pszPileName, char *pszMyBuffer,     DWORD dwBufferLen)
{
    ASSERT(!pszPileName);
    ASSERT(!pszMyBuffer);
    
    // do something you need
}

断言的使用可以帮助我们在编译和debug 的时候发现一些“根本不应该有”的错误,但是断言不能代替正常的防御代码,比如用来处理诸如内存分配失败之类的异常情况的代码,因为断言在Release 版本是不存在的。对断言的使用时机、使用分寸以及相关的更进一步的讨论,可以参考一些关于编程“契约”的文章,比如myan 的《什么是契约——Eiffel的观点》。

    当然,我们还要进一步表达我们的需要:“不错,拿了一个有效的指针给我,可是不要以为随便什么样的指针我都要,我可不是随便的函数”。那么我们可以检查指针的有效性。比如用 IsBadWritePtr(pszMyBuffer, dwBufferLen)这个API 来做检测。应该注意:无论什么情况下什么理由,都不应该出现标明的 dwBufferLen 比 pszMyBuffer 的容量大很多,或者这个指针虽然不为空但是指向的地方不是缓冲区的情况,所以这是一个“根本不应该有”的错误。应该在测试阶段彻底的消灭之,并且不应该在发布版本为此负上额外的负担。于是这个检查我们把它包括进断言里:

ASSERT(!IsBadWritePtr(pszMyBuffer, dwBufferLen));

IsBadWritePtr 可以检查指定的缓冲区中,指定范围的所有字节,是否具有可写权限。可以用来检查缓冲区的有效性。

 

    嘿,从缓冲区溢出谈起,我们既聊到了缓冲区溢出的原理、函数参数的设计,以及如何表达自己的设计思路与函数条件。但是最后,我们的关注的焦点还在如何表达设计思想与函数限制条件上。与对具体的技术的钻研相比相比,对如何使用代码表述设计思想这点上,可能大家关注得相对少一些。也许,这也是我们越来越多的被文档压得喘不过气来,为了维护大量的注释而绞尽脑汁的缘故之一吧。

    Anyway,我仍然坚持这样一个观点:从本质上说,编程就是表达,所以适当的注意一下代码表达的方式,将有助于提供代码的可读性,帮助我们更加容易的和别人分享我们的思想和工作。

 

欢迎访问作者的个人主页:http://www.alloysoft.com

 

本文地址:http://com.8s8s.com/it/it37277.htm