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

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

本文关键字:越界 我们 为什么      更新时间:2023-10-16

我最近读完了关于虚拟内存的文章,我有一个关于malloc如何在虚拟地址空间和物理内存中工作的问题。

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

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

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

这是我的猜测。

当malloc被调用时,也许操作系统决定给进程一个完整的页面。我只是假设每个页面都有4KB的空间。整个东西都标记为可写吗?这就是为什么您可以在页面中使用500*sizeof(int)(假设32位系统中int的大小为4字节)。

我看到,当我尝试以更大的值进行编辑时。。。

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

Seg故障。

如果是这样,那么这是否意味着有专门用于代码/指令/文本段并标记为不可写的页面与堆栈/变量所在的页面(情况确实发生了变化)完全分离,并标记为可写?当然,该进程认为它们位于32位系统上4gb地址空间中的每个顺序旁边。

"为什么允许这种情况发生"(写入超出界限)

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=%pn", &staticvar);
        printf("&constvar=%pn", &constvar);
        printf("&stackvar=%pn", &stackvar);
        printf("p=%pn", p);
        printf("undefined behaviour: &p[500]=%pn", &p[500]);
        printf("undefined behaviour: &p[50000000]=%pn", &p[50000000]);
        p[500] = 999999; //undefined behaviour
        printf("undefined behaviour: p[500]=%dn", 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

或者,为什么p[500]的地址是可写的?

Heap是0824d000-0826f000和&p[500]偶然是0x824ea70,因此内存是可写和可读的,但此内存区域可能包含将被更改的真实数据!在示例程序的情况下,它很可能是未使用的,因此对该内存的写入不会对进程的工作造成危害。

&p[550000000]偶然为0x1410a4a0,它不在内核映射到进程的页面中,因此是不可写和不可读取的,因此是seg错误。

如果使用-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

如果是这样,那么这是否意味着有专门用于代码/指令/文本段并标记为不可写的页面与堆栈/变量所在的页面(情况确实发生了变化)完全分离,并标记为可写?

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

为什么允许这种情况发生?

C(和C++)语言的主要设计目标之一是尽可能提高运行时效率。C(或C++)的设计者本可以决定在语言规范中包含一条规则,即"在数组边界之外写入必须导致X发生"(其中X是一些定义良好的行为,如崩溃或抛出异常)。。。但如果他们这样做了,每个C编译器都会被要求为C程序所做的每一次数组访问生成边界检查代码。根据目标硬件和编译器的聪明程度,强制执行这样的规则可以很容易地使每个C(或C++)程序比目前慢5-10倍

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

然后,现实世界的实现可以自由地做任何他们想做的事情——例如,在具有内存保护的操作系统上,你可能会看到像你描述的那样基于页面的行为,或者在嵌入式设备中(或者在MacOS9、MS-DOS或AmigaDOS等旧操作系统上),计算机可能会很乐意让你在内存中的任何地方写入,因为否则会使计算机速度太慢。

作为一种低级(按照现代标准)语言,C(C++)希望程序员遵守规则,并且只有在不产生运行时开销的情况下才能机械地执行这些规则。

未定义的行为

这就是事实。您可以尝试写出界,但不能保证工作。它可能会起作用,也可能不会。发生的事情完全没有定义。

为什么允许这种情况发生?

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

为什么p[500]的地址是可写的?

它只是碰巧是。未定义的行为。

我看到,当我尝试以更大的值进行编辑时。。。

看到了吗?同样,它只是发生在segfault上

当malloc被调用时,也许操作系统决定给进程一个完整的页面。

也许吧,但是C和C++标准并不要求这样的行为。它们只要求操作系统至少提供所需数量的内存供程序使用。(如果有可用的内存。)

简单地说,在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中,编译器甚至不知道你有脚。

这是未定义的行为。。。

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

  • 对于某些编译器/标志/OS/一周中的某一天等,内存可能是可写的,而不会出现明显的故障,因为:

    • malloc()实际上可能会分配一个更大大小的已分配块,其中[500]可以被写入(但在程序的另一次运行中可能不会),或者
    • [500]可能在分配的块之后,但程序仍然可以访问内存
      • [500](一个相对较小的增量)很可能仍在堆中,它可能会扩展到比malloc调用迄今为止产生的地址更远的地址,这是由于为预期使用做准备而提前预留了堆内存(例如使用sbrk()
      • [500]可能是堆的"末端",并且您最终会写入其他内存区域,例如静态数据、线程特定数据(包括堆栈)

为什么允许这种情况发生?

这有两个方面:

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

  • 非常精确地管理内存,使得某些CPU故障报告越界访问,这在很大程度上取决于硬件,并且通常只可能在页面边界(例如,1k到4k范围内的粒度),以及需要额外的指令(无论是在某些增强的malloc函数中还是在某些malloc包装代码中)和时间来协调。

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

C标准允许但不要求实现无条件地如上所述进行操作,即使在地址超出被索引的数组对象的范围的情况下也是如此。依赖于这种行为的代码通常是不可移植的,但在一些平台上,可能能够比其他情况下更有效地完成一些任务。