53、内存逆向(二):内存分配与释放追踪、堆栈回溯

上一讲我们聊了内存布局的基础。今天要深入一个更实际的问题——内存分配与释放的追踪,以及堆栈回溯

说实话,搞逆向这么多年,我遇到最多的问题就是:谁分配了这块内存?谁把它释放了? 尤其是分析崩溃 dump 或者内存泄漏时,这两个问题能卡住你半天。

嗯,咱们今天就把这块硬骨头啃下来。

一、内存分配追踪:从堆管理器入手

Windows 下,用户态的内存分配最终都会落到 HeapAllocmallocnew 这些函数上。但底层其实都走 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 函数内尽早调用。因为一旦进入日志输出,栈帧就被污染了。

注意: 在 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);
        }
    }
}
小技巧: 在 x64 系统上,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,看不到内核态调用链。这种情况需要用内核调试器。
避坑指南: 我曾经在分析一个崩溃时,发现堆栈回溯显示调用链在某个 DLL 中断了。查了半天,原来是那个 DLL 的基址被 ASLR 随机化了,而符号引擎没加载正确的模块列表。解决方案是在回溯前先调用 SymLoadModule64 加载所有模块。

五、知识体系图

下面这张图总结了内存分配追踪与堆栈回溯的核心流程:

内存分配追踪与堆栈回溯流程 目标进程 Hook RtlAllocateHeap / RtlFreeHeap 分配时:记录地址+大小 调用 RtlCaptureStackBackTrace 释放时:查分配记录表 匹配成功则移除记录 SymFromAddr 解析函数名 残留记录 = 内存泄漏 输出:分配/释放日志 + 调用栈

六、总结

内存分配与释放追踪,说白了就是两件事:在分配点记录上下文,在释放点匹配上下文。堆栈回溯则是获取上下文的关键技术。

我个人觉得,这个技术最大的价值不在于理论,而在于实战。下次你遇到内存泄漏、野指针、重复释放等问题时,不妨试试今天讲的方法。写一个简单的 hook DLL,挂上去跑几分钟,问题往往就水落石出了。

嗯,今天就到这里。记住:工具是死的,思路是活的。掌握了追踪的思路,换什么平台、什么语言,都能快速上手。