读第三方程序的变量的原理

读第三方程序的变量的原理

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)翻译成真正的内核对象。

步骤:

  1. 在当前进程的句柄表(EPROCESS→ObjectTable)里查索引,找到 HANDLE_ENTRY
  2. 取出真正指向的 OBJECT_HEADER
  3. 再往后找到真正的 PROCESS 对象(类型 ObpProcessType)
  4. 拿到目标进程的 EPROCESS 结构(里面有 DirectoryTableBase,也就是 CR3 值)

如果这一步发现没有权限(比如目标是 PPL 进程而你只是普通管理员),立刻返回 STATUS_ACCESS_DENIED,后面全部不执行。

第 6 层:安全令牌与完整性检查(Token + Integrity Level)

内核继续检查:

当前线程的 Token(SeToken)完整性级别是否 ≥ 目标进程完整性级别

是否拥有 SeDebugPrivilege(管理员默认有,但可以被策略关掉)

目标进程是否被标记为 Protected Process Light / Protected Process(PPL

只要有一项不通过,直接返回访问拒绝。可以在这里做防护。

第 7 层:内存管理器真正干活(页表切换与跨进程映射)

现在才到最精彩的部分------MmCopyVirtualMemory 的核心逻辑,极度简化版流程:

  1. 拿到源地址(目标进程里的 0x00007FF712345678)
  2. 拿到当前进程的 EPROCESS 和 目标进程的 EPROCESS
  3. 用目标进程的 DirectoryTableBase(也就是 CR3 值)去走页表翻译,把 0x00007FF712345678 翻译成真正的物理页框(PFN)
  4. 检查这页的 PTE 权限(必须是 Valid + 可读/可写)
  5. 内核在当前进程的虚拟地址空间里找一段临时的内核地址(通常用 MiMapPageInHyperSpace 之类),把目标物理页临时映射进来
  6. 现在当前进程突然"看得见"目标进程的那一页了
  7. 用 rep movsd 或 memcpy(内核版)把数据拷贝到你传进去的缓冲区(这个缓冲区本身就在你进程的用户态地址,内核也帮你映射好了)
  8. 拷贝完立刻解除临时映射,留下的痕迹几乎为零

第 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*,不要试图对它做算术运算

实现效果


一个程序点击事件的汇编指令与解析 - 目标变量的真实虚拟地址 = 逐级解引用并叠加偏移后的结果

相关推荐
西西弗Sisyphus13 小时前
一个程序点击事件的汇编指令与解析 - 目标变量的真实虚拟地址 = 逐级解引用并叠加偏移后的结果
汇编
2501_918126911 天前
nes游戏语言是6502,有没有一种方法可以实现,开发另一种更高效的汇编语言,替代6052,并本土化,弯道超过nes的底层语言?
汇编·硬件工程·个人开发
啊森要自信2 天前
【C语言】 C语言文件操作
c语言·开发语言·汇编·stm32·单片机
云qq2 天前
x86操作系统19——键盘驱动
linux·c语言·汇编
_Voosk2 天前
C指针存储字符串为何不能修改内容
c语言·开发语言·汇编·c++·蓝桥杯·操作系统
天途小编2 天前
融合空域相关法规核心条款汇编
汇编·无人机
天途小编2 天前
无人机相关国家根本条例核心汇编
汇编·无人机
2301_789015623 天前
C++:模板进阶
c语言·开发语言·汇编·c++
Hollis Arthur5 天前
mips栈帧详解
开发语言·汇编·学习·mips