

从 VirtualAlloc 入手理解 DEP 与 ASLR 内存保护机制
掌握 Windows 内存利用与防御的关键。本文从虚拟内存和页表讲起,重点分析如何使用 VirtualAlloc/VirtualProtect 控制内存页的读、写、执行(RWX)权限,以及 DEP 和 ASLR 如何影响内存布局。
虚拟内存和物理内存#
在多任务操作系统中,一个核心问题是如何管理有限的物理内存,以支持多个进程同时、安全地运行。Windows 采用虚拟内存机制来解决此问题。
- 物理内存 (Physical Memory): 指计算机中实际存在的RAM硬件。它是所有进程和操作系统内核共享的公共资源。
- 虚拟内存 (Virtual Memory): 是操作系统为每个进程创建的一个独立的、私有的地址空间。在32位系统上,这个空间大小为4GB;在64位系统上,理论大小为128TB。进程代码所操作的地址均为虚拟地址。
现代操作系统中的内存不会直接映射到物理内存中,进程使用虚拟内存地址,而虚拟内存通过页表映射到物理内存或磁盘。
从虚拟地址到物理地址的转换由CPU的内存管理单元 (MMU) 在硬件层面完成。每个进程都拥有自己的页表 (Page Table),该数据结构负责记录虚拟地址页与物理地址页之间的映射关系。MMU通过查询页表来完成地址翻译。
我们可以通过一个简单的图书馆类比来理解这个过程:
-
虚拟地址 就像一本书的“索引号”(如:CS-101)。
-
物理地址 则是这本书在书架上的“真实位置”(如:三楼A区)。
-
MMU和页表 协同工作,扮演着“图书管理员和索引卡”的角色,负责将索引号翻译成真实位置。
下图则是从技术层面展示了两个独立进程如何共享物理内存。得益于各自独立的页表,进程A的虚拟地址0x1000和进程B的虚拟地址0x1000可以映射到完全不同的物理地址,实现了隔离。同时,对于共享库(DLL),它们的虚拟地址可以被映射到同一块物理内存,从而节约了资源。
总体而言,虚拟内存机制带来了几大关键优势:
- 进程隔离: 每个进程的地址空间独立,一个进程的错误不会影响其他进程,保证了系统的稳定性与安全性。
- 简化的内存模型: 开发者可以在一个连续、线性的地址空间上工作,无需关心物理内存的碎片化问题。
- 内存超售: 虚拟内存允许将暂时不用的内存页从物理内存换出到硬盘上的页面文件(Pagefile.sys),从而支持运行超过物理内存总量的程序。这一过程对应用程序是透明的。
内存的三种状态#
Windows 对虚拟内存页的管理定义了三种状态,这为内存的高效使用提供了灵活性。
- 空闲 (Free): 表示该段虚拟地址空间未被使用,可用于分配。
- 保留 (Reserved): 进程向系统申请保留一段连续的虚拟地址。此操作仅占用地址空间,不消耗物理内存或页面文件。这对于需要大块连续地址,但又不希望立即消耗物理资源的场景非常有用。
- 提交 (Committed): 为预定的地址空间分配物理存储(物理内存或页面文件)。只有提交后的内存才能被进程访问。
这三种状态的转换关系由 VirtualAlloc
和 VirtualFree
函数控制:
内存的分配与保护#
VirtualAlloc
是Windows提供的用户态底层虚拟内存分配API。与C/C++运行时库的malloc或Win32堆API的HeapAlloc
相比,VirtualAlloc
直接以页为单位操作虚拟地址空间,并提供了最关键的功能:指定内存页的保护属性(读、写、执行)。
内存保护机制功能#
现代操作系统通常内置了内存保护功能来阻止攻击。这些功能在构建或调试恶意软件时也需要考虑。
- 数据执行保护 (DEP) - DEP 是从 Windows XP 和 Windows Server 2003 开始内置到操作系统中的系统级内存保护功能。如果页面保护选项设置为 PAGE_READONLY,DEP 将阻止代码在该内存区域中执行。
- 地址空间布局随机化 (ASLR) - ASLR 是一种内存保护技术,用于防止利用内存损坏漏洞。ASLR 随机排列进程关键数据区域(包括可执行文件的基地址以及堆栈、堆和库的位置)的地址空间位置。我因为方便调试的原因,关闭了这个选项,这里不做演示。
#include <windows.h>
#include <stdio.h>
int main() {
// 准备一段shellcode,0xC3在x86/x64汇编中代表 `ret` 指令
char shellcode[] = { 0xC3 };
printf("Shellcode is located on the stack at address: %p\n", shellcode);
printf("Attempting to execute code from the stack...\n");
// 创建一个函数指针,指向栈上的shellcode
void (*pFunc)() = (void(*)())shellcode;
// 尝试调用!
pFunc();
// 如果你看到了这条消息,说明DEP没有生效
printf("Execution successful. (This should not happen!)\n");
return 0;
}
c通过调试我们可以发现程序出现了Access Violation异常,这就是因为CPU在硬件层面尝试从一个被标记为“不可执行”(NX, No-Execute)的内存页(我们的栈)读取并执行指令时,被强制阻止了。这样一来DEP就成功地阻止了一次最原始的栈溢出攻击。
内存保护属性 (Memory Protection)#
内存保护是操作系统提供的一种安全机制,用于限制特定内存区域的访问方式。通过为每个内存页设置保护属性,可以防止常见的编程错误和恶意攻击,例如:
- 防止代码意外地修改只读数据。
- 防止程序执行非代码区(如堆、栈)的数据,这是数据执行保护 (DEP) 的核心原理。
VirtualAlloc
和 VirtualProtect
函数允许我们使用一系列常量来精确定义这些属性,其中VirtualAlloc
通常用于内存的分配以及状态变更,VirtualProtect
则被用来修改属性。以下是一些最常用的保护常量:
常量 | 描述 | 常见用途 |
---|---|---|
PAGE_NOACCESS | 禁止任何访问。访问此内存页将引发访问冲突异常。 | 保护区域、预定内存 |
PAGE_READONLY | 只读。 | 存放常量数据 |
PAGE_READWRITE | 可读可写。 | 存放常规变量、堆栈 |
PAGE_EXECUTE | 只执行。 | 不常见,用于高度安全的纯代码段 |
PAGE_EXECUTE_READ | 可执行、可读。 | 存放代码段(.text section) |
PAGE_EXECUTE_READWRITE | 可执行、可读、可写。 | 高风险权限,常用于JIT编译器和Shellcode注入 |
调试器跟踪内存变化#
实例代码:
#include <windows.h>
#include <stdio.h>
int main() {
LPVOID memoryAddress = NULL;
SIZE_T memorySize = 4096; // 分配一个页的大小 (4KB)
printf("Press Enter to reserve memory...\n");
getchar();
// 步骤 1: 保留 (Reserve) 内存
// 此时内存不可访问,状态为 MEM_RESERVE
memoryAddress = VirtualAlloc(NULL, memorySize, MEM_RESERVE, PAGE_NOACCESS);
if (memoryAddress == NULL) {
printf("Failed to reserve memory. Error: %lu\n", GetLastError());
return 1;
}
printf("Memory reserved at: 0x%p\n", memoryAddress);
printf("--> Check the memory map in your debugger now.\n");
printf("Press Enter to commit memory...\n");
getchar();
// 步骤 2: 提交 (Commit) 内存
// 将预定的内存提交,并赋予读写权限。状态变为 MEM_COMMIT,保护属性为 RW-
// 在正常开发时我们一般直接将步骤1和2简化为VirtualAlloc(NULL, memorySize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE)
if (VirtualAlloc(memoryAddress, memorySize, MEM_COMMIT, PAGE_READWRITE) == NULL) {
printf("Failed to commit memory. Error: %lu\n", GetLastError());
VirtualFree(memoryAddress, 0, MEM_RELEASE);
return 1;
}
printf("Memory committed with READWRITE protection.\n");
printf("--> Check the memory map again.\n");
printf("Press Enter to change protection to EXECUTABLE...\n");
getchar();
// 步骤 3: 修改保护属性 (Protect)
// 使用 VirtualProtect 修改内存为可读、可写、可执行。保护属性变为 RWX
DWORD oldProtect;
if (!VirtualProtect(memoryAddress, memorySize, PAGE_EXECUTE_READWRITE, &oldProtect)) {
printf("Failed to change memory protection. Error: %lu\n", GetLastError());
VirtualFree(memoryAddress, 0, MEM_RELEASE);
return 1;
}
printf("Memory protection changed to EXECUTE_READWRITE.\n");
printf("--> Check the memory map for the final time.\n");
printf("Press Enter to free memory and exit...\n");
getchar();
// 步骤 4: 释放内存
VirtualFree(memoryAddress, 0, MEM_RELEASE);
printf("Memory released.\n");
return 0;
}
c在执行完 memoryAddress = VirtualAlloc(NULL, memorySize, MEM_RESERVE, PAGE_NOACCESS);
时我们能看到内存区域的状态已经变为保留,无访问权限。
执行完VirtualAlloc(memoryAddress, memorySize, MEM_COMMIT, PAGE_READWRITE)
时内存状态已提交并显示拥有读写权限
在执行完VirtualProtect(memoryAddress, memorySize, PAGE_EXECUTE_READWRITE, &oldProtect)
后,内存区域被设置为读写执行权限