提问人:ChrisD 提问时间:3/27/2013 最后编辑:Jonathan LefflerChrisD 更新时间:12/23/2022 访问量:58280
越界访问数组有多危险?
How dangerous is it to access an array out of bounds?
问:
访问超出其边界(在 C 中)的数组有多危险?有时可能会发生我从数组外部读取的情况(我现在明白了,然后我访问了程序的其他部分甚至超出该部分使用的内存),或者我正在尝试为数组外部的索引设置值。程序有时会崩溃,但有时只是运行,只会给出意想不到的结果。
现在我想知道的是,这到底有多危险?如果它损坏了我的程序,那还不错。另一方面,如果它破坏了我的程序之外的某些东西,因为我以某种方式设法访问了一些完全不相关的内存,那么我想这是非常糟糕的。 我读过很多“任何事情都可能发生”、“分段可能是最不坏的问题”、“你的硬盘可能会变成粉红色,独角兽可能在你的窗户下唱歌”,这些都很好,但真正的危险是什么?
我的问题:
- 从数组外部读取值会损坏任何东西吗? 除了我的程序?我想只要看东西就可以了 不更改任何内容,或者它会更改“上次” 我碰巧到达的文件的 opened' 属性?
- 在数组之外设置值会损坏除我之外的任何东西吗? 程序?从这个 Stack Overflow 问题中,我收集到可以访问 任何内存位置,都没有安全保证。
- 我现在在 XCode 中运行我的小程序。这样做 在我的程序周围提供一些额外的保护,如果它不能 超出自己的记忆?它会伤害 XCode 吗?
- 关于如何安全地运行我固有的错误代码的任何建议?
我使用 OSX 10.7、Xcode 4.6。
答:
除了你自己的程序,我认为你不会破坏任何东西,在最坏的情况下,你会尝试从与内核未分配给你的进程的页面相对应的内存地址读取或写入,生成适当的异常并被杀死(我的意思是,你的进程)。
评论
不以 root 或任何其他特权用户身份运行程序不会损害您的任何系统,因此通常这可能是一个好主意。
通过将数据写入某个随机内存位置,您不会直接“损坏”计算机上运行的任何其他程序,因为每个进程都在自己的内存空间中运行。
如果尝试访问任何未分配给进程的内存,操作系统将停止程序执行,并出现分段错误。
因此,直接(无需以 root 身份运行并直接访问 /dev/mem 等文件)不会有程序干扰操作系统上运行的任何其他程序的危险。
然而 - 可能这就是你听说过的危险 - 通过盲目地将随机数据意外地写入随机内存位置,你肯定会损坏任何你能损坏的东西。
例如,程序可能希望删除由存储在程序中某处的文件名给出的特定文件。如果不小心覆盖了文件名的存储位置,则可以删除一个非常不同的文件。
评论
你写:
我读过很多“任何事情都可能发生”、“细分可能是 最不坏的问题“,”你的硬盘可能会变成粉红色,独角兽可能会变成粉红色 在你的窗下唱歌“,这很好,但真正是什么 危险?
让我们这样说吧:装枪。将它指向窗外,没有任何特定的目标和火力。有什么危险?
问题是你不知道。如果你的代码覆盖了导致程序崩溃的内容,你没事,因为它会阻止它进入定义的状态。但是,如果它没有崩溃,那么问题就会开始出现。哪些资源在您的程序的控制之下,它可能对它们产生什么影响?我知道至少有一个主要问题是由这种溢出引起的。问题出在一个看似毫无意义的统计函数中,该函数搞砸了生产数据库的一些不相关的转换表。结果是事后进行了一些非常昂贵的清理工作。实际上,如果此问题会格式化硬盘,那么处理起来会便宜得多,也更容易处理......换句话说:粉红色的独角兽可能是你最不成问题的问题。
您的操作系统将保护您的想法是乐观的。如果可能的话,尽量避免越界写入。
评论
NSArray
在 Objective-C 中,被分配了一个特定的内存块。超出数组的边界意味着您将访问未分配给阵列的内存。这意味着:
- 此内存可以具有任何值。无法根据数据类型知道数据是否有效。
- 此内存可能包含敏感信息,例如私钥或其他用户凭据。
- 内存地址可能无效或受保护。
- 内存的值可能会发生变化,因为它正在被另一个程序或线程访问。
- 其他事物使用内存地址空间,例如内存映射端口。
- 将数据写入未知内存地址可能会使程序崩溃,覆盖操作系统内存空间,并且通常会导致太阳内爆。
从程序的角度来看,您总是想知道代码何时超出了数组的边界。这可能会导致返回未知值,从而导致应用程序崩溃或提供无效数据。
评论
NSArrays
有越界例外。这个问题似乎是关于C数组的。
通常,当今的操作系统(无论如何都是流行的操作系统)使用虚拟内存管理器在受保护的内存区域中运行所有应用程序。事实证明,简单地读取或写入已分配/分配给您的流程的区域之外的 REAL 空间中的位置并不是非常容易(本身)。
直接回答:
读取几乎永远不会直接损坏另一个进程,但是如果您碰巧读取了用于加密、解密或验证程序/进程的 KEY 值,它可能会间接损坏进程。如果根据正在读取的数据做出决策,则越界读取可能会对代码产生一些不利/意外的影响
通过写入内存地址可访问的位置来真正损坏某些东西的唯一方法是,如果您正在写入的内存地址实际上是硬件寄存器(实际上不是用于数据存储的位置,而是用于控制某些硬件的位置),而不是RAM位置。事实上,你通常不会损坏某些东西,除非你正在编写一些不可重写的一次性可编程位置(或类似性质的东西)。
通常,从调试器中运行会在调试模式下运行代码。在调试模式下运行往往会(但并非总是)在您做了一些被认为不合时宜或完全非法的事情时更快地停止代码。
永远不要使用宏,使用已经内置了数组索引边界检查的数据结构,等等。
附加我应该补充一点,上述信息实际上仅适用于使用具有内存保护窗口的操作系统的系统。如果为嵌入式系统编写代码,甚至是使用没有内存保护窗口(或虚拟寻址窗口)的操作系统(实时或其他)的系统编写代码,则在读取和写入内存时应更加谨慎。此外,在这些情况下,应始终采用 SAFE 和 SECURE 编码实践来避免安全问题。
评论
就 ISO C 标准(语言的官方定义)而言,访问超出其边界的数组具有“未定义的行为”。这句话的字面意思是:
使用不可移植或错误程序结构的行为,或 错误数据,本国际标准没有规定 要求
一份非规范性说明对此进行了扩展:
可能的未定义行为范围从忽略情况 完全具有不可预测的结果,在翻译过程中的行为 或以记录的方式执行程序,其特征 环境(无论是否发出诊断消息),以 终止翻译或执行(通过签发 诊断消息)。
这就是理论。现实情况如何?
在“最佳”情况下,您将访问当前正在运行的程序拥有的某些内存(这可能会导致程序行为异常),或者不属于当前正在运行的程序(这可能会导致程序崩溃并出现分段错误)。或者,您可以尝试写入程序拥有的内存,但这被标记为只读;这也可能导致您的程序崩溃。
这是假设您的程序在尝试保护并发运行的进程彼此隔离的操作系统下运行。如果你的代码运行在“裸机”上,比如说它是操作系统内核或嵌入式系统的一部分,那么就没有这样的保护;您的行为不端代码本应提供这种保护。在这种情况下,损坏的可能性要大得多,在某些情况下,包括对硬件(或附近的事物或人员)的物理损坏。
即使在受保护的操作系统环境中,保护也并不总是 100%。例如,存在允许非特权程序获取 root(管理)访问权限的操作系统错误。即使使用普通用户权限,出现故障的程序也会消耗过多的资源(CPU、内存、磁盘),从而可能导致整个系统瘫痪。许多恶意软件(病毒等)利用缓冲区溢出来获得对系统的未经授权的访问。
(一个历史例子:我听说在一些具有核心内存的旧系统上,在紧密循环中重复访问单个内存位置可能会导致该内存块融化。其他可能性包括破坏 CRT 显示器,以及使用驱动器机柜的谐波频率移动磁盘驱动器的读/写磁头,使其穿过桌子并掉到地板上。
而且总是有天网需要担心。
底线是这样的:如果你可以编写一个程序来故意做一些坏事,那么至少从理论上讲,一个有缺陷的程序可能会意外地做同样的事情。
在实践中,在MacOS X系统上运行的有缺陷的程序不太可能做任何比崩溃更严重的事情。但是,要完全防止有缺陷的代码做非常糟糕的事情是不可能的。
评论
我正在使用一个 DSP 芯片的编译器,该编译器故意生成代码,该代码会从 C 代码中访问数组末尾的代码,但不会!
这是因为循环的结构是这样的,因此迭代的末尾会为下一次迭代预取一些数据。因此,在上次迭代结束时预取的基准从未实际使用过。
像这样编写 C 代码会调用未定义的行为,但这只是标准文档的一种形式,它关注的是最大的可移植性。
更常见的情况是,越界访问的程序没有得到巧妙的优化。这简直是越野车。代码获取一些垃圾值,与上述编译器的优化循环不同,代码随后在后续计算中使用该值,从而损坏了该值。
捕获这样的错误是值得的,因此即使仅仅因为这个原因,也值得使行为未定义:以便运行时可以生成诊断消息,例如“main.c 的第 42 行中的数组溢出”。
在具有虚拟内存的系统上,可能碰巧分配了一个数组,以便后面的地址位于虚拟内存的未映射区域中。然后,访问将轰炸程序。
顺便说一句,请注意,在 C 语言中,我们被允许创建一个指针,该指针位于数组的末尾。并且此指针必须比任何指针与数组内部的比较都大。 这意味着 C 实现不能将数组放在内存的末尾,其中 1 加号地址会环绕并且看起来比数组中的其他地址小。
然而,访问未初始化或越界值有时是一种有效的优化技术,即使不能最大限度地移植。例如,这就是为什么 Valgrind 工具在发生对未初始化数据的访问时不报告这些访问的原因,而仅在以后以可能影响程序结果的某种方式使用该值时才报告。你会得到一个诊断,比如“xxx:nnn 中的条件分支取决于未初始化的值”,有时很难追踪它的来源。如果所有此类访问都立即被捕获,则编译器优化的代码以及正确的手动优化代码将产生大量误报。
说到这一点,我正在使用来自供应商的一些编解码器,该编解码器在移植到 Linux 并在 Valgrind 下运行时会发出这些错误。但供应商说服了我,实际上只有几个位来自未初始化的内存,而这些位被逻辑小心翼翼地避免了。只使用了值的好位,Valgrind 无法追踪到单个位。未初始化的材料来自读取编码数据比特流末尾的单词,但代码知道流中有多少位,并且不会使用比实际更多的比特。由于超出比特流阵列末端的访问不会对DSP架构造成任何损害(阵列之后没有虚拟内存,没有内存映射端口,并且地址不换行),因此它是一种有效的优化技术。
“未定义的行为”实际上并没有多大意义,因为根据 ISO C,简单地包含一个 C 标准中未定义的标头,或者调用程序本身或 C 标准中未定义的函数,都是未定义行为的例子。未定义的行为并不意味着“不被地球上的任何人定义”,只是“不被ISO C标准定义”。但是,当然,有时未定义的行为确实绝对不是由任何人定义的。
评论
memcheck
--expensive-definedness-checks=yes
在测试代码时,您可能想尝试使用 Valgrind 中的 memcheck
工具——它不会捕获堆栈帧中的单个数组边界冲突,但它应该捕获许多其他类型的内存问题,包括那些会导致单个函数范围之外的微妙、更广泛的问题的问题。
从手册:
Memcheck 是一个内存错误检测器。它可以检测 C 和 C++ 程序中常见的以下问题。
- 访问不应访问的内存,例如堆块溢出和运行不足,堆栈顶部溢出,以及在释放内存后访问内存。
- 使用未定义的值,即尚未初始化的值,或已从其他未定义值派生的值。
- 不正确地释放堆内存,例如双重释放堆块,或者 malloc/new/new[] 与 free/delete/delete[] 的使用不匹配
- 在 memcpy 和相关函数中重叠 src 和 dst 指针。
- 内存泄漏。
伊塔:不过,正如 Kaz 的回答所说,它不是灵丹妙药,并不总是能提供最有用的输出,尤其是当你使用令人兴奋的访问模式时。
评论
不检查边界会导致丑陋的副作用,包括安全漏洞。其中一个丑陋的问题是任意代码执行。在经典示例中:如果你有一个固定大小的数组,并且用于将用户提供的字符串放在那里,则用户可以给你一个字符串,该字符串会溢出缓冲区并覆盖其他内存位置,包括函数完成时 CPU 应返回的代码地址。strcpy()
这意味着您的用户可以向您发送一个字符串,该字符串将导致您的程序基本上调用 ,这会将其变成 shell,在您的系统上执行他想要的任何内容,包括收集您的所有数据并将您的计算机变成僵尸网络节点。exec("/bin/sh")
有关如何做到这一点的详细信息,请参阅粉碎堆栈以获得乐趣和利润。
评论
foo[0]
foo[len-1]
len
如果你曾经做过系统级编程或嵌入式系统编程,如果你写入随机的内存位置,可能会发生非常糟糕的事情。较旧的系统和许多微控制器使用内存映射 IO,因此写入映射到外设寄存器的内存位置可能会造成严重破坏,尤其是在异步完成的情况下。
一个例子是对闪存进行编程。存储芯片上的编程模式是通过将特定值序列写入芯片地址范围内的特定位置来启用的。如果另一个进程在写入芯片中的任何其他位置时,将导致编程周期失败。
在某些情况下,硬件会将地址包裹起来(地址的最高有效位/字节将被忽略),因此写入物理地址空间末尾之外的地址实际上会导致数据被写入到事物的中间。
最后,像 MC68000 这样的旧 CPU 可以锁定到只有硬件重置才能让它们再次运行的地步。几十年来没有对它们进行过研究,但我相信当它在尝试处理异常时遇到总线错误(不存在内存)时,它只会停止,直到断言硬件重置。
我最大的建议是为一个产品做一个公然的插头,但我对它没有个人兴趣,我与他们没有任何关系 - 但基于几十年的 C 编程和嵌入式系统,其中可靠性至关重要,Gimpel 的 PC Lint 不仅会检测这些错误, 它会通过不断喋喋不休地喋喋不休地谈论坏习惯,使你成为一个更好的 C/C++ 程序员。
我还建议您阅读 MISRA C 编码标准,如果您可以从某人那里获得副本。我没有看到任何最近的,但在过去,他们很好地解释了为什么你应该/不应该做他们所涵盖的事情。
不知道你,但是大约第二次或第三次我从任何应用程序收到核心转储或挂断时,我对任何公司生产它的看法都会下降一半。第 4 次或第 5 次,无论包装是什么,我都会变成货架,我把一根木桩穿过包装/光盘的中心,它进来只是为了确保它永远不会回来困扰我。
评论
具有两个或更多维度的数组会带来超出其他答案中提到的考虑因素。请考虑以下功能:
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 以外的任何值。尽管该标准将 的含义定义为精确等价于 ,但 gcc 似乎以不同的方式解释数组边界和指针衰减的概念,这些概念涉及对数组类型的值使用 [] 运算符,而不是使用显式指针算术。array[index]
(*((array)+(index)))
我只想为这个问题添加一些实际的例子 - 想象一下下面的代码:
#include <stdio.h>
int main(void) {
int n[5];
n[5] = 1;
printf("answer %d\n", 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 %d\n", (exit(-1), n[5]));
return (0);
}
相反,这是完美的定义)。
这是因为第一个条件语句永远不会达到其真实状态(始终为 false)。0
在第二个参数上,我们有一个序列点,在该序列点之后,我们调用该序列点,程序在第二个逗号运算符中调用 UB 之前终止(因此定义良好)。printf
exit
因此,第二个要点是,只要UB从未被实际评估过,它就不是UB。
此外,我没有看到这里提到有相当现代的 Undefined Behaviour 清理器(至少在 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(第一个示例和 -Ofast clang)
https://godbolt.org/z/cGcY7Ta9M(第二个示例和UB消毒剂)
https://godbolt.org/z/vE531EKo4(第一个示例和UB消毒剂)
评论