MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++中数组越界与指针悬挂的安全风险

2021-02-146.3k 阅读

C++ 中数组越界的安全风险

数组越界的概念

在 C++ 中,数组是一种固定大小的数据结构,其元素在内存中是连续存储的。数组越界指的是访问数组时使用的索引值超出了数组有效范围。例如,对于一个定义为 int arr[5]; 的数组,有效的索引范围是从 0 到 4。如果尝试访问 arr[5] 或者 arr[-1],就发生了数组越界。

数组越界在内存层面的表现

从内存角度看,数组在内存中占据一段连续的空间。假设 int 类型占用 4 个字节,int arr[5]; 会在内存中分配 20 个字节(4 字节/元素 * 5 个元素)的连续空间。当发生数组越界访问时,比如访问 arr[5],实际上是在访问数组结束后的下一个内存位置。这个位置可能属于其他变量、程序代码段,甚至可能是操作系统保护的内存区域。

数组越界导致的数据损坏

  1. 覆盖相邻变量数据 假设我们有以下代码:
#include <iostream>

int main() {
    int num = 10;
    int arr[5] = {1, 2, 3, 4, 5};
    arr[5] = 100; // 数组越界
    std::cout << "num 的值: " << num << std::endl;
    return 0;
}

在这个例子中,arr[5] 越界访问可能会覆盖 num 的内存空间。由于 num 紧跟在 arr 数组之后存储(在栈上,变量声明顺序决定存储顺序),arr[5] = 100; 这行代码可能会把 num 的值从 10 改为 100。运行这段代码,输出可能就是 “num 的值: 100”,而不是预期的 “num 的值: 10”。这种数据损坏很难调试,因为错误发生的位置(越界访问处)和实际表现出问题的位置(num 值被改变)可能相隔较远。

  1. 破坏堆上的数据结构 当数组在堆上分配时,越界访问同样会带来严重问题。例如:
#include <iostream>

int main() {
    int* arr = new int[5];
    for (int i = 0; i < 5; i++) {
        arr[i] = i;
    }
    arr[5] = 100; // 数组越界
    delete[] arr;
    int num = 20;
    std::cout << "num 的值: " << num << std::endl;
    return 0;
}

这里 arr 是在堆上分配的数组。arr[5] 的越界访问可能会破坏堆上的内存管理结构。当执行 delete[] arr; 时,由于堆内存结构被破坏,可能导致内存释放错误,进而引发程序崩溃或者其他未定义行为。而后续定义的 num 变量的值也可能因为堆内存混乱而受到影响。

数组越界引发的程序崩溃

  1. 访问非法内存区域 如果越界访问的内存地址属于操作系统保护的区域,操作系统会检测到这种非法访问并终止程序。例如,访问负索引的数组元素,如 arr[-1],可能会尝试访问进程地址空间之外的内存,这是不被允许的。下面的代码演示了这种情况:
#include <iostream>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    std::cout << "arr[-1] 的值: " << arr[-1] << std::endl; // 尝试访问非法内存
    return 0;
}

在大多数操作系统上运行这段代码,程序会因为访问违规而崩溃,抛出类似 “Segmentation fault”(在 Linux 系统上)或者 “Access Violation”(在 Windows 系统上)的错误。

  1. 递归调用栈溢出 数组越界还可能在递归函数中导致调用栈溢出。考虑以下递归函数,它使用一个数组来存储递归调用的中间结果,但错误地发生了数组越界:
#include <iostream>

void recursiveFunction(int n, int* arr, int index) {
    if (n == 0) {
        return;
    }
    arr[index] = n; // 可能越界
    recursiveFunction(n - 1, arr, index + 1);
}

int main() {
    int arr[5];
    recursiveFunction(10, arr, 0);
    return 0;
}

在这个例子中,recursiveFunction 期望 n 的值在 0 到 4 之间,以确保 arr 数组不会越界。但由于 n 初始值为 10,arr[index] = n; 语句会在递归过程中导致数组越界。随着递归的深入,不断越界访问栈上的内存,最终导致栈溢出,程序崩溃。

数组越界带来的安全漏洞

  1. 缓冲区溢出攻击 数组越界是缓冲区溢出漏洞的主要原因之一。攻击者可以利用程序中的数组越界漏洞,通过精心构造输入数据,覆盖程序的返回地址或者关键数据,从而改变程序的执行流程。例如,在一个处理用户输入的程序中,如果存在数组越界:
#include <iostream>
#include <cstring>

void processInput(char* input) {
    char buffer[10];
    std::strcpy(buffer, input); // 可能发生数组越界
    std::cout << "处理后的输入: " << buffer << std::endl;
}

int main() {
    char userInput[50];
    std::cout << "请输入: ";
    std::cin.getline(userInput, 50);
    processInput(userInput);
    return 0;
}

攻击者可以输入超过 10 个字符的字符串,使 std::strcpy(buffer, input); 发生数组越界,覆盖 buffer 之后的内存。如果这个内存区域存储着函数的返回地址,攻击者就可以修改返回地址,让程序跳转到恶意代码处执行,从而获取系统权限或者窃取敏感信息。

  1. 信息泄露 数组越界还可能导致信息泄露。例如,当越界访问读取到内存中的敏感数据时,这些数据可能会被输出或者以其他方式暴露。假设程序在内存中存储了用户密码等敏感信息,而数组越界访问到了这个内存区域:
#include <iostream>

int main() {
    char password[8] = "secret";
    int arr[5];
    std::cout << "越界读取的数据: " << *(char*)(arr + 5) << std::endl; // 假设越界读取到密码部分
    return 0;
}

在这个简单示例中,虽然代码逻辑不合理,但它展示了数组越界可能读取到内存中其他敏感数据的风险。实际应用中,如果程序处理敏感数据,数组越界可能导致这些数据泄露给外部攻击者。

C++ 中指针悬挂的安全风险

指针悬挂的概念

指针悬挂指的是指针指向的内存已经被释放,但指针本身仍然存在且未被置为 nullptr。此时,该指针成为了一个 “悬挂指针”,继续使用这个指针会导致未定义行为。

指针悬挂在内存层面的原理

当使用 new 操作符在堆上分配内存并将其地址赋给指针时,指针指向这块内存区域。例如:

int* ptr = new int;
*ptr = 10;

这里 ptr 指向了一块新分配的存储 int 类型数据的内存。当使用 delete 操作符释放这块内存时:

delete ptr;

内存被归还给系统,ptr 所指向的地址处的数据不再有效。然而,ptr 本身仍然保存着原来的地址值,如果没有将 ptr 置为 nullptr,继续使用 ptr,比如 std::cout << *ptr;,就会访问已释放的内存,引发未定义行为。

函数返回值导致的指针悬挂

  1. 局部变量指针返回 考虑以下函数:
int* createNumber() {
    int num = 10;
    return &num;
}

int main() {
    int* ptr = createNumber();
    std::cout << "指针指向的值: " << *ptr << std::endl;
    return 0;
}

createNumber 函数中,num 是一个局部变量,存储在栈上。当函数返回时,num 的生命周期结束,其占用的栈内存被释放。但是函数返回了指向 num 的指针 &num。在 main 函数中,ptr 接收了这个悬挂指针。当尝试访问 *ptr 时,由于 num 的内存已被释放,这是未定义行为。程序可能输出错误的值,也可能崩溃。

  1. 动态分配内存后返回指针并释放内存
int* createArray() {
    int* arr = new int[5];
    for (int i = 0; i < 5; i++) {
        arr[i] = i;
    }
    return arr;
}

int main() {
    int* ptr = createArray();
    // 对 ptr 进行一些操作
    delete[] ptr;
    // 这里 ptr 成为悬挂指针
    std::cout << "指针指向的第一个元素: " << ptr[0] << std::endl; // 未定义行为
    return 0;
}

createArray 函数中,动态分配了一个数组并返回其指针。在 main 函数中,ptr 接收这个指针,使用完后释放了内存。之后再次访问 ptr[0] 时,ptr 已经是悬挂指针,访问导致未定义行为。

容器操作引发的指针悬挂

  1. std::vector 导致的指针悬挂
#include <iostream>
#include <vector>

void vectorExample() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    int* ptr = &vec[0];
    vec.push_back(6);
    std::cout << "指针指向的值: " << *ptr << std::endl; // 可能是未定义行为
}

int main() {
    vectorExample();
    return 0;
}

vectorExample 函数中,获取了 vec 内部数组的指针 ptr。当调用 vec.push_back(6) 时,如果 vec 的容量不足以容纳新元素,vec 会重新分配内存,将原来的元素复制到新的内存位置,并释放旧的内存。此时,ptr 指向的内存已被释放,ptr 成为悬挂指针。访问 *ptr 就会导致未定义行为。

  1. std::list 与指针悬挂
#include <iostream>
#include <list>

void listExample() {
    std::list<int> lst = {1, 2, 3, 4, 5};
    auto it = lst.begin();
    int* ptr = &(*it);
    lst.erase(it);
    std::cout << "指针指向的值: " << *ptr << std::endl; // 未定义行为
}

int main() {
    listExample();
    return 0;
}

listExample 函数中,获取了 lst 中第一个元素的指针 ptr。当调用 lst.erase(it) 时,被删除元素的内存被释放,ptr 成为悬挂指针。访问 *ptr 会导致未定义行为。

指针悬挂带来的安全风险

  1. 数据泄露与篡改 指针悬挂可能导致数据泄露或篡改。如果悬挂指针指向的内存区域被重新分配用于其他敏感数据,而程序继续错误地使用悬挂指针,就可能读取或修改这些敏感数据。例如,假设一个程序先分配内存存储用户登录信息,释放后没有正确处理指针,之后该内存区域被重新分配用于存储系统配置信息:
#include <iostream>

void sensitiveFunction() {
    char* userInfo = new char[50];
    std::strcpy(userInfo, "username:password");
    // 处理用户信息
    delete[] userInfo;
    // 这里 userInfo 成为悬挂指针,但未处理
    char* systemConfig = new char[50];
    std::strcpy(systemConfig, "config:settings");
    char* danglingPtr = userInfo;
    std::cout << "悬挂指针指向的数据: " << danglingPtr << std::endl; // 可能输出系统配置信息
    // 更危险的是,如果错误地写入,可能篡改系统配置
    std::strcpy(danglingPtr, "malicious_data");
}

int main() {
    sensitiveFunction();
    return 0;
}

在这个例子中,userInfo 释放后成为悬挂指针,之后该内存被重新分配给 systemConfig。如果程序继续使用 danglingPtr(即 userInfo),可能会泄露系统配置信息,甚至篡改这些信息,带来严重的安全风险。

  1. 程序崩溃与稳定性问题 指针悬挂导致的未定义行为常常会引发程序崩溃。由于悬挂指针指向的是无效内存,任何对其的解引用操作都可能导致操作系统检测到访问违规并终止程序。在大型复杂程序中,这种崩溃可能很难调试,因为指针悬挂可能发生在程序的一个部分,而崩溃发生在另一个看似不相关的部分。例如,一个图形渲染程序中,如果存在指针悬挂,可能在渲染某个复杂场景时突然崩溃,排查问题时很难快速定位到是指针悬挂导致的。

如何避免数组越界与指针悬挂

  1. 避免数组越界的方法
    • 边界检查:在访问数组元素之前,始终检查索引是否在有效范围内。可以使用 if 语句或者封装函数来实现。例如:
#include <iostream>

void safeAccess(int* arr, int index, int size) {
    if (index >= 0 && index < size) {
        std::cout << "数组元素: " << arr[index] << std::endl;
    } else {
        std::cout << "索引越界" << std::endl;
    }
}

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    safeAccess(arr, 3, 5);
    safeAccess(arr, 5, 5);
    return 0;
}
- **使用 `std::vector` 等容器**:`std::vector` 提供了自动的边界检查机制。`at` 成员函数在访问越界时会抛出 `std::out_of_range` 异常,而 `operator[]` 虽然不检查边界,但 `std::vector` 本身管理内存,减少了手动管理数组可能导致的越界风险。例如:
#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    try {
        std::cout << "vec.at(3): " << vec.at(3) << std::endl;
        std::cout << "vec.at(5): " << vec.at(5) << std::endl; // 抛出异常
    } catch (const std::out_of_range& e) {
        std::cout << "捕获到越界异常: " << e.what() << std::endl;
    }
    return 0;
}
  1. 避免指针悬挂的方法
    • 释放内存后置为 nullptr:在使用 deletedelete[] 释放内存后,立即将指针置为 nullptr。这样可以避免意外地继续使用悬挂指针。例如:
int* ptr = new int;
*ptr = 10;
delete ptr;
ptr = nullptr;
if (ptr) {
    std::cout << "指针有效,值为: " << *ptr << std::endl;
} else {
    std::cout << "指针已置为 nullptr" << std::endl;
}
- **使用智能指针**:`std::unique_ptr` 和 `std::shared_ptr` 等智能指针可以自动管理内存的释放。当智能指针超出作用域时,其指向的内存会自动释放,并且智能指针会自动处理指针悬挂问题。例如:
#include <iostream>
#include <memory>

void smartPtrExample() {
    std::unique_ptr<int> ptr(new int(10));
    std::cout << "智能指针指向的值: " << *ptr << std::endl;
    // 当 ptr 超出作用域时,内存自动释放
}

int main() {
    smartPtrExample();
    return 0;
}

通过了解数组越界与指针悬挂的安全风险,并采取相应的预防措施,可以提高 C++ 程序的稳定性和安全性,避免潜在的漏洞和错误。在实际编程中,始终要保持对内存操作的谨慎,遵循良好的编程实践,以确保程序的健壮性。无论是在小型项目还是大型复杂系统中,这些问题都不容忽视,因为它们可能带来严重的后果,从数据丢失到系统被攻击等各种问题。在处理数组和指针时,养成边界检查和正确管理内存的习惯,能够有效地减少程序中的错误和安全隐患。同时,使用现代 C++ 提供的工具,如智能指针和标准容器,也能大大降低这些风险。对于复杂的程序逻辑和大型代码库,进行代码审查和静态分析工具的使用也有助于发现潜在的数组越界和指针悬挂问题,从而提前解决,保障程序的质量和安全。