ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java内存修改实战:JNA进程读写、扫描与多级指针解析

Java内存修改实战:JNA进程读写、扫描与多级指针解析

简介:面向希望理解游戏内存搜索与修改原理的Java学习者与逆向入门者,这份资源以类似Cheat Engine的桌面程序为例,演示从选择进程、输入搜索数值、筛选变化地址到改写内存的完整流程,可用于Java桌面开发、JNA调用与内存读写实践的对照学习。压缩包共86个文件、约25.27MB,包含32个java源码、46个class、3个jar以及1个exe安装包,另有Eclipse工程配置;源码可直接导入并调整Build Path,成品JAR与EXE分别覆盖已装Java和未装Java环境的使用场景。已有4954人学习下载,适合作为课程设计、工具原型或外挂开发入门参考,帮助读者梳理进程通信、地址搜索、界面搭建与打包发布的实现思路。

1. 把 100 改成 9999 只要三行 API,但让它在 Java 里稳定跑起来我返工了四次

第一次看这套源码,我以为是玩具:无非OpenProcess+ReadProcessMemory+WriteProcessMemory,三行就能把一个 int 改掉。真动手才发现,真正的难度在 Java 侧——句柄怎么在 JNA 里安全传递、指针参数为什么不能写成long、一次读多少字节才不会跨页截断、候选地址集怎么存才不把堆撑爆。这套「Java 外挂开发之内存修改程序(含源码)」解决的就是这件事:把进程内存读写、内存区域枚举、首次精确扫描、多轮变动过滤、模块基址 + 多级指针链解析这一整套流程,用纯 Java 实现到一个可复现的程度,思路对标市面上的通用内存扫描器。它适合四类人:想搞懂 Java 外挂开发底层链路的、做客户端调试需要批量定位变量的、逆向与 CTF 入门想找练手项目的、以及安全课程实验需要一套能跑的最小实现。注意适用范围只有一处——自己启动的进程、离线单机环境、授权测试目标。

2. 用 JNA 打通 Java 和原生内存:句柄、模块基址与读写原语

这一章要先把「能读能写」这件事做扎实。后面所有扫描算法都建立在读写原语不出错的前提上,一旦这里埋了坑,后面扫描出来的候选地址会全错,而且错得很有迷惑性。

2.1 为什么不用 Unsafe:JVM 内内存和跨进程内存是两套体系

很多人第一反应是sun.misc.Unsafe,因为它确实能直接拿到对象地址:objectFieldOffset拿到字段偏移,加上对象头地址就得到一个绝对地址,然后getInt(address)直接读。这条路在「改自己 JVM 里的变量」时完全成立,用来做压测、做对象布局实验都很好用,但它有一条明确的边界:地址只在当前进程的虚拟地址空间里有效。

跨进程读取的目标是别人的地址空间,必须让操作系统代劳,也就是ReadProcessMemory/WriteProcessMemory这一组 API,或者 Linux 下直接打开/proc/<pid>/mem再pread。Java 调用原生 API 常见做法是用 JNA:不需要写 C 头文件、不需要javac -h生成胶水代码,声明一个接口就能调,代价是每次调用有一次 JNI 边界开销,但这个开销在「按 64KB 块读」的量级下完全可以接受。

所以这套源码的分工很清楚:JNA 负责跨进程读写,Unsafe只在需要分析自身 JVM 内存布局时出场。两者的关系必须分清,混着用是第一个翻车点。

2.2 最小可用的 Kernel32 声明

先声明接口。这里我特意没用long接地址,而是用Pointer.createConstant(...):

import com.sun.jna.*; import com.sun.jna.ptr.IntByReference; public interface Kernel32Ex extends Library { // DEFAULT_OPTIONS 让 JNA 按当前 JVM 位数解析符号与结构体对齐 Kernel32Ex INSTANCE = Native.load("kernel32", Kernel32Ex.class, W32APIOptions.DEFAULT_OPTIONS); Pointer OpenProcess(int desiredAccess, boolean inheritHandle, int pid); boolean CloseHandle(Pointer handle); // 地址参数用 Pointer,而不是 long:long 在 JNA 里恒为 64 位, // 32 位 JVM 上会与栈上的 4 字节实参错位 boolean ReadProcessMemory(Pointer hProcess, Pointer baseAddress, byte[] buffer, int size, IntByReference bytesRead); boolean WriteProcessMemory(Pointer hProcess, Pointer baseAddress, byte[] buffer, int size, IntByReference bytesWritten); Pointer CreateToolhelp32Snapshot(int flags, int pid); boolean Module32First(Pointer snapshot, MODULEENTRY32W entry); boolean Module32Next(Pointer snapshot, MODULEENTRY32W entry); @FieldOrder({"dwSize", "th32ModuleID", "th32ProcessID", "GlblcntUsage", "ProccntUsage", "modBaseAddr", "modBaseSize", "hModule", "szModule", "szExePath"}) class MODULEENTRY32W extends Structure { public int dwSize; public int th32ModuleID; public int th32ProcessID; public int GlblcntUsage; public int ProccntUsage; public Pointer modBaseAddr; // 模块基址,x64 下占 8 字节 public int modBaseSize; public Pointer hModule; public char[] szModule = new char[256]; public char[] szExePath = new char[260]; } }

三个参数说明值得单独拎出来。desiredAccess不要图省事写0x1F0FFF(PROCESS_ALL_ACCESS),在开了保护的进程上反而更容易被拒;我一般只申请0x0008(VM_OPERATION)+0x0010(VM_READ)+0x0020(VM_WRITE)+0x0400(QUERY_INFORMATION),够用且成功率更高。MODULEENTRY32W.dwSize在调用Module32First之前必须赋new MODULEENTRY32W().size(),漏掉这一步函数直接返回失败,而错误码往往还是 0,非常难查。CreateToolhelp32Snapshot的 flags 用0x8 | 0x10(SNAPMODULE | SNAPMODULE32),枚举 32 位模块时这个组合是必需的。

2.3 打开进程、读一块内存、算模块基址

把上面的声明包成一个AutoCloseable,句柄泄漏的问题一次解决:

public final class ProcessMemory implements AutoCloseable { private static final int VM_OPERATION = 0x0008, VM_READ = 0x0010, VM_WRITE = 0x0020, QUERY_INFORMATION = 0x0400; private final Pointer handle; private final int pid; public ProcessMemory(int pid) { this.pid = pid; this.handle = Kernel32Ex.INSTANCE.OpenProcess( VM_OPERATION | VM_READ | VM_WRITE | QUERY_INFORMATION, false, pid); if (handle == null) { throw new IllegalStateException("OpenProcess failed, err=" + Native.getLastError()); } } // 返回实际读到的字节数;-1 表示整块失败 public int read(long addr, byte[] dst, int len) { IntByReference got = new IntByReference(0); boolean ok = Kernel32Ex.INSTANCE.ReadProcessMemory( handle, Pointer.createConstant(addr), dst, len, got); int n = got.getValue(); // 部分成功时 ok=false 但 n>0,必须看 n if (n > 0) return n; return ok ? 0 : -1; } public boolean write(long addr, byte[] src) { IntByReference put = new IntByReference(0); boolean ok = Kernel32Ex.INSTANCE.WriteProcessMemory( handle, Pointer.createConstant(addr), src, src.length, put); return ok && put.getValue() == src.length; } public long moduleBase(String name) { Pointer snap = Kernel32Ex.INSTANCE.CreateToolhelp32Snapshot(0x0008 | 0x0010, pid); if (snap == null) return -1; try { Kernel32Ex.MODULEENTRY32W e = new Kernel32Ex.MODULEENTRY32W(); e.dwSize = e.size(); if (!Kernel32Ex.INSTANCE.Module32First(snap, e)) return -1; do { String mod = new String(e.szModule).trim(); if (mod.equalsIgnoreCase(name)) { return Pointer.nativeValue(e.modBaseAddr); // 转成 long 便于后续加法 } e.dwSize = e.size(); } while (Kernel32Ex.INSTANCE.Module32Next(snap, e)); return -1; } finally { Kernel32Ex.INSTANCE.CloseHandle(snap); } } @Override public void close() { if (handle != null) Kernel32Ex.INSTANCE.CloseHandle(handle); } }

read里那行int n = got.getValue()是血泪经验。ReadProcessMemory在跨内存页边界时会「读一部分然后返回 FALSE」,此时bytesRead里是有值的。如果你只看布尔返回值,就会把一次成功的 3/4 块读取判定为失败,然后整片区域被跳过,扫描结果少一大截——而且现象是「某些值扫不到」,不是报错,特别难定位。

2.4 一次读多少:分块大小其实是精度和速度的折中

块大小不能随手拍。太小(比如 4KB),一次扫描几百 MB 区域就是十几万次系统调用,扫描器直接卡成 PPT;太大(比如 8MB),跨页失败的概率上升,而且失败时浪费的读取范围也大。实测下来 64KB 是比较舒服的区间:一次系统调用覆盖 16384 个 int 候选,失败影响面又足够小。

还有一个必须处理的细节:目标值可能正好跨在两块的接缝上。做法是每块多读needle.length - 1个字节当「尾巴」,然后在尾巴区内不做新增匹配(因为下一块开头会覆盖它),只保证接缝处的匹配不被漏掉。这个偏移量看起来很小,漏掉就会产生「明明在内存里但第一次扫描扫不出来」的玄学现象,我在第 3 章的代码里会把它写实。

3. 扫描算法:从首次精确扫描到多轮过滤的候选集压缩

读写通了,接下来是把「值 100」变成「那个唯一正确的地址」。这一步的难点不在算法多高深,而在内存占用控制——朴素实现几乎必然 OOM。

3.1 候选集为什么必须用 long[],而不是 List

算一笔账。假设目标进程的可读区域有 200MB,按 4 字节对齐扫描,理论候选位是 5000 万个。如果用List<Long>,每个Long对象在 64 位 JVM 上是 16 字节对象头 + 8 字节 value,再加上ArrayList里 8 字节的引用槽,单候选接近 32 字节,五千万个就是 1.6GB,直接顶到堆上限。

换成裸long[],每候选 8 字节,同样规模是 400MB,勉强能跑;再配合两个策略就能压到几十 MB 以内:一是把扫描区间限制在「模块的映像范围 + 堆区间的可疑段」,而不是整个用户地址空间;二是首轮记录命中,第二轮开始候选数量通常会掉两到三个数量级,越扫越轻松。

提示:如果确实要全地址空间扫描,把候选集换成堆外ByteBuffer或分段long[][],别硬堆long[]的单次扩容,Arrays.copyOf在大数组上会瞬间吃掉双倍内存。

3.2 首次扫描:分块、对齐、按位模式比较

public final class MemoryScanner { private final ProcessMemory mem; private ValueType type = ValueType.INT32; private long[] hits = new long[1 << 16]; private int hitCount = 0; private byte[] prev = new byte[(1 << 16) * 8]; // 与 hits 一一对应的“上一轮值” public MemoryScanner(ProcessMemory mem) { this.mem = mem; } // 首次扫描:在 [start, start+size) 内找等于 needle 的地址 public int firstScan(long start, long size, byte[] needle, int alignment) { hitCount = 0; final int BLOCK = 1 << 16; final int L = needle.length; byte[] buf = new byte[BLOCK + L - 1]; for (long off = 0; off < size; off += BLOCK) { long addr = start + off; int readLen = (int) Math.min((long) BLOCK + L - 1, size - off); if (readLen < L) break; int got = mem.read(addr, buf, readLen); if (got < L) continue; int limit = Math.min((int) Math.min(BLOCK, size - off), got - L + 1); for (int i = 0; i < limit; i++) { long a = addr + i; if ((a & (alignment - 1)) != 0) continue; // 对齐过滤,先砍掉 3/4 噪声 if (matches(buf, i, needle)) addHit(a); } } return hitCount; } private void addHit(long addr) { if (hitCount == hits.length) { hits = java.util.Arrays.copyOf(hits, hits.length << 1); prev = java.util.Arrays.copyOf(prev, prev.length << 1); } hits[hitCount++] = addr; } private static boolean matches(byte[] buf, int off, byte[] needle) { for (int i = 0; i < needle.length; i++) { if (buf[off + i] != needle[i]) return false; } return true; } }

参数含义逐个说清。alignment通常取 4(int/float)或 8(long/double/指针),个别紧密打包的结构体是 2 甚至 1,但取 1 会让候选量翻四倍,只在明确知道目标不是对齐存放时才用。buf长度比BLOCK多出L-1,就是上一节说的接缝预留。limit那个双重min是为了防止在块尾越界比较到未初始化数据,同时保证不会把got之外的垃圾字节当作有效读取结果。

matches用逐字节比较而不是转成int再比,是为了让同一套逻辑同时服务 int、float、double,传输比较的时候不需要分支。

3.3 第二轮开始:变动、未变动、精确值四类过滤

首次扫描之后候选集通常还有几千到几十万条,靠「精确值」继续筛是最快的。做法是先让目标值变一下(自己控制的靶进程里改一下计数器,或者在游戏里换个数值),然后按「变动 / 未变动 / 变大 / 变小 / 等于新值」筛一轮:

public enum Filter { EXACT, CHANGED, UNCHANGED, INCREASED, DECREASED } public int nextScan(Filter filter, byte[] target) { int w = 0; // 写指针,永远不越过读指针,可原地压缩 byte[] cur = new byte[type.size()]; for (int i = 0; i < hitCount; i++) { long addr = hits[i]; if (mem.read(addr, cur, cur.length) != cur.length) continue; // 页已释放,丢弃 boolean keep = switch (filter) { case EXACT -> matches(cur, 0, target); case CHANGED -> !matches(cur, 0, prevOf(i)); case UNCHANGED -> matches(cur, 0, prevOf(i)); case INCREASED -> toDouble(cur) > toDouble(prevOf(i)); case DECREASED -> toDouble(cur) < toDouble(prevOf(i)); }; if (keep) { hits[w] = addr; writePrev(w, cur); // 先写 prev[w] 再 w++,原地压缩不会覆盖未读数据 w++; } } hitCount = w; return w; } private byte[] prevOf(int i) { int s = type.size(); return java.util.Arrays.copyOfRange(prev, i * s, i * s + s); }

这里两个设计决定值得说。一是「原地压缩」:因为w <= i恒成立,把命中的候选往前挪不会破坏还没读到的候选,省掉一次全量拷贝,五十万候选能省下几十毫秒。二是prev单独存一份上一轮的值:像 CHANGED / UNCHANGED 这类过滤必须知道历史值,而目标进程里的值已经被改过,回读是拿不到历史的。别图省事在读的时候顺手覆盖prev,那样第二轮之后所有过滤都会失真。

3.4 值类型与精度:float 为什么必须按位比

类型宽度编码方式典型误用
INT8 / INT161 / 2直接截断用 int 的 needle 去扫,永远扫不到
INT324小端 putInt目标是无符号数时用带符号比较,变大/变小判断反了
INT648小端 putLong对齐取 4,扫出大量假地址
FLOAT4Float.floatToRawIntBits直接用==比,0.1f 的精度差异导致永远不命中
DOUBLE8Double.doubleToRawLongBits同上,且对齐必须是 8

float 这一行是新手最容易翻车的地方。0.1f在内存里是0x3DCCCCCD,如果你用(float)0.1去算 needle,中间可能经过一次 double 转换再转回来,位模式未必一致。正确做法是全程走位模式:把用户输入的0.1声明成float再取 raw bits,比较时也是位模式逐字节比。要用「大于/小于」过滤时才转成double比数值,两套路径分开走,不要混。

4. 让地址活过重启:模块基址、偏移与多级指针链

扫到地址只是第一步,那个地址是动态的。杀掉目标进程重新启动,同样的值大概率跑到另一处去了,这时候需要把「绝对地址」变成「基址 + 偏移」这个可复用的表达式。

4.1 绝对地址为什么必然失效

三个原因叠在一起。第一是 ASLR,模块的映像基址每次启动都不同,所以模块内偏移稳定、绝对地址不稳定。第二是堆分配顺序,同样的启动流程,分配器的行为会因线程调度、瞬时内存压力产生偏移,同一个对象可能落在不同页上。第三是对象池、双缓冲这类设计,值本身会在几个位置之间轮换。

所以真正需要固化的是「从模块基址出发,经过 N 次解引用,加上固定偏移,最终到达目标」。这就是多级指针链。层级越深越稳定,但也越容易因为结构体变动而整体失效。

4.2 从模块基址算静态地址

最基础的一层:如果目标变量是全局变量或被放在模块的.data段,那它就是模块基址 + 固定偏移,重启后依然成立。

try (ProcessMemory mem = new ProcessMemory(pid)) { long base = mem.moduleBase("target.exe"); if (base < 0) throw new IllegalStateException("module not found"); long staticAddr = base + 0x0A3F20L; // 扫描得到的“偏移”,注意不是绝对地址 byte[] buf = new byte[4]; if (mem.read(staticAddr, buf, 4) == 4) { int value = java.nio.ByteBuffer.wrap(buf) .order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt(); System.out.printf("base=0x%X value=%d%n", base, value); } }

这里0x0A3F20是「相对模块基址的偏移」,绝对不是「扫描得到的地址」。从扫描结果反推偏移的方法:offset = hitAddress - base,然后用两次不同启动的进程各扫一次,取两次结果一致的偏移,才算真正稳定。只验证一次就写死,几乎一定会在下次启动时失效——这是我早期的标准操作,代价是每次都要重扫。

4.3 反向指针扫描:谁指向了目标地址

当目标变量在堆上的对象里,光有模块基址不够,需要找到「哪个内存槽里存着指向目标的指针」。思路是反向的:已知目标地址 T,全区间搜「值接近 T 的 4/8 字节槽位」。

// 在 [start, end) 内找值为 target±delta 的指针槽位 public java.util.List<Long> findPointers(long start, long end, long target, long delta, int ptrSize) { java.util.List<Long> out = new java.util.ArrayList<>(); byte[] buf = new byte[1 << 16]; for (long off = start; off < end; off += buf.length) { int got = mem.read(off, buf, buf.length); if (got < ptrSize) continue; for (int i = 0; i + ptrSize <= got; i += ptrSize) { long v = 0; for (int b = ptrSize - 1; b >= 0; b--) { // 小端拼装 v = (v << 8) | (buf[i + b] & 0xFFL); } if (v >= target - delta && v <= target + delta) { out.add(off + i); } } } return out; }

delta是关键参数。因为指针指向的可能是结构体头部而目标字段在结构体内部,实际存的指针值比 T 小几十到几千字节,所以搜索区间要放宽。经验值是先用0x0到0x2000试,命中太多再收窄。找不到就说明这一层存的是偏移计算出来的临时值(基址存在别处),需要继续往上找,或者目标本身不在堆上。

递归往上找要设上限,否则候选会指数膨胀。我一般限制三层、每层最多保留 64 个候选,并且对已经访问过的地址做去重,否则在链表结构上会绕圈。

4.4 链的验证与固化

一条链写下来是这样的:

层级地址来源偏移说明
1模块基址base+0x010A2B40全局指针,指向对象容器
2解引用上层的值+0x18容器里的槽位,指向角色对象
3解引用上层的值+0x2C角色对象里的属性块
4解引用上层的值+0x00属性块首个字段即目标值

解析代码只有一个循环,但两处防御不能省:

public long resolveChain(long base, int... offsets) { long addr = base; byte[] p = new byte[8]; for (int i = 0; i < offsets.length; i++) { int n = mem.read(addr, p, p.length); if (n != p.length) return -1; // 页不可读 long v = java.nio.ByteBuffer.wrap(p) .order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong(); if (v < 0x10000L || v > 0x00007FFFFFFEFFFFL) return -1; // 空指针/越界直接放弃 addr = v + offsets[i]; } return addr; }

验证方式只有一个笨办法,但最可靠:连续启动目标进程五次,每次都跑一遍resolveChain+ 读值回验,五次全中才算固化成功。只重启一次就宣布「链是稳的」,后面一定会后悔——很多结构体在第二次启动时因为加载顺序变化就变了。

5. 避坑与排查:五个我真实翻过车的地方

这一章按「现象 → 原因 → 解决」写,都是我自己踩过的,不是从文档里抄的条目。

5.1 OpenProcess 返回 0,错误码 5

现象:OpenProcess拿到空指针,GetLastError是 5(拒绝访问),但任务管理器里目标进程明明在跑,用管理员权限启动 IDE 也没用。

原因:三种可能叠在一起。一是 JVM 位数与目标不匹配——32 位 JVM 就算提权也读不了部分 64 位进程,Module32First会直接返回失败。二是申请了PROCESS_ALL_ACCESS,而目标进程在受保护状态下会拒绝全权限请求。三是 IDE 提权了但实际运行的是 IDE 派生的子进程,权限没继承过来。

解决:先确认 JVM 位数(System.getProperty("sun.arch.data.model")),再确认目标位数;权限位改成最小集合VM_OPERATION | VM_READ | VM_WRITE | QUERY_INFORMATION;最后把「以管理员身份运行」打在真正的启动入口上,而不是打在 IDE 上。三条都过一遍基本就通了。

5.2 读成功、字节数也对,但值全是 0 或明显不合理

现象:read返回的字节数等于请求长度,解析出来却全是 0,或者是一个明显不是目标的值。

原因:三个高发点。地址忘了加模块基址,直接拿偏移当地址用了;值类型宽度选错,拿 4 字节去解析一个 int64 的前半段;或者目标变量在读取瞬间被目标进程改写,读到的是一次中间态。

解决:先打印addr的十六进制,和base对比看是否落在模块映像范围内;再用VirtualQueryEx查一下这块地址的State是不是MEM_COMMIT、Protect是否可读;连续读三次看是否稳定,不稳定说明目标在频繁改写,改用「多轮采样取众数」而不是单次读取。

5.3 首次扫描直接把堆打爆

现象:第一次全量扫描,OutOfMemoryError,或者扫描耗时超过十分钟还没结束。

原因:把整个用户地址空间(从 0 扫到0x7FFFFFFFFFFF)当成目标,且每个候选都存进List<Long>。前者导致绝大多数读取都在未提交内存上返回失败,白白消耗系统调用;后者让候选集的内存占用放大四倍以上。

解决:先枚举内存区域,只扫MEM_COMMIT且Protect可读的段,通常能把范围压到实际可用内存的两三倍以内;候选集换裸long[]并预分配;再加一条「首轮之后立即做一次变动过滤」,把候选数砍到万级以下再继续。

5.4 改完值立刻回弹

现象:write返回成功,回读也确实是新值,但过几十毫秒再读又变回原值。

原因:目标程序每帧或每次逻辑更新都会把内存里的值从上游重算一遍,你改的只是它的显示缓存。如果值来自服务端下发的状态,那本地改动的有效期只到下一次同步为止——这类结构改多少遍都没意义。

解决:先判断是「本地重算」还是「外部同步」。本地重算的特征是回弹有固定周期,可以用高频写入压住,但这只是调试手段;外部同步的特征是回弹时机不可预测、且改后行为没有任何变化。落到实际做法上:把可控目标换成自己启动的进程或离线单机环境,那里的值不会被第三方覆盖,验证链路才干净。

5.5 指针链今天能用明天就废

现象:昨天验证五次的链,今天启动后resolveChain全返回 -1,或者解析出的地址能读但值是垃圾。

原因:目标程序更新、配置差异、加载模块顺序变化,都会让结构体偏移漂移。写死绝对偏移的方案天生脆弱。

解决:把「偏移」抽象成「特征 + 相对偏移」——先用一段字节特征在模块里定位结构体头部,再从头部算相对偏移。这样即使整体布局变了,只要特征没变就能自动重算。代价是要维护特征片段,但这比每次重扫便宜太多。

6. 用自建靶进程做闭环回归:我每次改代码都跑的验收清单

扫描器这类代码最怕「改了一处、坏了一片」而你不知道。我的习惯是永远准备一个靶进程,所有改动都先在它身上跑一遍回归,再去碰真实目标。靶进程很简单,一个稳定存放的变量加一个缓慢变化的循环:

// 靶进程:故意让 hp 长期稳定,counter 每秒自增,方便验证“精确”和“变动”两类过滤 public class TargetProcess { static volatile int hp = 100; // 精确值扫描的靶子 static volatile int counter = 0; // 变动过滤的靶子 public static void main(String[] args) throws Exception { while (true) { Thread.sleep(1000); counter++; System.out.println("hp=" + hp + " counter=" + counter); } } }

配套的回归用例我固定成五条,跑完大概十几秒:

用例操作期望结果
T1 读写闭环对hp地址写 7777,回读读到 7777,靶进程打印同步变化
T2 首次扫描扫100(INT32,对齐 4)候选数不为 0,且包含 T1 的地址
T3 变动过滤等 2 秒后做 CHANGEDhp被筛掉,counter存留
T4 模块基址moduleBase("java.exe")返回值落在合理映像范围,两次启动不同
T5 指针链resolveChain五次独立启动五次全部命中同一字段

T3 是最有价值的一条。它同时验证了prev快照没被污染、原地压缩没破坏未读数据、对齐参数没写错。这三件事只要错一件,T3 就会给出「该留下的没留下、该筛掉的没筛掉」这种一半对一半错的结果,比全错更容易定位。

再补一个我常用的自检动作:每次write之后立刻read回验,并且把回验结果和期望值一起打进日志,而不是只看WriteProcessMemory的返回值。写入 API 返回 true 只代表「系统接受了这次写入」,不代表「目标进程下一次逻辑更新没有覆盖它」。这个习惯帮我抓到过至少三次「写对了地址但写错了时机」的问题。

最后说一条边界。这套代码的定位是理解进程内存布局、做客户端调试和授权环境下的安全实验。在线联机环境里状态由服务端权威计算,本地改内存既不会生效,也不属于这套实现该用的场景;把精力放在自己可控的靶进程上,链路才跑得通、结论才站得住。从那以后我每次动扫描器之前,都会先把靶进程起来跑一遍 T1 到 T5,哪怕只改了一行对齐参数也不例外。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进