09-内存安全性
5.6 内存安全性
不过我们所说的程序内存安全性是什么意思呢?内存安全性是指你的程序永远不会访问它不应该访问的位置,程序中声明的变量不能指向无效内存,并且在所有代码路径中都保持有效。换句话说,安全性基本上会归结为在程序中始终具有有效引用的指针,并且使用指针的操作不会导致未定义的行为。未定义的行为指程序的状态出现了编译器未考虑到的情况,因为编译器规范中没有说明在该情况下会发生什么。
C语言中未定义的行为的一个例子是访问越界和未初始化的数组元素:
// uninitialized_reads.c
#include <stdio.h>
int main() {
int values[5];
for (int i = 0; i < 5; i++)
printf("%d ", values[i]);
}
在上述代码中,我们有一个包含5个元素的数组,并且会循环输出数组中的值。使用gcc -o main uninitialized_reads.c &&./main命令运行程序后得到以下输出结果:
4195840 0 4195488 0 609963056
在计算机上,这可以输出任何值,甚至可以输出能够被漏洞利用的指令地址。这是一种未定义的行为,可能发生任何事情。你的程序可能会立即崩溃,这是“最好”的情况,因为你可以随时了解它;它也可能继续工作,破坏程序的内部状态,以后可能会导致应用程序生成错误的输出结果。
违反内存安全性的另一个示例是C++中的迭代器失效问题:
// iterator_invalidation.cpp
#include <iostream>
#include <vector>
int main() {
std::vector <int> v{1, 5, 10, 15, 20};
for (auto it=v.begin();it!=v.end();it++)
if ((*it) == 5)
v.push_back(-1);
for (auto it=v.begin();it!=v.end();it++)
std::cout << (*it) << " ";
return 0;
}
在上述C++代码中,我们创建了一个整数向量v,尝试在for循环中调用迭代器it对它进行迭代访问。上述代码的问题在于我们有一个迭代器it的指针指向v,同时我们迭代访问v,并向其中推送值。
现在由于向量的实现方式,如果它们的尺寸达到其容量,则会在内部重新分配内存以增加其容量。当发生这种情况时,会使it指针指向某个垃圾值,这被称为迭代器失效问题,因为现在指针指向的是无效的内存地址。
内存不安全性的另一个示例是C语言中的缓冲区溢出。以下用一段简单的代码来演示此问题:
// buffer_overflow.c
int main() {
char buf[3];
buf[0] = 'a';
buf[1] = 'b';
buf[2] = 'c';
buf[3] = 'd';
}
上述程序能够通过编译,并在没有错误的情况下运行,但是最后一次赋值操作越过了分配的缓冲区,且可能覆盖其他数据或地址中的指令。此外,兼容架构和环境的特定恶意输入值可能会导致任意代码执行。这些错误在实际代码中以非常隐蔽的方式产生,并可能产生影响全球企业的漏洞。在最新版本的GCC编译器中,这会被检测为堆栈粉碎攻击,GCC会通过发送SIGABRT(abort)信号来将程序挂起。
内存安全性bug会导致内存泄漏,以分段错误的形式导致程序崩溃,或者在最糟糕的情况下产生安全漏洞。要在C语言中创建正确且安全的程序,程序员必须在使用完内存后进行适当的free函数调用。如今的C++通过智能指针类型来处理与手动内存管理有关的问题,但这并不能完全消除它们。基于虚拟机的语言(JVM是最典型的例子)使用垃圾收集机制来消除所有和类有关的内存安全问题。虽然Rust没有内置GC,但由于该语言中采用了相同的RAII原则,同时根据变量的作用域为我们自动释放使用过的内存,因此比C/C++更安全。它为我们提供了几个细粒度的抽象,用户可以根据自己的需要进行选择。为了了解Rust是如何做到这一切的,让我们讨论一下有助于程序员在编译期管理内存的内存安全三原则。