简介:这份Java内存修改程序源码面向希望理解游戏内存搜索与修改原理的开发者,也可作为类似Cheat Engine工具的Java实现参考。内容围绕进程内存读写、目标数值搜索、变化结果筛选与内存改写等核心环节展开,兼顾桌面界面交互、依赖组织与工程打包,适合具备一定Java基础、想深入系统级调用与工具开发实践的读者研究。压缩包共86个文件、约25.27MB,主体由32个Java源文件与46个class文件构成,并配有3个jar依赖及Windows可执行安装包,分别对应源码工程、可直接运行的打包产物和脱离Java环境的使用形态,便于对照学习代码结构与发布方式。目前已有4954人学习下载。通过完整工程可观察内存扫描、结果过滤、读写封装及界面层协作的实现脉络,也能借鉴JNA调用、第三方外观库集成和Eclipse工程配置等细节,为自行实现轻量级内存修改工具、排查打包与运行环境问题提供可复用的参考。
1. 从一次改错偏移说起:Java 内存修改工具真正的门槛在哪
第一次把 Java 外挂开发里那套内存修改流程跑通时,我以为最难的是调 API。结果 OpenProcess 一次就返回了句柄,ReadProcessMemory 也顺利读出了数值,真正让我卡住的是第三天:程序重启,昨天还能改的地址全变了,写进去的值像石沉大海。那晚我对着日志排查了三个小时,最后发现只是漏加了一级模块基址。这件事让我明白,Java 做内存修改工具的门槛不在“能不能读写”,而在“能不能稳定复现”。
这篇讲的就是这套东西:用 Java 写一个类似 CE 的内存扫描与修改程序,从底层通道选型、扫描器实现、指针链验证,到几个把我拖到通宵的坑。适合想拿它做逆向学习、给自己写的程序做调试、在单机离线环境里研究内存结构的人。边界得先说清:只碰自己有权调试的进程,不针对联机对战,不绕过任何反作弊机制,这条线我从来不越。
2. 底层通道怎么选:JNI、JNA、独立进程三条路的实测取舍
Java 本身没有直接读别人进程内存的能力,必须借一层桥。这一层怎么搭,决定了后面是舒舒服服写业务,还是天天跟编译链和崩溃打交道。我三条路都试过,结论先给:做工具类项目,JNA 是性价比最高的起点;追求扫描性能再考虑下沉。
2.1 三条路线摆在一起比
| 维度 | 手写 JNI | JNA 动态映射 | 独立小进程 + 管道 |
|---|---|---|---|
| 上手成本 | 高,要 C 头文件、编译链、平台区分 | 低,写接口声明即可 | 中,要设计通信协议 |
| 单次调用开销 | 最低 | 中等,有反射与封送成本 | 高,进程间通信 |
| 批量读写性能 | 高 | 高(一次读大块) | 高 |
| 调试难度 | 高,崩溃直接带走 JVM | 中,异常能捕获 | 低,子进程挂了主进程还在 |
| 部署复杂度 | 高,要带动态库 | 低,一个 jar 加依赖 | 中,要带可执行文件 |
| 适用场景 | 高频调用、产品化 | 学习、脚本、内部工具 | 隔离风险、跨语言复用 |
我一般会这么比:先看调用频率。如果是每秒钟几千次的逐值读取,JNA 的封送开销会显现,但注意,扫描场景不该在 Java 层逐字节调用,正确做法是一次读 4MB 到 byte[] 里,在 Java 内存里做解析,这样 JNA 的开销被摊薄到可以忽略。这条经验值不少时间:我早期写了个逐地址读 4 字节的扫描循环,扫 200MB 内存跑了十几分钟,改成块读取之后掉到十几秒。
JNI 适合你已经有 C 侧代码、或者要做产品分发时用。独立进程那条路我留给“目标进程可能崩”的场景,主进程只负责 UI 和结果展示,读写全在子进程,崩了重启就行,代价是要自己设计一套请求响应协议。
2.2 把句柄拿对:权限位和失败码比代码更重要
选定 JNA 之后,第一段代码就是打开进程。这里翻车的人特别多,因为方法签名看起来很简单,参数含义却全靠常量。
import com.sun.jna.*; import com.sun.jna.platform.win32.WinNT; import com.sun.jna.ptr.IntByReference; public interface Kernel32 extends Library { Kernel32 INSTANCE = Native.load("kernel32", Kernel32.class); // desiredAccess 决定你能做什么,三个权限位按需组合 Pointer OpenProcess(int desiredAccess, boolean inheritHandle, int processId); // 读:baseAddress 是目标进程里的地址,buffer 是本进程接收缓冲 boolean ReadProcessMemory(Pointer hProcess, Pointer baseAddress, Pointer buffer, int size, IntByReference bytesRead); // 写:注意这里传 byte[],由 JNA 负责封送 boolean WriteProcessMemory(Pointer hProcess, Pointer baseAddress, byte[] buffer, int size, IntByReference bytesWritten); boolean CloseHandle(Pointer handle); int GetLastError(); }参数说明:PROCESS_VM_READ = 0x0010只读;PROCESS_VM_WRITE = 0x0020配合PROCESS_VM_OPERATION = 0x0008才能写;PROCESS_QUERY_INFORMATION = 0x0400用于查模块信息。只想扫描就先只要读权限,最小权限原则在这里同样成立,少要权限能显著降低被系统拒绝的概率。
调用失败先看GetLastError()。错误码 5 是拒绝访问,通常是权限不够或目标进程有保护;87 是参数错误,多半是把 64 位地址塞进了 32 位 int;6 是句柄无效,常见于句柄没关就复用变量。我现在的习惯是每次 OpenProcess 之后立刻判空,失败就打印错误码,不让问题飘到后面才暴露。
2.3 位数、权限、进程状态:三个前置条件不满足,后面全是白搭
第一是位数对齐。32 位 JVM 读 64 位进程,指针被截断,读出来的地址全是垃圾,而且不报错,只是数值离谱。判断办法很简单,看任务管理器里进程有没有“32 位”标记,自己的 JVM 用System.getProperty("sun.arch.data.model")打印一下,两边必须一致。我现在的项目固定用 64 位 JVM,地址一律用long承载,Pointer只在中转时出现。
第二是权限。同一用户启动的普通进程一般能读,但系统级进程、带保护的服务进程需要提升权限。这不是让你去搞提权,而是提醒:如果你连自己的测试程序都读不了,先检查是不是两边权限级别不一致,而不是怀疑代码写错。
第三是目标进程状态。进程没启动、刚启动还没加载模块、或者正处于初始化阶段,你拿到的模块列表可能不完整。我吃过这个亏:脚本启动得比目标进程快,枚举模块返回空,后面所有偏移计算全部错位。解决办法是加一个等待循环,直到目标模块出现在列表里再继续,别用固定sleep赌时间。
3. 扫描器从零到能用:精确值、范围值、模糊扫描的三段式实现
扫描器是整个工具的核心,它的本质是一次次“缩小候选集”。CE 之所以好用,是因为它把多轮扫描的中间结果保存下来了,你也得这么做:首扫得到百万级候选,复扫做交集,几轮之后剩几十个,再人工验证。
3.1 先定值类型:显示值不等于内存值
新手最容易犯的错是看到界面上写着 100,就直接按 int 搜 100。实际上这个 100 可能是 float 存的小数、可能是放大十倍的定点数、可能是被加密过再异或的字节。我的判断顺序是:先看数值有没有小数位和小数点后几位,有就按 float 或 double 搜;整数就先按 int 搜,搜不到再试 short 和 byte;如果这个数值在变化时幅度很大(比如从 100 掉到 97 中间有过渡),大概率是浮点。
还有一个隐蔽问题:多副本。界面上一个血量可能是内存里三份数据同步显示的,你改了一份,另外两份立刻把它覆盖回去。这就是很多人“改完瞬间恢复”的真正原因,跟你的写操作没关系。判断方法很简单,搜出来的候选如果同时有好几个地址的值一样且同步变化,八成就是多副本,得全改或者找到真正的逻辑源头。
3.2 两遍扫描法:首扫存快照,复扫做交集
首扫是线性遍历所有可读内存区段,把匹配的地址和值存下来;复扫只在上一轮的候选地址上重读,把不匹配的剔除。这套逻辑听着简单,实现上有两个细节决定成败。
// 分块读取 + 本地解析,避免逐地址调用系统 API static final int CHUNK = 4 * 1024 * 1024; List<Long> firstScan(Pointer hProcess, long start, long end, int target) { List<Long> hits = new ArrayList<>(); Memory buf = new Memory(CHUNK); IntByReference read = new IntByReference(); for (long addr = start; addr < end; addr += CHUNK) { int size = (int) Math.min(CHUNK, end - addr); // 一块一块读,读失败就跳过这个区段,不要中断整体扫描 if (!Kernel32.INSTANCE.ReadProcessMemory( hProcess, Pointer.createConstant(addr), buf, size, read)) { continue; } byte[] data = buf.getByteArray(0, read.getValue()); // 按 4 字节步进解析小端整数,命中即记录绝对地址 for (int i = 0; i + 4 <= data.length; i += 4) { int v = (data[i] & 0xFF) | (data[i + 1] & 0xFF) << 8 | (data[i + 2] & 0xFF) << 16 | (data[i + 3] & 0xFF) << 24; if (v == target) hits.add(addr + i); } } return hits; }这段的关键点在三个地方:CHUNK控制单次读取体积,4MB 是在速度和内存占用之间比较稳的取值,太大容易在 32 位 JVM 上直接 OOM;continue保证读到不可读区段时跳过而不是整轮报废,进程内存里总有空洞;步进用 4 字节对齐是常见优化,但如果你怀疑目标值没对齐,改成 1 字节步进再扫一次,命中数会多很多但能捞到漏网之鱼。
复扫就更简单了,遍历上一轮的命中列表,重读 4 字节比对。
// 复扫:只在上轮候选上重读,几万次调用在可接受范围内 List<Long> nextScan(Pointer hProcess, List<Long> candidates, int target) { List<Long> kept = new ArrayList<>(); Memory buf = new Memory(4); IntByReference read = new IntByReference(); for (long addr : candidates) { if (Kernel32.INSTANCE.ReadProcessMemory( hProcess, Pointer.createConstant(addr), buf, 4, read)) { if (buf.getInt(0) == target) kept.add(addr); } } return kept; }3.3 范围扫描与模糊扫描:数值不确定时怎么下手
精确值扫描要求你知道确切数字,但进度条、蓄力值、冷却时间这类东西,屏幕上根本不显示数值。这时候用模糊扫描:先存一份全量快照,操作一下让数值变大,再扫“比上次大”的地址,反复几轮就能锁定。实现上把每个候选的旧值和新值比较,条件分大于、小于、变化、未变四种。
范围扫描适合浮点数。因为浮点在内存里几乎不可能精确相等,搜 100 要用[99.9, 100.1]这样的区间。判定时把字节按小端还原成 float 再比区间,别直接比字节,字节序搞反了会得到一片乱码一样的数值。
模糊扫描的内存开销要提前算:200MB 可读区段按 4 字节步进就是五千万个候选,每个用 long 存地址就是 400MB,JVM 直接顶不住。我的做法是首轮只存地址的哈希集合,或者干脆限制扫描区段,只扫目标模块附近的范围,命中率反而更高。这也是老手和新手的差别:新手想扫全宇宙,老手先猜数据大概在哪个模块。
4. 地址漂移的解法:模块基址、多级指针与偏移验证
前面说的“重启就失效”,根源在于你找到的是堆上动态分配的地址,每次运行都在变。真正稳定的写法是“模块基址 + 固定偏移”,而现实中这个路径往往要走好几级指针才能到达。
4.1 模块枚举:拿到基址就等于拿到半张地图
模块基址是程序加载时系统分配的,每次启动都不同,但模块内部的相对布局是固定的。所以只要拿到基址,加固定偏移,就能定位到静态数据区里的指针槽位。枚举模块通常走EnumProcessModules或者读进程的模块快照,拿到模块名和基址、大小。我习惯先把目标主模块的基址打印出来,写进日志,后面所有计算都以它为原点。
这一步的坑在于“同名模块”。有些程序会加载多个同名库,你得用完整路径或者大小来区分,光比文件名会拿错。还有一种情况是模块延迟加载,你枚举的时候它还没出现,等到功能触发才加载,这时候基址根本不存在,必须等。
4.2 多级指针:一层层剥,每层都要回读验证
假设偏移链是[[[base + 0x01A2B3C4] + 0x18] + 0x2C] + 0x8,写起来就三行,但每剥一层都必须验证这一层读出来的是不是一个合法地址。
// 逐级解引用,每层都校验结果落在合理地址空间内 Long resolvePointerChain(Pointer hProcess, long moduleBase, long[] offsets) { long addr = moduleBase + offsets[0]; Memory buf = new Memory(8); IntByReference read = new IntByReference(); for (int i = 1; i < offsets.length; i++) { if (!Kernel32.INSTANCE.ReadProcessMemory( hProcess, Pointer.createConstant(addr), buf, 8, read)) { return null; // 中途读失败,说明这一级偏移错了 } addr = buf.getLong(0); // 64 位指针占 8 字节 if (addr < 0x10000 || addr > 0x7FFFFFFFFFFFL) { return null; // 明显不是合法用户态地址,偏移链有问题 } addr += offsets[i]; // 加下一级偏移 } return addr; }参数说明:offsets[0]是相对模块基址的静态偏移,后面每一项是“解引用之后再加”的偏移,最后一个偏移加完就是最终数据地址。这个函数返回 null 就说明链条断了,别硬改,回去检查偏移。
判断偏移对不对,最直接的办法是两次运行对比。第一次运行记下每级的中间地址和最终值,重启程序再跑一遍,最终值必须一样(比如都是血量 100),中间地址允许不同。只要最终值对得上,这条链就是有效的。我一般会存三组样本,三次都对上才敢写进配置文件。
4.3 偏移怎么找:结构体、特征码与差分对比
如果你没有现成的偏移表,得自己找。常用三种思路。第一种是结构体推断:找到对象的一个字段地址后,在其前后按 4 或 8 字节步进读一批值,观察哪些跟着业务变化,就能推断出相邻字段的偏移,一个对象往往能连带挖出十几个有用字段。第二种是特征码定位:找一段在内存中唯一出现的字节序列,搜到之后用“命中地址 - 序列起始偏移”反推函数或数据位置,适合找被混淆过的静态数据。第三种是差分对比:同一个操作做两次,一次改一个值,对比两次的内存快照差异,差异收敛的区域就是你要找的地方。
第三种最费内存但最有效,尤其对“改了没反应”的情况。我通常把快照范围压到 50MB 以内,两次快照对比只保留变化的地址段,人工看几十个候选比看几百万个现实得多。
5. 避坑与排查:五个把调试拖成通宵的问题
5.1 句柄拿到了,读取一直返回 false
现象:OpenProcess 返回非空句柄,但 ReadProcessMemory 每次都是 false,错误码 5。
原因:权限位不够或者目标进程有保护。常见于只申请了PROCESS_VM_READ却去写,或者句柄是用低权限进程打开的。
解决:按用途组合权限位,写操作必须带上PROCESS_VM_OPERATION;同时确认自己的进程和目标进程权限级别一致,不要一边普通权限一边系统级。不要把“提权”当默认解法,先查权限位拼错没有。
5.2 扫描能出结果,改完数值纹丝不动
现象:候选地址里有正确值,写进去也返回成功,界面上毫无变化。
原因:大概率改的是显示副本,真正的值在另一处;或者该值有校验,改动后立刻被纠正;还有一种是被写保护页面,写入静默失败。
解决:先按 3.1 的多副本思路,把同值同变的地址全改一遍看反应;确认写入返回的bytesWritten等于预期字节数,不等于就是没写进去;再用“改前快照、改后对比”确认这块内存是否真的变了。
5.3 扫描一跑就 OutOfMemory
现象:扫大内存区段时 JVM 直接抛内存溢出,或者卡死。
原因:把全部候选地址一次性放进ArrayList<Long>,几千万个元素直接把堆吃满;或者一次性申请整个区段的直接内存。
解决:首轮限制扫描范围,只扫目标模块附近;候选改用原始 long 数组或分片存储;块读取体积降到 4MB 以内,边扫边过滤,别攒着。
5.4 同一份代码换个进程就读不到数据
现象:A 程序一切正常,B 程序读出来全是零或乱码。
原因:位数不一致,32 位 JVM 读 64 位进程;或者字节序、值类型判断错,把 float 当 int 解。
解决:先打印 JVM 位数和目标进程位数,一致再往下查;读出来的字节先按十六进制打印前 32 字节,肉眼判断像不像浮点;浮点扫描一律用区间不用等值。
5.5 重启之后偏移全部失效
现象:昨天验证通过的偏移链,今天开机后算出来的地址读不到东西。
原因:把堆地址当成了静态地址,没走模块基址;或者模块版本变了,内部布局调整。
解决:所有地址一律从模块基址算起,禁止硬编码绝对地址;配置文件里存“模块名 + 偏移链”而不是最终地址;每次启动先跑一遍链验证,不通过就报警而不是继续用错地址改数据。
6. 收在一套自检流程上:让改动可复现、可回滚、可验证
写这类工具,我最看重的不是“能不能改”,而是“改完能不能说清楚发生了什么”。因为内存修改是不可见操作,出错时没有报错弹窗,只有一个莫名其妙的数值。所以最后落到一套我每次都会走的自检流程。
| 步骤 | 做什么 | 通过标准 |
|---|---|---|
| 1 | 确认位数与权限 | JVM 与目标进程位数一致,权限位按用途最小化 |
| 2 | 枚举模块并记录基址 | 目标模块出现且基址非零,写入日志 |
| 3 | 解析指针链并三次验证 | 三次启动最终值一致,中间地址可不同 |
| 4 | 改动前做快照 | 目标地址前后 64 字节留存原值 |
| 5 | 写入并回读 | 回读值等于写入值,写入字节数匹配 |
| 6 | 观察业务反馈 | 界面或逻辑实际变化,而非仅内存变化 |
| 7 | 回滚或保留 | 异常时用快照原值还原,避免留下脏状态 |
这套流程里最容易被跳过的是第 4 步和第 7 步。我早期图快,直接写不备份,结果一次改错偏移,把一个无关的结构体字段覆盖了,目标程序当场行为异常,只能重启重来,之前半小时的扫描结果全废。从那以后我每次写内存前,都强制把目标地址前后 64 字节读出来存一份,写入失败或者观察结果不对,立刻用原值写回去。这一步只多花几十毫秒,省下的却是整轮重扫的时间。
还有一个具体技巧值得单独说:写入限速。有些程序对某块内存有轮询校验,你一秒写五十次,它一秒纠五十次,看起来就是“改了没用”。把写入频率降到两秒一次,往往就能观察到底层是否真的接受了这个值。另外,改浮点数不要写整数值的字节,一定用floatToIntBits转成位模式再写,直接写 int 的 100 进去,浮点解析出来是个天文数字,这种现象我见过不止一次。
这套东西的价值不在于能改什么,而在于你能通过它把“内存里发生了什么”变成可观察、可验证、可回滚的过程。源码里我把扫描器、指针解析和自检流程拆成了独立模块,每块都能单独跑,方便你按自己的目标程序替换参数。拿过去之后建议先跑一遍自检流程,把日志打开,看清楚每一步读到了什么,再动写入那部分——顺序反了,踩坑的概率会高很多。
希望帮到你。
本文还有配套的精品资源,点击获取