浅析 Windows 内存分配——何时发生 bad_alloc 异常?

new的背后。

一次 CTF 校赛,遇到一个类似以下逻辑的代码,目的是要使程序退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main()
{
try {
string name;
cin >> name;

...(一个循环不会退出)

} catch (const exception& e) {
// cerr << "Error: " << e.what() << endl;
cout << "Error: " << e.what() << endl;
return 1;
}
}

于是想到了 std::string 内部会进行内存分配,于是又想到了关于内存分配有一个异常 std::bad_alloc,因此立刻生成了一个巨大的二进制文件,cat test.bin | nc ... 打向服务器。略去细节问题,总之最终成功使程序退出并获得 flag。

但赛后运维校赛的学长对此进行了 研究,发现事情没有我想象的那么简单:实际上是程序在读取输入的过程中,分配了大于实际可用物理内存的虚拟内存,而导致的OOM Killer。程序是被 docker 杀的。

查询资料易得 Linux 环境,malloc/new 在申请内存时,linux 内核会给予申请大小的虚拟内存地址范围,但并没有保证这些地址一定可用。当程序真的使用了过量的内存时,linux 会用 OOM 机制杀死某个进程。docker 环境里除了题目本身就没什么其他进程,因此杀掉的就是题目进程了。


那么 Windows 环境又如何呢?先来看一些基础知识。

Windows 用户态分配内存主要由两个系列函数组成:VirtualAllocHeapCreateHeapAlloc。前者直接向内核请求内存,可自定义的选项更多;后者在用户态实现了堆的概念,会先在堆内尝试分配内存,当请求的内存大小大于 16KB(NT 堆)或 508KB(段堆)时,将请求交由堆后端 / 内核处理,也就是VirtualAlloc 这一串的 api。

Windows 进程虚拟地址空间中的页面有四类:free(空闲的)、reserved(保留的)、committed(提交的)和 shareable(可共享的)¹。提交的页面又叫做私有页面(private page),也就是实际分配的、可使用的、在使用时最终会指向物理内存中有效页面的内存。而访问 free 和 reserved 的内存 会触发访问冲突异常。

VirtualAlloc在分配内存时可选择分配的类型,这里主要关注 MEM_COMMITMEM_RESERVE

当分配类型中包含 MEM_RESERVE 时,内核会“保留进程的虚拟地址空间范围,而不在内存或磁盘上的分页文件中分配任何实际物理存储”,也就是说此时再通过 VirtualAlloc 分配其他内存时,不会占用已保留的空间,但由于没有实际进行内存分配,因此对系统资源的占用更少。只有再次对同一地址进行分配类型包含 MEM_COMMITVirtualAlloc调用时,程序才能实际使用这片内存。当使用 MEM_COMMIT 分配内存时,除非分配失败即返回 0,内核保证至少返回分配大小的可用的内存。

VirtualAlloc的实现是对 NtAllocateVirtualMemory 做了简单包装,HeapAlloc则是直接链接到了 ntdll.dll 里的RtlAllocateHeap

实践一下,我使用以下程序做测试,输入是 999999999999:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <new>

int main() {
std::size_t n;
std::cout << " 请输入要分配的字节数: ";
if (!(std::cin >> n))
{
std::cerr << " 输入无效 " << std::endl;
return 1;
}

try
{
char* p = new char[n];

std::cout << " 成功分配了 " << n << " 字节的内存。" << std::endl;

delete[] p;
}
catch (const std::bad_alloc& e)
{
std::cerr << " 内存分配失败: " << e.what() << std::endl;
return 1;
}

return 0;
}

可发现 msvcrt/ucrt 对 new 的实现是简单调用 malloc,而malloc 又只是简单的对 HeadAlloc 的包装。经过动态调试发现在当前输入下,堆管理器会直接通过 ZwAllocateVirtualMemory 向内核申请内存,分配类型为 0x1000 即 MEM_COMMIT。(Nt 系列 api 与 Zw 系列 api 有一些细微的差别参见 微软的文档,但在用户态下,二者一般无区别。)

回看 ucrt 中对 new 的实现,其内部检测到 malloc 的返回值为 0 时直接进入 C++ 的 std::new_handlerstd::bad_alloc流程,在无 new_handler 时抛出 std::bad_alloc 异常。

因此,在 Windows 用户态下,new 一旦成功、未抛异常,则返回的内存几乎总是可立即安全使用,不会出现 Linux 下分配成功但写入时被 OOM 杀死的情况。

也在 Ubuntu 22.04 测试了上面的程序。即使输入 18446744073709551615,程序也会输出 成功分配了 18446744073709551615 字节的内存。


[¹]: 四分法来自《Windows Internal》,但 API 层面并没有一个与 reserved 和 committed 并列的 shareable。