为什么我们可以在 C 中写越界?

Why is it that we can write outside of bounds in C?

我最近读完了有关虚拟内存的文章,我对 malloc 如何在虚拟地址 space 和物理内存中工作有疑问。

例如(从另一个 SO post 复制的代码)

void main(){
int *p;
p=malloc(sizeof(int));
p[500]=999999;
printf("p[0]=%d\n",p[500]); //works just fine. 
}

为什么允许这种情况发生?或者为什么 p[500] 的地址甚至是可写的?

这是我的猜测。

当 malloc 被调用时,也许 OS 决定给进程一个完整的页面。我将假设每个页面值 4KB space。整个东西都标记为可写吗?这就是为什么您最多可以将 500*sizeof(int) 放入页面(假设 32 位系统,其中 int 的大小为 4 个字节)。

我发现当我尝试以更大的值进行编辑时...

   p[500000]=999999; // EXC_BAD_ACCESS according to XCode

段错误。

如果是这样,那是否意味着有专用于您的 code/instructions/text 段并标记为不可写的页面与您的 stack/variables 所在的页面完全分开(其中做改变)并标记为可写?当然,进程认为它们在 32 位系统上的 4gb 地址 space 中的每个订单旁边。

未定义的行为

就是这样。您可以 尝试写越界,但保证 不会工作。它可能有效,也可能无效。会发生什么是完全不确定的。

Why is this allowed to happen?

因为 C 和 C++ 标准允许这样做。这些语言被设计为快速。必须检查越界访问需要 运行 次操作,这会减慢程序速度。

why is that address at p[500] even writable?

刚好是。未定义的行为。

I see that when I try to edit at a larger value...

看到了吗?同样,刚好发生段错误。

When malloc is called, perhaps the OS decides to give the process an entire page.

也许吧,但是 C 和 C++ 标准不需要这样的行为。他们只要求 OS 至少提供请求的内存量供程序使用。 (如果有可用内存。)

这是未定义的行为...

  • 如果您尝试访问边界外,可能会发生任何事情,包括 SIGEGV 或堆栈中其他地方的损坏,这会导致您的程序产生错误结果、挂起、稍后崩溃等。

  • 内存 可能 是可写的,在某些给定 运行 对于某些 compiler/flags/OS/day-of-the-week 等上没有明显的失败,因为:

    • malloc() 实际上可能会分配一个更大的分配块,其中 [500] 可以写入(但在程序的另一个 运行 上,可能不会),或者
    • [500] 可能在分配的块之后,但程序仍然可以访问内存
      • 很可能 [500] - 作为一个相对较小的增量 - 仍会在堆中,这可能会超出 malloc 调用迄今为止由于某些原因产生的地址提前预留堆内存(例如使用sbrk())以准备预期使用
      • 很可能 [500] 是 "off the end of" 堆,您最终会写入其他内存区域,例如关于静态数据、线程特定数据(包括堆栈)

Why it this allowed to happen?

这有两个方面:

  • 在每次访问时检查索引会膨胀(添加额外的机器代码指令)并减慢程序的执行速度,通常程序员可以对索引进行一些最小的验证(例如,当一个函数的输入,然后多次使用索引),或者以保证其有效性的方式生成索引(例如,从 0 循环到数组大小)

  • 极其精确地管理内存,这样一些 CPU 错误就会报告越界访问,这高度依赖于硬件并且通常只能在页面边界(例如粒度在 1k 到 4k 范围内),以及采取额外的指令(无论是在一些增强的 malloc 函数中还是在一些 malloc-wrapping 代码中)和编排时间。

只是C语言中数组的概念比较基础

p[] 的赋值在 C 中等同于:

*(p+500)=999999;

所有编译器实现的是:

fetch p;
calculate offset : multiply '500' by the sizeof(*p) -- e.g. 4 for int;
add p and the offset to get the memory address
write to that address.

在许多体系结构中,这可以通过一条或两条指令实现。

请注意,编译器不仅不知道值 500 不在数组中,实际上也不知道数组的开头!

在 C99 及更高版本中,已经做了一些工作来使数组更安全,但从根本上说,C 是一种旨在快速编译和快速 运行 的语言,并不安全。

换句话说。在 Pascal 中,编译器会阻止你开枪。在 C++ 中,编译器提供了一些方法来让你更难踢你的脚,而在 C 中,编译器甚至不知道你有脚。

Why is this allowed to happen?

C(和 C++)语言的主要设计目标之一是尽可能提高 运行 的时间效率。 C(或 C++)的设计者本可以决定在语言规范中包含一条规则,说 "writing outside the bounds of an array must cause X to happen"(其中 X 是一些定义明确的行为,例如崩溃或抛出的异常)......但是如果他们这样做,每个 C 编译器都需要 为 C 程序执行的每个数组访问生成边界检查代码。根据目标硬件和编译器的聪明程度,执行这样的规则很容易使每个 C(或 C++)程序比目前慢 5-10 倍。

因此,他们没有要求编译器强制执行数组边界,而是简单地指出在数组边界之外写入是未定义的行为——也就是说,你不应该不要去做,但是如果你去做去做,那么就无法保证会发生什么,任何你不喜欢的事情都是你的问题,而不是他们的问题。

现实世界的实现然后可以自由地做任何他们想做的事——例如,在具有内存保护的 OS 上,您可能会看到您描述的基于页面的行为,或者在嵌入式设备中(或者在较旧的 OS 上,例如 MacOS 9、MS-DOS 或 AmigaDOS),计算机可能会很乐意让您写入内存中的任何位置,因为否则会使计算机速度变慢。

作为一种低级(按现代标准)语言,C (C++) 希望程序员遵守规则,并且只会机械地执行这些规则 if/when 它可以这样做而不会招致 运行时间开销。

"Why is this allowed to happen?" (write outside of bounds)

C 不需要额外的 CPU 指令,通常需要这些指令来防止这种超出范围的访问。

这就是 C 的速度 - 它信任程序员,为编码人员提供执行任务所需的所有绳索 - 包括足够的绳索来吊死自己。

考虑 Linux 的以下代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int staticvar;
const int constvar = 0;

int main(void)
{
        int stackvar;
        char buf[200];
        int *p;

        p = malloc(sizeof(int));
        sprintf(buf, "cat /proc/%d/maps", getpid());
        system(buf);

        printf("&staticvar=%p\n", &staticvar);
        printf("&constvar=%p\n", &constvar);
        printf("&stackvar=%p\n", &stackvar);
        printf("p=%p\n", p);
        printf("undefined behaviour: &p[500]=%p\n", &p[500]);
        printf("undefined behaviour: &p[50000000]=%p\n", &p[50000000]);

        p[500] = 999999; //undefined behaviour
        printf("undefined behaviour: p[500]=%d\n", p[500]);
        return 0;
}

它打印进程的内存映射和一些不同类型内存的地址。

[osboxes@osboxes ~]$ gcc tmp.c -g -static -Wall -Wextra -m32
[osboxes@osboxes ~]$ ./a.out
08048000-080ef000 r-xp 00000000 fd:00 919429                /home/osboxes/a.out
080ef000-080f2000 rw-p 000a6000 fd:00 919429                /home/osboxes/a.out
080f2000-080f3000 rw-p 00000000 00:00 0
0824d000-0826f000 rw-p 00000000 00:00 0                     [heap]
f779c000-f779e000 r--p 00000000 00:00 0                     [vvar]
f779e000-f779f000 r-xp 00000000 00:00 0                     [vdso]
ffe4a000-ffe6b000 rw-p 00000000 00:00 0                     [stack]
&staticvar=0x80f23a0
&constvar=0x80c2fcc
&stackvar=0xffe69b88
p=0x824e2a0
undefined behaviour: &p[500]=0x824ea70
undefined behaviour: &p[50000000]=0x1410a4a0
undefined behaviour: p[500]=999999

Or like why is that address at p[500] even writable?

Heap来自0824d000-0826f000,&p[500]恰好是0x824ea70,所以内存是可写可读的,但是这个内存区域可能包含真正的数据,会被改变!对于示例程序,它很可能未被使用,因此写入此内存不会对进程的工作造成危害。

&p[50000000] 偶然是 0x1410a4a0,它不在内核映射到进程的页面中,因此不可写且不可读,因此出现段错误。

如果你用-fsanitize=address编译它,内存访问将被检查,许多但不是所有非法内存访问将被AddressSanitizer报告。减速比没有使用 AddressSanitizer 慢大约两倍。

[osboxes@osboxes ~]$ gcc tmp.c -g -Wall -Wextra -m32 -fsanitize=address
[osboxes@osboxes ~]$ ./a.out
[...]
undefined behaviour: &p[500]=0xf5c00fc0
undefined behaviour: &p[50000000]=0x1abc9f0
=================================================================
==2845==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5c00fc0 at pc 0x8048972 bp 0xfff44568 sp 0xfff44558
WRITE of size 4 at 0xf5c00fc0 thread T0
    #0 0x8048971 in main /home/osboxes/tmp.c:24
    #1 0xf70a4e7d in __libc_start_main (/lib/libc.so.6+0x17e7d)
    #2 0x80486f0 (/home/osboxes/a.out+0x80486f0)

AddressSanitizer can not describe address in more detail (wild memory access suspected).
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/osboxes/tmp.c:24 main
[...]
==2845==ABORTING

If so, then does that mean that there are pages that are dedicated to your code/instructions/text segments and marked as unwrite-able completely separate from your pages where your stack/variables are in (where things do change) and marked as writable?

是的,请参阅上面进程内存映射的输出。 r-xp表示可读可执行,rw-p表示可读可写。

在 1974 年 C 参考手册描述的语言中,int arr[10]; 在文件范围内的含义是“保留一个连续存储位置的区域,其大小足以容纳 10 个 int 类型的值,并将名称 arr 绑定到该区域开头的地址。表达式 arr[someInt] 的含义将是“将 someInt 乘以 int 的大小,添加arr 基地址的字节数,并访问恰好存储在结果地址中的任何 int。如果 someInt 在 0..9 范围内,则结果地址将落在声明 arr 时保留的 space 范围内,但语言不知道该值是否会下降在那个范围内。如果在 int 是两个字节的平台上,程序员碰巧知道某个对象 x 的地址比 arr 的起始地址晚了 200 字节,那么访问 arr[100] 将是对 x 的访问。至于程序员如何碰巧知道 xarr 开始后 200 个字节,或者为什么程序员想要使用表达式 arr[100] 而不是 x访问 x,语言的设计完全不知道这些事情。

C 标准允许但不要求实现无条件地按上述方式运行,即使在地址落在被索引的数组对象范围之外的情况下也是如此。依赖于此类行为的代码通常是 non-portable,但在某些平台上可能能够比其他方式更有效地完成某些任务。