内存和内存损坏






3.50/5 (9投票s)
一篇旨在描述 Console 调试器用法的文章
引言
本文将重点介绍如何使用 Windows 调试工具的控制台调试器来理解内存损坏。内容将涉及程序分段、Windows 内存管理器、Windows 堆管理器以及如何在怀疑内存损坏时单步跟踪应用程序。然而,有必要对内存分配和 Windows 内存管理器进行一些基本介绍。
关于程序分段的一些说明
当程序被加载到内存时,它会被分解成几个部分。.text 部分对应代码段,是只读的。写入机制已被禁用,写入 .text 部分将导致段错误。其大小在进程加载时在运行时确定。.data 部分用于存储已初始化的全局变量,例如 `int a = 0;` 该变量已被声明并初始化。其大小在运行时也固定不变。.bss 部分用于存储未初始化的全局变量,例如 `int a`。堆部分用于存储动态存储的变量,并从低地址内存空间增长到高地址内存空间。堆栈用于跟踪函数调用(递归调用,即从底部到顶部读取 - 线程调用堆栈是找出进程挂起原因的关键)。缓冲区可以比作水平存储字节数组,用于接收和保存数据,直到进程可以使用它。
Windows 操作系统将虚拟地址空间映射到物理地址
内存分析的一个基本方面是,操作系统使用的数据的位置与其在内存转储中定位数据所需的物理位置不同。由于没有足够的物理内存来容纳所有同时运行的进程,Windows 操作系统必须模拟更大的内存空间。Windows 内存管理器会创建一种扁平的虚拟地址空间的错觉,而实际上,微处理器的硬件单元会将虚拟地址映射到物理地址。它以页为粒度进行此操作。一页是四千字节的物理内存。这种内存方案称为按需分页虚拟寻址:当加载可执行文件时,在初始化期间只加载可执行文件的部分。相应的 DLL 也被“虚拟加载”——此时只加载引用的部分,而不是整个 DLL。一旦可执行文件的功能被使用,并且调用了更多要导入的功能,可执行文件的其他部分就会根据需要被读入磁盘,相应的 DLL 也会被读入。任何可以共享的内存都会被共享:代码和 DLL。由于一个 DLL 的单个实例可以被多个进程引用,因此 DLL 只在物理内存中加载一次,这样进程就可以根据需要引用页面。因此,通过一系列数据结构来实现模拟更大的内存空间:两个主要结构是页目录和页表。要在例如内存转储中定位数据,需要将虚拟地址转换为物理地址。
回想一下,进程的虚拟地址空间的一部分对该进程是私有的。进程使用的虚拟地址并不代表对象的实际物理位置。相反,系统会为每个进程维护一个页映射,这是一个用于将虚拟地址转换为相应物理地址的内部数据结构。每次线程引用一个地址时,系统都会将虚拟地址转换为物理地址。这些私有字节(如它们被称为)对进程的最准确视图可以在 Process Explorer(由 Mark Russinovich 编写的免费工具)中通过“私有字节”、“私有字节增量”和“私有字节历史记录”列看到。选择“视图”,然后选择“选择列”。分配给每个进程的物理内存量称为其“工作集”。系统提交限制可防止进程增长,因为内存管理器会监视所有进程以记录内存需求和分页速率。
每个进程都从一个空或零大小的工作集开始。当进程中的线程开始触摸虚拟地址时(工作集的诞生),工作集就开始增长。当内存管理器认为工作集足够大时,内存管理器将开始从工作集中首先移除最旧的页面,即长时间未访问的页面。然而,当它们被移除时,它们并不会被清零或销毁,因为它们代表了该进程曾经引用的数据副本。因此 Windows 会将它们保存在几个列表中。理解这些列表是理解如何使用性能和可靠性监视器的关键。为了理解这些计数器,我们需要了解系统中不属于任何进程的物理内存量。Windows 通过按类型组织未拥有的页面来实现这一点。
- 空闲页列表
- 零页列表
- 修改页列表
- 待定列表
当 Windows 从进程的工作集中移除一个页面时,它移除的是进程未声明不需要重新使用的页面。这样的页面是缓存数据。它在待定(干净)列表或修改(脏)列表中,准备供另一个进程重用,如果该页面代表 DLL 的代码部分(回想共享内存)。页面去哪个列表取决于它是否被修改过。如果已被写入,那么内存管理器必须确保页面被写回到它来自的文件。该文件可能是映射到进程地址空间的某个数据文件。如果页面已被修改但并非代表磁盘上的数据文件,那么它可能代表进程的私有数据,该进程可能希望再次使用它。已修改并返回工作集的页面称为软错误(非分页文件读取或映射文件读取),因为不涉及硬盘 I/O——它们只是简单地插入回工作集。如果引用的数据不再内存中,因为它现在已经回到了磁盘文件或分页文件中,那么系统将产生一个硬页错误并执行分页读取操作。
系统启动时没有空闲页列表,只有在私有内存返回给系统时才会增长。换句话说,私有进程内存永远不会在不先清零的情况下返回给系统。这应该是合乎逻辑的。如果数据仅对进程是私有的(例如 `notepad.exe` 文件中键入的文本),则必须将其清零,因为它不能共享。当 Windows 需要执行页面读取操作,因为发生了一个页面错误,并且内存管理器将执行一个将完全覆盖页面内容的 I/O 操作时,它会查找空闲列表。当空闲列表达到一定大小时,一个名为零页线程(优先级为零)的内核模式线程会被唤醒。该线程的工作是清零脏页面,因为空闲列表过高,并且当 Windows 需要零页时,它手头就有了。
Windows 堆管理器
每当需要创建在程序构建时无法确定大小的数据结构时,程序就需要某种形式的动态内存管理。堆是应用程序可以用来动态分配和释放内存的一种内存管理器。如前所述,堆从低内存地址向高内存地址增长,而堆栈则相反。但是,它们不可能相互侵占。内存通常以 2 的幂次分配,当预先不知道数量且内存大小不适合堆栈(自动内存)时,就会使用堆。堆是 Windows 对象,Windows 在堆中维护内存池。一个进程可以包含多个堆,您可以从其中一个堆中分配内存。内存可以从 C 运行时、虚拟内存管理器或某些其他形式的私有内存管理器请求。操作需求决定了程序员是否只使用进程堆和 C 库。初始堆大小(可以为零,并且始终向上舍入到页面大小的倍数)决定了提交给堆的物理存储(在分页文件中)的量。也就是说,从堆中分配所需的空间。当程序超过初始大小时,会自动提交其他页面,直到达到最大大小。话虽如此,我们必须认识到需要将这些概念分解到线程执行,因为它通常是向其不拥有的内存块写入数据,从而损坏了该内存块的状态。仅凭这一点就可以说,指针用于引用包含内存对象位置的内存。操作系统使用指针而不是移动数据。
当进程加载时,堆管理器会自动创建一个名为默认进程堆的新堆。随着工作集的增长,Windows 会不断调整任何进程的工作集大小。这些调整可能是由于系统负载和进程优先级的变化。每个进程都有一个句柄表。每当线程打开一个资源时,进程的句柄表中就会创建一个句柄,该句柄的值可以被线程引用,以便持续访问该资源。操作系统资源可以是文件对象、注册表项、TCP/UDP 端口、设备等。每当进程挂起时,关键是检查线程调用堆栈。从底部到顶部读取此堆栈。每当执行线程写入其不拥有的内存块时,它很可能会损坏该内存块的状态。应用程序可能会立即崩溃,或者部分运行并行为异常。下面是引用自“高级 Windows 调试”的代码,模拟了内存损坏场景。
/*++
Copyright (c) Advanced Windows Debugging (ISBN 0321374460)
from Addison-Wesley Professional. All rights reserved.
THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR
PURPOSE.
--*/
#include <windows.h>
#include <conio.h>
#include <stdio.h>
VOID SimulateMemoryCorruption ( ) ;
class CAppInfo
{
public:
CAppInfo(LPWSTR wszAppName, LPWSTR wszVersion)
{
m_wszAppName=wszAppName;
m_wszVersion=wszVersion;
}
VOID PrintAppInfo()
{
wprintf(L"\nFull application Name: %s\n", m_wszAppName);
wprintf(L"Version: %s\n", m_wszVersion);
}
private:
LPWSTR m_wszAppName ;
LPWSTR m_wszVersion ;
} ;
CAppInfo* g_AppInfo ;
int __cdecl wmain (int argc, WCHAR* args[])
{
wint_t iChar = 0 ;
g_AppInfo = new CAppInfo(L"Memory Corruption Sample", L"1.0" );
if(!g_AppInfo)
{
return 1;
}
wprintf(L"Press: \n");
wprintf(L" 1 To display application information\n");
wprintf(L" 2 To simulated memory corruption\n");
wprintf(L" 3 To exit\n\n\n>");
while((iChar=_getwche())!='3')
{
switch(iChar)
{
case '1':
g_AppInfo->PrintAppInfo();
break;
case '2':
SimulateMemoryCorruption();
wprintf(L"\nMemory Corruption completed\n");
break;
default:
wprintf(L"\nInvalid option\n");
}
wprintf(L"\n\n> ");
}
return 0;
}
VOID SimulateMemoryCorruption ( )
{
char* pszWrite="Corrupt";
BYTE* p=(BYTE*) g_AppInfo;
CopyMemory(p, pszWrite, strlen(pszWrite));
}
编译说明

该应用程序包含一个类(`CAppInfo`),该类封装了应用程序特定的信息(应用程序名称和版本信息)。请注意,它将此应用程序信息打印到控制台屏幕,但如果再次按下选项 1,它就会崩溃。这个例子取自 Mario Hewardt 和 Daniel Pravat 编写的“高级 Windows 调试”(由 Mark Russinovich 题词),可以通过控制台调试器 `cdb.exe` 单步跟踪,以找出应用程序的哪个部分导致了内存损坏。
c:\Program Files\Debugging Tools for Windows>md c:\symbols
c:\Program Files\Debugging Tools for Windows>set _NT_SYMBOL_PATH=srv*c:\symbols*
http://msdl.microsoft.com/download/symbols
c:\Program Files\Debugging Tools for Windows>cdb.exe 05memcorrupt.exe
Microsoft (R) Windows Debugger Version 6.8.0004.0 X86
Copyright (c) Microsoft Corporation. All rights reserved.
CommandLine: 05memcorrupt.exe
Symbol search path is: srv*c:\symbols*http://msdl.microsoft.com/download/symbols
Executable search path is:
ModLoad: 01000000 01005000 05memcorrupt.exe
ModLoad: 772b0000 773d7000 ntdll.dll
ModLoad: 77430000 7750b000 C:\Windows\system32\kernel32.dll
ModLoad: 75e50000 75efa000 C:\Windows\system32\msvcrt.dll
(1420.143c): Break instruction exception - code 80000003 (first chance)
eax=00000000 ebx=00000000 ecx=0006fb08 edx=77309a94 esi=fffffffe edi=7730b6f8
eip=772f7dfe esp=0006fb20 ebp=0006fb50 iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000246
ntdll!DbgBreakPoint:
772f7dfe cc int 3
从断点异常代码 80000003 和 int 3 指令来看,调试器在执行开始前初始化进程后会自动中断。请注意 `ntdll!DbgBreakPoint` 线程已开启。
0:000> g
Press:
1 To display application information
2 To simulated memory corruption
3 To exit
>1
Full application Name: Memory Corruption Sample
Version: 1.0
> 2
Memory Corruption completed
> 1(1420.143c): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=72726f43 ebx=72726f43 ecx=00000007 edx=00000073 esi=7ffffffe edi=010012bc
eip=75e5bbb1 esp=0006fa4c ebp=0006fed0 iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010202
msvcrt!_woutput_l+0x983:
75e5bbb1 66833800 cmp word ptr [eax],0 ds:0023:72726f43=????
0:000> kb
ChildEBP RetAddr Args to Child
0006fed0 75e63dae 75ef0978 01001288 00000000 msvcrt!_woutput_l+0x695
0006ff18 0100154a 01001288 72726f43 00741dc8 msvcrt!wprintf+0x35
0006ff2c 010014b5 00000031 00741dc8 00741dc8memcorrupt!CAppInfo::PrintAppInfo+0x18
0006ff44 01001731 00000001 00741d60 00744cf0 05memcorrupt!wmain+0xb2
0006ff88 77474911 7ffdf000 0006ffd4 772ee4b6 05memcorrupt!wmainCRTStartup+0x12f
0006ff94 772ee4b6 7ffdf000 7737ccca 00000000 kernel32!BaseProcessStart+0x12f
从堆栈(我们从底部读取)可以看出,main 函数调用了 `CAppInfo` 类的 `PrintAppInfo` 函数。那么 `wprintf` 函数为什么会失败呢?我们知道 `wprintf` 像 `wchar_t` 一样,期望宽字符,即两个字节的字符:Unicode 或某种 Unicode 的两字节变体,如 UTF-8。这种编码与 ISO-8559 或 ASCII 明文不一致。查看传递给 `wprintf()` 的参数,我们可以看到两个指针,因此可以推断这两个指针是无效的。`wprintf()` 函数假定传递的指针代表一个以 NULL 结尾的宽字符字符串。所以我们检查对象的状态。
0:000>x 05memcorrupt!g_*
000210080 05memcorrupt!g_AppInfo = 0x00032cb0
0:000>dt CAppInfo 0x00032cb0
+0x000 m_wszAppName : 0x72726f43 ? ?
+0x004 m_wszVersion : 0x01747075 ? ?
问号表示内存不可访问。应用程序第一次运行时,它打印了一切。第二次运行应用程序时,指针现在指向不可访问的内存;一个它不拥有的内存块。所以,我们不使用“du”命令转储 Unicode,而是使用“da”命令转储明文。
da 00x00032cb0
00032cb0 “Corrupt………”
那么这个故事的寓意是什么呢?使用 Application Verifier 等内存损坏工具!
参考文献
- Mario Hewardt 和 Daniel Pravat 著《高级 Windows 调试》
- Mark Russinovich 和 David Solomon 的 SysInternals 视频库