这个同样是滴水线上班驱动章节的课后习题,比那个调用PspTerminateProcess的要复杂一些。
写拷贝是操作系统的一种内存优化机制,当多个进程或对象共享同一份数据时,只要它们不修改数据,就共享同一块内存;只有在某一方需要修改数据时,系统才会为该修改方创建一个独立的副本,以确保互不影响。
为什么要绕过写拷贝?
每个进程加载的动态链接库,在不被修改的情况下,都是共享一个物理页的,

且条件允许的情况下,这些进程加载动态链接库的虚拟地址也都和第一个加载这个动态链接库的虚拟地址一样(这个特性之后会用到)

如果能绕过写拷贝而对这个共享的物理页进行修改,则可实现全局修改的效果。
习题的目标
绕过写拷贝直接对共享的物理页进行写入全局inline hook,从而实现对user32.dll中的MessageBoxA进行监控。
如何实现绕过写拷贝?
写拷贝是操作系统在虚拟内存层次维护的特性,windows进程的eprocess结构中的vadroot成员标记了所有写拷贝的虚拟内存,

一个物理页如果在页表中被标记为只读(R/W位为0),而在虚拟内存中被标记为写拷贝,当它被写入的时候会触发cpu的异常,操作系统捕捉到这个异常后判断当前页所映射的虚拟内存是否被标记为写拷贝,如果是,则分配一个新的物理页来复制这个页。
但是如果我们能够修改页表中的物理页属性,我们可以将R/W位改为1,这样就不会触发异常,自然也就能绕过写拷贝了。
具体实现
本文中仍然使用64位win10环境。
上文说到要修改物理页属性,那么我们就需要对页表进行控制,需要使用到eprocess的dirbase。我们知道对页表进行控制可以通过0xc0系列的自映射地址实现,但是win10之后的页表自映射地址变为随机化地址了,不能使用原先的固定地址,并且MmIoMapSpace函数不允许用于映射页表所在的物理地址,那么我们要怎么找到这些地址呢?
寻找自映射地址
由于自映射是通过pml4上一个直接指向dirbase本身的项实现的,pml4表最多就512项,因此我们可以通过穷举来找到这个项(最多只需要穷举256次,因为自映射地址肯定是内核地址,最高位为1),但是我们没办法访问pml4表本身,因此我使用了一个取巧的办法:
既然pml4表上这一项直接指向自己,那就构造一个4级页表下标全一样的虚拟页,然后使用MmIsAddressValid判断其是否是有效地址,如果有效,再将这个地址作为QWORD数组,读取这个下标中的QWORD,如果QWORD的值&0x0000fffffffffffff000(也就是清除高位与低位的flag)与dirbase相等,那么我们就可以认为这个下标就是自环下标。
ULONG64 findSelfMappingInd(ULONG64 dirbase) {
ULONG64 startInd = 0x100;
for (ULONG64 i = 0; i <= 0xff; i++) {
ULONG64 curInd = startInd + i;
ULONG64 va = (curInd << 0xc) + (curInd << (0xc + 0x9)) + (curInd << (0xc + 0x9 * 2)) + (curInd << (0xc + 0x9 * 3)) | 0xffff000000000000;
if (MmIsAddressValid((PVOID)va)) {
ULONG64 suspiciousDirbase = ((ULONG64*)va)[curInd] & 0x0000fffffffff000;
if (suspiciousDirbase == dirbase)
return curInd;
}
}
return -1;
}
代码注入
找到这个下标后,我们就可以利用这个下标对任意一级的页表进行访问,在本习题中我们要改动的是4级表,就可以使用一次自环下标访问四级表中的项,然后修改它的R/W位
ULONG msgBoxApml4Ind = (ULONG)((ULONG64)addrMsgBoxA >> (9+9+9+12)) & 0x1ff;
ULONG msgBoxApdptInd = (ULONG)((ULONG64)addrMsgBoxA >> (9+9+12)) & 0x1ff;
ULONG msgBoxApdeInd = (ULONG)((ULONG64)addrMsgBoxA >> (9+12)) & 0x1ff;
ULONG msgBoxApteInd = (ULONG)((ULONG64)addrMsgBoxA >> 12) & 0x1ff;
//...
ULONG64* msgPageEntry = buildVirtualAddress(selfMappingInd, msgBoxApml4Ind, msgBoxApdptInd, msgBoxApdeInd, msgBoxApteInd * 8, TRUE);
//...
*msgPageEntry |= 0x2; // R/W 位
修改完成之后,就可以往其中写入hook代码了,在MessageBoxA中写入跳转到hook代码的跳转语句。
for (i = 0; i < 5; i++)
*((CHAR*)addrMsgBoxA+i) = jmpCode[i];
然后通过调试user32.dll,发现其尾部有一段大概0x200大小左右的空间用于对齐,这个空间就可以被用于存储hook代码。我这里使用的hook代码是一个DllLoader,加载指定路径的DLL并执行其中的函数。
CHAR shellcode[] = { 0x51, 0x52, 0x41, 0x50, 0x41, 0x51,
0x48, 0x83, 0xec, 0x38, 0xc6, 0x44, 0x24, 0x30, 0x00, 0xc6, 0x44, 0x24, 0x26, 0x00, 0x48, 0xb8, 0x43, 0x3a, 0x5c, 0x31,
0x2e, 0x64, 0x6c, 0x6c, 0x48, 0x89, 0x44, 0x24, 0x28, 0x66, 0xc7, 0x44, 0x24, 0x24, 0x66, 0x31, 0x48, 0x8d, 0x4c, 0x24,
0x28, 0x48, 0xb8, 0x56, 0x34, 0x12, 0x90, 0x78, 0x56, 0x34, 0x12, 0xff, 0xd0, 0x48, 0x8d, 0x54, 0x24, 0x24, 0x49, 0xb8,
0x56, 0x34, 0x12, 0x90, 0x78, 0x56, 0x34, 0x22, 0x48, 0x89, 0xc1, 0x41, 0xff, 0xd0, 0xff, 0xd0, 0x90, 0x48, 0x83, 0xc4,
0x38, 0x41, 0x59, 0x41, 0x58, 0x5a, 0x59, 0x48, 0x83, 0xec, 0x38, 0x45, 0x33, 0xdb, 0xe9, 0xaa, 0xa3, 0xfe, 0xff};
不过既然要加载dll,就需要用到LoadLibrary和GetProcAddress函数,但是由于地址随机化,我们无法将这两个函数的地址硬编码到shellcode中,所以我这里使用的方法是先定义占位符,然后在注入时动态替换。
void __declspec(noinline) shellcode5() {
HMODULE(*pLoadLibA)(LPCSTR) = (HMODULE(*)(LPCSTR))0x1234567890123456; // 占位符
FARPROC(*pGetProcAddr)(HMODULE, LPCSTR) = (FARPROC(*)(HMODULE, LPCSTR))0x2234567890123456; // 占位符
char libPath[9] = { 0 };
char funcName[3] = { 0 };
*((ULONG64*)libPath) = 0x6c6c642e315c3a43;
*((WORD*)funcName) = 0x3166;
HMODULE hDll = pLoadLibA(libPath);
VOID(*f1)() = (VOID(*)())pGetProcAddr(hDll, funcName);
f1();
}
由于上文中提到的动态链接库加载的虚拟地址固定的特性,而且kernel32肯定是已经被加载的,我们可以在调用驱动时3环获取这两个函数的地址,然后传入到驱动中替换(这样就避免使用通过遍历模块表实现的pic-shellcode,这个代码量太大了,装不下)。
*((ULONG64*)(shellcode + 49)) = addrLoadLibA;
*((ULONG64*)(shellcode + 66)) = addrGetProcAddr;
整个改权限+写入hook的完整的代码如下。
VOID cowBypassMsgBoxA(PVOID addrMsgBoxA) {
ULONG msgBoxApml4Ind = (ULONG)((ULONG64)addrMsgBoxA >> (9+9+9+12)) & 0x1ff;
ULONG msgBoxApdptInd = (ULONG)((ULONG64)addrMsgBoxA >> (9+9+12)) & 0x1ff;
ULONG msgBoxApdeInd = (ULONG)((ULONG64)addrMsgBoxA >> (9+12)) & 0x1ff;
ULONG msgBoxApteInd = (ULONG)((ULONG64)addrMsgBoxA >> 12) & 0x1ff;
PVOID user32Base = (ULONG64)addrMsgBoxA - 0x78b70; // win10 only
PVOID freeSpace = (ULONG64)user32Base + 0x8e764; // .text段的尾部,大概有0x200左右的空间
ULONG freeSpacePml4Ind = (ULONG)((ULONG64)freeSpace >> (9 + 9 + 9 + 12)) & 0x1ff;
ULONG freeSpacePdptInd = (ULONG)((ULONG64)freeSpace >> (9 + 9 + 12)) & 0x1ff;
ULONG freeSpacePdeInd = (ULONG)((ULONG64)freeSpace >> (9 + 12)) & 0x1ff;
ULONG freeSpacePteInd = (ULONG)((ULONG64)freeSpace >> 12) & 0x1ff;
PVOID eproc = PsGetCurrentProcess();
ULONG64 dirBase = *((ULONG64*)((CHAR*)eproc + 0x28)) & 0xfffffffffffff000;
*((ULONG64*)(shellcode + 49)) = addrLoadLibA;
*((ULONG64*)(shellcode + 66)) = addrGetProcAddr;
ULONG64 selfMappingInd = findSelfMappingInd(dirBase);
ULONG64* msgPageEntry = buildVirtualAddress(selfMappingInd, msgBoxApml4Ind, msgBoxApdptInd, msgBoxApdeInd, msgBoxApteInd * 8, TRUE);
*msgPageEntry |= 0x2; // R/W 位
ULONG64* freeSpacePageEntry = buildVirtualAddress(selfMappingInd, freeSpacePml4Ind, freeSpacePdptInd, freeSpacePdeInd, freeSpacePteInd * 8, TRUE);
*freeSpacePageEntry |= 0x2;
KeInvalidateAllCaches();
int i;
for (i = 0; i < 5; i++)
*((CHAR*)addrMsgBoxA+i) = jmpCode[i];
for (i = 0; i < 105; i++)
*((CHAR*)freeSpace + i) = shellcode[i];
}
现在驱动的主要代码已经完成了,我们就要开始写3环代码了,3环代码功能相对简单,就只是获取3个重要函数的地址然后通过DeviceIoControl传入驱动
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>
#define IOCTL_GET_SINGLE_HOOK_RECORD CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_RECORD_HOOK CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_HOOK_FUNCTION CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS)
int main(void) {
HANDLE hDev = CreateFileA("\\\\.\\HookCli", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
if (hDev == INVALID_HANDLE_VALUE) {
printf("Failed! %d\n", GetLastError());
return 1;
}
ULONG64 inBuffer[3] = { 0 };
HMODULE hKer32 = LoadLibraryA("kernel32.dll");
HMODULE hUser32 = LoadLibraryA("user32.dll");
if (hKer32 == NULL || hUser32 == NULL)
return 1;
inBuffer[0] = (ULONG64)(GetProcAddress(hUser32, "MessageBoxA"));
inBuffer[1] = (ULONG64)(GetProcAddress(hKer32, "LoadLibraryA"));
inBuffer[2] = (ULONG64)(GetProcAddress(hKer32, "GetProcAddress"));
PVOID freeSpace = (PVOID)((ULONG64)hUser32 + 0x8e764);
printf("%p\n", freeSpace);
// 先读取内容,否则这些页不会真的被加载到内存
printf("%c %c %c %d\n", *((CHAR*)inBuffer[0]), *((CHAR*)inBuffer[1]), *((CHAR*)inBuffer[2]), *((CHAR*)freeSpace));
system("pause");
DeviceIoControl(hDev, IOCTL_HOOK_FUNCTION, &inBuffer, sizeof(inBuffer), NULL, 0, NULL, NULL);
}
需要注意的是,这几个函数都是使用延迟加载机制加载到内存中的,这也就是说如果这些函数没有被调用或者被访问,那它们就不会真的被映射到虚拟内存,使用驱动写入时会引发错误。因此才需要通过
printf("%c %c %c %d\n", *((CHAR*)inBuffer[0]), *((CHAR*)inBuffer[1]), *((CHAR*)inBuffer[2]), *((CHAR*)freeSpace));
这句访问,将其加载到内存中。
动态链接库的代码
由于我的hook代码是加载一个动态链接库并运行其中的f1函数,因此我这里将动态链接库的源码也一并给出。
#include <Windows.h>
#define IOCTL_RECORD_HOOK CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)
extern "C" VOID __declspec(dllexport) f1() {
HANDLE hFile = CreateFileA("\\\\.\\HookCli", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
ULONG64 pid = GetCurrentProcessId();
DeviceIoControl(hFile, IOCTL_RECORD_HOOK, &pid, sizeof(pid), NULL, 0, NULL, NULL);
}
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
完整的驱动代码
见附件。
运行结果



题外话
除了修改pte的R/W位之外,我在写这个文章时还有想到另一种方法来绕过写拷贝:由于我们可以直接控制页表,因此我们可以直接读取要绕过写拷贝的物理页的地址,然后在页表上面新增可写项来控制页表;页表基本上是不会每个都完全占满的,完全可以找一个地方存一个可写的项,然后往其中写入。但是这个方法我没去实践。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
最后于 2025-6-6 11:35
被nstlgst134编辑
,原因: 更新