233

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

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

我的问题:

  1. 除了我的程序之外,从数组外部读取值会损坏任何东西吗?我想只看东西不会改变任何东西,或者它会改变我碰巧到达的文件的“上次打开时间”属性吗?
  2. 除了我的程序之外,在数组之外设置值会损坏任何东西吗?从这个 Stack Overflow question我收集到可以访问任何内存位置,没有安全保证。
  3. 我现在从 XCode 中运行我的小程序。这是否为我的程序提供了一些额外的保护,使其无法到达自己的内存之外?它会伤害 XCode 吗?
  4. 关于如何安全地运行我固有的错误代码的任何建议?

我使用 OSX 10.7、Xcode 4.6。

4

11 回答 11

131

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

使用不可移植或错误程序结构或错误数据时的行为,本国际标准对此没有要求

非规范性说明对此进行了扩展:

可能的未定义行为范围从完全忽略具有不可预测结果的情况,到在翻译或程序执行期间以环境特征的记录方式表现(有或没有发出诊断消息),到终止翻译或执行(发出的诊断消息)。

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

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

这是假设您的程序在试图保护同时运行的进程彼此之间的操作系统下运行。如果您的代码在“裸机”上运行,比如它是操作系统内核或嵌入式系统的一部分,那么就没有这种保护;你的行为不端的代码应该提供这种保护。在这种情况下,损坏的可能性要大得多,在某些情况下,包括对硬件(或附近的事物或人)的物理损坏。

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

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

而且总是有天网需要担心。

底线是这样的:如果您可以编写一个程序来故意做坏事,那么至少从理论上讲,一个有缺陷的程序可能会意外地做同样的事情。

实际上,在 MacOS X 系统上运行的有缺陷的程序不太可能发生比崩溃更严重的事情。但是不可能完全防止有缺陷的代码做坏事。

于 2013-03-26T21:20:23.557 回答
26

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

直接回答:

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

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

  3. 通常从调试器内部运行以调试模式运行代码。在调试模式下运行确实倾向于(但并非总是)在您做了一些被认为不合时宜或完全非法的事情时更快地停止您的代码。

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

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

于 2013-03-26T20:59:29.707 回答
11

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

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

有关如何做到这一点的详细信息,请参阅Smashing The Stack For Fun and Profit 。

于 2013-03-27T11:50:41.333 回答
9

你写:

我读了很多“任何事情都可能发生”、“分段可能是最不严重的问题”、“你的硬盘可能会变成粉红色,而独角兽可能会在你的窗下唱歌”,这些都很好,但真正的危险是什么?

让我们这么说吧:装枪。将它指向窗外,没有任何特别的目标和射击。有什么危险?

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

您的操作系统将保护您的想法是乐观的。如果可能的话,尽量避免写越界。

于 2013-03-26T20:54:00.227 回答
7

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

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

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

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

尽管如此 - 可能这就是你听说过的危险 - 通过偶然盲目地将随机数据写入随机内存位置,你肯定会损坏你能够损坏的任何东西。

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

于 2013-03-26T20:50:28.903 回答
4

NSArrays in Objective-C are assigned a specific block of memory. Exceeding the bounds of the array means that you would be accessing memory that is not assigned to the array. This means:

  1. This memory can have any value. There's no way of knowing if the data is valid based on your data type.
  2. This memory may contain sensitive information such as private keys or other user credentials.
  3. The memory address may be invalid or protected.
  4. The memory can have a changing value because it's being accessed by another program or thread.
  5. Other things use memory address space, such as memory-mapped ports.
  6. Writing data to unknown memory address can crash your program, overwrite OS memory space, and generally cause the sun to implode.

From the aspect of your program you always want to know when your code is exceeding the bounds of an array. This can lead to unknown values being returned, causing your application to crash or provide invalid data.

于 2013-03-26T20:54:09.777 回答
4

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

从手册:

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

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

ETA:不过,正如 Kaz 的回答所说,它不是灵丹妙药,而且并不总是提供最有用的输出,尤其是当您使用令人兴奋的访问模式时。

于 2013-03-27T01:33:52.180 回答
3

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

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

在某些情况下,硬件将环绕地址(地址的最高有效位/字节被忽略),因此写入超出物理地址空间末尾的地址实际上会导致数据被写入事物的中间。

最后,像 MC68000 这样的旧 CPU 可以锁定到只有硬件重置才能让它们再次运行的程度。几十年来一直没有处理它们,但我相信它是在尝试处理异常时遇到总线错误(不存在的内存)时,它会简单地停止,直到硬件复位被断言。

我最大的建议是一个产品的公然插件,但我对它没有个人兴趣,我与他们没有任何关系 - 但基于几十年的 C 编程和可靠性至关重要的嵌入式系统,Gimpel 的 PC Lint 不仅会检测到这些错误,它还会通过不断地对你的坏习惯喋喋不休,让你成为一个更好的 C/C++ 程序员。

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

不知道你,但大约第二次或第三次我从任何应用程序中得到一个核心转储或挂断,我对任何生产它的公司的看法下降了一半。第 4 次或第 5 次,无论包裹是什么,都变成了架子,我用木桩穿过包裹/光盘的中心,它进来只是为了确保它永远不会回来困扰我。

于 2013-04-04T01:53:20.837 回答
2

我正在使用 DSP 芯片的编译器,该芯片故意生成代码,该代码从 C 代码中访问超出数组末尾的代码,而 C 代码没有!

这是因为循环是结构化的,因此迭代结束时会为下一次迭代预取一些数据。因此,在最后一次迭代结束时预取的数据从未实际使用过。

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

更常见的是,越界访问的程序没有得到巧妙的优化。这简直是​​越野车。该代码获取一些垃圾值,并且与上述编译器的优化循环不同,该代码然后在后续计算中使用该值,从而破坏它们。

捕获这样的错误是值得的,因此即使仅仅因为这个原因,也值得将行为设为未定义:以便运行时可以生成诊断消息,如“main.c 的第 42 行中的数组溢出”。

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

顺便说一句,请注意,在 C 语言中,我们可以创建一个指针,该指针位于数组末尾之后。并且这个指针必须比任何指向数组内部的指针都要大。这意味着 C 实现不能将数组放在内存的末尾,因为加号地址会环绕并且看起来比数组中的其他地址要小。

然而,访问未初始化或越界值有时是一种有效的优化技术,即使不是最大程度地可移植。这就是为什么 Valgrind 工具在访问发生时不报告对未初始化数据的访问,但仅在以后以某种可能影响程序结果的方式使用该值时才报告。您会得到类似“xxx:nnn 中的条件分支取决于未初始化的值”之类的诊断信息,并且有时很难找到它的来源。如果所有此类访问都立即被捕获,那么编译器优化代码以及正确手动优化的代码将会产生很多误报。

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

“未定义的行为”实际上并没有多大意义,因为根据 ISO C,简单地包括一个未在 C 标准中定义的标头,或调用未在程序本身或 C 标准中定义的函数,都是未定义的示例行为。未定义的行为并不意味着“地球上的任何人都没有定义”,只是“ISO C 标准没有定义”。但是,当然,有时未定义的行为确实不是由任何人定义的。

于 2013-03-26T23:02:44.597 回答
1

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

于 2013-03-26T20:49:03.390 回答
0

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

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 似乎以不同的方式解释数组边界和指针衰减的概念,而不是那些使用显式的指针算术。

于 2021-05-24T16:01:50.730 回答