资讯动态

Java内存修改源码实战:JNA附加进程与内存扫描

发布时间:2026/10/20 22:56:29 来源:尧图企业网站定制
简介这是一份面向Java外挂开发入门者的内存修改程序源码资源功能思路类似Cheat Engine适合想了解进程内存读写、数据搜索与修改原理的开发者研究交流。资源包共86个文件以32个java源码与46个class字节码为主另含3个jar依赖库、1个exe安装包及Eclipse工程配置文件压缩包约25.27MB。源码可直接导入Eclipse注意调整Build Path打包好的JAR在已装Java的电脑上可直接运行EXE安装包则支持无Java环境使用。使用流程为打开进程、输入搜索数据、开始搜索、改变游戏数据后搜索变化并完成内存修改完整呈现了从搜索到写入的实现链路。目前已有4953人学习下载读者可借此掌握JNA调用、内存地址定位与界面交互等关键思路并基于源码二次调试与扩展。1. 从一次内存翻车说起Java 做内存修改到底行不行去年帮一个做单机游戏辅助的朋友看代码他用 Java 写了个读取进程内存的小工具跑起来直接抛IOException: 拒绝访问折腾两天没进展最后发现是没开调试权限。这件事让我意识到很多人对「Java 能不能做内存修改」这件事本身就有误解——有人觉得 Java 跑在虚拟机上碰不了底层内存有人觉得随便调个 API 就能读写结果连进程都打不开。这份资源是一套 Java 内存修改程序的完整源码思路对标 CECheat Engine的核心能力附加进程、扫描内存、定位地址、修改数值。它解决的不是「教你做外挂」这种空泛问题而是把 Java 操作进程内存这条链路真正跑通——从权限申请到内存扫描算法从数据类型解析到地址偏移计算每一步都有可运行的代码。如果你正在做单机游戏的数值调试、逆向学习的辅助工具或者单纯想搞清楚 Java 到底能深入到系统哪一层这份源码能帮你省掉大量试错时间。2. 核心原理拆解Java 操作进程内存的四个关键环节2.1 为什么 Java 能碰内存JNA 与 JNI 的选择Java 本身没有直接操作其他进程内存的标准 API必须借助本地调用。常见两条路JNI 写 C/C 动态库或者用 JNAJava Native Access直接映射系统调用。这份源码走的是 JNA 路线原因是省去编译动态库的环节纯 Java 工程就能跑起来。核心依赖是 Windows 的kernel32.dll和psapi.dll通过 JNA 的Native.load加载后直接调用OpenProcess、ReadProcessMemory、WriteProcessMemory这几个函数。我一般会这么比JNI 性能略高但开发成本大JNA 性能损耗在内存扫描场景下几乎可以忽略因为瓶颈在系统调用本身而不是 Java 到 C 的桥接层。// 定义 Kernel32 接口映射需要调用的原生函数 public interface Kernel32 extends Library { Kernel32 INSTANCE Native.load(kernel32, Kernel32.class); // 打开进程返回句柄 HANDLE OpenProcess(int dwDesiredAccess, boolean bInheritHandle, int dwProcessId); // 读取进程内存 boolean ReadProcessMemory(HANDLE hProcess, Pointer lpBaseAddress, Pointer lpBuffer, int nSize, IntByReference lpNumberOfBytesRead); // 写入进程内存 boolean WriteProcessMemory(HANDLE hProcess, Pointer lpBaseAddress, Pointer lpBuffer, int nSize, IntByReference lpNumberOfBytesWritten); // 关闭句柄 boolean CloseHandle(HANDLE hObject); }这里dwDesiredAccess是关键参数读内存需要PROCESS_VM_READ0x0010写内存需要PROCESS_VM_WRITE0x0020和PROCESS_VM_OPERATION0x0008。三个权限按位或组合成 0x0038少一个都会导致读写失败。lpNumberOfBytesRead是输出参数返回实际读了多少字节调试时一定要检查这个值它不等于请求大小时说明地址越界了。2.2 附加进程权限、句柄与失败排查附加进程是整个链路的第一步也是最容易翻车的地方。OpenProcess返回 null 的情况我见过至少四种进程不存在、权限不足、目标进程受保护、PID 传错。源码里对返回值做了判空但实际排查时还需要配合GetLastError看具体错误码。public HANDLE attach(int pid) { // 0x1F0FFF 包含全部访问权限调试场景常用 HANDLE handle Kernel32.INSTANCE.OpenProcess(0x1F0FFF, false, pid); if (handle null) { int err Kernel32.INSTANCE.GetLastError(); // 5 拒绝访问87 参数错误87 通常是 PID 不存在 throw new RuntimeException(附加进程失败错误码: err); } return handle; }错误码 5 基本就是权限问题解决办法是以管理员身份运行 Java 程序。错误码 87 说明 PID 无效检查任务管理器里的进程 ID 是否变化——有些程序每次启动 PID 都不同。还有一种情况是目标进程是 64 位而 Java 跑在 32 位 JVM 上这种架构不匹配也会导致附加失败换 64 位 JDK 即可。2.3 内存扫描从首地址到目标值的搜索策略CE 最核心的功能是数值扫描Java 实现这套逻辑需要遍历进程的虚拟内存空间。源码里的做法是先枚举内存区域拿到每个可读区域的基址和大小再逐块读取并比对目标值。// 枚举内存区域只扫描可读且已提交的区域 public ListMemoryRegion enumRegions(HANDLE handle) { ListMemoryRegion regions new ArrayList(); MemoryBasicInformation mbi new MemoryBasicInformation(); Pointer address Pointer.NULL; while (VirtualQueryEx(handle, address, mbi, mbi.size()) ! 0) { // MEM_COMMIT 0x1000, PAGE_READWRITE 0x04 if (mbi.State 0x1000 (mbi.Protect 0x04) ! 0) { regions.add(new MemoryRegion(mbi.BaseAddress, mbi.RegionSize)); } // 移动到下一个区域 address mbi.BaseAddress.share(mbi.RegionSize); } return regions; }VirtualQueryEx每次返回一个内存区域的信息State为MEM_COMMIT表示已提交可访问Protect包含PAGE_READWRITE表示可读写。扫描时按 4 字节对齐读取比对目标数值。这里有个性能坑如果区域很大比如几百 MB一次性读进来会占大量堆内存源码里做了分块读取每块 4KB读完就比对不保留原始数据。扫描策略上首次扫描用精确值匹配后续用「变大了」「变小了」「没变」做二次过滤。这套逻辑和 CE 的「Next Scan」一致能快速缩小候选地址范围。我一般会建议先扫一个变化明显的数值比如血量扫出几十个地址后再通过改变数值二次过滤通常两三轮就能定位到唯一地址。2.4 数据类型解析字节序、对齐与偏移计算内存里的数据没有类型全靠解析方式。源码支持 byte、short、int、long、float、double 六种类型核心是处理字节序和对齐。// 从字节数组按小端序解析 int public static int bytesToInt(byte[] data, int offset) { return (data[offset] 0xFF) | ((data[offset 1] 0xFF) 8) | ((data[offset 2] 0xFF) 16) | ((data[offset 3] 0xFF) 24); } // 写入时反向操作 public static void intToBytes(int value, byte[] data, int offset) { data[offset] (byte) (value 0xFF); data[offset 1] (byte) ((value 8) 0xFF); data[offset 2] (byte) ((value 16) 0xFF); data[offset 3] (byte) ((value 24) 0xFF); }Windows 是小端序低位字节在前。 0xFF是为了把 byte 转成无符号整数再参与运算否则负数会出错。float 和 double 需要先转成对应的 int/long 位模式再按同样方式处理源码里用Float.floatToIntBits和Double.doubleToLongBits做转换。偏移计算是另一个重点。很多数值不是独立存储的而是结构体的一部分比如[基址 0x10]存血量[基址 0x14]存魔法值。源码里提供了多级指针解析先读基址指向的地址再加偏移读最终值。这部分逻辑在PointerResolver类里支持任意级数的指针链。3. 源码结构拆解五个模块怎么配合3.1 模块划分与调用关系源码按职责分成五个包core放 JNA 接口定义和进程操作scanner放内存扫描逻辑parser放数据类型解析resolver放指针链解析ui放一个简单的 Swing 界面用于输入 PID 和数值。调用关系是单向的ui调scannerscanner调core和parserresolver被scanner调用。这种分层的好处是替换 UI 不影响核心逻辑想改成命令行工具只需要重写ui包。模块核心类职责依赖coreKernel32、ProcessManager加载原生库、附加进程、读写内存JNAscannerMemoryScanner、ScanResult枚举区域、精确扫描、二次过滤core、parserparserTypeConverter六种数据类型与字节数组互转无resolverPointerResolver多级指针链解析、偏移计算core、parseruiMainWindow输入 PID、数值、显示扫描结果scanner3.2 关键类的方法说明ProcessManager是入口类attach(int pid)返回句柄detach()关闭句柄。句柄用完必须关否则目标进程会残留句柄导致无法正常退出。MemoryScanner的firstScan(int value, DataType type)做首次扫描返回候选地址列表nextScan(int value, ScanType type)做二次过滤ScanType有EXACT、INCREASED、DECREASED、UNCHANGED四种。PointerResolver的resolve(HANDLE handle, long baseAddress, int[] offsets)接收基址和偏移数组逐级解引用。比如resolve(handle, 0x400000, new int[]{0x10, 0x14})表示先读0x400000处的指针值加0x10后再读指针值再加0x14得到最终地址。3.3 一次完整的修改流程以修改某个单机游戏的金钱值为例。先启动游戏记下 PID运行 Java 程序输入 PID 和当前金钱值类型选 int点首次扫描回到游戏让金钱变化输入新值点二次过滤重复两三次后候选地址通常只剩一个双击地址输入新值点写入。整个过程和 CE 的操作逻辑一致区别在于这套代码是 Java 写的可以嵌入到其他 Java 工具里做自动化。比如批量修改多个数值、定时监控某个地址的变化、把扫描结果导出成配置文件。4. 避坑与常见问题六个真实翻车记录4.1 附加进程返回 null 但错误码是 0现象OpenProcess返回 nullGetLastError却是 0。原因JNA 调用GetLastError的时机不对中间插了其他 JNA 调用导致错误码被覆盖。解决在OpenProcess之后立刻调GetLastError中间不要有任何其他原生调用或者用Native.getLastError()替代。4.2 扫描结果为空但数值确实存在现象目标数值明明在游戏里能看到扫描却返回零个地址。原因数值类型选错了比如显示的是整数但实际存储为 float或者数值经过了加密/异或处理。解决换类型重扫int 不行试 floatfloat 不行试 double如果都不行可能是加密存储需要先做数值变换再扫描。4.3 写入成功但游戏数值没变现象WriteProcessMemory返回 true但游戏里数值没变化。原因写到了只读的镜像地址或者游戏有反修改机制定时回写。解决检查目标地址的Protect属性只读区域需要先VirtualProtectEx改成可写如果是定时回写需要找到回写逻辑的源头或者用冻结功能持续写入。4.4 扫描大进程时 Java 堆内存溢出现象扫描一个占用 2GB 内存的进程时抛OutOfMemoryError。原因一次性把所有区域的数据读进byte[]数组。解决改成分块读取每块 4KB读完立即比对后释放同时调大 JVM 堆内存-Xmx2g但根本解法还是分块。4.5 64 位进程读出来的地址是负数现象地址值显示为负数比如-2147483648。原因Java 的 int 是有符号的64 位地址的低 32 位可能超过Integer.MAX_VALUE。解决地址统一用 long 存储读取时用Integer.toUnsignedLong转换指针运算全部用 long 类型。4.6 程序退出后目标进程卡死现象Java 程序异常退出后目标游戏无响应。原因进程句柄没关闭目标进程的句柄计数没减某些操作被阻塞。解决用 try-finally 确保CloseHandle一定执行或者在Runtime.getRuntime().addShutdownHook里注册清理逻辑。注意以上排查思路仅适用于自己拥有合法调试权限的单机程序不要用于任何未经授权的场景。5. 从源码到实战三个可复用的技巧5.1 用配置文件管理地址偏移硬编码地址偏移在游戏更新后会全部失效。源码里留了一个address.properties文件把基址和偏移写成配置项更新时只改配置不改代码。# 基址 多级偏移逗号分隔 player.hp.base0x400000 player.hp.offsets0x10,0x14,0x8 player.mp.base0x400000 player.mp.offsets0x10,0x18,0x8读取时用PointerResolver.resolve传入解析后的数组这样一套代码可以适配多个版本只需要维护不同的配置文件。5.2 扫描结果持久化与对比首次扫描可能得到几百个候选地址程序关掉就没了。源码里提供了导出功能把地址列表写成 JSON下次启动直接加载做二次过滤省去重新首次扫描的时间。// 导出扫描结果 public void exportResults(ListLong addresses, String path) { JSONArray arr new JSONArray(); for (Long addr : addresses) { arr.add(addr); } try (FileWriter fw new FileWriter(path)) { fw.write(arr.toJSONString()); } catch (IOException e) { // 导出失败不影响主流程只记录日志 logger.warn(导出失败: {}, e.getMessage()); } }这个技巧在调试复杂数值时特别有用比如某个数值需要触发特定条件才会出现可以先扫出候选地址存下来等条件触发后再加载对比。5.3 定时监控与自动修改把扫描和写入逻辑放到定时任务里可以实现自动监控。比如每 500ms 读一次目标地址低于阈值就写回满值。源码里用ScheduledExecutorService做调度注意读写操作要加锁避免多线程同时操作同一个句柄导致状态混乱。ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { synchronized (handleLock) { int current readInt(handle, hpAddress); if (current 100) { writeInt(handle, hpAddress, 999); } } }, 0, 500, TimeUnit.MILLISECONDS);单线程调度器保证不会并发读写handleLock是防止其他线程同时操作句柄。间隔不要设太短500ms 是个比较稳的值太短会增加目标进程负担太长可能来不及回写。5.4 跨版本适配的思路游戏更新后基址和偏移通常会变但变化有规律。常见做法是先扫描一个特征值比如某个不变的字符串或固定数值用特征值的地址反推基址偏移。源码里留了findPattern方法的扩展点支持字节数组模式匹配比精确值扫描更抗版本变化。// 字节模式匹配?? 表示通配 public Long findPattern(HANDLE handle, byte[] pattern, byte[] mask) { for (MemoryRegion region : enumRegions(handle)) { byte[] data readBytes(handle, region.base, region.size); for (int i 0; i data.length - pattern.length; i) { boolean matched true; for (int j 0; j pattern.length; j) { if (mask[j] 0x01 data[i j] ! pattern[j]) { matched false; break; } } if (matched) { return region.base i; } } } return null; }mask数组控制哪些字节需要精确匹配0x01表示匹配0x00表示通配。这个思路和逆向工程里的特征码定位一致适合在版本更新后快速找到新的基址。6. 写在最后一份能跑通的源码比十篇教程管用这套 Java 内存修改程序我从头到尾跑过三遍第一遍卡在权限第二遍卡在扫描性能第三遍卡在指针解析。每次翻车都让我对「Java 到底能做什么」有了更具体的认知——它不是只能写 Web 和 Android借助 JNA 一样能深入到系统调用层。回过头看最有价值的不是某个具体 API 的用法而是这套代码把「附加进程 → 枚举内存 → 扫描数值 → 解析类型 → 写入修改」这条链路完整串起来了。你可以直接拿它改单机游戏的数值也可以把scanner和parser模块抽出来做内存分析工具甚至只是读一读PointerResolver的实现来理解多级指针是怎么回事。源码里我特意留了几个扩展点findPattern支持特征码扫描address.properties支持配置化偏移exportResults支持结果持久化。这些不是必须的但实际用起来会省很多事。如果你正在学逆向或者想做单机辅助工具这份源码能帮你跳过最耗时的环境搭建和 API 试错阶段。从那以后我每次碰内存操作都强制先确认三件事权限够不够、句柄关没关、地址是不是 64 位。这三条看起来简单但每一条我都见人翻过车。希望这份源码和这些踩坑记录能帮你少走点弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑