Windows进程内存读写与指针链解析:从偏移表到透明悬浮窗

发布时间:2026/10/23 17:41:24
Windows进程内存读写与指针链解析:从偏移表到透明悬浮窗

简介:面向《魔力宝贝》辅助工具研究与C++网络编程学习者的工程资料,围绕角色血量查看功能,提供可运行的辅助程序及完整源码,便于理解客户端与服务器通信数据包解析、界面事件处理等实现思路。压缩包共18个文件、约207KB,以h头文件、cpp源文件、rc资源脚本、vcproj与sln工程配置为主,并含exe可执行程序及资源、项目用户配置,可分别在VS2005中查看工程结构、资源定义和核心逻辑,适合具备C++基础、想还原开发环境的读者参考。已有9126人学习下载,说明其在游戏辅助实现与逆向分析话题中具有一定参考热度。读者可据此梳理MFC/WinAPI界面搭建、内存与指针操作、数据字段识别等关键技术,也可将工程作为网络协议分析与C++小工具开发的对照案例;需注意此类工具可能违反游戏协议,宜仅用于技术学习与合规研究。

1. 从一个「血条对不上」的排查说起:读血工具到底在做什么

前阵子帮人看一个老问题:某经典 2D 回合制网游里,队伍列表的血条更新慢半拍,人物被打到丝血,界面上还显示半管。顺手拆了一份看血工具的源码,才发现它压根没打算跟 UI 打交道——它直接读客户端进程里的角色对象数组,把 HP、MP 字段拎出来,自己也画一层悬浮窗。这就是「看血」的本质:不是截屏识别,也不是读日志,是内存级的数据提取。适合谁看:想学 Windows 进程内存读写、指针链定位、透明覆盖层这套东西的人;或者在做单机存档工具、自建服务端调试面板这类正当用途的人。边界先划清楚:只在自己搭的模拟环境、单机客户端里练手,别拿去线上服务里搅局,那是另一回事。

2. 进程内存怎么读:OpenProcess 到三级指针的完整链路

读内存这件事,技术上没有玄学,翻车的全是细节。整条链路是四步:拿到进程句柄 → 找到模块基址 → 顺着指针链解引用 → 按结构体偏移取字段。缺任何一步,读出来就是一堆 0 或者干脆崩掉。

这里要先建立一个和「修改器思维」的差别。改数值的人关心的是「这个地址的值变了没有」,看血工具关心的是「这个地址明年还在不在」。血量每回合都在变,靠一次搜索定住的地址,下一场战斗就废了;能长期用的是「基址 + 偏移」这条链,只要客户端版本不变,链就不变。所以下面所有代码都围绕「稳定寻址」写,而不是围绕「找到某个值」。

2.1 打开目标进程与最小可用读取器

先写底层两个函数:开进程、按长度读字节。这两个函数是后面一切的基础,出问题也基本出在这里。注意权限位,只要读权限就够了,多要反而容易被系统拦。

import ctypes from ctypes import wintypes k32 = ctypes.WinDLL("kernel32", use_last_error=True) PROCESS_VM_READ = 0x0010 # 只申请读内存 PROCESS_QUERY_INFORMATION = 0x0400 # 查模块信息要用 def open_target(pid: int) -> int: """按 PID 打开目标进程,返回句柄。失败抛 OSError 并带上系统错误码。""" h = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid) if not h: raise OSError(ctypes.get_last_error(), "OpenProcess failed") return h def read_bytes(h: int, addr: int, size: int): """从 addr 读 size 字节,读不满就返回 None,绝不返回半截数据。""" buf = ctypes.create_string_buffer(size) got = ctypes.c_size_t(0) ok = k32.ReadProcessMemory( wintypes.HANDLE(h), ctypes.c_void_p(addr), buf, ctypes.c_size_t(size), ctypes.byref(got), ) if not ok or got.value != size: return None # 跨页、地址失效、权限不足都会走到这里 return buf.raw

逻辑说明:OpenProcess的第一个参数是权限掩码,这里只组合了读和查询;第二个参数是「句柄是否可继承」,工具场景一律False。ReadProcessMemory的坑在返回值——它成功返回非 0,但即使只读到一部分也算成功,所以必须比对got.value和请求的size,否则你会拿着半截 buffer 去解析结构体,后面全是错位数据。参数上size控制在 4~64 字节之间,别一口气读几 KB,跨页失败率会明显上升。

2.2 模块基址与 RVA:为什么不能用绝对地址

客户端每次启动,主模块加载基址都可能不一样(系统开了地址随机化就更是如此)。所以你在内存扫描器里看到的那个漂亮地址,重启一次就失效了。正确做法是存「模块名 + 相对偏移(RVA)」,运行时用模块基址加上 RVA 还原。

TH32CS_SNAPMODULE = 0x00000008 class MODULEENTRY32(ctypes.Structure): _fields_ = [ ("dwSize", wintypes.DWORD), ("th32ModuleID", wintypes.DWORD), ("th32ProcessID", wintypes.DWORD), ("GlblcntUsage", wintypes.DWORD), ("ProccntUsage", wintypes.DWORD), ("modBaseAddr", ctypes.POINTER(ctypes.c_byte)), ("modBaseSize", wintypes.DWORD), ("hModule", wintypes.HMODULE), ("szModule", ctypes.c_char * 256), ("szExePath", ctypes.c_char * 260), ] def module_base(pid: int, name: bytes) -> int: """在目标进程的模块快照里按名字找模块,返回加载基址。""" snap = k32.CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, pid) if snap == -1: raise OSError(ctypes.get_last_error(), "snapshot failed") try: me = MODULEENTRY32() me.dwSize = ctypes.sizeof(MODULEENTRY32) if not k32.Module32First(snap, ctypes.byref(me)): raise OSError(ctypes.get_last_error(), "Module32First failed") while True: if me.szModule.lower() == name.lower(): return ctypes.cast(me.modBaseAddr, ctypes.c_void_p).value if not k32.Module32Next(snap, ctypes.byref(me)): break finally: k32.CloseHandle(snap) raise LookupError(f"module {name!r} not found")

逻辑说明:走CreateToolhelp32Snapshot而不是EnumProcessModules,因为快照方式对权限要求更松,且能直接拿到模块路径,便于你确认找到的是主模块而不是同名的加载库。dwSize必须先赋值,这是 Win32 结构体遍历的通用约定,忘了就是Module32First直接失败。返回值用ctypes.cast(...).value转成整数,方便后面做加法。

2.3 三级指针链:从静态基址到角色对象

指针链的形状通常是:[[基址 + 0x10] + 0x1C] + 0x8,最外层落在某个全局管理器对象上,中间层是场景对象,最内层是角色数组或角色对象。写成一个通用解析器,比硬编码一堆加法好维护得多。

PTR_SIZE = 4 # 32 位客户端;64 位改成 8,见第 4 章第 1 条 def read_ptr(h: int, addr: int): raw = read_bytes(h, addr, PTR_SIZE) if raw is None: return None return int.from_bytes(raw, "little") def resolve_chain(h: int, root: int, offsets: list): """offsets 的最后一项是字段偏移,前面的每一项都是一次解引用。""" addr = root for off in offsets[:-1]: addr = read_ptr(h, addr + off) if not addr: # 空指针说明场景还没加载完 return None return addr + offsets[-1] # 用法:读当前场景的角色数组首地址与数量 CHAR_LIST = [0x10, 0x1C] # 相对模块基址的两级指针 base = module_base(pid, b"client.exe") list_ptr_addr = resolve_chain(h, base, CHAR_LIST)

逻辑说明:offsets[:-1]做解引用,最后一项直接加偏移取字段,这样同一套函数既能定位数组首地址,也能定位数组内部某个字段,不用写两份代码。参数上root是「模块基址」,不是「基址 + 偏移」——把0x10这一层也放进 offsets 里,配置文件和代码都能保持统一格式。返回值None有两种含义:指针解到了 0,或者中间某次read_bytes失败,调用方统一按「这一帧没有数据」处理即可,别在这里抛异常,否则切场景时会刷屏。

2.4 源码包里的模块怎么切

把上面这些东西全塞一个文件里,三天后自己都不想看。一个能维护的目录大致是四层:进程访问层、数据模型层、视图层、配置层。进程访问层只认地址和字节,不认「血量」这种业务概念;数据模型层负责把字节翻译成角色对象;视图层只拿到已经翻译好的字典;配置层存偏移表,跟代码分离。

src/ core/ # 进程句柄、内存读写、模块基址、指针链解析 model/ # 角色结构体定义、数组遍历、字段解码 view/ # 悬浮窗、行渲染、颜色规则 config/ # offsets.json、字段类型表 app.py # 组装:定时器 + 读取 + 渲染

这样切的直接好处是:客户端更新时只改config/offsets.json,core和view一行不动。我见过太多把偏移硬编码在 UI 回调里的写法,改一次版本等于重写一遍。另外model层要允许「字段读失败」这种状态存在,返回None而不是抛异常,因为角色进出场景的瞬间,数组里必然有脏数据。

3. 把字节变成血条:数值解码与透明悬浮层的落地

读到字节只是中间态,真正让人信服的是两件事:解码对不对,界面稳不稳。这一章把「4 个字节怎么变成 1234 点血」和「怎么画一层不挡鼠标的窗」讲透。这两块分开看都不难,合起来出错的地方特别多,尤其是血量和魔法值串位这类问题,十有八九是结构体步长算错了,不是解码算法有问题。

3.1 血量字段的四种常见编码

同一个数值,不同客户端存法完全不一样。见过最多的四种:int32原值、int32定点数(真实值放大了 10 倍或 100 倍)、float、int16。判断方法不靠猜,靠约束:满血时当前血量应该等于血量上限,用这个等式反推编码,比逐个试快得多。

编码字节特征判定方式典型坑
int32 原值小端 4 字节,值域合理满血时 hp == max_hp无
int32 定点 ×10数值为真实值 10 倍hp/max_hp 比值正常,绝对值偏大忘记除回去,显示 12340
float4 字节,指数位有明显特征按 float 解出后落在 0~max 之间与 int 混读得到天文数字
int162 字节,上限 32767后 2 字节变化、前 2 字节恒 0步长按 4 算导致整体错位
import struct def decode_hp(raw: bytes, mode: str): """按 config 里声明的模式解码血量,未知模式返回 None 而不是瞎猜。""" if mode == "i32": return struct.unpack("<i", raw[:4])[0] if mode == "i32_div10": return round(struct.unpack("<i", raw[:4])[0] / 10) if mode == "i32_div100": return round(struct.unpack("<i", raw[:4])[0] / 100) if mode == "f32": return round(struct.unpack("<f", raw[:4])[0]) if mode == "i16": return struct.unpack("<h", raw[:2])[0] return None def sanity_check(hp, max_hp) -> bool: """一条最朴素的业务约束,能挡掉八成解码错误。""" if hp is None or max_hp is None: return False if max_hp <= 0 or max_hp > 999999: return False return 0 <= hp <= max_hp

逻辑说明:struct.unpack一律用小端<,Windows 客户端基本都是小端,写成=在跨平台时会翻车。定点数用round而不是整除,避免出现 1233.9 显示成 1233 这种视觉误差。sanity_check是这套代码里性价比最高的十行——它不能告诉你「哪个编码是对的」,但能立刻告诉你「当前这个编码是错的」,配合「满血状态下 hp 必须等于 max_hp」这条,基本两次就能试出来。

3.2 角色数组遍历与字段表

有了数组首地址和数量,遍历就是简单的步长加法。关键是别越界:数量字段偶尔会读出异常值(比如 65535),必须夹一个上限,否则一次读取能扫掉几十 MB 内存,工具直接卡死。

UNIT_STRIDE = 4 # 指针数组,每项 4 字节;若是结构体数组则填结构体大小 MAX_UNITS = 64 # 场景内角色数量的硬上限,防止脏数据拖死 def iter_units(h: int, list_ptr: int, count: int): """遍历角色指针数组,逐个大结构体读取,读失败就跳过这一个。""" for i in range(min(count, MAX_UNITS)): obj = read_ptr(h, list_ptr + i * UNIT_STRIDE) if not obj: continue blob = read_bytes(h, obj, 0x200) # 一次读一整块,减少调用次数 if blob is None: continue yield i, obj, blob def parse_unit(blob: bytes, offsets: dict, encodings: dict): """把一块内存按偏移表切出字段,全部走解码函数。""" hp = decode_hp(blob[offsets["hp"]: offsets["hp"] + 4], encodings["hp"]) max_hp = decode_hp(blob[offsets["max_hp"]: offsets["max_hp"] + 4], encodings["max_hp"]) mp = decode_hp(blob[offsets["mp"]: offsets["mp"] + 4], encodings["mp"]) if not sanity_check(hp, max_hp): return None return {"idx": None, "hp": hp, "max_hp": max_hp, "mp": mp}

逻辑说明:UNIT_STRIDE是最容易搞错的参数——如果数组里存的是指针,步长就是 4(32 位)或 8(64 位);如果直接是结构体数组,步长就是结构体大小。判断方法很土但有效:取相邻两个元素,看它们的内容差多少,差值是 4 的整数倍且第二项内容也像个对象指针,那就是指针数组。0x200这个块大小是为了「一次读够所有字段」,比每个字段读一次少几百次系统调用,实测在 60ms 一轮的节流下 CPU 占用能压到 3% 以内。

3.3 透明置顶悬浮层的画法

界面这块,我用 Python 的tkinter做过,够用且不用引第三方包。要点三个:无边框、置顶、半透明;再加一个「穿透鼠标」,否则窗口会挡住下面的游戏点击。

import tkinter as tk COLOR_OK, COLOR_LOW, COLOR_DEAD = "#7ee787", "#f0b429", "#8b949e" class Overlay: def __init__(self, width=260, height=360): self.root = tk.Tk() self.root.overrideredirect(True) # 去掉标题栏和边框 self.root.attributes("-topmost", True) # 永远在最上层 self.root.attributes("-alpha", 0.82) # 整体透明度,0~1 self.root.geometry(f"{width}x{height}+40+40") self.root.configure(bg="#0d1117") self.canvas = tk.Canvas(self.root, width=width, height=height, bg="#0d1117", highlightthickness=0) self.canvas.pack() self.root.after(200, self._make_click_through) def _make_click_through(self): """给窗口追加 transparent 扩展样式,鼠标事件直接穿过。""" GWL_EXSTYLE, WS_EX_TRANSPARENT, WS_EX_LAYERED = -20, 0x20, 0x80000 hwnd = ctypes.windll.user32.GetParent(self.root.winfo_id()) style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE) ctypes.windll.user32.SetWindowLongW( hwnd, GWL_EXSTYLE, style | WS_EX_TRANSPARENT | WS_EX_LAYERED) def render(self, rows): self.canvas.delete("all") for i, r in enumerate(rows): y = 12 + i * 22 ratio = r["hp"] / r["max_hp"] color = COLOR_DEAD if r["hp"] == 0 else (COLOR_LOW if ratio < 0.3 else COLOR_OK) self.canvas.create_text(12, y, anchor="w", fill="#c9d1d9", text=f"#{r['idx']:02d} HP {r['hp']}/{r['max_hp']}") self.canvas.create_rectangle(170, y - 6, 250, y + 6, outline="#30363d", fill="#161b22") self.canvas.create_rectangle(170, y - 6, 170 + int(80 * ratio), y + 6, outline="", fill=color)

逻辑说明:overrideredirect(True)之后窗口就脱离了窗口管理器的装饰,代价是自己得管位置和关闭逻辑。-alpha 0.82是整体透明度,别设太低,否则文字在亮背景的场景里看不清。穿透样式必须通过SetWindowLongW加WS_EX_TRANSPARENT,tkinter自己没暴露这个能力;GetParent取的是真正的顶层窗口句柄,直接用winfo_id()拿到的子窗口改样式是不生效的。渲染用「先清空再全量重画」,行数控制在 20 以内,否则Canvas的图元数量上来之后会有明显掉帧。

3.4 节流、线程与失败容忍

读内存和画界面必须分开节奏。读太快,CPU 和游戏进程都会被拖;画太快,窗口闪烁。我一般的参数是读 60ms 一轮、画 200ms 一轮,中间用队列传数据。

import threading, time, queue q = queue.Queue(maxsize=4) def reader_loop(h, cfg, stop_event): while not stop_event.is_set(): try: rows = snapshot(h, cfg) # 内部就是 3.2 的遍历 + 解析 if q.full(): q.get_nowait() # 丢掉旧帧,永远保留最新数据 q.put(rows) except Exception: pass # 单帧失败不终止线程,等下一轮 time.sleep(0.06) def ui_loop(overlay, stop_event): while not stop_event.is_set(): try: overlay.render(q.get(timeout=0.3)) except queue.Empty: pass time.sleep(0.14)

逻辑说明:队列长度设 4 是刻意的——读取比渲染快,队列会堆积,满了就丢最旧的一帧,保证界面上永远是当前血量而不是三秒前的。timeout=0.3让 UI 线程在没数据时也能醒来检查退出标志,不至于卡死在get上。except Exception: pass看着不优雅,但在这里是对的:切场景、进程退出、句柄失效都会瞬间抛异常几十次,写日志只会把磁盘写满,等下一轮自然恢复。

4. 避坑清单:五次翻车记录与对应的排查手法

这一章全是血泪经验。下面五条是我实际踩过的,按「现象 → 原因 → 解决」写,照着对号入座能省不少时间。

4.1 现象:进程能打开,读出来全是 0

原因:目标客户端是 64 位,而代码里PTR_SIZE写死成 4。指针宽度不对,解引用出来的地址永远是垃圾,后续读取要么失败要么返回 0。

解决:启动时先判断目标进程位数,再决定指针宽度。别用「试一下 4 和 8 哪个能读出东西」这种碰运气的方式,判断一次就够了。

def is_wow64(h: int) -> bool: """目标在 64 位系统上跑 32 位进程时返回 True。""" wow = wintypes.BOOL() k32.IsWow64Process(wintypes.HANDLE(h), ctypes.byref(wow)) return bool(wow.value) # PTR_SIZE = 4 if is_wow64(h) else 8,注意宿主进程本身也得是 64 位

顺带提醒一句:32 位 Python 进程去读 64 位目标进程,地址空间根本装不下,ReadProcessMemory会直接返回失败。所以工具本身要跟着目标位数走,这一点没得绕。

4.2 现象:客户端小版本更新后,偏移全部失效

原因:不是所有变化都是「偏移改了」。更常见的是模块基址变了但 RVA 没变,而代码里存的是上次运行时的绝对地址。

解决:配置文件里只存「模块名 + RVA + 偏移列表」,运行时现算绝对地址。每次启动打一行日志把解析出的基址打出来,两次启动对比一下就知道是 RVA 变了还是基址变了。我习惯把这行日志留在--verbose里,出问题第一时间翻。

4.3 现象:工具跑几分钟后自己崩溃,或者目标进程无响应

原因:ReadProcessMemory读到了无效地址或跨了不可读页,一次读太大块时尤其容易触发;另一种是遍历数量没夹上限,拿脏数据当角色数量扫了几十 MB。

解决:单次读取块大小压到 4KB 以内,数组数量加硬上限,所有读取都走「失败返回 None」的封装,不在底层抛异常。另外把遍历放在独立线程里,主线程只负责渲染,这样即使读卡住,界面也不会假死。

4.4 现象:血量和魔法值串位,或者第二个角色的数据和第一个一样

原因:结构体步长算错。指针数组按 4 字节步长、结构体数组按结构体大小步长,两者搞混,第二项读到的其实是第一个对象的某个字段。

解决:用差分法验证步长——让两个不同角色站在场景里,血量上限设成明显不同的值(比如 500 和 1200),遍历出来的第一项和第二项如果一样,步长一定错了。把步长从 4 改成 8 或结构体大小再试,两次之内能定下来。

4.5 现象:悬浮窗盖住了游戏,点击没反应

原因:只设了-topmost和-alpha,没加WS_EX_TRANSPARENT。alpha只影响绘制,不影响命中测试,鼠标事件照样打到窗口上。

解决:按 3.3 里的_make_click_through追加扩展样式,并且注意必须在窗口创建之后调用,after(200, ...)那种延迟不是玄学,是等窗口句柄真正生成。改完可以用一个笨办法验证:在悬浮窗上能否点到底下的图标,能点到就是通了。

5. 偏移热更新与启动自检:让工具在版本变动后还能活

最后聊一个我后来强制加进流程的东西:偏移表配置化 + 启动自检。早期我把偏移写死在代码里,每次小更新都要改源码重打包,效率低得离谱。改成 JSON 之后,更新只需要改一个文件,配合热重载,连重启都省了。

{ "module": "client.exe", "ptr_size": 4, "unit_stride": 4, "char_list": { "chain": [16, 28], "count_offset": 32 }, "fields": { "hp": {"offset": 24, "encoding": "i32"}, "max_hp": {"offset": 28, "encoding": "i32"}, "mp": {"offset": 32, "encoding": "i32"} }, "sanity": {"max_hp_limit": 999999, "max_units": 64} }

光有配置还不够,得让工具自己知道「这版偏移是坏的」。启动时跑一组断言,全部通过才进入渲染循环,任何一条失败就弹一行红字提示「偏移可能已失效」,而不是画一堆 0/0 让人以为游戏卡了。

def self_check(h: int, base: int, cfg: dict) -> list: """返回失败项列表,空列表表示自检通过。""" problems = [] list_ptr = resolve_chain(h, base, cfg["char_list"]["chain"]) if not list_ptr: return ["角色数组指针解析失败,偏移或版本不匹配"] count = read_ptr(h, list_ptr + cfg["char_list"]["count_offset"]) if count is None or not (1 <= count <= cfg["sanity"]["max_units"]): problems.append(f"角色数量异常: {count}") rows = list(iter_units(h, list_ptr, count or 0)) if not rows: problems.append("角色数组为空,可能仍在加载中") full_hp_hit = 0 for _, _, blob in rows: u = parse_unit(blob, {k: v["offset"] for k, v in cfg["fields"].items()}, {k: v["encoding"] for k, v in cfg["fields"].items()}) if u is None: problems.append("有角色字段未通过 sanity_check") break if u["hp"] == u["max_hp"]: full_hp_hit += 1 if rows and full_hp_hit == 0: problems.append("没有任何角色处于满血,血量编码可能选错") return problems

这段自检的价值在于把「隐性错误」变成「显性提示」。count越界、数组为空、字段不满足约束、满血角色数为 0,这四条对应四类不同的失效原因,看提示就知道该去改哪一项。参数上max_hp_limit和max_units都来自配置,不同客户端量级不一样,别硬编码。

还有一个进阶玩法:把自检结果打成一行结构化日志(时间、模块基址、角色数、首个角色血量),每次启动追加一条。版本更新后对比前后两次日志,通常一眼就能看出是基址变了还是偏移变了,比重新扫一遍内存快得多。

从那以后我每次动这类内存读取的工具,第一步永远是先跑自检、再看渲染,哪怕界面已经能画出来也不跳过——因为画出来不等于画对了,0/0 也能画得很整齐。希望帮到你。

本文还有配套的精品资源,点击获取