写在前面:
一些开发人员可能对检查不屑一顾:他们故意不检查malloc函数是否分配了内存。他们的推理很简单——他们认为会有足够的记忆。如果没有足够的内存来完成操作,请让程序崩溃。似乎是一个糟糕的事实。
注意。在本文中,malloc 函数下将暗示问题不仅与这个特定函数有关,还与 calloc、realloc、_aligned_malloc、_recalloc、strdup 等有关。我不想用所有这些函数名称弄乱文章。所有这些函数的共同点是它们可以返回空指针。
目录
malloc
你需要自己检查
空指针取消引用是未定义的行为
空指针取消引用是一个漏洞
在哪里保证将取消引用恰好一个空指针?
内存集以直接顺序填充内存的保证在哪里?
结论
malloc
如果 malloc 函数无法分配内存缓冲区,它将返回 NULL。任何正常的程序都应该检查 malloc 函数返回的指针,并适当地处理无法分配内存的情况。
不幸的是,许多程序员忽略了检查指针,有时他们故意不检查内存是否已分配。他们的理由如下:
如果 malloc 函数无法分配内存,则程序不太可能继续正常运行。最有可能的是,内存不足以执行其他操作,因此为什么要为内存分配错误而烦恼。空指针对内存的第一个寻址会导致在 Windows 中生成结构化异常。当涉及到类Unix系统时,该过程接收SIGSEGV [RU]信号。结果,程序崩溃,这是完全可以接受的。没有记忆,没有痛苦。或者,您可以捕获结构化异常/信号,并以更集中的方式处理空指针的取消引用。这比写数千张支票更方便。
我不是在编造这个。我和一些人交谈过,他们认为这种方法是合适的,并且有意识地从不检查malloc函数返回的结果。
顺便说一句,开发人员还有另一个借口,为什么他们不进行检查。malloc 函数只保留内存,但不保证当我们开始使用分配的内存缓冲区时会有足够的物理内存。因此,如果仍然没有保证,为什么要进行检查?例如,EFL Core 库的开发人员之一 Carsten Haitzler 解释了为什么我在库代码中没有检查的情况下计算了 500 多个片段。以下是他对文章的评论:
这是一个普遍的共识,至少在Linux上,这一直是我们的主要关注点,很长一段时间是我们的唯一目标,malloc/calloc/realloc的返回值不能被信任,特别是小的时候。Linux默认会过度使用内存。这意味着您获得了新的内存,但内核还没有实际分配实际的物理内存页给它。只有虚拟空间。除非你碰了它。如果内核不能服务这个请求,你的程序无论如何都会崩溃,试图访问一个看起来像有效指针的内存。因此,总的来说,检查allocs的返回值的价值很小,至少在Linux上是这样的。有时我们这样做……有时不是。但是一般情况下,返回值不能被信任,除非它是非常大的内存,并且你的alloc永远不会被服务——例如,你的alloc根本不适合虚拟地址空间(有时在32位上发生)。是的,过度投入是可以调整的,但它的代价是大多数人都不想付出的,甚至没有人知道他们可以调整。其次,如果分配一小块内存失败——例如一个链表节点……实际上,如果返回NULL…坠毁是你能做的最好的事情。你的内存很低,可能会崩溃,像glib对g_malloc那样调用abort(),因为如果你不能分配20-40字节…你的系统无论如何都会崩溃,因为你已经没有工作内存了。这里我说的不是微型嵌入式系统,而是具有虚拟内存和几兆字节内存等的大型机器,这一直是我们的目标。我可以理解为什么PVS-Studio不喜欢这样。严格来说,它实际上是正确的,但实际上,在实际情况下,花在处理这些东西上的代码是一种代码浪费。稍后我会详细介绍。
开发人员的给定推理是不正确的。下面,我将详细解释原因。
你需要自己检查
这里同时有四个原因,每个原因都足以证明在调用malloc函数后写检查。如果有人从你的团队不写检查,让他阅读本文。
在我开始之前,这里有一个小的理论参考,为什么发生空指针的解引用会发生结构性异常或信号。这对进一步讲故事很重要。
在地址空间的开头,操作系统保护一个或多个内存页。这允许通过空指针或值接近0的指针来检测内存寻址的错误。
在各种操作系统中,为这些目的保留的内存数量不同。此外,在某些操作系统中,这个值是可配置的。因此,调用特定字节数的内存预留是没有意义的。让我提醒您,在Linux系统中,标准值是64Kb。
重要的是,当向空指针添加任何足够大的数字时,您可能会“删除”控制内存页,并意外地进入不受写入保护的页。因此,可能会损坏一些数据。操作系统不会注意到这一点,也不会产生任何信号/异常。
请注意。如果我们讨论嵌入式系统,可能没有任何内存保护来防止由空地址写入。有些系统内存很低,所有的内存都用来存储数据。然而,具有少量RAM的系统很可能没有动态内存管理,因此也没有malloc函数。
准备好你的咖啡,让我们开始吧!
空指针取消引用是未定义的行为
就 C 和C++语言而言,空指针取消引用会导致未定义的行为。当调用未定义的行为时,任何事情都可能发生。不要假设您知道如果发生 nullptr 取消引用,程序将如何运行。现代编译器利用了认真的优化。因此,有时无法预测特定代码错误将如何表现。
程序的未定义行为非常令人讨厌。应避免代码中未定义的行为。
不要以为你能使用结构化异常处理程序(Windows中的SEH)或信号(在类UNIX系统中)来处理空指针取消引用。如果发生了空指针取消引用,则程序工作已经中断,任何事情都可能发生。让我们看一个抽象的例子,为什么我们不能依赖 SEH 处理程序等。
size_t *ptr = (size_t *)malloc(sizeof(size_t) * N * 2);
for (size_t i = 0; i != N; ++i)
{
ptr[i] = i;
ptr[N * 2 - i - 1] = i;
}
此代码填充从边缘到中心的数组。元素值向中心增加。我在 1 分钟内想出了这个例子,所以不要猜测为什么有人需要这样的数组。我什至不认识我自己。对我来说,相邻行中的记录发生在数组的开头和末尾的某个地方很重要。有时你在实际任务中需要这样的东西,当我们到达第 4 个原因时,我们将查看实际代码。
让我们再次仔细看看这两行:
ptr[i] = i;
ptr[N * 2 - i - 1] = i;
从程序员的角度来看,在循环开始时,ptr[0] 元素中会出现一条记录 — 将出现结构化异常/信号。它会被处理,一切都会好起来的。
但是,编译器可能会出于某些优化目的交换赋值。它拥有这样做的所有权利。根据编译器,如果指针被取消引用,则它不能等于 nullptr。如果指针为 null,则它是未定义的行为,编译器不需要考虑优化的后果。
因此,编译器可能会决定,出于优化目的,按如下方式执行赋值更有利可图:
ptr[N * 2 - i - 1] = i;
ptr[i] = i;
因此,在开始时,将通过 ((size_t *)nullptr)[N * 2 - 0 - 1] 地址进行记录。如果值 N 足够大,则内存开头的受保护页面将被“跳过”,并且 i 变量的值可以写入任何可用于写入的单元格中。总的来说,一些数据将被损坏。
只有在该地址之后,才会执行 ((size_t *)nullptr)[0] 地址的赋值。操作系统将注意到尝试写入它控制的区域,并将生成信号/异常。
程序可以处理此结构异常/信号。但为时已晚。在内存中的某个地方,有损坏的数据。此外,目前尚不清楚哪些数据已损坏以及可能产生什么后果!
编译器是否应该为交换赋值操作负责?不。程序员允许取消引用空指针,从而引导程序处于未定义的行为状态。在这种特殊情况下,程序的未定义行为将是数据在内存中的某个地方损坏。
结论:
遵循以下原则:
- 任何空指针解引用都是程序的未定义行为。没有所谓的“无害的”未定义的行为。任何未定义的行为是不可接受的。
- 如果没有事先检查,不允许对malloc函数及其类似物返回的指针进行解引用。不要依赖任何其他方法来拦截空指针的解引用。只使用老式的if运算符。
空指针取消引用是一个漏洞
一些开发人员根本不认为是错误,其他人则认为是漏洞。这是空指针取消引用时发生的确切情况。
在许多项目中,如果程序由于取消引用空指针而崩溃,或者如果使用信号拦截/结构异常以某种常规方式处理错误,这是可以接受的。
在其他应用程序中,空指针取消引用表示一种可用于应用层 DoS 攻击的潜在漏洞。程序或其中一个执行线程不会正常处理内存不足,而是终止其工作。这可能会导致数据丢失、数据完整性等。
下面是一个示例。有这样一个程序,如Ytnef,用于解码TNEF线程,例如,在Outlook中创建。调用calloc后没有检查被认为是CVE-2017-6298漏洞。
所有可能包含空指针取消引用的固定片段大致相同:
vl->data = calloc(vl->size, sizeof(WORD));
temp_word = SwapWord((BYTE*)d, sizeof(WORD));
memcpy(vl->data, &temp_word, vl->size);
如果您正在开发的应用程序不是非常重要,并且在工作期间崩溃不是问题,那么是的 - 不要编写检查。
但是,如果您正在开发真正的软件项目或库,则不进行检查是不可接受的!
如果要创建库,请注意,在某些应用程序中,取消引用空指针是一个漏洞。您必须处理内存分配错误并正确返回有关失败的信息。
在哪里保证将取消引用恰好一个空指针?
那些懒得写检查的人,出于某种原因认为取消引用正好影响空指针。是的,这种情况经常发生。但是程序员能够为整个应用程序的代码担保吗?我肯定没有。
我将用实际例子来说明我的意思。例如,让我们看看在Chromium中使用的LLVM-subzero库的代码片段。
void StringMapImpl::init(unsigned InitSize) {
assert((InitSize & (InitSize-1)) == 0 &&
"Init Size must be a power of 2 or zero!");
NumBuckets = InitSize ? InitSize : 16;
NumItems = 0;
NumTombstones = 0;
TheTable = (StringMapEntryBase **)
calloc(NumBuckets+1,
sizeof(StringMapEntryBase **) +
sizeof(unsigned));
// Allocate one extra bucket, set it to look filled
// so the iterators stop at end.
TheTable[NumBuckets] = (StringMapEntryBase*)2;
}
在分配内存缓冲区后,TheTable[NumBuckets] 单元格中将立即发生一条记录。如果变量 NumBuckets 的值足够大,我们将污染一些数据,带来不可预测的后果。在这种损害之后,推测程序将如何运行是没有意义的。可能会有最意想不到的后果。
当库开发人员不检查调用malloc函数的结果时,他们明白自己在做什么。恐怕他们低估了这种方法的危险性。例如,让我们看一下 EFL 库中的以下代码片段:
static void
st_collections_group_parts_part_description_filter_data(void)
{
....
filter->data_count++;
array = realloc(filter->data,
sizeof(Edje_Part_Description_Spec_Filter_Data) *
filter->data_count);
array[filter->data_count - 1].name = name;
array[filter->data_count - 1].value = value;
filter->data = array;
}
这里我们有一个典型的情况:缓冲区中没有足够的空间来存储数据,应该增加。为了增加缓冲区的大小,使用了 realloc 函数,该函数可能会返回 NULL。
如果发生这种情况,由于空指针取消引用,不一定会发生结构化异常/信号。让我们看一下这些行:
array[filter->data_count - 1].name = name;
array[filter->data_count - 1].value = value;
如果 filter->data_count 变量的值足够大,则这些值将被写入一个奇怪的地址。
在内存中,某些数据将损坏,但程序仍会运行。后果是不可预测的,肯定不会有好处。
结论:
我再次问这个问题:“哪里能保证对空指针的解引用会发生?”没有这样的保证。在开发或修改代码时,不可能记住最近考虑过的细微差别。你可以很容易地在内存中弄乱一些东西,而程序将继续执行,因为什么都没有发生。
编写可靠和正确的代码的唯一方法是始终检查malloc函数返回的结果。检查一下,少写点bug。
内存集以直接顺序填充内存的保证在哪里?
会有人说这样的话:
我非常了解 realloc 和文章中写的其他一切。但我是专业人士,在分配内存时,我只是立即使用内存集用零填充它。必要时,我使用支票。但我不会在每个 malloc 之后编写额外的检查。
通常,在缓冲区分配后立即填充内存是一个很奇怪的想法。这很奇怪,因为有一个calloc函数。然而,人们经常这样做。你不需要看很远就能找到例子,这是来自WebRTC库的代码:
int Resampler::Reset(int inFreq, int outFreq, size_t num_channels) {
....
state1_ = malloc(8 * sizeof(int32_t));
memset(state1_, 0, 8 * sizeof(int32_t));
....
}
分配内存,然后用零填充缓冲区。这是一种非常普遍的做法,尽管实际上,使用calloc可以将两条线减少为一条。不过这并不重要。
最主要的是,即使是这样的代码也是不安全的!memset 函数不需要从头开始填充内存,从而导致空指针取消引用。
memset 函数有权从末尾开始填充缓冲区。如果分配了大量缓冲区,则可以清除一些有用的数据。是的,在填充内存的同时,memset 函数最终将到达受保护的页面,并且操作系统将生成结构异常/信号。但是,处理它们不再有意义。到这个时候,一大块内存将被破坏——程序的后续工作将是不可预测的。
读者可能会争辩说,这一切都纯粹是理论上的。是的,memset 函数理论上可以从缓冲区的末尾开始填充缓冲区,但实际上,没有人会以这种方式实现此函数。
void *memset(void *dest, int c, size_t n)
{
unsigned char *s = dest;
size_t k;
if (!n) return dest;
s[0] = c;
s[n-1] = c;
....
}
注意以下几行:
s[0] = c;
s[n-1] = c;
在这里,我们来到原因 N1“取消引用空指针是未定义的行为”。不能保证编译器不会交换赋值。如果您的编译器这样做,并且 n 参数非常有价值,那么一开始就会损坏一个字节的内存。空指针取消引用将仅在该之后发生。
又不服气了?那么,这个实现怎么样?
void *memset(void *dest, int c, size_t n)
{
size_t k;
if (!n) return dest;
s[0] = s[n-1] = c;
if (n <= 2) return dest;
....
}
您甚至不能信任内存集函数。是的,这可能是一个人为和牵强附会的问题。我只是想展示如果不检查指针的值会出现多少细微差别。根本不可能考虑到所有这些。因此,您应该仔细检查 malloc 函数返回的每个指针和类似指针。这就是你成为一名专业人士并编写可靠代码的时候。
结论
始终立即检查由 malloc 函数或其类似物返回的指针。