【问题标题】:Is it possible to check if dereferencing arbitrary memory will crash the program apriori in C?是否可以检查取消引用任意内存是否会使 C 中的程序先验崩溃?
【发布时间】:2016-07-13 21:53:15
【问题描述】:

我想编写一个交互式解释 C shell,它允许您寻址任意内存并在这些内存地址上执行命令。

例如(运行程序外壳):

prompt> 10 bytes starting 0x400000 

该指令将尝试访问地址 0x400000 并显示从那里开始的 10 个字节。例如范围 [0x400000, 0x400009]。

并会产生如下输出:

{0x00, 0x01, 0x02, 0x03, 0x04, <bad>, <bad>, 0x07, 0x08, 0x09, 0x0a}

“坏”表示尝试寻址“非法”内存。

我想知道 C 中是否有标准方法来检查程序是否被允许访问我试图访问的内存,或者访问该内存是否会导致程序崩溃(在它实际崩溃之前),并向用户报告不允许程序访问该内存的信息。

我问这个是因为关于这个主题的大多数问题都倾向于通过“你不能肯定地检查指针是否有效”来回答,但我确信必须有某种方法来检查指针是否至少是“绝对无效,会崩溃”或“可能无效,但不会崩溃”,很遗憾我找不到这个问题的答案。

提前谢谢。

【问题讨论】:

  • 除非您已经访问过内存,否则您将如何查看是否可以访问内存?不,您无法检查指针是否有效;您可以检查它是否为 NULL,但您无法判断它指向的位置是有效内存。它可能看起来是一个有效的地址,而只是一个随机值。我认为回答“不,你不能那样做”的不是大多数问题,而是所有问题。如果您找到了一个不这么说的人,那么您已经得到了问题的答案。
  • 我不在乎它是否有效,我在乎它是否会使程序崩溃。我不介意破坏内存或访问我尚未分配的内存,我只关心它是否可以访问。
  • 什至什么。这是您的硬件架构和操作系统的问题,而不是 C。该语言只能编译为前实体在运行时允许或拒绝的机器代码。诸如是否允许任意编号的地址之类的问题恰恰与 C 无关。此外,仅尝试访问编号地址的行为并不是定义的行为,更像是实现定义的充其量。对于大多数平台来说,指针是用来指向对象的,而不是特定的地址,而 C 认为后者只是一个必要的偶然事件。
  • 我在第一句话中提到了这一点。你可以像调试器那样做,但是如何做这个问题在这里太宽泛了。有关于编写调试器的书籍,并且已经有很多调试器可用(包括许多免费的)。
  • 所以检查内存是否合法的唯一方法是通过系统调用(依赖于平台)如果这样的调用首先存在?我真的不想相信这是这种情况,因为程序被告知了它映射到的地址以及它的虚拟地址空间之外的信息,我不明白为什么这些信息不可用C.

标签: c pointers operating-system


【解决方案1】:

我认为仅使用标准 C 语言无法做到这一点。

但是,您可以使用特定于平台的邪恶技巧来了解内存映射的外观。在 Linux 上,文件/proc/(pid)/maps 将列出进程pid 的内存映射,包括读/写权限状态。这就是它在我的机器上寻找一个简单的cat 进程的方式:

00400000-0040c000 r-xp 00000000 00:13 1237228                            /usr/bin/cat
0060b000-0060c000 r--p 0000b000 00:13 1237228                            /usr/bin/cat
0060c000-0060d000 rw-p 0000c000 00:13 1237228                            /usr/bin/cat
01864000-01885000 rw-p 00000000 00:00 0                                  [heap]
7fe7a5e0b000-7fe7a6121000 r--p 00000000 00:13 1487092                    /usr/lib/locale/locale-archive
7fe7a6123000-7fe7a62ba000 r-xp 00000000 00:13 1486770                    /usr/lib/libc-2.23.so
7fe7a62ba000-7fe7a64ba000 ---p 00197000 00:13 1486770                    /usr/lib/libc-2.23.so
7fe7a64ba000-7fe7a64be000 r--p 00197000 00:13 1486770                    /usr/lib/libc-2.23.so
7fe7a64be000-7fe7a64c0000 rw-p 0019b000 00:13 1486770                    /usr/lib/libc-2.23.so
7fe7a64c0000-7fe7a64c4000 rw-p 00000000 00:00 0 
7fe7a64cb000-7fe7a64ee000 r-xp 00000000 00:13 1486769                    /usr/lib/ld-2.23.so
7fe7a66cc000-7fe7a66ee000 rw-p 00000000 00:00 0 
7fe7a66ee000-7fe7a66ef000 r--p 00023000 00:13 1486769                    /usr/lib/ld-2.23.so
7fe7a66ef000-7fe7a66f0000 rw-p 00024000 00:13 1486769                    /usr/lib/ld-2.23.so
7fe7a66f0000-7fe7a66f1000 rw-p 00000000 00:00 0 
7fe7a66f5000-7fe7a66f8000 rw-p 00000000 00:00 0 
7ffe398e8000-7ffe39909000 rw-p 00000000 00:00 0                          [stack]
7ffe3999b000-7ffe3999e000 r--p 00000000 00:00 0                          [vvar]
7ffe3999e000-7ffe399a0000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

所以从这里你可以看到程序映像本身映射到虚拟内存的开头附近,堆稍微高一点,堆栈映射到7ffe398e8000-7ffe39909000,并且C库和动态链接器也被加载到内存中.

请注意,每个文件都会被映射多次。例如,/usr/bin/cat 有一个只读、可执行和读写段。这是为了防止进程写入const 内存和执行数据。

从映射表中,您可以大致了解您的内存是如何布局的,以及可以对这些内存部分进行哪些操作。

这是个好主意吗?没有

除非您正在编写调试器或类似的开发工具,否则很可能不会。


顺便说一句,您正在考虑编写的“shell”听起来很像调试器。像 gdb 这样的调试器可以做你所说的事情,包括评估 C 表达式和检查内存。


顺便说一句,因为我觉得这很有趣,所以这里有一个小练习:

如您所见,ffffffffff600000 映射了一些内核内存。如果这个理论是正确的,我们应该能够读取该内存,即使通常我们无法访问内核的内存。让我们试试:

int main(void)
{
  unsigned long *p = 0xffffffffff600000;

  for (;;)
    printf("0x%lx, ", *p++);
}

我们得到

0xf00000060c0c748, 0xccccccccccccc305, 0xcccccccccccccccc, ... Segmentation fault

如果你想知道为什么这个内存对用户空间进程是可读的,它是为了加速某些系统调用,例如 gettimeofday 并允许它们工作,而不必像其他系统调用那样切换到内核模式。参见例如this question.

【讨论】:

  • 它本质上是一个“类似调试器”的应用程序。除了调试的目的之外,目的是将 C 功能暴露到运行时交互式 shell 中并“玩弄它”而没有特定的目标。例如,在这种情况下,我不关心我是否损坏了内存,但我关心我的程序是否崩溃并且不让我对此做任何事情并且没有提供关于代码的哪一部分导致崩溃的信息(只是一个无聊一般错误)。基本上是杂耍电锯。
  • @Dmitry 听起来很有趣。但是你不能真正编写一个真正意义上的跨平台调试器,所以如果你在问题中包含你选择的操作系统作为标签,你会得到更好的答案。如果您只是在此站点上将其标记为“C”,人们将假设纯粹是跨平台的,并且您往往会在人们引用 C 标准和类似标准的地方得到答案,这当然是正确和正确的,但对您在这种情况。
  • 我认为这是我暂时能得到的最好的解决方案。本质上,仅在 C 中没有办法做到这一点,需要一些平台调用或包装库来处理它,这是可能的,但前提是程序是平台感知的。平台不需要向 C 公开处理此类情况的方法,因此 C 无法普遍检查地址是否会崩溃。
  • @Dmitry 我想是的。您可以完全通过条件编译使其有效地跨平台,但这与(任何)语言本身意义上的可移植性不同。包装器库是个好主意,肯定胜过无穷无尽的#ifdefs! (尽管它可能至少需要一个!)然后你可以让另一层是可移植的 C 代码,它实际上与告诉它地址和诸如此类的底层机制无关。
【解决方案2】:

我想知道 C 中是否有标准方法来检查程序是否被允许访问我试图访问的内存,或者访问该内存是否会导致程序崩溃(在它实际崩溃之前),并向用户报告不允许程序访问该内存的信息。

不,没有。

标准只说不能取消引用空指针。除此之外,有效的指针值范围取决于平台。您希望完成的任务无法在独立于平台的代码中完成。

来自 C99 标准的脚注 87:

一元 * 运算符取消引用指针的无效值包括空指针、与指向的对象类型不恰当对齐的地址以及对象在其生命周期结束后的地址

【讨论】:

  • 详细信息:...其值比较等于NULL
  • FWIW,这是我的坚定信念:如果代码必须检查指针是否有效,则应该重新设计它。应该从不需要执行此类检查。然而,如何避免这种情况无法在评论中轻易说明。
  • @RudyVelthuis,我同意。您应该看到的唯一检查是指针比较是否等于NULL
  • 实际上,我通常会尽量避免编写也需要检查 NULL 的代码,但我知道并不是每个人都同意这一点。
【解决方案3】:

标准方式?不。您所经历的崩溃是未定义行为的结果。该标准没有深入研究这些细节。

Windows API 提供了一个IsBadReadPtr 函数,这似乎正是您所追求的。 documentation 非常清楚你不应该使用它。

您忽略了一些无效访问,您无法从中恢复。如果您触摸了保护页面,并在没有给保护页面访问处理程序运行机会的情况下捕获了错误,那么您就错过了机会。下次访问同一个地址时,您会遇到访问冲突和核心转储。尽管在正常执行时,这会很好。见Raymond Chen's IsBadXxxPtr should really be called CrashProgramRandomly

在 Unix 上,你可以让内核为你做这些脏活by passing the pointer to write(2)。如果它返回EFAULT,这意味着你的进程会崩溃。

请注意,虽然看起来您在检查先验,但实际上是在检查后果。提前检查不可靠(检查和实际访问之间的映射可能会发生变化)。

如果您想在失败后得到通知,请在 UNIX 上为 SIGSEGV 编写信号处理程序。在 Windows 上,处理 EXCEPTION_ACCESS_VIOLATION SEH 异常。


附录:你想做的事情听起来有点像mmbbq 所做的事情。它将一个 lua 解释器注入到外部应用程序中,并允许调用和取消引用地址。如果你捏造它,只有重新启动的线程受到影响并且程序本身继续工作(至少有一段时间......)。该网站已不在线,但也许您已成功找到镜像。

【讨论】:

  • 所以一种方法就是这样做并从支持它们的系统中捕获异常,否则报告错误并恢复(除非系统无论如何都不喜欢你并杀死你的进程)?
  • 没有防故障方法。但在大多数情况下,你可以有一个访问冲突处理程序并推迟崩溃。
  • @Dmitry 如果您特别对 Windows 感兴趣,请查看 mmbbq 项目(我编辑了答案)。
  • 似乎是一个很酷的项目,有点旧(有些链接不起作用)。感谢您的链接。
【解决方案4】:

如前所述,我们离“标准 C”还很远。

不过,您可以(在某种程度上)通过处理分段错误来实现这一点。当然有一个库:GNU libsigsegv

【讨论】: