用 mprotect / VirtualProtect 抓内存越界
用 mprotect / VirtualProtect 抓内存越界
调内存破坏(memory corruption)最难的地方在于:等你发现数据被写花了,破坏现场早就过去了,你看到的是一堆垃圾值,却不知道是哪个线程、哪行代码写的。一个实用的思路是给可疑缓冲区加一道没有访问权限的守护页(guard page),谁越界写就地触发段错误,调用栈直接停在凶手那一帧。
下面这套 Linux 方法最早是 Liran B.H. 写的;Windows 上把 mprotect 换成 VirtualProtect 即可。
栈上的越界:线程栈之间天然有守护页
多线程程序里,一个线程踩了另一个线程的缓冲区是经典 bug。好在 POSIX 线程库默认就在相邻线程栈之间留了一页(4K)无权限的 guard size。先看个例子:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/mman.h>
void *threadfn(void *p)
{
int val = 100;
printf("thread val address=%lx\n", &val);
sleep(100);
return NULL;
}
int main()
{
int val = 100;
printf("main val address=%lx\n", &val);
pthread_t t1, t2, t3;
pthread_create(&t1, NULL, threadfn, NULL);
pthread_create(&t2, NULL, threadfn, NULL);
pthread_create(&t3, NULL, threadfn, NULL);
sleep(100);
pthread_exit(NULL);
return 0;
}
/* 输出:
main val address=7ffe2438515c
thread val address=7fdba3989f44
thread val address=7fdba3a8af44
thread val address=7fdba4274f44
*/
cat /proc/[pid]/maps 看进程内存映射,能看到每个线程栈之间夹着一页用来防止互相踩踏:

guard 大小可以在建线程时用 pthread_attr_t 配合 pthread_attr_setguardsize 改。
堆上 / BSS / DATA 的越界:手动用 mprotect 造守护页
栈有库帮你兜底,但动态分配的缓冲、BSS/DATA 段里的数组没有。这时自己用 mprotect 改 MMU 权限:
mprotect(buffer, 0x1000, PROT_READ);
把这一页设成只读,之后任何写操作都会触发 SIGSEGV。
有个前提:被保护的地址必须页对齐,所以不能直接用 malloc。memalign / aligned_alloc 可以,但更干净的做法是直接 mmap 拿整页,配合 MAP_ANONYMOUS 向内核要匿名内存:
p1 = mmap(0, 0x20000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
mprotect(p1 + page_size, page_size, PROT_NONE);
mprotect(p1 + 0x1f000, 0x1000, PROT_NONE);
pthread_attr_setstack(&attr, p1 + 0x1000, 0x1d000);
pthread_create(&t1, &attr, threadfn1, NULL);
这里先要了 128KB(0x20000),把首尾两页设成无权限,再把中间这段当成线程栈。

线程一旦栈溢出就吃 SIGSEGV,而且信号处理函数跑在出事线程的上下文里,所以能就地抓到。完整示例:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/mman.h>
void *threadfn1(void *p1)
{
int num;
int val = 90;
/* 取消注释看线程崩溃
int *p = &val;
printf("val addr=%lx\n", &val);
for (num = 0; num < 0x500; num++) {
*p = 20;
p++;
}
*/
while (1) {
puts("thread1");
sleep(10);
}
return NULL;
}
void *threadfn2(void *p)
{
while (1) {
puts("thread2");
sleep(10);
}
return NULL;
}
int main()
{
void *p1, *p2;
pthread_t t1, t2, t3;
int page_size = sysconf(_SC_PAGESIZE);
printf("page size=%x\n", page_size);
pthread_attr_t attr;
pthread_attr_init(&attr);
p1 = mmap(0, 0x20000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
printf("mmap ret=%lx\n", p1); // 返回地址页对齐
mprotect(p1 + page_size, page_size, PROT_NONE);
mprotect(p1 + 0x1f000, 0x1000, PROT_NONE);
p2 = malloc(0x20000);
printf("malloc ret=%lx\n", p2); // malloc 返回地址不页对齐
pthread_attr_setstack(&attr, p1 + 0x1000, 0x1d000);
pthread_create(&t1, &attr, threadfn1, NULL);
pthread_attr_setstack(&attr, p2, 0x20000);
pthread_create(&t2, &attr, threadfn2, NULL);
sleep(100);
puts("end test");
return 0;
}
效果和栈守护页一样,但这套对任何缓冲区都管用,不限于栈。

Windows:换成 VirtualProtect
Windows 上对应的是 VirtualProtect,配合 VirtualAlloc 拿页对齐内存:
SYSTEM_INFO si;
GetSystemInfo(&si);
DWORD page = si.dwPageSize;
// 申请两页,第二页设为不可访问当守护页
char *buf = (char *)VirtualAlloc(NULL, page * 2, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
DWORD old;
VirtualProtect(buf + page, page, PAGE_NOACCESS, &old);
// 越界写 buf + page 会触发 EXCEPTION_ACCESS_VIOLATION
越界访问会抛 EXCEPTION_ACCESS_VIOLATION,用 __try/__except 或向量化异常处理(AddVectoredExceptionHandler)就能在出事那一刻拿到调用栈。
思路是相通的:把缓冲区紧贴一页无权限内存,让越界从「悄悄写花一块数据、很久以后才爆」变成「当场触发异常、栈停在凶手」。