53、内存逆向(二):内存分配与释放追踪、堆栈回溯
上一讲我们聊了内存布局的基础。今天要深入一个更实际的问题——内存分配与释放的追踪,以及堆栈回溯。
说实话,搞逆向这么多年,我遇到最多的问题就是:谁分配了这块内存?谁把它释放了? 尤其是分析崩溃 dump 或者内存泄漏时,这两个问题能卡住你半天。
嗯,咱们今天就把这块硬骨头啃下来。
一、内存分配追踪:从堆管理器入手
Windows 下,用户态的内存分配最终都会落到 HeapAlloc、malloc、new 这些函数上。但底层其实都走 ntdll!RtlAllocateHeap。
我个人习惯,在追踪内存分配时,直接 hook RtlAllocateHeap 和 RtlFreeHeap。为什么?因为这是所有分配路径的最终汇聚点。
1.1 实战:Hook RtlAllocateHeap
我曾在分析一个游戏外挂时用过这个手法。当时怀疑某个模块频繁分配大块内存导致卡顿。直接上 detours 做 inline hook:
// 定义函数指针类型
typedef LPVOID(WINAPI* RtlAllocateHeap_t)(HANDLE HeapHandle, ULONG Flags, SIZE_T Size);
RtlAllocateHeap_t OriginalRtlAllocateHeap = nullptr;
// Hook 函数
LPVOID WINAPI HookRtlAllocateHeap(HANDLE HeapHandle, ULONG Flags, SIZE_T Size) {
// 先调用原始函数
LPVOID pMem = OriginalRtlAllocateHeap(HeapHandle, Flags, Size);
// 记录分配信息
if (Size > 1024) { // 只记录大块分配
LogAllocation(pMem, Size, GetCallStack());
}
return pMem;
}
这里有个坑——GetCallStack 必须在 hook 函数内尽早调用。因为一旦进入日志输出,栈帧就被污染了。
GetCallStack 时,记得跳过 hook 函数自身的帧。否则你看到的调用栈第一帧永远是 HookRtlAllocateHeap,毫无意义。
1.2 释放追踪:匹配分配记录
释放追踪其实更简单。Hook RtlFreeHeap,拿到要释放的地址,去分配记录表里查一下:
BOOL WINAPI HookRtlFreeHeap(HANDLE HeapHandle, ULONG Flags, LPVOID lpMem) {
// 查分配记录
AllocationRecord* pRecord = FindAllocationRecord(lpMem);
if (pRecord) {
LogFree(lpMem, pRecord->Size, pRecord->CallStack, GetCallStack());
RemoveAllocationRecord(lpMem);
}
return OriginalRtlFreeHeap(HeapHandle, Flags, lpMem);
}
我曾经遇到过一个 bug:某个模块分配了内存,但释放时传了错误的堆句柄。结果 RtlFreeHeap 直接返回 FALSE,内存泄漏了。嗯,这种问题靠日志一眼就能看出来。
二、堆栈回溯:获取调用栈信息
堆栈回溯是内存追踪的核心。说白了,就是从当前栈帧往上爬,拿到每一层的返回地址。
Windows 下常用的 API 是 StackWalk64,但说实话,这玩意儿用起来挺麻烦的。我更喜欢用 RtlCaptureStackBackTrace,简单粗暴:
// 获取调用栈
DWORD GetCallStack(PVOID* pFrames, DWORD dwMaxFrames) {
// 跳过当前函数自身
return RtlCaptureStackBackTrace(1, dwMaxFrames, pFrames, nullptr);
}
这个函数返回实际捕获的帧数。然后你可以用 SymFromAddr 把地址转成函数名:
void LogCallStack(PVOID* pFrames, DWORD dwCount) {
for (DWORD i = 0; i < dwCount; i++) {
char szSymbol[256] = {0};
DWORD64 dwDisplacement = 0;
// 解析符号
if (SymFromAddr(GetCurrentProcess(), (DWORD64)pFrames[i],
&dwDisplacement, (SYMBOL_INFO*)szSymbol)) {
printf(" [%d] %s+0x%llx\n", i,
((SYMBOL_INFO*)szSymbol)->Name, dwDisplacement);
}
}
}
RtlCaptureStackBackTrace 最多能捕获 62 帧。如果不够用,可以连续调用两次,把结果拼接起来。不过说实话,62 帧对绝大多数场景都够了。
三、实战案例:追踪内存泄漏
咱们来看一个完整的例子。假设有个程序,每 5 秒分配一次 1MB 内存,但从不释放。我们要找出是谁干的。
首先,写一个简单的监控 DLL:
// 全局分配记录表
std::map<LPVOID, AllocationInfo> g_AllocMap;
CRITICAL_SECTION g_cs;
// Hook 分配
LPVOID WINAPI HookRtlAllocateHeap(HANDLE hHeap, ULONG dwFlags, SIZE_T dwBytes) {
LPVOID pMem = OriginalRtlAllocateHeap(hHeap, dwFlags, dwBytes);
if (pMem && dwBytes > 0x10000) { // 只监控大块分配
PVOID frames[32] = {0};
DWORD dwCount = RtlCaptureStackBackTrace(2, 32, frames, nullptr);
EnterCriticalSection(&g_cs);
g_AllocMap[pMem] = {dwBytes, dwCount, {0}};
memcpy(g_AllocMap[pMem].Frames, frames, dwCount * sizeof(PVOID));
LeaveCriticalSection(&g_cs);
}
return pMem;
}
// Hook 释放
BOOL WINAPI HookRtlFreeHeap(HANDLE hHeap, DWORD dwFlags, LPVOID lpMem) {
EnterCriticalSection(&g_cs);
g_AllocMap.erase(lpMem); // 正常释放的移除记录
LeaveCriticalSection(&g_cs);
return OriginalRtlFreeHeap(hHeap, dwFlags, lpMem);
}
运行一段时间后,查看 g_AllocMap 中残留的记录。这些就是只分配没释放的内存。
我记得有一次帮朋友分析一个服务器程序,用这个手法发现某个线程池在任务完成后忘记释放 OVERLAPPED 结构。每次泄漏 64 字节,但每秒几千次请求,一天下来就是几百 MB。嗯,找到根因后,改一行代码就解决了。
四、堆栈回溯的陷阱
堆栈回溯看着简单,实际坑不少。我踩过的几个:
- 帧指针优化:Release 编译下,编译器可能省略 EBP/RBP 帧指针。这时
StackWalk64会失败。解决办法是用RtlCaptureStackBackTrace,它不依赖帧指针。 - 符号文件缺失:没有 PDB 文件,你只能看到地址,看不到函数名。我建议在目标机器上部署时,把关键模块的 PDB 一起带上。
- 内核态栈:如果分配发生在系统调用中,用户态栈回溯只能看到
ntdll!NtAllocateVirtualMemory,看不到内核态调用链。这种情况需要用内核调试器。
SymLoadModule64 加载所有模块。
五、知识体系图
下面这张图总结了内存分配追踪与堆栈回溯的核心流程:
六、总结
内存分配与释放追踪,说白了就是两件事:在分配点记录上下文,在释放点匹配上下文。堆栈回溯则是获取上下文的关键技术。
我个人觉得,这个技术最大的价值不在于理论,而在于实战。下次你遇到内存泄漏、野指针、重复释放等问题时,不妨试试今天讲的方法。写一个简单的 hook DLL,挂上去跑几分钟,问题往往就水落石出了。
嗯,今天就到这里。记住:工具是死的,思路是活的。掌握了追踪的思路,换什么平台、什么语言,都能快速上手。