记一次有教益的 vs2022 内存分配失败崩溃分析
2023-10-31 18:5:46 Author: mp.weixin.qq.com(查看原文) 阅读量:5 收藏

原以为64位进程很难出现内存分配异常,因为64位进程的虚拟内存空间非常大(总共64位,目前只用了48位,也就是256TB,用户态可以使用一半,也就是128TB)。没想到,前一阵子居然遇到了vs2022(vs终于有了64位的版本)分配内存失败的情况。分析到最后是因为分配MEM_COMMIT类型的内存失败导致的异常,一起来看看吧。

vs2022 卡死了

前一阵子,我在使用vs2022编辑代码的时候,不知道做了什么操作导致vs2022卡住了。过了一段时间后,procdump自动运行起来了(因为我把procdump设置为了JIT调试器。当有进程崩溃的时候,procdump会自动执行转储操作),等了好一会儿才退出。查看d:\dumps\目录下的转储文件,真是不看不知道,一看吓一跳,对应的转储文件居然有将近20GB

看来,vs2022应该是遇到了内存方面的异常。


查看调用栈

使用windbg打开对应的转储文件,执行k查看调用栈。如下图:

可以很明显的看到是在调用new()分配内存失败后抛出了异常。再多查看几个栈帧,可以发现是由vector_Emplace_reallocate()函数触发的内存分配。vs202264位的进程,虚拟内存空间可以说是大的离谱。居然内存分配会失败!有点意思,那到底分配了多大内存呢?

分配了多大内存?

查看栈帧0a0b的反汇编代码,如下图:



从上图可知,new()的参数rcx来自栈帧0a中的raxrax又来自rcx+0x27rcx来自栈帧0b中的raxraxvcpkg!std::_Get_size_of_n<16>()的返回值,vcpkg!std::_Get_size_of_n<16>()的参数rcx来自rax,而这个rax保存到栈上rsp+0x78的位置。可以先拿到rsp+0x78处的值,然后一步步推导出传递给new()的参数。

在开始之前,先了解一下vcpkg!std::_Get_size_of_n<size_t>()的逻辑。vcpkg!std::_Get_size_of_n<size_t>()是一个模板函数,模板参数是元素类型的大小,这里是0n16。该函数的实现很简单,就是返回_Count * _Ty_size(当然还有一些界限检查,下面是精简后的代码)。

template <size_t _Ty_size>
constexpr size_t _Get_size_of_n(const size_t _Count) {
return _Count * _Ty_size;
}

可以通过查看栈帧0brsp+0x78位置的值得到要分配的元素个数,然后乘以0n16就可以得到最终传递给new()的值。


计算后得知,本次分配的空间大约为923MB

注意:虽然分配的内存空间不是超级大,但是本次尝试分配的元素个数是0x039bc719,通过.formats 0x039bc719可以查看对应的十进制数是60540697,也就是大约6千万个对象!

说实话,分析到这里的时候,我是有点儿没底气的。vs2022可是64位的进程啊!没想到只分配大概923MB就失败了!带着这个疑问,继续查看当前进程的地址空间情况。

查看空闲空间

可以使用!address -summary看一下内存使用情况,如下图:



可以发现最大的空闲空间大概有119.96TB这么大。

说明:既可以通过上面的Largest Region by Usage查看最大的空闲空间,还可以通过!address -f:Free -c:".if(%3 > 0x80000000) {.echo %1 %2 %3}"显示出大于0x80000000的空闲段,如下图:

顺便说一句,第一次执行的时候是真的慢!

既然有足够大的空闲空间,为什么分配内存还会失败呢?看到这里我更疑惑了,同时心里有了另外一个疑问—— 在x64进程中,用户态代码到底可以分配多大内存?

测试内存分配

于是我写了一段使用malloc分配内存的测试代码。如下:

int main()
{
size_t size = 1024 * 1024 * 1024;
size *= 20;
auto p = malloc(size);
return 0;
}

经过几次调整后发现,大概分配20GB的时候就失败了。

当使用malloc()分配大块内存时,会调用ntdll!NtAllocateVirtualMemory()进行分配。使用windbg运行程序,当执行到auto p = malloc(size);这一行的时候,执行bp ntdll!NtAllocateVirtualMemory设置好断点。然后执行g让程序继续运行,很快就中断下来了。执行gu跳出当前函数,使用r命令查看寄存器,主要关注rax,因为它保存了函数的返回值。发现rax的值是00000000c000012d。由ntdll!NtAllocateVirtualMemory()的函数原型可知,返回值是NTSTATUS类型的。

NTSTATUS NtAllocateVirtualMemory(
[in] HANDLE ProcessHandle,
[in, out] PVOID *BaseAddress,
[in] ULONG_PTR ZeroBits,
[in, out] PSIZE_T RegionSize,
[in] ULONG AllocationType,
[in] ULONG Protect
);

查看官方文档可知,0xc000012d的意义是STATUS_COMMITMENT_LIMIT



根据Description列的描述可知,增大页面文件的大小可能会有帮助。看到这里的时候,我突然想起来,好像malloc()在调用ntdll!NtAllocateVirtualMemory()的时候,传递的AllocationType应该是包含MEM_COMMIT标志的(因为可以直接对返回的地址空间进行读写操作了)。而分配这种类型的内存,windows会检查是否有足够的内存(物理内存+页文件)支撑,如果剩余的物理内存+页文件(会被系统中的所有进程共同使用)的大小不能满足本次分配,那么会报错。

如果把MEM_COMMIT换成MEM_RESERVE,能分配多大的内存呢?

测试 MEM_RESERVE 最大分配尺寸

于是我又写了一段测试代码,直接调用VirtualAlloc()进行内存分配。测试代码如下:

#include <iostream>
#include "windows.h"

const size_t one_gb = 1LL * 1024LL * 1024LL * 1024LL; // 1 GB

double ToGb(size_t bytes)
{
return bytes / 1024.0 / 1024.0 / 1024.0;
}

double ToTb(size_t bytes)
{
return bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0;
}

size_t TestMaxAllocateMemory(size_t init_size, size_t decrease_size, DWORD allocation_type)
{
LPVOID p = nullptr;
while (nullptr == (p = VirtualAlloc(nullptr, init_size, allocation_type, PAGE_READWRITE)))
{
auto last_error = GetLastError();
std::cout << "allocate " << ToGb(init_size) << " GB failed. last error:" << last_error
<< ". try allocate " << ToGb(init_size - decrease_size) << " GB." << std::endl;

init_size -= decrease_size;
}

return init_size;
}

void TestMaxReserveMemory()
{
size_t size_reserve = 128LL * 1024LL * one_gb; // 128 TB
size_reserve = TestMaxAllocateMemory(size_reserve, one_gb, MEM_RESERVE);
std::cout << "allocate " << ToTb(size_reserve) << " TB reserver memory success." << std::endl;
}

int main()
{
TestMaxReserveMemory();
std::getchar();
return 0;
}

运行结果如下:



当分配类型是MEM_RESERVE的时候,一次性最多可以分配大概126 TB的虚拟内存。基本符合之前的认知。

注意:每次运行结果不完全一致,不过相差不多。

测试 MEM_COMMIT 最大分配尺寸

为了更好的展示不同情况下分配MEM_COMMIT的结果,我又添加如下测试代码:

void TestMaxCommitMemory()
{
size_t size_commit = 128LL * one_gb; // 128 GB
size_commit = TestMaxAllocateMemory(size_commit, one_gb, MEM_COMMIT);
std::cout << "allocate " << ToGb(size_commit) << " GB commit memory success." << std::endl;
}

int main()
{
//TestMaxReserveMemory();
TestMaxCommitMemory();
std::getchar();
return 0;
}

我分别在不同系统内存占用的情况下运行了三次,三次运行结果如下:

当系统内存相对充裕的时候,运行结果如下:

当系统内存被消耗了一部分的时候,运行结果如下:

当使用TestLimit64 -m 2048 -c 10分配20GBMEM_COMMIT内存后,运行结果如下:


画外音:当我尝试模拟系统内存吃紧的时候,突然想起来Testlimit就是用来测试各种资源极限的。-m模拟的是分配MEM_COMMIT类型内存的,-r模拟的是分配MEM_RESERVER类型内存的。-c是分配数量,如果不指定,则无限分配。

现在还剩下两个问题待证实:

① 明确malloc()调用ntdll!NtAllocateVirtualMemory()传递的AllocationType参数。

② 确定分配失败时,剩余物理内存+页文件的大小是否足够大。

明确 AllocationType

使用k 3显示3个调用栈帧。栈帧01会调用ntdll!NtAllocateVirtualMemory(),所以栈帧01会传递参数给ntdll!NtAllocateVirtualMemory()。使用ub 00007ffe30492762 L1a查看相关调用代码,可以发现AllocationType保存在rsp+0x20的位置,Protect保存在rsp+0x28的位置,前四个参数分别由rcx, rdx, r8, r9进行传递。



由此,可以确定之前的理解是正确的。malloc()调用ntdll!NtAllocateVirtualMemory()时,AllocationType的值是MEM_COMMIT

剩余的内存是否足够大

因为转储文件只包含当前进程的信息,没有系统级的转储文件,不好确认系统中的其它进程的内存使用情况。但是转储文件的大小已经达到了18.3GB,本次尝试分配的大小是923MB,加上18.3GB,大概是19GB

通过.time命令,可以发现系统已经运行了接近2天,当前进程已经运行了大概18.5个小时。由系统开机时间可以推算,当时应该有不少进程在运行(我的系统上,chromefirefox基本是常开状态)。



而且在vs2022无响应的时候,整个系统确实有些卡顿。以上种种迹象表明,系统当时的内存吃紧。这时候出现内存分配异常,确实合情合理。

彩蛋

前几天,客户的程序也遇到了一个类似的问题。她机器上内存紧张的时候,执行程序中的一个功能需要分配196MB的内存,由于物理内存不足,失败了。因为我之前已经调查过类似的问题了,在调查客户的问题的时候,非常快速而且有信心。我想这就是写文章记录的价值之一吧!

总结

procdump真是事后调试的好帮手。以管理员权限运行procdump -i -ma d:\dumps\即可安装。-i表示安装(如果要卸载,可以使用-u参数)。-ma表示执行完整转储,d:\dumps\表示.dmp文件保存的位置。

◆相较于32位进程的4GB232次方)虚拟内存空间而言,64位进程的虚拟内存空间超级大,目前是256TB(总共64位,目前只用了48位),内核态和用户态平均分,用户态可以使用一半,也就是128TB

◆如果使用malloc()或者new()(内部会调用malloc())分配的内存大小超出堆阈值,那么内部会使用NtAllocateVirtualMemory()分配内存,而且AllocationType的值是MEM_COMMIT。分配MEM_COMMIT类型的内存是受物理内存+分页文件大小限制的。

参考资料

NTSTATUS Values

https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/596a1078-e883-4972-9bbc-49e60bebca55

看雪ID:编程难

https://bbs.kanxue.com/user-home-873494.htm

*本文为看雪论坛优秀文章,由 编程难 原创,转载请注明来自看雪社区

# 往期推荐

1、IOFILE exploit入门

2、入门编译原理之前端体验

3、如何用纯猜的方式逆向喜马拉雅xm文件加密(wasm部分)

4、反恶意软件扫描接口(AMSI)如何帮助您防御恶意软件

5、sRDI — Shellcode反射式DLL注入技术

6、对APP的检测以及参数计算分析

球分享

球点赞

球在看


文章来源: https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458526832&idx=1&sn=faaec7c87627be8d10e9b3db99978d8e&chksm=b18d14fa86fa9dec575d6e03c48863b8fe7cb78234da73886a1a8ce9e702605dfff4f5791dca&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh