在深入探讨传统 C++ 与现代 C++ 的内存安全问题之前,让我们先明确其定义。援引白宫报告所述:“内存安全漏洞是指一类由于内存访问、写入、分配或释放操作违反预期方式而产生的漏洞……主要分为两大类:空间安全和时间安全。空间安全问题源于对变量和对象内存‘正确’边界之外的访问;时间安全问题则发生在内存访问违反时序或状态时,例如访问已释放对象,或出现意外交叉的内存访问。”完整定义参见报告第 7 页:https://www.whitehouse.gov/wp-content/uploads/2024/02/Final-ONCD-TechnicalReport.pdf。
所有 C++ 开发者都应对这两类问题耳熟能详。空间安全问题在使用原生数组时最为常见。例如,下面这个程序创建了一个数组,在赋值后尝试越界读写元素:
int doSomeWork(int value1, int value2, int value3, int value4) {
int array[3];
array[0] = value1;
array[1] = value2;
array[3] = value3;
array[4] = value4;
return array[0] + array[1] + array[3] + array[4];
}
这段代码最令人意外的是它属于未定义行为(undefined behavior)—— 编译器可能以各种方式处理数组越界访问,从静默允许到主动报错都有可能。更复杂的是,根据操作系统和运行环境的不同,该代码甚至可能“正常”运行,并覆盖未知的内存区域。
我在 Ubuntu Linux 系统上使用 clang 和 g++ 编译器测试了这段代码。g++ 即使启用了所有警告选项也未给出任何提示,而 clang 则在编译时给出了数组越界的警告。实际运行程序时,系统提示:“检测到堆栈破坏:程序已终止”。这表明运行时确实存在一定的保护机制,但并不能完全阻止潜在副作用的发生。
需要注意的是,这只是一个简单的示例。如果我们在代码中创建数组后将其传递给多个函数,并通过复杂的公式计算索引,恐怕没有任何编译器能够准确识别出所有的越界访问行为。最终我们只能依赖运行时测试和操作系统的防护机制。
这是编程语言本身的缺陷吗?鲜为人知的是,那些被归类为“内存安全”的语言也可能写出类似的问题代码。例如 C# 允许通过 unsafe 代码块使用指针和指针运算(尽管有诸多限制)。两者的关键区别在于:
C++ 的问题并不在于它允许这些操作,而在于在默认情况下,开发者太容易犯下此类错误。既然我们已经谈到了指针,请看下面这段通过 void* 指针和指针运算,来越界访问 int 类型内存的代码:
int pointerBounds() {
int *aPointerToInt;
void *aPointerToVoid;
aPointerToVoid = new int();
aPointerToInt = (int*)aPointerToVoid;
*aPointerToInt = 234;
aPointerToInt = (int*)((char*)aPointerToVoid + sizeof(int));
*aPointerToInt = 2423;
int value = *aPointerToInt;
delete aPointerToVoid;
return value;
}
TEST_CASE("try pointer bounds"){
int result = pointerBounds();
CHECK_EQ(2423, result);
}
我们再次遭遇未定义行为:通过指针算术运算得到的指针赋值操作,其具体行为由编译器决定。这次,g++ 和 clang 都给出了警告 —— 但仅针对删除 void* 指针这一操作。两个编译器对我尝试读写越界内存区域的行为都毫无反应。更有趣的是,测试运行完全“正常”,函数返回了预期结果,皆大欢喜!甚至连操作系统都没对这荒谬操作提出抗议 ——但愿只是因为没有超出进程的内存分配范围。
但愿如此。
至此我们看到的都是空间内存安全问题,情况已经不容乐观。那么时间安全问题呢?任何使用过原始指针的人都深有体会:必须在指针不再需要时完成两件事 —— 释放分配的内存,并将其置为 nullptr。这两者都至关重要,因为忽略任一项都会引发时间安全问题:要么产生悬垂指针(仍能访问已释放内存区域),要么导致内存泄漏(指针未释放且可能已被重置,致使内存区域永久不可访问)。
请看下面这个示例函数:它初始化一个 int 指针并赋值,释放内存后,又返回该内存存储的值:
int danglingPointer() {
int *aPointerToInt = new int(234);
delete aPointerToInt;
return *aPointerToInt;
}
TEST_CASE("Try dangling pointer"){
int result = danglingPointer();
CHECK_EQ(234, result);
}
这段代码再次顺利通过了编译 —— 无论是g++还是clang都没有发出任何警告。程序也能运行,但测试最终失败:因为此时该内存地址存储的值已非预期结果。
test.cpp:8: ERROR: CHECK_EQ( 234, result ) is NOT correct!
values: CHECK_EQ( 234, 721392248 )
每次调用该函数时,该内存地址存储的值都会发生变化,可能产生如下不同的结果:
test.cpp:8: ERROR: CHECK_EQ( 234, result ) is NOT correct!
values: CHECK_EQ( 234, 1757279720 )
test.cpp:8: ERROR: CHECK_EQ( 234, result ) is NOT correct!
values: CHECK_EQ( 234, -1936531037 )
在后续代码中使用这个值进行计算并返回异常结果,简直出乎意料地简单。如果对程序执行的运算稍有了解,并能够反复输入特定的值,这种行为甚至可能被用作探查内存内容的手段。
时间安全问题则更加棘手 —— 在庞大的代码迷宫中追踪指针的生命周期,远比确保不越界访问要困难得多。很不幸,内存问题确实是 C++ 中的重大隐患。
前述所有示例都采用了传统 C++ 的编程风格:使用原生数组、裸指针和指针运算。正因为如此,在现代 C++ 中应当极其谨慎地使用这些特性。虽然不能说应该完全禁用它们(毕竟在某些特定场景,如内存优化或底层系统编程中仍不可或缺),但通常可以划定清晰的边界,将这些危险操作隔离在现代 C++ 所倡导的安全区域之外。
那么,现代 C++ 是否已经解决了这些问题?