好久没有更新博客了,一方面是最近一段时间很忙很忙,策划需求不明确,开会喷案子,修改再修改,最后形成定稿,不容易;另一方面是负责了比较重要的系统,需要花更多的时间去分析,整理方案,评估更好的方案和未考虑到的风险,还要不断的完善和修改策划丢过来的需求,然后写文档(会在周会上,可能拿出来分享,发现问题),编码,自测,联调,回归测试等等。这篇主要是总结阅读书籍:《深入理解计算机系统》和《C/C++安全编码》上的一些点和面,这些问题在工作中也常会碰见,虽然很少会发生那些错误。
一般内存错误都是因为读或写非法地址造成的,写比读严重很多,运气好点造成宕机早点发现问题,运气不好会修改不该修改的数据而不被发现,那样排查问题就比较困难。
1:间接引用坏指针
由于进程的虚拟地址空间中存在较大的洞,没有映射到任何有意义的数据,如果程序中解引用它,那么就会段错误:
比如语句scanf("%d", &val)被无意的写成了scanf("%d", val),编译的时候是不能发现这种隐晦的错误,那么scanf会把val的内容解释为一个地址,去写该地址,如果该地址不代表任何合法地址,那么试图写入就会段错误。
2:读写未初始化的变量
主要发生在申请堆内存或栈空间时,定义的变量一定要初始化,否则会读到上一次使用后脏的数据位,因为在释放栈空间或者堆内存时,并不会把对应的位模式清零,在使用该变量时在初始化它。
3:缓冲区溢出
当使用一些C库函数从源缓冲区赋值内容到目标缓冲区时,需要注意复制的长度,相反会多写后面的内存影响其他数据;也可能会乱写函数的返回地址和帧指针(32bit是ebp),那么会造成漏洞使得函数跳转到攻击者指定的地方[后期会专门写一篇来介绍缓冲区溢出的危害]。
4:越界
主要是没有正确设置好截止条件,造成写了不应该写的内存,一般引起越界的情况包括差一错误,没有正确以空字符结尾的字符数组等,比如
int32_t HandleArray()
{
int32_t iArrList[5];
for (int i = 0; i <= 5; ++i)
{
iArrList[i] = 100;
}
}
上门这种情况可能会写乱ebp的内容,退栈时使得esp指向其他地方,或者当i <= 6时,把函数地址给覆盖了,会造成3情况中的问题,当然在栈上分配空间规则看编译器,也许不会出现那些问题;
这里有个例子,具体问题是来自一位实习生面试腾讯时面试官问的,具体是"有一个类指针,指向类实例化的对象,在这个对象程序的运行过程中,程序崩溃了,后来发现是这个类指针的虚函数表被破坏了,现在如何定位这个问题"[https://www.zhihu.com/question/43416744],下面有些不错的回答(看了笔者的描述,好像有些问题)。如果让我来分析:
class CBase
{
public:
virtual int foo() {...}
};
CBase * pBase = new CBase; //暂不考虑引用
pBase->foo(); //1
这样类的每个实例,都会在实例的首或者尾部有个指针,具体多少位看机器的架构,指向某个地址,该指针被称为vptr,某地址所代表的是一个虚函数表,表里的每个项是虚函数的地址和其他类型信息;这个虚函数表存放在只读的.rodata区(read only data),受操作系统保护,那么,题中已经说明了该虚函数表【非对象的vptr内容被非法修改】被破坏了(笔者描述错误?不是对象的vptr?),导致程序宕机了。由于调用函数会被名字重整,故1处会被改写大概如下形式:(*pBase->vptr[1])(pBase),在没有调用前,就宕机了。
这里有两种可能,pBase的值是该对象的地址,pBase分配在栈上,对象分配在堆上[由于虚机制只能以引用或指针触发(不考虑如int *,void *等)],很可能因为越界导致pBase的内容被非法改写,如果此时pBase为非法地址,那么pBase->vptr会宕机,但不是笔者描述的虚函数表被破坏了;如果pBase没有被非法改写,而它所代表的对象的内容被非法改写了[由于发生在堆上,下面会分析此类问题的原因],那么pBase->vptr的值(地址)是有问题的,然后在这个有问题地址的偏移量(+sizeof(函数指针))获得有问题的值,去解引用,即调用函数,就会宕机。这两个问题都与虚函数表无关,因为还没有到那一步。
那么虚函数表被破坏,即.rodata被非法改写了,那么在调用前就应该由操作系统保护而触发宕机,而不是到在pBase->foo()时宕机。所以,这个问题要么是笔者写错了,要么是笔者没有理解面试官的提问,再要么是想面试者回答这个bug不是因为调用虚函数而引起的(已经宕机了,程序已经休息了)。
总结:数组越界错误很难排查,可能出现问题了,已经里错误代码很远了,也可能很难复现,比如在多线程中。
5:引用指针,而不是它所指向的对象
主要发生在没有弄清楚C语言操作符的优先级和结合性,就会错误的操作指针,而不是指针所指向的对象,比如 int * pInt = &iValue;
本意是想对pInt所指向的内容减一:(*pInt)--,却写成了*pInt--,由于这两个操作符的优先级相同,故从右向左结合,那么语句”*pInt--“等价与”*(pInt--)",后者表示对指针移动到pInt + sizeof(int)位置再解引用,那么就造成了内存非法访问,而不是修改最初的地址,所以当写代码时最好加上括号。
6:引用不存在的变量
如果返回指向函数局部变量地址的指针或引用时就会出现这个问题,比如:
int * GetWrongPointer()
{
int iValue = 10;
return & iValue;
}
由于iValue在函数调用时在栈上分配的,会有个地址,当函数返回栈回退时,该地址所代表的已经不是一个合法的变量了,虽然地址是有效的;如果该地址是个类实例,那么会调用析构函数,已经是残骸了,脏的位模式,一切的访问都是未定义的。如果该地址正好被其他函数的局部变量使用,那么就发生了非法访问。
7:引用已经释放的内存
在malloc,new获得的指针后,调用了相对应的free,delete后,没有把该指针赋值为NULL,free和delete本身不会做p =
NULL这件事。如果已经释放的内存重新分配,那么在使用p访问或者修改相应的内存,会引起难排查的bugs,如果没有分配,可能会破坏内存管理器所使用
的数据结构。
如 int * p = new int(100);
delete p; //最好再写一条 p = NULL
int * q = new(10);
*p = 200;
即使用了已经释放的内存,p的值还是个有效地址,此时解引用p可能修改了q值的内存地址处的内容,造成非法访问了。
8:内存泄露
主要是那些有限资源,向系统申请用完后需要归还的资源,但没有归还。平时中指的较多的是堆内存,随着进程的运行,申请内存的次数越来越多,这个在初期较难发现,在最糟糕的情况下,会占用整个虚拟地址空间,导致进程无法正常工作。
对于内存泄露定位的问题,使用valgrind工具可以发现出来,或者code review,或者gdb pid,然后shell pmap pid,分析,我自己没有在工作中实践过。
http://blog.csdn.net/lw1a2/article/details/5598006
9:多次释放同一块内存
比如 int * p = new int;
delete p;
//more code
delete p;
一方面会造成内存管理器的数据结构,另一方面造成可被利用的漏洞。
10:没有正确的检查分配失败
使用malloc分配内存,失败时返回NULL,用户代码需要检查,而new分配时,要么成功,要么跑出异常,所以下面用法:
int * p = new int;
if (p != NULL)
{
//more code
}
是不正确的,应该这样使用: int * p = new(std::nothrow) int;在失败时返回NULL而不是个异常。
11:不正确的内存分配和释放配对使用
如用malloc分配的使用delete释放,用new分配的用free释放,用new[]分配的用delete释放等等,会引起一系列的问题。
malloc只申请内存不初始化,而new申请并调用构造函数,后者底层实现也是使用malloc来分配的,如果使用new申请而free释放就会造成内存泄露什么的,不会调用析构函数;在分配对象数组时,会多申请空间存放管理信息比如个数,为后来的delete []正确调用;如果申请一个对象而使用delete[],就可能破坏内存管理器的数据结构,破坏其他内存,导致对内存损坏。
还有重载operator new的问题