读第三方程序的变量的原理
flyfish
第 1 层: C/C++ 代码(纯用户态,Ring 3)
敲下这几行:
cpp
HANDLE hProcess = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, pid);
ReadProcessMemory(hProcess, (LPCVOID)0x00007FF712345678, &value, 8,NULL);
这一刻 CPU 还在 Ring 3,代码运行在自己的 64 位进程虚拟地址空间里。所有变量(hProcess、value、pid)都在你自己的栈上,所有字符串都在你自己的只读数据段。这些 API 函数的实现根本不在你的 exe 里,而是在 C:\Windows\System32\kernel32.dll 里。只是"调用"了它们,CPU 现在执行的是 kernel32.dll 导出的函数序言。
第 2 层:kernel32.dll → kernelbase.dll → ntdll.dll(用户态跳板)
kernel32.dll 几乎是空的,它立刻把控制权交给 kernelbase.dll(Win10/11 之后大部分实现挪到这里)。
kernelbase.dll 做一点参数检查和版本兼容,然后直接跳转到 ntdll.dll 里的同名函数(NtOpenProcess、NtReadVirtualMemory)。
ntdll.dll 是整个 Windows 用户态的最底层,所有真正的系统调用入口都在这里。此时 CPU 仍然在 Ring 3,但已经站在了通往内核的门口。
第 3 层:ntdll.dll 发起系统调用(真正跨越 Ring 3 → Ring 0)
在 64 位 Windows 上,系统调用不再用 int 0x2e 或 sysenter,而是用 syscall 指令。
ntdll!NtReadVirtualMemory 的最后几条汇编大概长这样:
asm
mov r10, rcx ; Windows x64 调用约定影子空间
mov eax, 0x3F ; NtReadVirtualMemory 的系统服务号(随版本微调)
syscall ; 关键指令!CPU 立刻从 Ring 3 切到 Ring 0
ret
执行 syscall 那一瞬间,CPU 读取 IA32_LSTAR 寄存器(里面存着内核入口地址 KiSystemCall64),然后:
保存用户态栈指针
切换到内核栈
跳转到 ntoskrnl.exe 里的 KiSystemServiceDispatch
第 4 层:ntoskrnl.exe 通用分发器(Ring 0 开始)
CPU 现在彻底进入内核态,运行在最高权限 Ring 0。
KiSystemServiceDispatch 根据 eax 里的服务号(0x3F)查一个大表格(KiServiceTable),找到真正的函数地址:NtReadVirtualMemory → IopReadVirtualMemory → 最终到 MmCopyVirtualMemory。
现在开始全都是内核代码,没有任何 DLL,全部在 ntoskrnl.exe 单一镜像里。
第 5 层:句柄子系统与对象管理器(Handle → 真实内核对象)
内核第一件大事:把传进来的 HANDLE(比如 0x1234)翻译成真正的内核对象。
步骤:
- 在当前进程的句柄表(EPROCESS→ObjectTable)里查索引,找到 HANDLE_ENTRY
- 取出真正指向的 OBJECT_HEADER
- 再往后找到真正的 PROCESS 对象(类型 ObpProcessType)
- 拿到目标进程的 EPROCESS 结构(里面有 DirectoryTableBase,也就是 CR3 值)
如果这一步发现没有权限(比如目标是 PPL 进程而你只是普通管理员),立刻返回 STATUS_ACCESS_DENIED,后面全部不执行。
第 6 层:安全令牌与完整性检查(Token + Integrity Level)
内核继续检查:
当前线程的 Token(SeToken)完整性级别是否 ≥ 目标进程完整性级别
是否拥有 SeDebugPrivilege(管理员默认有,但可以被策略关掉)
目标进程是否被标记为 Protected Process Light / Protected Process(PPL
只要有一项不通过,直接返回访问拒绝。可以在这里做防护。
第 7 层:内存管理器真正干活(页表切换与跨进程映射)
现在才到最精彩的部分------MmCopyVirtualMemory 的核心逻辑,极度简化版流程:
- 拿到源地址(目标进程里的 0x00007FF712345678)
- 拿到当前进程的 EPROCESS 和 目标进程的 EPROCESS
- 用目标进程的 DirectoryTableBase(也就是 CR3 值)去走页表翻译,把 0x00007FF712345678 翻译成真正的物理页框(PFN)
- 检查这页的 PTE 权限(必须是 Valid + 可读/可写)
- 内核在当前进程的虚拟地址空间里找一段临时的内核地址(通常用 MiMapPageInHyperSpace 之类),把目标物理页临时映射进来
- 现在当前进程突然"看得见"目标进程的那一页了
- 用 rep movsd 或 memcpy(内核版)把数据拷贝到你传进去的缓冲区(这个缓冲区本身就在你进程的用户态地址,内核也帮你映射好了)
- 拷贝完立刻解除临时映射,留下的痕迹几乎为零
第 8 层:返回路径(Ring 0 → Ring 3)
拷贝完后,内核把返回值(TRUE/FALSE)和实际拷贝字节数填好,执行 sysexit(或 swapgs + iretq)指令:
恢复用户态栈指针
切回 Ring 3
跳回 ntdll.dll 中 syscall 指令的下一条(ret)
ntdll 把返回值通过调用约定传回 kernelbase → kernel32 → 你的函数
ReadProcessMemory 终于返回 TRUE,value 里已经是想要的值了
敲下的 ReadProcessMemory,先在 kernel32 → kernelbase → ntdll 里层层跳转,在 ntdll 里执行一条 syscall 指令,CPU 瞬间从 Ring 3 飞到 Ring 0,进入 ntoskrnl.exe 的 KiSystemCall64,系统服务分发器根据服务号找到 MmCopyVirtualMemory,内核先把你的 HANDLE 翻译成目标进程的 EPROCESS,再检查 Token 和完整性级别,通过后拿着目标进程的 CR3 去走四级(或五级)页表把虚拟地址翻译成物理页,然后在当前进程临时映射这页物理内存,直接 memcpy 到你的缓冲区,最后解除映射、sysexit 回到 Ring 3,整个过程不到 10 微秒,却跨越了用户/内核、两个进程的虚拟地址空间、页表、物理内存、完整性策略所有壁垒------这就是 Windows 官方给调试器。
如果不想被读,可以将进程标记为 PPL(Protected Process Light),把进程升级为 Protected Process,系统强制签名级别保护(Protected Process Full) 或签了微软 WHQL 证书的受保护进程。
必要的函数
cpp
#include <windows.h>
#include <tlhelp32.h>
#include <tchar.h>
/*
1. 根据进程名称获取进程 PID(完全支持 Unicode 进程名)
参数:lpProcessName - 进程的可执行文件名(如 L"notepad.exe" 或 "WeChat.exe")
返回:找到返回进程 PID,找不到或出错返回 0
*/
DWORD GetProcessIDByName(LPCTSTR lpProcessName)
{
DWORD dwPID = 0;
// 创建进程快照,包含系统中所有进程信息
// TH32CS_SNAPPROCESS 表示只拍进程,不拍线程/堆/模块
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE)
return 0;
PROCESSENTRY32 pe32 = { 0 };
pe32.dwSize = sizeof(pe32); // 必须填写结构大小
// 读取第一个进程信息
if (Process32First(hSnapshot, &pe32))
{
do
{
// 比较进程文件名(支持 Unicode,使用 _tcscmp)
if (_tcscmp(pe32.szExeFile, lpProcessName) == 0)
{
dwPID = pe32.th32ProcessID; // 找到目标进程,记录 PID
break;
}
} while (Process32Next(hSnapshot, &pe32)); // 继续枚举下一个进程
}
CloseHandle(hSnapshot); // 关闭快照句柄,释放资源
return dwPID;
}
/*
2. 根据进程 PID 和模块名获取模块基地址(完全兼容 64 位进程和 WOW64)
参数:
dwPID - 目标进程的 PID
lpModuleName - 模块名,如 L"kernel32.dll"、"client.dll"
返回:模块加载基址(64位下返回 ULONGLONG),失败返回 0
*/
ULONGLONG GetModuleBaseAddr(DWORD dwPID, LPCTSTR lpModuleName)
{
ULONGLONG ulBase = 0;
// 创建模块快照
// TH32CS_SNAPMODULE - 64位进程的模块(默认)
// TH32CS_SNAPMODULE32 - 同时获取 WOW64 进程的32位模块
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, dwPID);
if (hSnapshot == INVALID_HANDLE_VALUE)
return 0;
MODULEENTRY32 me32 = { 0 };
me32.dwSize = sizeof(me32); // 必须填写结构大小
if (Module32First(hSnapshot, &me32)) // 读取第一个模块
{
do
{
if (_tcscmp(me32.szModule, lpModuleName) == 0)
{
ulBase = (ULONGLONG)me32.modBaseAddr; // 模块基址(指针大小与进程位数一致)
break;
}
} while (Module32Next(hSnapshot, &me32)); // 继续枚举下一个模块
}
CloseHandle(hSnapshot); // 关闭快照句柄
return ulBase;
}
/*
3. 多级指针链解析
参数:
hProcess - 目标进程的句柄(必须拥有 PROCESS_VM_READ 权限)
baseAddress 模块基址或任意已知静态地址
offsets[] 每一级的偏移数组
offsetCount 偏移数量(链的层数)
返回:最终指向的真实地址,读取失败返回 0
*/
ULONGLONG GetFinalAddress(HANDLE hProcess, ULONGLONG baseAddress, const DWORD offsets[], int offsetCount)
{
ULONGLONG addr = baseAddress; // 初始地址 = 模块基址
for (int i = 0; i < offsetCount; ++i)
{
addr += offsets[i]; // 当前级加上偏移
// 如果还不是最后一级,需要把当前地址当做指针再读一次
if (i < offsetCount - 1)
{
ULONGLONG temp = 0;
// 从目标进程内存读取 8 字节指针(64位系统必须读 8 字节)
if (!ReadProcessMemory(hProcess, (LPCVOID)addr, &temp, sizeof(temp), NULL))
return 0; // 读取失败,直接返回 0
addr = temp; // 下一级基址 = 刚刚读出来的指针值
}
}
return addr; // 返回最终地址
}
/*
4. 向目标进程指定地址写入一个 32 位整数(4 字节)
参数:
hProcess - 目标进程句柄(必须有 PROCESS_VM_WRITE | PROCESS_VM_OPERATION 权限)
address - 要写入的完整 64 位地址(来自多级指针解析后的结果)
value - 要写入的 int 值(例如 9999)
返回:成功返回 TRUE,失败返回 FALSE
*/
BOOL WriteIntToProcess(HANDLE hProcess, ULONGLONG address, int value)
{
// WriteProcessMemory 参数解释:
// hProcess : 目标进程句柄(本质是 void*)
// (LPVOID)address : 目标地址,转成 void*(LPVOID 本质就是 void*)
// &value : 要写入的数据的指针
// sizeof(value) : 写入 4 字节(int 在 32/64 位下都是 4 字节)
// NULL : 可选参数,接收实际写入字节数
return WriteProcessMemory(hProcess, (LPVOID)address, &value, sizeof(value), NULL);
}
读取
cpp
LPCTSTR processName = _T("NumAddOne.exe"); // 目标进程名
LPCTSTR moduleName = _T("NumAddOne.exe"); // 主模块名(一般和进程名相同)
// 示例指针链: "NumAddOne.exe" + 0x266F20 + 0x178
DWORD offsets[] = { 0x266F20, 0x178 };
int offsetCount = _countof(offsets);
// ===============================================================================
// 1. 找进程ID
DWORD pid = GetProcessIDByName(processName);
if (pid == 0)
{
MessageBox(_T("错误:未找到进程 ") + CString(processName) + _T("!\n请先运行目标程序。"), _T("进程未找到"), MB_ICONWARNING);
return;
}
// 2. 获取模块基址
ULONGLONG moduleBase = GetModuleBaseAddr(pid, moduleName);
if (moduleBase == 0)
{
MessageBox(_T("错误:获取模块基址失败!"), _T("失败"), MB_ICONERROR);
return;
}
// 3. 打开进程(必须加 QUERY_INFORMATION,64位系统很多情况只读也需要)
HANDLE hProcess = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, pid);
if (hProcess == NULL)
{
MessageBox(_T("错误:打开进程失败!\n请以管理员身份运行本程序。"), _T("权限不足"), MB_ICONERROR);
return;
}
// 4. 解析多级指针得到最终地址
ULONGLONG finalAddress = GetFinalAddress(hProcess, moduleBase, offsets, offsetCount);
if (finalAddress == 0)
{
CloseHandle(hProcess);
MessageBox(_T("错误:解析指针链失败!\n可能游戏已更新,指针链失效。"), _T("指针错误"), MB_ICONERROR);
return;
}
// 5. 读取最终地址的 4 字节整数值
int value = 0;
if (!ReadProcessMemory(hProcess, (LPCVOID)finalAddress, &value, sizeof(value), NULL))
{
CloseHandle(hProcess);
MessageBox(_T("错误:读取目标地址内存失败!"), _T("读取失败"), MB_ICONERROR);
return;
}
CloseHandle(hProcess);
// 6. 显示结果
CString result;
result.Format(
_T("读取成功!\n\n")
_T("进程名:%s\n")
_T("模块基址:0x%llX\n")
_T("最终地址:0x%llX\n")
_T("当前数值:%d"),
processName,
moduleBase,
finalAddress,
value
);
MessageBox(result, _T("内存读取结果"), MB_OK | MB_ICONINFORMATION);
写入
cpp
// ====================== 配置区(和读取共用) ======================
LPCTSTR processName = _T("NumAddOne.exe");
LPCTSTR moduleName = _T("NumAddOne.exe");
DWORD offsets[] = { 0x266F20, 0x178 };
int offsetCount = _countof(offsets);
// =================================================================
// 1. 获取用户输入的数值
UpdateData(TRUE); // 把控件内容 → 变量(必须!)
if (m_nWriteValue < 0)
{
MessageBox(_T("请输入大于等于 0 的数值!"), _T("输入错误"), MB_ICONWARNING);
return;
}
// 2. 找进程
DWORD pid = GetProcessIDByName(processName);
if (pid == 0)
{
MessageBox(_T("未找到进程 ") + CString(processName) + _T("!\n请先运行目标程序。"), _T("错误"), MB_ICONERROR);
return;
}
// 3. 获取模块基址
ULONGLONG moduleBase = GetModuleBaseAddr(pid, moduleName);
if (moduleBase == 0)
{
MessageBox(_T("获取模块基址失败!"), _T("错误"), MB_ICONERROR);
return;
}
// 4. 打开进程(写入必须加 VM_WRITE 和 VM_OPERATION)
HANDLE hProcess = OpenProcess(
PROCESS_VM_READ |
PROCESS_VM_WRITE |
PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION,
FALSE, pid);
if (hProcess == NULL)
{
MessageBox(_T("打开进程失败!\n请以管理员身份运行本程序!"), _T("权限不足"), MB_ICONERROR);
return;
}
// 5. 解析最终地址
ULONGLONG finalAddr = GetFinalAddress(hProcess, moduleBase, offsets, offsetCount);
if (finalAddr == 0)
{
CloseHandle(hProcess);
MessageBox(_T("解析指针链失败!地址已失效"), _T("错误"), MB_ICONERROR);
return;
}
// 6. 写入数值
if (WriteIntToProcess(hProcess, finalAddr, m_nWriteValue))
{
CloseHandle(hProcess);
CString result;
result.Format(_T("读取成功!\n\n模块基址:0x%llX\n最终地址:0x%llX\n当前数值:%d"),
moduleBase, finalAddr, m_nWriteValue);
MessageBox(result, _T("内存读取结果"), MB_OK | MB_ICONINFORMATION);
}
else
{
CloseHandle(hProcess);
MessageBox(_T("写入失败!可能被反作弊保护或地址错误。"), _T("失败"), MB_ICONERROR);
}
UpdateData(FALSE); // 变量 → 控件(把读到的值显示在输入框里)
类型解释
| 类型 | 原始类型 | 常见定义 | 说明 |
|---|---|---|---|
| HANDLE | void* 或 __int64(64位下) |
typedef void* HANDLE; |
句柄的通用抽象,所有内核对象(进程、文件、事件等)都用 HANDLE 表示 |
| DWORD | unsigned long → 32位无符号整数 |
typedef unsigned long DWORD; |
32位无符号整数,几乎所有旧 API 都用这个 |
| ULONGLONG | unsigned __int64 → 64位无符号整数 |
typedef unsigned __int64 ULONGLONG; |
64位地址、64位大小必须用这个 |
| LONG | long → 32位有符号整数 |
typedef long LONG; |
32位有符号 |
| BOOL(Windows) | int |
typedef int BOOL; |
不是 C++ 的 bool!返回 TRUE(1)/FALSE(0) |
| BYTE | unsigned char |
typedef unsigned char BYTE; |
一个字节 |
| WORD | unsigned short → 16位无符号 |
typedef unsigned short WORD; |
老古董 16位时代遗留 |
| LPCVOID | const void* |
typedef const void* LPCVOID; |
"只读指针",ReadProcessMemory 要这个 |
| LPVOID | void* |
typedef void* LPVOID; |
可读可写指针,WriteProcessMemory 要这个 |
| LPCTSTR | Unicode 编译时是 const wchar_t* ANSI 编译时是 const char* |
根据是否定义 UNICODE 切换 | 支持 Unicode 和 ANSI 通用字符串 |
| TCHAR | Unicode 下是 wchar_t,ANSI 下是 char |
同上 | 让你一行代码同时支持宽窄字符 |
| PROCESSENTRY32 | 结构体(里面包含 dwSize、th32ProcessID、szExeFile 等) | tlhelp32.h | 进程快照条目结构 |
| MODULEENTRY32 | 结构体(modBaseAddr、szModule 等) | tlhelp32.h | 模块快照条目结构 |
| SIZE_T | unsigned __int64(64位系统) |
typedef ULONG_PTR SIZE_T; |
表示"大小"的无符号整数",跟指针同宽 |
凡是带 L 的基本是"Long/Pointer"相关的(LPVOID、LPCSTR、LPDWORD...)
凡是带 P 的都是指针(PVOID = void*,PDWORD = DWORD*)
凡是 ULONGLONG / ULONG64 就是 64 位无符号整数,用来存地址最保险
凡是结构体名带 32 的,都是 ToolHelp 快照用的(PROCESSENTRY32、MODULEENTRY32)
HANDLE 本质就是 void*,不要试图对它做算术运算
