C++ 的黑暗角落和陷阱





5.00/5 (12投票s)
C++:爱与情趣
引言
C++ 是一种非常强大且通用的工具,但你必须为此付出代价。
正如 Bjarne Stroustrup 曾说
"C 语言让你很容易自找麻烦;C++ 让你更难,但一旦你自找麻烦,它会炸掉你整条腿"。
本文将教你如何以你能想象到的最有趣、最不可预测、最刺激的方式,彻底炸掉你的所有腿(手臂、头部和其他部位)!
背景
在本文中,我们希望展示理解编写稳定、安全和可靠代码的重要性,以及如何轻松地无意中注入漏洞。我们希望这对你来说既有趣又有用。
开始编码!
这里是一个抽象 C++ 代码的短片段。如你所见,这是来自 Windows DLL 的代码(这一点非常重要!)。假设有人希望在某个(当然是安全的!)解决方案中使用这段代码。
花点时间看看。谁知道你能在这里找到什么?这段代码中可能出了什么问题?
// Singleton
class Finalizer
{
struct Data
{
int i = 0;
char* c = nullptr;
union U
{
long double d;
int i[sizeof(d) / sizeof(int)];
char c [sizeof(i)];
} u = {};
time_t time;
};
struct DataNew;
DataNew* data2 = nullptr;
typedef DataNew* (*SpawnDataNewFunc)();
SpawnDataNewFunc spawnDataNewFunc = nullptr;
typedef Data* (*Func)();
Func func = nullptr;
Finalizer()
{
func = GetProcAddress(OTHER_LIB, "func")
auto data = func();
auto str = data->c;
memset(str, 0, sizeof(str));
data->u.d = 123456.789;
const int i0 = data->u.i[sizeof(long double) - 1U];
spawnDataNewFunc = GetProcAddress(OTHER_LIB, "SpawnDataNewFunc")
data2 = spawnDataNewFunc();
}
~Finalizer()
{
auto data = func();
delete[] data2;
}
};
Finalizer FINALIZER;
HMODULE OTHER_LIB;
std::vector<int>* INTEGERS;
DWORD WINAPI Init(LPVOID lpParam)
{
OleInitialize(nullptr);
ExitThread(0U);
}
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
static std::vector<std::thread::id> THREADS;
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
srand(time(nullptr));
OTHER_LIB = LoadLibrary("B.dll");
if (OTHER_LIB = nullptr)
return FALSE;
CreateThread(nullptr, 0U, &Init, nullptr, 0U, nullptr);
break;
case DLL_PROCESS_DETACH:
CoUninitialize();
OleUninitialize();
{
free(INTEGERS);
const BOOL result = FreeLibrary(OTHER_LIB);
if (!result)
throw new std::runtime_error("Required module was not loaded");
return result;
}
break;
case DLL_THREAD_ATTACH:
THREADS.push_back(std::this_thread::get_id());
break;
case DLL_THREAD_DETACH:
THREADS.pop_back();
break;
}
return TRUE;
}
__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()
{
for (int i : integers)
i *= c;
INTEGERS = new std::vector<int>(integers);
}
int Random()
{
return rand() + rand();
}
__declspec(dllexport) long long int __cdecl _GetInt(int a)
{
return 100 / a <= 0 ? a : a + 1 + Random();
}
你觉得这段代码非常简单、显而易见、绝对安全且没有麻烦吗?或者你在这里发现了一些问题?或者你甚至发现了一打或两打?
实际上,这段代码中存在超过 43(是的,四十三!)个不同严重程度的潜在威胁。
关注点
sizeof(d)
(其中d
是long double
)不一定是sizeof(int)
的倍数int i[sizeof(d) / sizeof(int)];
这种情况下未在此处进行检查或处理。例如,在某些平台上,
long double
的大小可能是10(对于 MS VS 编译器并非如此,但对于 RAD studio,前身为 C++ Builder 则是)。int
的大小也可能因平台而异(好吧,上面的代码是针对 Windows 的,所以具体到当前情况,问题有些牵强,但对于可移植代码,问题确实存在)。如果我们要在这里进行类型双关,所有这些都将成为问题。顺便说一句,根据 C++ 语言标准,类型双关会导致未定义行为(然而,这仍然是常见做法,因为现代编译器通常**会**为它定义正确的预期行为,例如 GCC)。
顺便说一句,在现代 C 语言中,类型双关是完全允许的(你确实明白 C 和 C++ 是不同的语言,你不应该期望如果你懂 C++ 就懂 C,反之亦然,对吗?)
解决方案:在编译时使用
static_assert
来控制所有这些假设。如果类型大小出现问题,这会警告你static_assert(0U == (sizeof(d) % sizeof(int)), "Size of the bigger type is not multiple of sizes of the smaller type");
time_t
是一个宏,在 Visual Studio 中,它可以指代 32 位(旧)或 64 位(新)整数类型time_t time;
如果两个不同的二进制模块(例如,一个可执行文件和它加载的 DLL)使用该类型的不同物理表示进行编译,则访问它可能会导致越界读/写或类型切片(损坏内存或导致读取垃圾数据)。
解决方案:确保使用相同的严格大小类型在所有通信模块之间共享数据
int64_t time;
- B.dll(应该由
OTHER_LIB
句柄引用)在此刻**尚未**加载,因此我们尝试从中获取地址将失败。 - 静态初始化顺序问题(
OTHER_LIB
对象在使用,而它**尚未**初始化并包含垃圾数据)。func = GetProcAddress(OTHER_LIB, "func");
FINALIZER
是一个静态对象,它在调用DllMain
之前构造。所以在它的构造函数中,我们试图使用稍后加载的库。问题变得更糟,因为FINALIZER
静态对象使用的OTHER_LIB
静态对象在翻译单元中定义得更晚,这意味着它将更晚初始化(置零)。这意味着它将简单地包含一些伪随机垃圾数据。幸运的是 WinAPI 应该正确处理,因为很可能没有模块加载了这样的句柄值,即使存在——它也可能缺少“func
”函数(但如果最终确实存在,那可就麻烦了……)解决方案:一般建议是完全避免使用全局对象,特别是复杂的对象,尤其是如果它们相互依赖,特别是在 DLL 中。但是,如果你出于某种原因仍然需要它们,请非常小心它们的初始化顺序。为了控制该顺序,请将**所有**全局对象实例(定义)按正确顺序放置在**一个**翻译单元中,以确保它们正确初始化。
- 先前返回的结果在使用前未进行检查
auto data = func();
func
是一个指向函数的指针。它应该指向 B.dll 中的函数。但是,因为我们在上一步中完全失败了所有事情,它将是nullptr。所以尝试解引用它将导致一些有趣且引人入胜的事情,例如访问冲突或一般保护错误等。解决方案:处理外部代码(在本例中为 WinAPI)时,**始终**检查所提供函数的返回结果。对于可靠和故障安全系统,即使这些函数存在严格的契约,此规则仍然有用。
- 如果使用不同的对齐/填充设置进行编译,则会产生垃圾数据
auto str = data->c;
如果 `Data` `struct`(用于在通信模块之间共享信息)在二进制模块中具有不同的物理表示,我们将遇到前面提到的访问冲突、一般保护错误、段错误、堆损坏等问题。或者我们将读取垃圾数据。具体结果取决于使用该内存的实际场景。所有这些都可能发生,因为 `struct` 本身缺乏明确的对齐/填充设置,因此如果这些通信模块在编译时具有不同的全局设置,我们就会遇到麻烦。
解决方案:确保所有共享数据结构都具有严格、明确定义和清晰的物理表示(固定大小类型、对齐定义等)和/或通信二进制文件使用相同的对齐/填充设置进行编译。
另请参阅
- 使用指针的大小而不是它指向的数组的大小
memset(str, 0, sizeof(str));
这通常是打字错误。但在处理静态多态或使用
auto
关键字时(尤其是在过度使用时),事情可能会变得复杂。我真的希望现代编译器已经足够智能,能够在编译阶段利用其内部静态代码分析能力来检测此类问题。解决方案
- 切勿混淆
sizeof
(<完整对象类型>) 和sizeof
(<对象指针类型>) - 切勿盲目忽略编译器警告
- 你甚至可以使用 C++ 模板魔法,结合
typeid
、constexpr
和static_assert
来确保编译阶段类型的正确性(此外,类型特性在这里也很有用,例如std::is_pointer)
- 切勿混淆
- 访问另一个字段时出现 UB,而不是已设置的字段
- 如果
long double
的大小在二进制模块之间不同,可能会发生越界访问const int i0 = data->u.i[sizeof(long double) - 1U];
嗯,这在前面已经提到了,所以这里我们只是又发现了一个之前讨论过的问题。
解决方案:**不要**访问另一个字段,除非你非常确定你的编译器能正确处理。确保共享对象的类型大小在所有通信模块中都相同。
另请参阅
- 即使 B.dll 已正确加载,并且“
func
”函数已正确导出和定位,B.dll 仍会在此时卸载(因为DllMain
/DLL_PROCESS_DETACH
回调部分中调用了FreeLibrary
),因此我们将在此处崩溃auto data = func();
可能,使用已销毁的多态对象调用成员函数,或从已卸载的动态库中调用函数,将导致纯虚函数调用。
解决方案:在应用程序中实现正确的终结例程,确保所有动态库完成工作并以正确的顺序卸载。避免在 DLL 中使用带有复杂逻辑的静态对象。避免在 DLL 最终退出其入口点(并开始销毁静态对象)后执行操作。
了解 DLL 生命周期:
... 其他模块调用
LoadLibrary
...- 库静态对象的构造(应仅包含非常简单的逻辑,自动调用)
DllMain
->DLL_PROCESS_ATTACH
回调事件(应仅包含非常简单的逻辑,自动调用)[!!] 从现在开始,应用程序的其他线程可以开始调用
DllMain
->DLL_THREAD_ATTACH
/DLL_THREAD_DETACH
并行执行(自动调用,参见第 30 页注释)这些部分_可能_包含一些复杂的逻辑(例如每个线程的随机种子),但仍需注意
- 调用自定义初始化例程(由 DLL 开发者导出)。
(包含所有繁重的初始化工作,应由加载您的库的人手动调用)
[您的库现在和以后可以创建自己的线程]
[..] 库执行其主要工作
- 调用自定义反初始化例程(由 DLL 开发者导出)。
(包含所有繁重的终结工作,应由加载您的库的人手动调用。)
[在此之后,避免在您的库中执行任何操作,所有先前启动的库线程应在从该函数返回**之前**完成。]
... 其他模块调用
FreeLibrary
... DllMain
->DLL_PROCESS_DETACH
(应只包含非常简单的逻辑,自动调用)- 库静态对象的销毁(应只包含非常简单的逻辑,自动调用)
- 删除不透明指针(编译器需要知道完整的类型才能调用析构函数,因此通过不透明指针删除对象可能导致内存泄漏和其他问题)
- (假设 `DataNew` 的析构函数是 `virtual`)即使类已正确导出和导入,并且我们获得了关于它的完整信息,此时调用其析构函数仍然是一个问题——它很可能导致纯`virtual`函数调用(因为 `DataNew` 类型是从已卸载的 `B.dll` 导入的)。即使不是(`virtual`),我们这里也遇到了问题。
- 如果 `DataNew` 类是一个抽象多态类型,并且它的基类有一个没有实现的纯`virtual`析构函数,那么无论如何都会发生纯`virtual`函数调用
- 如果使用
new
分配,使用delete[]
删除,则为 UBdelete[] data2;
一般来说,在释放和删除从外部模块接收的对象时,应始终保持谨慎。
此外,删除对象后将指针置空是一种良好的做法。
解决方案是确保
- 当对象被删除时,其完整类型(由我们删除的指针指向)是已知的
- 所有析构函数都有主体
- 导出任何代码的库不会过早卸载
- 始终使用正确形式的
new
和delete
- 指向已删除对象(或多个对象)的指针被置空
此外请注意
- 调用
void
指针的delete
运算符将导致未定义行为 - 纯
virtual
函数不得从构造函数中调用 - 构造函数中对
virtual
函数的调用不是虚函数 - 倾向于避免手动内存管理(改用容器、移动语义和智能指针)
另请参阅
ExitThread
是 C 代码中退出线程的首选方法。在 C++ 代码中,线程在任何析构函数被调用或任何其他自动清理被执行之前退出,因此你应该从你的线程函数中返回ExitThread(0U);
解决方案:切勿在 C++ 代码中手动使用此函数,而是通过从线程函数正常退出(通过返回语句)来退出。
- 调用需要 Kernel32.dll 以外的 DLL 的函数可能会导致难以诊断的问题。调用 User、Shell 和 COM 函数可能会导致访问冲突错误,因为某些函数会加载其他系统组件
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
解决方案 - 在
DllMain
入口点- 避免任何复杂的(去)初始化
- 避免调用其他库中的函数(或者至少要非常小心)
- 多线程环境中随机种子初始化不正确
- 由于
time
具有 1 秒的分辨率,程序中在该时间段内调用time
的任何线程都将拥有相同的种子,这可能导致冲突(例如,生成相同的伪随机临时文件名、相同的端口号等)。可能的解决方案之一是使用一些其他伪随机值(例如任何堆栈或更好的堆对象的地址、更精确的时间等)来混淆(异或)种子的位。srand(time(nullptr));
解决方案:MS VS 要求种子应按线程初始化。此外,使用 Unix 时间作为种子不能提供足够的随机性,请优先使用更高级的种子生成方式。
另请参阅
- 可能导致死锁或崩溃(或在 DLL 加载顺序中创建依赖循环)
OTHER_LIB = LoadLibrary("B.dll");
解决方案:**不要**在
DllMain
入口点使用LoadLibrary
。任何复杂的(去)初始化都应在特定的导出函数中完成,例如“Init
”和“Deint
”。你的模块根据导入和导出模块之间建立的契约提供这些函数。双方都必须严格执行契约。 - 错别字(条件总是
false
),程序逻辑不正确和可能的资源泄漏(因为OTHER_LIB
如果加载成功则永不卸载)if (OTHER_LIB = nullptr) return FALSE;
拷贝赋值运算符返回左值引用,即
if
将检查OTHER_LIB
的值(这将是nullptr
),并且nullptr
将被解释为false
。解决方案:始终使用反向形式以避免此类错别字
if/while (<constant> == <variable/expression>)
- 最好使用
_beginthread
(特别是如果链接到静态 C 运行时库),否则在调用ExitThread
、DisableThreadLibraryCalls
时可能会出现内存泄漏。 - DLL 通知是序列化的,入口点函数(
DllMain
)**不**应尝试创建或与其他线程或进程通信(可能发生死锁)CreateThread(nullptr, 0U, &Init, nullptr, 0U, nullptr);
- 在终止期间调用 COM 函数可能会导致访问冲突错误,因为相应的组件可能已被卸载或未初始化
CoUninitialize();
- 无法控制进程内服务器的加载或卸载顺序,因此**不要**从
DllMain
函数中调用OleInitialize
或OleUninitialize
OleUninitialize();
另请参阅
- 对使用
new
分配的内存块调用free
- 如果进程正在终止(
lpvReserved
参数为非NULL
),进程中除当前线程以外的所有线程要么已经退出,要么已被调用ExitProcess
函数显式终止,这可能会使一些进程资源(例如堆)处于不一致状态,因此 DLL 清理资源是不安全的。相反,DLL 应该允许操作系统回收内存free(INTEGERS);
解决方案:确保不将旧的 C 动态内存处理方式与现代 C++ 方式混合使用。在
DllMain
入口点管理资源时要非常小心。 - 可能导致 DLL 在系统执行其终止代码后被使用
const BOOL result = FreeLibrary(OTHER_LIB);
解决方案:**不要**在
DllMain
入口点调用FreeLibrary
。 - 将使当前(可能是主)线程崩溃
throw new std::runtime_error("Required module was not loaded");
解决方案:尽量**不要**在
DllMain
入口点抛出异常。如果 DLL 因任何原因无法正确加载,它应该返回FALSE
。在DLL_PROCESS_DETACH
期间抛出异常不仅是一种糟糕的设计方法(而且几乎毫无意义),还可能导致在反初始化阶段出现问题。无论如何,在 DLL 之外抛出异常时务必非常小心。某些情况下,任何复杂的对象(例如标准库的类)可能具有不同的物理表示(甚至逻辑),例如,如果两个二进制模块使用不同(不兼容)版本的运行时库进行编译。
优先在模块之间交换简单数据类型(具有固定大小和确定表示)
另外,请记住,退出或终止主线程将自动终止所有其他线程(这些线程将没有机会正确完成,因此它们可能会损坏内存,使互斥量、堆和其他对象处于不可预测、不一致的状态,而且在静态对象开始自身销毁时,这些线程已经死亡,因此不要尝试在此处等待线程)。
另请参阅
- 可能抛出异常(例如
std::bad_alloc
),此处未捕获THREADS.push_back(std::this_thread::get_id());
由于
DLL_THREAD_ATTACH
部分是从某些未知的外部代码调用的,因此不要期望此处有正确的行为。解决方案:将可能抛出异常且无法预期被正确处理的指令(特别是当它们超出 DLL 范围时)用
try
/catch
块包围起来。另请参阅
- 如果在此 DLL 加载**之前**存在线程,则为 UB
THREADS.pop_back();
现有线程(包括实际加载 DLL 的线程)不调用新加载 DLL 的入口点函数(因此在
DLL_THREAD_ATTACH
事件期间它们未在THREADS
向量中注册),而它们在完成时仍以DLL_THREAD_DETACH
调用它。这意味着认为对
DLL_THREAD_ATTACH
和DLL_THREAD_DETACH
的调用次数总是相等的考虑是**错误**的,这使得任何依赖于它的逻辑都变得危险。 - 编译器相关的
int
大小,最好使用 C++11 固定大小整数 - 在模块之间传递复杂的对象(如果它们使用不同的运行时(发布/调试、不同版本等)编译,可能会导致崩溃)
- 通过其虚拟地址(在模块之间共享)访问对象
c
可能会导致问题,如果指针在这些模块中的处理方式不同(例如,如果模块链接时使用了不同的 [/LARGEADDRESSAWARE
] 选项)__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()
另请参阅
- 设置了 LARGEADDRESSAWARE 标志的应用程序获取的虚拟内存较少
- 对 32 位 Windows 可执行文件使用 /LARGEADDRESSAWARE 的缺点?
- 如何检查 exe 是否设置为 LARGEADDRESSAWARE [C#]
- /LARGEADDRESSAWARE 可能会毁掉你的一天 [俄语]
- ASLR (地址空间布局随机化) [俄语]
还有...
- 虚拟内存
- 物理地址扩展
- 带标签的指针
std::ptrdiff_t
- 什么是
uintptr_t
数据类型 - 指针算术
- 指针别名
- 什么是严格别名规则?
- reinterpret_cast 转换
- restrict 类型限定符
最后...
等等,我忘了什么吗?我肯定忘了! :)
因为指针实际上比人们通常认为的要复杂得多。我非常确定你可以在评论中添加一些重要的东西(也许是一些关于对象指针和函数指针区别的东西,可能并非所有指针值中的位都可用于形成地址等等)。
for (int i : integers) i *= c;
- 异常可能会在函数内部抛出
INTEGERS = new std::vector<int>(integers);
然而,该函数的
throw
规范是空的__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()
当违反动态异常规范时,C++ 运行时会调用
std::unexpected
:从一个其异常规范禁止抛出此类型异常的函数中抛出异常。解决方案:使用
try
/catch
(特别是在分配资源时,尤其是在 DLL 中)或使用nothrow
形式。无论如何,不要期望无限资源。另请参阅
问题 1:形成这样一个“更随机”的值是不正确的。正如中心极限定理所述,独立随机变量的和趋向于正态分布(即使原始变量本身不是正态分布的)。
问题 2:可能的整数溢出(对于有符号整数是 UB)
return rand() + rand();
处理随机化、加密等事物时,请警惕使用一些自制的“解决方案”。如果你缺乏特定的数学教育和知识、对这些概念的丰富经验,那么你很可能会聪明反被聪明误,使事情变得更糟。
- 导出的函数名将被修饰(mangled),为防止这种情况,请使用
extern "C"
- 以 '_' 开头的名称在 C++ 中是隐式禁止的,因为这种命名风格是为 STL 保留的
__declspec(dllexport) long long int __cdecl _GetInt(int a)
多种问题(及其可能的解决方案)
rand
**不是**线程安全的,需要改用rand_r
/rand_s
rand
已过时,考虑使用现代 C++11 <random>rand
的种子可能尚未为该线程初始化(MS VS 需要按线程初始化)- 它不具备密码安全性,请使用特定的操作系统 API 或一些可移植解决方案(Libsodium/
randombytes_buf
,OpenSSL/RAND_bytes
等) - 可能发生除以零:可能导致当前线程终止
- 运算符优先级(使用括号)和/或序列点
- 可能发生整数溢出
return 100 / a <= 0 ? a : a + 1 + Random();
另请参阅
还有...
这还没完!我们还有更多有趣的(有问题的)代码等着你;)
想象一下你的内存中有一些重要内容(例如用户密码)。当然,你不想长时间将其保留在内存中(增加了有人从这里读取它的可能性)。
实现这一目标的简单方法会是这样
bool login(char* const userNameBuf, const size_t userNameBufSize,
char* const pwdBuf, const size_t pwdBufSize) throw()
{
if (nullptr == userNameBuf || '\0' == *userNameBuf || nullptr == pwdBuf)
return false;
// Here is some actual implementation, which does not checks params
// nor does it care of the 'userNameBuf' or 'pwdBuf' lifetime,
// while both of them obviously contains private information
const bool result = doLoginInternall(userNameBuf, pwdBuf);
// We want to minimize the time this private information is stored within the memory
memset(userNameBuf, 0, userNameBufSize);
memset(pwdBuf, 0, pwdBufSize);
}
嗯,那当然**不会**奏效。那么,该怎么办呢?
错误的“解决方案” #1:如果 memset
不起作用,我们手动来做!
void clearMemory(char* const memBuf, const size_t memBufSize) throw()
{
if (!memBuf || memBufSize < 1U)
return;
for (size_t idx = 0U; idx < memBufSize; ++idx)
memBuf[idx] = '\0';
}
顺便说一句,如果启用,memset 函数将是编译器固有函数。这在当前上下文中不会改变任何东西,只是一个有趣的事实。
另请参阅
错误的“解决方案” #2:尝试通过使用volatile
关键字来“改进”之前的“解决方案”
void clearMemory(volatile char* const volatile memBuf, const volatile size_t memBufSize) throw()
{
if (!memBuf || memBufSize < 1U)
return;
for (volatile size_t idx = 0U; idx < memBufSize; ++idx)
memBuf[idx] = '\0';
*(volatile char*)memBuf = *(volatile char*)memBuf;
// There is also possibility for someone to remove this "useless" code in the future
}
这会奏效吗?嗯,可能会。大概吧。例如,这种方法在 MS VS 的 RtlSecureZeroMemory
中使用(你可以在 Windows SDK 源代码中查看其实际实现)。然而,这严重依赖于编译器。
另请参阅
错误的“解决方案” #3:尝试使用错误的 OS API(例如 RtlZeroMemory
)甚至 STL(例如 std::fill
、std::for_each
)来代替 CRT 或自制代码
RtlZeroMemory(memBuf, memBufSize);
最后,如何真正解决这个问题?
引用与 memset 不同,对
memset_s
函数的任何调用都应严格按照抽象机的规则进行评估。
此外,我们可以通过将变量值输出(到文件、控制台或其他流)来阻止编译器优化代码,但这种方法显然不太有用。
待续...
当然,这并不是你在使用 C/C++ 编写应用程序时可能遇到的所有问题的完整列表。
还有一些很棒的东西,比如活锁、竞态条件(例如,由不正确的无阻塞算法实现、ABA 问题、同时不正确地更改多个原子变量、线程不安全的引用计数器、不正确的双重检查锁定模式实现等引起)、对象切片、算术精度损失(例如,由于舍入或数值不稳定算法,例如,不对许多双精度浮点数进行排序就进行求和)、线程和 GDI 对象、volatile vs atomic、不正确使用整数文字(603 vs 0603)、检查时间到使用时间、生命周期超出其引用捕获对象的 lambda、不正确的 printf
系列函数格式化程序、不同字节序的两个设备之间不正确地共享数据(例如,通过网络)、位域细节、混淆 C++ 异常和 SEH、执行不正确的堆栈分配、禁用ASLR、API 中可能存在的后门、混淆sizeof 与 _countof、**不**使用正确的内存锁定(还要注意笔记本电脑和某些台式电脑上的挂起模式会将系统 RAM 的副本保存到磁盘,一些架构惊喜,无论内存锁定如何)、堆栈损坏等等等等。
想添加更多?在评论中分享你自己的有趣资料!
想了解更多吗?
我们还想向您展示一些其他有用的外部链接。您可以参考这些资料来进一步扩展您的知识。感谢互联网上那些精彩的作者为我们带来了如此多令人兴奋的文章!
P. S.
当这篇文章实际完成并准备发表时,在网上搜索额外信息以添加到这里,发现了这条惊人的评论here
历史
2019年8月13日 - 增加了更多有用链接