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

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

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

实现效果


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

相关推荐
我在人间贩卖青春4 天前
汇编之伪指令
汇编·伪指令
我在人间贩卖青春4 天前
汇编之伪操作
汇编·伪操作
济6175 天前
FreeRTOS基础--堆栈概念与汇编指令实战解析
汇编·嵌入式·freertos
myloveasuka5 天前
汇编TEST指令
汇编
我在人间贩卖青春5 天前
汇编编程驱动LED
汇编·点亮led
我在人间贩卖青春5 天前
汇编和C编程相互调用
汇编·混合编程
myloveasuka5 天前
寻址方式笔记
汇编·笔记·计算机组成原理
请输入蚊子5 天前
《操作系统真象还原》 第六章 完善内核
linux·汇编·操作系统·bochs·操作系统真像还原
myloveasuka6 天前
指令格式举例
汇编·笔记·计算机组成原理
我在人间贩卖青春6 天前
汇编之分支跳转指令
汇编·arm·分支跳转