c++ 指针的安全问题

发布时间:2024年01月17日

指针是一个强大的工具,但它们可能导致多种安全问题。接下来我们一起研究一下会出现的安全问题。欢迎大家补充说明!!!

悬挂指针(也称为悬空指针或迷途指针)

是指向一块已经释放或无效内存的指针。悬挂指针的出现通常是由于对象被删除或释放后,有指针仍然保留着指向该内存地址的引用,但此时该地址上的数据已经不再有效或被分配给其他用途。

下面是悬挂指针出现的典型场景及其危险性:

int* ptr = new int(10); // 动态分配一个整数
delete ptr; // 释放内存
// ptr 现在成为悬挂指针,因为它指向被释放的内存

delete操作之后,ptr仍然保存着之前分配的内存地址,但这块内存已经被归还给操作系统或用来存放其他内容,在这段内存上进行读写操作会导致不可预期的行为,包括数据损坏和程序崩溃。

悬挂指针的危险性

1、未定义的行为:对悬挂指针解引用或在其上进行操作可能导致未定义行为,这意味着程序可能会崩溃,或者更糟糕的是,静默地继续运行并产生错误数据。
2、安全漏洞:悬挂指针可能会被恶意利用,导致安全漏洞,如缓冲区溢出和其他类型的攻击。

如何避免悬挂指针

1、及时清空指针:释放内存后,立即将指针设置为nullptr,这样任何对指针的后续访问都将是对空指针的访问,这虽然不能解引用,但至少避免了悬挂指针的问题。
	delete ptr;
   	ptr = nullptr; // 避免悬挂指针
2、局部变量的生命周期:尽量使用局部变量(包括RAII对象和智能指针)来管理资源,这样当变量离开作用域时,资源就会自动释放。
3、智能指针的使用:使用C++的智能指针(如std::unique_ptr和std::shared_ptr),它们能够自动管理内存,确保对象的生命周期得到适当的控制。
4、避免原始指针:在不需要低级内存操作的情况下,尽量避免使用原始指针。

野指针(也称为悬空指针)

指向不可预测内存位置的指针。它通常是由于指针没有被初始化、已经释放或已经删除的内存的指针。

野指针的危险性在于,它可能指向任何地方,包括无效或者保留的内存区域。对野指针的解引用是非法的,它会导致未定义的行为,可能会引发程序崩溃或者更糟糕的后果。与悬挂指针类似,野指针也有可能被用于攻击,从而导致安全漏洞。

总之野指针问题通常源于不当的内存管理。在C++程序设计中,应该密切关注指针的使用,确保它们在任何时刻都指向有效的内存地址或者为nullptr。

野指针产生的情况

1、未被初始化的指针:
int* ptr; // 未初始化的指针
*ptr = 5; // 未定义行为,对野指针解引用
2、指针释放后遗忘置空:
int* ptr = new int(10); // 动态分配内存
delete ptr; // 释放内存
// 如果在此后没有将 ptr 置为 nullptr,它将变成野指针

避免野指针的策略:

1、始终初始化指针: 声明指针时,应该立即将其初始化为nullptr,或者给它一个有效的内存地址。
int* ptr = nullptr; // 安全地初始化为nullptr
2、分配内存后进行检查: 在使用动态内存分配(如 new 或 malloc)后,检查是否分配成功,并在内存分配失败时采取适当的错误处理。
3、释放内存后立即置空: 释放指针指向的内存后,立即将指针置为nullptr。
delete ptr;
ptr = nullptr; // 避免野指针
4、使用智能指针: 尽量使用标准库中的std::unique_ptr或std::shared_ptr等智能指针,它们会自动管理生命周期,从而避免野指针问题。
5、避免复杂的指针操作: 尽量简化使用指针的代码,避免不必要的指针操作,减少出错机率。
6、代码审查和静态分析: 使用代码审查和静态分析工具来检测潜在的野指针使用。

内存泄漏

内存泄漏指的是程序在运行过程中分配了内存(通过 new、malloc 等),但没有适时释放(使用 delete 或 free),导致已分配的内存既不被使用也无法被回收。这会导致程序的内存消耗持续增加,最终可能耗尽系统资源,影响程序的性能,甚至导致程序崩溃

示例
函数 createMemoryLeak 分配了一个整型数的内存,但并没有释放它。因此,当函数执行完毕后,分配的内存空间由于没有指针引用它,变得无法访问,导致内存泄漏。
#include <iostream>

void createMemoryLeak() {
    int* leakyInt = new int(42); // 动态分配,没有相应的 delete
}

int main() {
    createMemoryLeak(); // 调用会产生内存泄漏的函数

    // ... 程序其他代码

    return 0;
    // 程序结束后,由于缺乏 delete,分配的内存被泄漏
}
避免内存泄漏的方法
1、使用智能指针:C++11以后,推荐使用智能指针 (如 std::unique_ptr 和 std::shared_ptr) 来自动管理内存。智能指针在销毁时会自动释放它们所拥有的资源,有效防止内存泄漏。
    #include <memory>

    void safeFunction() {
        auto safePtr = std::make_unique<int>(42); // 使用 unique_ptr,无需手动释放
    }
2、遵循 RAII 原则:资源获取即初始化(Resource Acquisition Is Initialization) 原则是现代 C++ 程序设计的基础。确保资源(如内存、文件句柄等)的获取和释放始终与对象的生命周期保持一致。
3、仔细管理裸指针:如果不得不使用裸指针,应确保每个 new 与一个 delete 相对应,每个 new[] 与一个 delete[] 相对应。
4、尽量使用标准库容器:例如 std::vector、std::string 等,它们管理内存的生命周期,减少了手动内存管理的需求。
5、代码审查和自动化测试:通过工具和测试来检测代码中的内存泄漏,写出更健壮的代码。
6、使用内存泄漏检测工具:使用像 Valgrind、AddressSanitizer,或者 Visual Studio 的内存检测工具来帮助发现和锁定内存泄漏的位置。
内存泄漏检测工具
Valgrind: 一个广泛使用的Linux下的内存检测工具,可以帮助发现内存泄漏以及其他内存相关错误。
AddressSanitizer(ASan): 一个快速的内存错误检测器,可以检测出包括内存泄漏在内的各种内存访问错误。
Visual Studio: 提供了内置的内存泄漏检测工具,在调试模式下运行程序时可以帮助发现内存泄漏。

缓冲区溢出

缓冲区溢出(buffer overflow)是一种安全漏洞,它发生在程序试图将数据写入一个固定长度的缓冲区,而写入的数据量超过了缓冲区可以容纳的大小。由于 C++ 不会自动检查数组边界,所以当进行数组操作时,如果不慎,很容易发生缓冲区溢出。

缓冲区溢出可能导致程序崩溃、数据损坏,甚至让攻击者有机会执行任意代码,这在某些情况下会导致安全漏洞。

缓冲区溢出示例
buffer 数组定义为长度 10,但在 for 循环中,我们写入了 11 个字符(从 0 到 10,包括两端),因此,当 i 为 10 时,我们会尝试写入数组的边界之外,这就会导致缓冲区溢出。
#include <iostream>

void bufferOverflowExample() {
    char buffer[10];
    for(int i = 0; i <= 10; i++) {
        buffer[i] = 'a'; // i = 10 时会导致缓冲区溢出
    }
}

int main() {
    bufferOverflowExample(); // 调用函数,演示缓冲区溢出
    return 0;
}
防止缓冲区溢出:
1、使用标准库容器: 例如 std::vector 和 std::string 来替代原始数组。这些容器会自动地管理内存,并提供边界检查操作。
2、边界检查: 如果必须使用原始数组,确保对所有的数组访问操作进行边界检查。
3、使用安全函数: 尽量使用安全的函数来代替旧的函数。比如使用 std::strncpy 代替 strcpy,使用 std::snprintf 代替 sprintf。
4、堆栈保护: 现代编译器通常提供选项以增加堆栈保护,可以帮助阻止缓冲区溢出攻击。
5、避免不安全的函数: 一些函数本身就存在安全隐患,比如 gets 函数,应当避免使用。
6、代码审查: 持续审查代码,查找可能的缓冲区溢出风险。
7、动态检测工具: 使用动态内存调试工具,如 Valgrind 或 AddressSanitizer,它们可以在开发过程中帮助发现缓冲区溢出。
8、静态代码分析: 许多现代 IDE 和静态代码分析工具可以扫描源代码,查找潜在的缓冲区溢出风险。
9、使用堆分配: 当分配大型数据结构时,使用堆 (通过 new 或 std::make_unique 等) 而非栈,以避免栈溢出。
10、开启编译器安全检查: 某些编译器提供编译时检查,可以帮助检测数组边界问题。
典型的编译器安全选项
GCC/Clang 有 -fstack-protector 选项开启堆栈保护。
Visual Studio 提供 /GS 以增加安全检查。

释放非动态分配的内存

尝试释放非动态分配的内存(即没有使用 new 或者 malloc 系列函数分配的内存)是一个严重的编程错误。这种错误会导致未定义行为(undefined behavior),这意味着程序可能会崩溃、损坏数据、运行异常或者有时看似正常运行,使得问题难以跟踪和调试。

非动态分配内存示例

指针 ptr 指向的是栈上分配的变量 i 的地址,而不是通过 new 关键字分配的堆内存。尝试用 delete 释放栈上的变量是不允许的,这会导致程序运行出现未定义行为。

int main() {
    int i = 42;
    int* ptr = &i;

    // 错误:试图释放一个非动态分配的指针
    delete ptr; // 未定义行为

    return 0;
}
正确的动态内存分配和释放

只有通过 new 分配的内存才应该用 delete 释放,通过 new[] 分配的内存应该用 delete[] 释放。类似地,malloc 分配的内存应该用 free 释放。

int main() {
    // 正确:使用 new 分配动态内存
    int* ptr = new int(42);

    // 使用指针做一些事情...

    // 正确:使用 delete 释放动态分配的内存
    delete ptr;

    return 0;
}
如何避免这种错误
1、使用智能指针:智能指针如 std::unique_ptr 或 std::shared_ptr 可以自动管理内存的释放,这样就不需要手动调用 delete2、尽量避免裸指针:如果可以,尽量使用栈分配或者标准库容器类,比如 std::vector 或 std::string,这样就不需要直接处理内存分配和释放。
3、代码审查和自动化测试:通过代码审查和测试可以帮助检测和防止这类错误。
4、清晰的所有权管理:在设计软件时,清晰地定义哪部分代码负责分配和释放内存。
5、使用 RAII (Resource Acquisition Is Initialization) 原则:封装资源管理在对象内,利用构造函数分配和析构函数释放资源,保证资源的正确管理。
6、编写自动化测试:通过自动化测试来检测内存管理的问题,确保在释放之前内存是通过正确的方式分配的。

前越界和后越界访问

在c++中,数组的前越界访问和后越界访问是两种常见的内存安全错误。它们都属于缓冲区溢出(buffer overflow)的范畴。

后越界访问

后越界访问发生在当你尝试访问一个数组或缓冲区结束之后的内存位置时。常见的情况是,访问数组时超出其已定义的长度范围。这种错误可能引起程序崩溃或行为异常,并且可以被恶意利用来攻击软件系统。

int arr[5] = {0, 1, 2, 3, 4};
// 越界访问:访问数组后面的元素
int value = arr[5]; // 错误,arr[5] 实际上是 arr 数组后的第一个位置
前越界访问

前越界访问发生在访问数组或缓冲区开始之前的内存位置时。这种情况不像后越界那么常见,但同样会导致未定义行为。

int arr[5] = {0, 1, 2, 3, 4};

// 越界访问:访问数组前面的元素
int value = *(arr - 1); // 错误,arr - 1 指向数组前面的位置
如何避免越界访问
1、使用标准库容器:使用 std::vector, std::array, std::string 等 STL 容器,它们提供了边界检查的方法(如 at() 成员函数),如果越界,它们会抛出异常。
2、边界检查:总是在数组操作之前执行边界检查,确保索引是有效的。
3、使用迭代器和范围基 for 循环:尽量使用迭代器或者范围基 for 循环进行数组或容器的遍历,这样可以避免直接操作索引。
4、静态和动态分析工具:利用静态分析工具(诸如 Clang Static Analyzer 或者 cppcheck)和动态分析工具(比如 Valgrind 或者 AddressSanitizer)检测代码中可能存在的越界访问。
5、自动化测试和代码审查:编写测试用例以捕获边界条件,并进行代码审查以确保遍历操作的正确性。
6、避免裸指针操作:尽量减少指针算术操作,如果不得不使用,确保操作是安全的。
7、初始化和清零:对于本地数组,可在声明时对它们进行初始化或清零,以避免不小心使用未初始化的内存。
注意:
C++ 标准库容器的 operator[] 方法通常不会做边界检查,而是假设你已经知道你在做什么。这使得这个操作更加高效,但也更危险。如果你需要边界检查,请使用容器的 at() 方法,它会在越界时抛出 std::out_of_range 异常。

异常和错误处理不当

在 C++ 编程中,异常和错误处理是关键的程序设计考量。不当的异常和错误处理可能会导致资源泄露、程序崩溃、不一致的状态和安全漏洞。以下是一些常见的异常和错误处理不当的情况和如何避免它们。

1. 不捕获可能抛出异常的代码

当你的代码调用可能抛出异常的函数或方法,而却没有捕获异常,可能会导致程序的非正常终止。
解决方法:使用 try-catch 块包围可能抛出异常的代码,并根据可能发生的错误类型来处理异常。

2. 过宽泛的异常捕获

使用过宽泛的异常捕获(如捕获所有类型的异常)可能会掩盖问题的真正原因,并导致调试困难。
解决方法:尽量捕获特定的异常类型,这有利于更精确地处理错误并提供更有用的调试信息。

3. 异常处理中忽略资源释放

在异常处理过程中,如果没能正确释放资源(如动态分配的内存、文件句柄、锁等),将会导致资源泄露。
解决方法:利用 RAII(Resource Acquisition Is Initialization)原则管理资源,使用智能指针如 std::unique_ptr 和 std::shared_ptr 管理动态分配的内存,使用 std::lock_guard 和 std::unique_lock 管理锁,确保在栈展开(stack unwinding)时资源可以被自动释放。

4. 安静捕获和吞噬异常

在 catch 块中什么都不做(或者只打印错误消息)然后继续执行,有可能导致程序在一个不确定的状态下继续运行。
解决方法:在 catch 块中恰当地处理异常。如果不能处理,应该再次抛出或终止程序。

5. 在构造函数和析构函数中抛出异常

如果构造函数抛出异常,在没有完全构建该对象的情况下,析构函数不会被调用,可能会导致资源泄露。此外,在析构函数中抛出异常,如果同时有其它异常抛出,可能会导致 std::terminate 被调用。
解决方法:保证构造函数中的代码能够安全地处理异常,不要在析构函数中抛出异常。

6. 检查错误代码

一些 C++ 的库和接口使用返回值来表示成功或错误。忽略这些返回值可能会导致错误未被检测到。
解决方法:总是检查函数返回的错误代码,并根据错误代码进行相应的处理。

7. 使用异常作为普通流程控制

异常应该仅用于异常情况,它们不应该被用作执行普通流程控制,因为这样会降低程序性能,并且会让程序逻辑更难以理解。
解决方法:只针对确实异常的情况抛出和处理异常,使用其他方式(如条件判断)来处理正常逻辑。

8. 内存不足异常 std::bad_alloc

在使用动态内存分配时,例如 new 操作符可能会抛出 std::bad_alloc。不处理这种异常可能会导致程序崩溃。
解决方法:对可能抛出 std::bad_alloc 的代码使用 try-catch 块并适当处理,或者使用 noexcept 的 new (std::nothrow) 形式。

9. 错误的异常规格说明(Exception Specification)

C++11 之前,异常规格说明(如 throw(Type))用于表明函数可能抛出的异常类型。在 C++11 后,这个语法被 noexcept 取代。
解决方法:尽量避免使用异常规格说明,转而使用 noexcept 关键字,这能提供更明确的异常安全保证。

10. 递归异常

在 catch 代码块中抛出一个新的异常,而没有释放捕获的异常,将导致异常递归,可能消耗过多资源甚至栈溢出。
解决方法:避免在 catch 块内抛出新的异常,如果必要,应该在之前将异常释放或转换为适当的类型。
通过积极地关注异常和错误处理,可以提高程序的可靠性、可维护性,并且减少潜在的安全风险。

文章来源:https://blog.csdn.net/weixin_43779276/article/details/135618855
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。