page_guard是windows一次性内存访问陷阱,首次读写触发异常后自动失效;需配合seh与virtualprotect重设才能持续拦截,但性能差、易出错,仅适用于调试或轻量审计,非生产级防护。

什么是 PAGE_GUARD,它真能拦截读写吗
PAGE_GUARD 是 Windows 内存保护标志之一,设置后,对页的首次访问(无论读或写)会触发 STATUS_GUARD_PAGE_VIOLATION 异常,然后系统自动清除该页的 PAGE_GUARD 标志——此后再次访问就不会再触发。它不是“持续拦截”,而是“一次性陷阱”。所以想靠它实现“隐形读写拦截”,必须配合结构化异常处理(SEH)+ 手动重设 PAGE_GUARD,否则只能捕获第一次。
常见错误现象:VirtualProtect 设置 PAGE_GUARD | PAGE_READWRITE 后,访问一次就失效,后续读写静默通过,误以为“没生效”。
使用场景:适合轻量级内存访问审计、调试器单步/断点模拟、极简 hook 入口(比如只关心某块配置内存首次被读取),但不适合高频、反复读写的拦截(性能差、易漏、易崩)。
关键点:必须用 SetThreadAffinityMask 或线程局部 SEH;多线程下未同步重设 PAGE_GUARD 会导致竞争,出现访问违规或漏触发。
怎么用 SEH + VirtualProtect 实现单次拦截后自动恢复
核心流程是:触发异常 → 在 __except 块中识别 STATUS_GUARD_PAGE_VIOLATION → 调用 VirtualProtect 重设 PAGE_GUARD(注意:要先设为 PAGE_READWRITE,再立刻加回 PAGE_GUARD,否则失败)→ 返回 EXCEPTION_CONTINUE_EXECUTION。
LONG WINAPI GuardHandler(EXCEPTION_POINTERS* pExp) {
if (pExp->ExceptionRecord->ExceptionCode == STATUS_GUARD_PAGE_VIOLATION) {
PVOID addr = (PVOID)pExp->ExceptionRecord->ExceptionInformation[1];
DWORD oldProt;
// 先移除 GUARD,设为可读写
VirtualProtect(addr, 4096, PAGE_READWRITE, &oldProt);
// 立即重新加回 GUARD(否则下次不触发)
VirtualProtect(addr, 4096, PAGE_READWRITE | PAGE_GUARD, &oldProt);
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
注意事项:
-
VirtualProtect必须作用于整个页(地址需对齐到 4KB,用((uintptr_t)ptr) & ~0xFFF计算) - 不能在异常处理中调用可能抛异常的 C++ 对象构造/析构(如
std::string),否则二次崩溃 -
SetUnhandledExceptionFilter(GuardHandler)是进程级,但多线程下推荐用AddVectoredExceptionHandler(TRUE, ...),更可靠
为什么直接用 PAGE_GUARD 拦截“写入”特别危险
Windows 对写操作的 GUARD 触发行为有隐含前提:目标页必须已提交且可写。如果页是 MEM_RESERVE 状态,或刚用 VirtualAlloc 分配但未提交(MEM_COMMIT),设置 PAGE_GUARD 会失败,GetLastError() 返回 ERROR_INVALID_PARAMETER。
更隐蔽的问题:某些优化编译器(如 MSVC /O2)可能把多个写合并成一条指令(如 mov eax, [mem] + mov [mem], ebx),导致异常发生时 EIP 指向的是下一条指令,无法精确定位哪条写触发了 GUARD——调试和日志都容易错位。
性能影响明显:每次触发都要走完整异常分发路径(内核态 ↔ 用户态切换),比普通内存访问慢 1000 倍以上。实测连续写同一地址 10 万次,用 PAGE_GUARD 拦截耗时 >2s,而普通写仅 ~0.1ms。
所以,若目标是拦截写入逻辑(如防篡改、审计修改源),应优先考虑其他机制:WriteProcessMemory 配合调试权限、ETW 事件订阅、或用户态页表影子(需驱动支持)。
实际部署时最容易忽略的三个细节
-
VirtualAlloc 分配内存时,必须指定 MEM_COMMIT | MEM_RESERVE,且保护属性初始不能含 PAGE_GUARD;需先分配好,再用 VirtualProtect 单独加上。
- 异常处理器里调用
VirtualProtect 失败时,GetLastError() 可能返回 ERROR_NOACCESS(说明地址无效)或 ERROR_INVALID_ADDRESS(页未提交),但不会抛 C++ 异常——必须手动检查返回值。
- 如果目标内存位于 DLL 数据段或全局变量区,链接时默认是
/DYNAMICBASE:NO 或 ASLR 关闭状态,可能导致地址固定但被其他模块覆盖;建议用 /DYNAMICBASE + /HIGHENTROPYVA 编译,并在运行时用 VirtualQuery 确认页状态。
VirtualAlloc 分配内存时,必须指定 MEM_COMMIT | MEM_RESERVE,且保护属性初始不能含 PAGE_GUARD;需先分配好,再用 VirtualProtect 单独加上。VirtualProtect 失败时,GetLastError() 可能返回 ERROR_NOACCESS(说明地址无效)或 ERROR_INVALID_ADDRESS(页未提交),但不会抛 C++ 异常——必须手动检查返回值。/DYNAMICBASE:NO 或 ASLR 关闭状态,可能导致地址固定但被其他模块覆盖;建议用 /DYNAMICBASE + /HIGHENTROPYVA 编译,并在运行时用 VirtualQuery 确认页状态。这种方案本质是“异常驱动的轻量钩子”,不是通用内存监控。真正需要稳定拦截读写,得上硬件断点(CONTEXT.Dr0–Dr3)、ETW、或者内核驱动级页表控制——PAGE_GUARD 只是调试和原型验证的快捷入口,别把它当生产级防护用。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











