C语言 越界访问数组有多危险



访问超出边界的数组(在C中)有多危险?有时可能会发生我从数组外部读取(我现在明白我然后访问由我的程序的一些其他部分使用的内存,甚至超越),或者我试图设置一个值到数组外部的索引。该程序有时会崩溃,但有时只是运行,只是给出意想不到的结果。

现在我想知道的是,这到底有多危险?如果它破坏了我的程序,也没那么糟糕。另一方面,如果它破坏了程序之外的某些东西,因为我设法访问了一些完全不相关的内存,那么我想这是非常糟糕的。我读过很多"任何事情都有可能发生","分割可能是最不坏的问题","你的硬盘可能变成粉红色,独角兽可能在你的窗户下唱歌",这些都很好,但真正的危险是什么?

我的问题:

  1. 从数组外读取值会损坏任何东西吗除了我的节目?我想只要看着就行了不会改变任何东西,或者它是否会改变"上次"我碰巧到达的文件的"打开"属性?
  2. 设置数组之外的值可以损坏任何东西,除了我的项目?从这个堆栈溢出问题我收集,这是可能的访问
  3. 我现在在XCode中运行我的小程序。这为我的程序提供一些额外的保护超越自己的记忆?它会损害XCode吗?
  4. 关于如何安全地运行我固有的错误代码的任何建议?

我使用OSX 10.7, Xcode 4.6.

就ISO C标准(该语言的官方定义)而言,访问超出其边界的数组具有"未定义行为"。它的字面意思是:

在使用不可移植的或错误的程序结构或时,

行为本国际标准没有规定的错误数据要求

一个非规范注释对此展开:

可能的未定义行为包括忽略情况完全无法预测的结果,在翻译过程中的行为或以文档化的方式执行程序环境(是否发出诊断消息),到终止翻译或执行(通过发出诊断消息)。

这就是理论。现实是什么?

在"最好"的情况下,您将访问一些内存,这些内存要么属于您当前正在运行的程序(这可能导致您的程序行为不正常),要么不属于您当前正在运行的程序(这可能会导致您的程序因分段错误而崩溃)。或者你可能会尝试写你的程序拥有的内存,但它被标记为只读;这也可能导致你的程序崩溃。

假设您的程序运行在试图保护并发运行进程的操作系统下。如果你的代码运行在"裸机"上,比如它是操作系统内核或嵌入式系统的一部分,那么就没有这样的保护;你的错误代码应该提供这种保护。在这种情况下,损坏的可能性要大得多,在某些情况下,包括对硬件(或附近的东西或人)的物理损坏。

即使在受保护的操作系统环境中,保护也不总是100%。例如,存在允许无特权程序获得根(管理)访问权限的操作系统错误。即使具有普通用户权限,出现故障的程序也会消耗过多的资源(CPU、内存、磁盘),可能导致整个系统瘫痪。很多恶意软件(病毒等)利用缓冲区溢出来获得对系统的未经授权的访问。

一个历史上的例子:我听说在一些具有核心内存的旧系统上,在一个紧密循环中反复访问单个内存位置可能会导致该内存块融化。其他的可能性包括损坏CRT显示器,和移动磁盘驱动器的读/写磁头与驱动器柜的谐波频率,导致它走过桌子,掉到地板上。

总有天网要担心。

底线是:如果你可以写一个程序故意做一些不好的,至少在理论上,一个有bug的程序可能会做同样的事情意外

在实践中,在MacOS X系统上运行的有bug的程序不太可能出现比崩溃更严重的情况,非常。但是不可能完全阻止有bug的代码做真正糟糕的事情。

一般来说,今天的操作系统(无论如何是流行的操作系统)使用虚拟内存管理器在受保护的内存区域中运行所有应用程序。事实证明,简单地读取或写入存在于已分配/分配给进程的区域之外的REAL空间中的位置(本质上)并不是非常容易。

直接回答:

  1. 读取几乎不会直接损坏另一个进程,但是如果您碰巧读取用于加密,解密或验证程序/进程的KEY值,则可能间接损坏进程。如果您根据正在读取的数据做出决策,越界读取可能会对代码产生一些不利/意想不到的影响

  2. 通过写入内存地址可访问的位置,您可以真正损坏的唯一方法是,如果您正在写入的内存地址实际上是一个硬件寄存器(实际上不是用于数据存储,而是用于控制某些硬件的位置)而不是RAM位置。事实上,通常情况下,你仍然不会损坏一些东西,除非你正在写一些一次性可编程的位置,不可重写(或其他性质的东西)。

  3. 通常从调试器内运行,以调试模式运行代码。当你做了一些被认为不符合实践或完全非法的事情时,在调试模式下运行往往会(但并不总是)更快地停止你的代码。

  4. 永远不要使用宏,使用已经内置数组索引边界检查的数据结构等....

另外

我应该补充说,上述信息实际上仅适用于使用带有内存保护窗口的操作系统的系统。如果为嵌入式系统编写代码,甚至是使用没有内存保护窗口(或虚拟寻址窗口)的操作系统(实时或其他)编写代码,则应该在读取和写入内存时更加小心。同样,在这些情况下,应该始终采用SAFE和SECURE编码实践来避免安全问题。

不检查边界会导致难看的副作用,包括安全漏洞。其中一个丑陋的问题是任意的代码执行。在经典的例子中:如果你有一个固定大小的数组,并且使用strcpy()将一个用户提供的字符串放在那里,用户可以给你一个字符串,这个字符串会溢出缓冲区并覆盖其他内存位置,包括当你的函数结束时CPU应该返回的代码地址。

这意味着你的用户可以给你发送一个字符串,这将导致你的程序本质上调用exec("/bin/sh"),这将把它变成shell,在你的系统上执行任何他想要的东西,包括收集你所有的数据和把你的机器变成僵尸网络节点。

参见粉碎堆栈以获得乐趣和利润,了解如何做到这一点的详细信息。

你写:

我读过很多"任何事情都有可能发生","分割可能是"最不坏的问题","你的硬盘可能会变成粉红色,独角兽可能会。在你的窗下唱歌,这很好,但真正的危险吗?

这么说吧:给枪上膛。把枪对准窗外,没有任何特定的目标,然后开火。危险是什么?

问题是你不知道。如果您的代码覆盖了导致程序崩溃的某些内容,那么没关系,因为它将使程序停止进入已定义的状态。但是如果它没有崩溃,那么问题就开始出现了。哪些资源在程序的控制之下,程序可以对它们做什么?我知道至少有一个主要问题是由这种溢出引起的。问题出在一个看似毫无意义的统计函数中,它弄乱了生产数据库的一些不相关的转换表。结果是一些非常昂贵的清理。实际上,如果这个问题格式化了硬盘,会更便宜,更容易处理……换句话说:粉红色的独角兽可能是你最不需要担心的问题。

认为操作系统会保护你的想法太乐观了。如果可能的话,尽量避免写越界

不以root或任何其他特权用户的身份运行程序不会损害您的系统,所以通常这可能是一个好主意。

通过将数据写入某个随机内存位置,您不会直接"破坏"计算机上运行的任何其他程序,因为每个进程都在自己的内存空间中运行。

如果您试图访问任何未分配给您的进程的内存,操作系统将以分段错误停止您的程序执行。

所以直接(不以root身份运行和直接访问/dev/mem之类的文件),您的程序就不会有干扰操作系统上运行的任何其他程序的危险。

然而——也许这就是你听说过的危险——盲目地将随机数据写入随机内存位置,你肯定会损坏任何你能损坏的东西。

例如,您的程序可能想要删除存储在程序某处的文件名给出的特定文件。如果不小心覆盖了存储文件名的位置,则可能会删除一个非常不同的文件。

NSArray在Objective-C中被分配了一个特定的内存块。超出数组的边界意味着您将访问未分配给该数组的内存。这意味着:

  1. 这个内存可以是任何值。没有办法知道数据是否有效基于您的数据类型。
  2. 此内存可能包含敏感信息,如私钥或其他用户凭证。
  3. 内存地址可能无效或受保护。
  4. 内存可以有一个变化的值,因为它正在被另一个程序或线程访问。
  5. 其他东西使用内存地址空间,如内存映射端口。
  6. 将数据写入未知的内存地址可能会导致程序崩溃,覆盖操作系统内存空间,并且通常会导致太阳内爆。

从程序的角度来看,你总是想知道你的代码何时超出了数组的边界。这可能导致返回未知值,从而导致应用程序崩溃或提供无效数据。

当你测试你的代码时,你可能想尝试使用Valgrind中的memcheck工具——它不会捕获堆栈框架内的单个数组边界违规,但它应该捕获许多其他类型的内存问题,包括那些会导致微妙的,在单个函数范围之外的更广泛的问题。

从手册:

Memcheck是一个内存错误检测器。它可以检测C和c++程序中常见的以下问题:

  • 访问你不应该访问的内存,例如溢出和不足堆块,溢出堆栈顶部,以及在内存被释放后访问内存。
  • 使用未定义值,即未初始化的值,或从其他未定义值派生的值。
  • 不正确地释放堆内存,例如双重释放堆块,或者使用malloc/new/new[]与free/delete/delete[]不匹配
  • src和dst指针在memcpy和相关函数中重叠
  • 内存泄漏

ETA:但是,正如Kaz的回答所说,它不是灵丹妙药,并且并不总是给出最有帮助的输出,特别是当您使用令人兴奋的访问模式时。

如果您曾经做过系统级编程或嵌入式系统编程,那么如果您写入随机内存位置,可能会发生非常糟糕的事情。较旧的系统和许多微控制器使用内存映射的IO,因此写入映射到外设寄存器的内存位置可能会造成严重破坏,特别是如果它是异步完成的。

一个例子是编写闪存。通过将特定的值序列写入芯片地址范围内的特定位置来启用存储芯片上的编程模式。如果另一个进程在此过程中写入芯片中的任何其他位置,则会导致编程周期失败。

在某些情况下,硬件会将地址包裹起来(地址中最重要的位/字节会被忽略),所以写到超出物理地址空间末端的地址实际上会导致数据被写在中间。

最后,像MC68000这样的旧cpu可以锁定到只有硬件复位才能使它们重新运行的程度。我已经有几十年没有处理它们了,但我相信当它在试图处理异常时遇到总线错误(不存在的内存),它会简单地停止,直到断言硬件复位。

我最大的建议是一个明显的产品插头,但我个人对它没有兴趣,我也没有以任何方式与他们联系在一起-但是基于几十年的C编程和嵌入式系统,可靠性是至关重要的,Gimpel的PC Lint不仅会检测这些类型的错误,它会通过不断地唠叨你的坏习惯,使你成为一个更好的C/c++程序员。

我还建议阅读MISRA C编码标准,如果你能从别人那里得到一份副本。我没有看到任何最近的,但在过去,他们给了一个很好的解释,为什么你应该/不应该做他们所涵盖的事情。

我不知道你的情况,但是当我第二次或第三次收到任何应用程序时,我对生产它的公司的看法就会下降一半。第四次或第五次,无论包装是什么,都变成了架子,我用木桩穿过它进来的包装/光盘的中心,只是为了确保它永远不会回来困扰我。

我正在使用DSP芯片的编译器,该芯片故意生成访问数组结束后的C代码的代码,而不是!

这是因为循环的结构使得迭代结束时可以为下一次迭代预取一些数据。因此,在最后一次迭代结束时预取的数据实际上永远不会被使用。

像这样编写C代码会调用未定义的行为,但这只是标准文档中考虑最大可移植性的形式。

通常,访问越界的程序没有被巧妙地优化。它只是有bug。该代码获取一些垃圾值,并且与前面提到的编译器的优化循环不同,该代码在随后的计算中使用该值,从而破坏了它们。

捕获这样的错误是值得的,因此,即使仅仅因为这个原因,也值得将行为定义为未定义:这样运行时就可以产生类似"main.c的第42行数组溢出"这样的诊断消息。

在具有虚拟内存的系统上,可能会分配一个数组,使得后面的地址位于虚拟内存的未映射区域。这个访问将会炸毁程序。

作为题外话,请注意,在C语言中,我们允许创建一个指针,它在数组结束后的另一个位置。这个指针比任何指向数组内部的指针都要大。这意味着C实现不能将数组放在内存的末尾,在那里1 +地址会绕起来,看起来比数组中的其他地址小。

然而,访问未初始化或越界值有时是一种有效的优化技术,即使不能最大限度地移植。例如,这就是为什么Valgrind工具在访问未初始化数据时不报告访问,而只在稍后以某种可能影响程序结果的方式使用该值时报告访问。你会得到类似"xxx中的条件分支:nnn依赖于未初始化的值"这样的诊断,有时很难追踪到它的起源。如果所有这样的访问都被立即捕获,那么编译器优化的代码和正确手动优化的代码将会产生大量的误报。

说到这个,我正在使用一个供应商的一些编解码器,当移植到Linux并在Valgrind下运行时,它会发出这些错误。但是供应商让我相信,只有几个所使用的值实际上来自未初始化的内存,并且这些位被逻辑小心地避免了。只有价值的好部分被使用,而Valgrind没有追踪单个比特的能力。未初始化的材料来自于从编码数据的位流的末尾读取一个单词,但是代码知道流中有多少位,并且不会使用超过实际数量的位。由于超出位流数组末端的访问不会对DSP架构造成任何损害(数组后没有虚拟内存,没有内存映射端口,地址不封装),因此它是一种有效的优化技术。

"未定义行为"并不意味着什么,因为根据ISO C,仅仅包括一个在C标准中没有定义的头文件,或者调用一个在程序本身或C标准中没有定义的函数,都是未定义行为的例子。未定义的行为并不意味着"没有被地球上的任何人定义",只是"没有被ISO C标准定义"。但是当然,有时候未定义的行为真的是绝对没有被任何人定义的。

除了您自己的程序,我不认为您会破坏任何东西,在最坏的情况下,您将尝试从内存地址读取或写入对应于内核没有分配给进程的页面,生成适当的异常并被杀死(我的意思是,您的进程)。

具有两个或多个维度的数组所要考虑的问题超出了其他答案中提到的问题。考虑以下函数:

char arr1[2][8];
char arr2[4];
int test1(int n)
{
  arr1[1][0] = 1;
  for (int i=0; i<n; i++) arr1[0][i] = arr2[i];      
  return arr1[1][0];
}
int test2(int ofs, int n)
{
  arr1[1][0] = 1;
  for (int i=0; i<n; i++) *(arr1[0]+i) = arr2[i];      
  return arr1[1][0];
}

gcc处理第一个函数的方式不允许尝试写入arr[0][i]可能会影响arr[1][0]的值,并且生成的代码无法返回除硬编码值1之外的任何值。尽管标准将array[index]的含义定义为与(*((array)+(index)))完全等价,但在涉及对数组类型的值使用[]运算符的情况下,与使用显式指针算术的情况下,gcc似乎对数组边界和指针衰减的概念的解释不同。

我只是想为这个问题添加一些实际的例子-想象一下下面的代码:

#include <stdio.h>
int main(void) {
    int n[5];
    n[5] = 1;
    printf("answer %dn", n[5]);
    return (0);
}

具有未定义行为。例如,如果你启用clang优化(-Ofast),它将导致类似于:

answer 748418584

(如果没有编译,可能会输出正确的answer 1结果)

这是因为在第一种情况下,对1的赋值从未在最终代码中实际组装(您也可以查看godbolt asm代码)。

(但是必须注意的是,根据逻辑main甚至不应该调用printf,所以最好的建议是不要依赖优化器来解决您的UB -而是要知道有时它可能以这种方式工作)

这里的结论是,现代C优化编译器将假设未定义行为(UB)永远不会发生(这意味着上面的代码将类似于(但不相同):
#include <stdio.h>
#include <stdlib.h>
int main(void) {
    int n[5];
    if (0)
        n[5] = 1;
    printf("answer %dn", (exit(-1), n[5]));
    return (0);
} 

相反,它是完全定义的)。

这是因为第一个条件语句永远不会达到它的真状态(0总是假)。

printf的第二个参数上,我们有一个序列点,在这个序列点之后,我们调用exit,程序在调用第二个逗号操作符中的UB之前终止(所以它是定义良好的)。

因此,第二个结论是,只要从未实际计算过,UB就不是UB。

另外,我没有看到这里提到有相当现代的未定义行为消毒器(至少在clang上),它(带有 -fsanitize=undefined 选项)将在第一个示例(但不是第二个)上给出以下输出:

/app/example.c:5:5: runtime error: index 5 out of bounds for type 'int[5]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.c:5:5 in 
/app/example.c:7:27: runtime error: index 5 out of bounds for type 'int[5]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.c:7:27 in 

这是godbolt中所有的样本:

https://godbolt.org/z/eY9ja4fdh(第一个示例,没有标志)

https://godbolt.org/z/cGcY7Ta9M(第一个例子)

https://godbolt.org/z/cGcY7Ta9M(第二个示例,打开UB杀菌剂)

https://godbolt.org/z/vE531EKo4(第一个示例,打开UB杀菌剂)

最新更新