纯Java实现内存修改器:JNA调用Windows API读取进程内存
发布时间:2026/9/29 16:49:58来源:尧图网络
简介面向Java外挂开发与游戏逆向人群这套内存修改程序完整工程以仿CE的交互方式演示了进程内存扫描与修改的常见流程。源码部分基于Eclipse构建并附带本地接口依赖库既可在开发环境中查看界面与底层读写逻辑也可用打包好的JAR快速运行另附EXE安装版适合没有Java环境的机器。程序内置进程选择、首轮搜索、二次查找、地址定位、数值写入等核心环节照此可以复现“打开进程—搜索数值—游戏内变动—再次搜索—修改内存”的典型操作帮助理解外部进程内存访问机制。压缩包共86个文件、约25.27MB其中32个Java源文件与46个class文件配套存在便于逐行调试3个依赖JAR齐全Eclipse工程配置完善导入后仅需修正Build Path即可运行。目前已有4950人学习下载适合具备基础Java语法、想尝试内存修改工具或跨平台打包的开发者也可作为信息安全方向学生研究外部进程读写的入门参照。1. Java外挂开发这个方向很多人一听就觉得是C/C的专属地盘——毕竟Windows的进程内存API都是C接口。但这个工程用事实证明纯Java一样能写出一套类似CECheat Engine的内存修改程序。它的核心思路不复杂通过JNA绑定Kernel32调用OpenProcess拿到进程句柄再用ReadProcessMemory把目标进程的内存抓到自己手里搜索、比对、写入。这套源码能帮你把进程虚拟内存、内存页属性、权限、字节序这些底层概念一次性串起来适合想做单机游戏数值修改调试、逆向入门、或者安全岗面试前想动手补原理的人。真正动起手来你会发现坑全都在权限和64位地址上。2. 原理与选型进程虚拟内存、JNA绑定与读写函数打通2.1 为什么Java能做内存修改先看进程虚拟内存长什么样每个Windows进程都活在自己独立的虚拟地址空间里。32位进程理论上有4GB虚拟地址64位进程拥有大得多的地址空间。这个“虚拟”是重点进程里看到的0x00400000之类的地址并不是一块实际的物理内存位置而是操作系统通过页表映射出来的一段假象。你改游戏内存本质上是把“虚拟地址→物理页”映射关系里某一个页的内容改掉。关键点是Windows为了进程隔离默认不允许一个进程直接摸另一个进程的内存。系统提供的正规入口就是ReadProcessMemory和WriteProcessMemory这一对API。打开目标进程、拿到句柄之后只要权限够就可以像读自己进程内存一样读对方。CE能做到的枚举进程、搜索数值、定位地址、写入锁定本质上都是围绕这两个API展开。所以Java做内存修改完全可行JNA可以直接加载kernel32.dll把C函数签名映射成Java接口。以前很多老教程默认要写JNI是因为觉得Java虚拟机离系统层太远但JNA出现之后日常的内存读写工具用纯Java写反而更省事毕竟不用维护一整套C/C编译环境。代价是JNA的反射和类型转换有一点性能损耗但对搜索内存数值这种秒级操作来说完全够用。这里补充一个容易误解的点修改器自身是32位还是64位影响的只是它自己能用多少地址空间能不能读写目标进程取决于OpenProcess拿到什么权限以及目标进程的位数。64位修改器读32位进程地址值只有4字节有效高位填0即可反过来32位修改器读64位进程地址会溢出这也是后面踩坑的大头。2.2 为什么选JNA而不是JNI两件事想清楚常见做法是 jna jna-platform 一起用。jna提供 Native.load 和 Pointerjna-platform提供 WinDef、WinNT 以及一堆现成的结构体定义。自己写JNI就得同时管Java侧和C侧的代码改一次函数签名就重新编一次DLL换台机器就崩JNA把这一步省成一个jar包。对内存修改器这种工具来说开发速度比那点原生调用开销重要得多。但选JNA之前有两件事必须想清楚。第一JNA的 Pointer 底层就是一个long地址任何API里接收地址参数的地方统一用 Pointer 或 long 传递不要混用 int。第二批量读内存时JNA会把 byte[] 自动转换成C数组这个转换过程有内存拷贝。单次几千字节没问题全进程扫描时一个区域一个区域地读拷贝开销会累积。解决办法是把每次读取的块放大到4MB左右而不是一次只读4字节扫描速度差距能有数量级。还有一点要注意JNA的接口定义里不能漏掉 StdCallLibrary 这个父接口。Windows 32位API用的是stdcall调用约定64位下x64调用约定统一了但加上它才能保证32位环境下不崩。定义完接口后Native.load(kernel32, Kernel32.class) 只会加载一次后面所有调用都走 INSTANCE 单例别每个类里都重新load那样会生成一堆重复映射。2.3 Kernel32核心接口映射先把手头函数磨利下面的接口定义是整份源码的地基。我没有直接依赖jna-platform里现成的Kernel32因为 ReadProcessMemory / WriteProcessMemory 这两个函数在官方封装里没有索性自己定义一份把用到的函数和权限常量都收在一起改起来也方便。import com.sun.jna.Native; import com.sun.jna.Pointer; import com.sun.jna.win32.StdCallLibrary; public interface Kernel32 extends StdCallLibrary { Kernel32 INSTANCE Native.load(kernel32, Kernel32.class); int PROCESS_VM_READ 0x0010; int PROCESS_VM_WRITE 0x0020; int PROCESS_QUERY_INFORMATION 0x0400; Pointer OpenProcess(int dwDesiredAccess, boolean bInheritHandle, int dwProcessId); boolean ReadProcessMemory(Pointer hProcess, long lpBaseAddress, byte[] lpBuffer, int nSize, int[] lpNumberOfBytesRead); boolean WriteProcessMemory(Pointer hProcess, long lpBaseAddress, byte[] lpBuffer, int nSize, int[] lpNumberOfBytesWritten); boolean CloseHandle(Pointer hObject); int GetLastError(); }这个接口里最需要注意的是权限常量的组合。OpenProcess 的 dwDesiredAccess 决定你能对目标进程做什么PROCESS_VM_READ 是读内存PROCESS_VM_WRITE 是写内存PROCESS_QUERY_INFORMATION 必须带上因为后面用 VirtualQueryEx 遍历内存区域时需要它。三个权限通常一起传写成按位或 0x0010 | 0x0020 | 0x0400漏掉任何一个都会在后续某一步莫名其妙失败。ReadProcessMemory 的参数值得逐一说清。hProcess 是 OpenProcess 返回的句柄lpBaseAddress 映射成 long是因为64位进程的地址可能超过 int 范围lpBuffer 是接收数据的 byte[]nSize 是期望读取的字节数lpNumberOfBytesRead 是一个长度为1的 int 数组函数返回后里面存实际读到的字节数。函数返回 booleanfalse 就代表失败GetLastError 能看到错误码5 是拒绝访问299 是 PARTIAL COPY 部分读取。WriteProcessMemory 的参数方向正好反过来buffer 里装的是要写进去的数据。返回值同样要检查写失败时最常见的错误码还是5意味着当前进程权限连写这个地址都不被允许。到这里读写链路已经通了下一步是把目标进程找出来、把句柄打开。3. 进程与权限模块枚举进程列表、打开进程与第一轮内存读取3.1 枚举进程列表CreateToolhelp32Snapshot照一遍修改器打开第一件事就是让用户选进程。常见做法是用 CreateToolhelp32Snapshot 对进程列表做一次快照再用 Process32First / Process32Next 逐条遍历。JNA 里需要自己定义 PROCESSENTRY32 这个结构体注意字段顺序必须和C头文件一致。import com.sun.jna.Pointer; import com.sun.jna.Structure; public class ProcessEntry extends Structure { public int dwSize; public int cntUsage; public int th32ProcessID; public Pointer th32DefaultHeapID; public int th32ModuleID; public int cntThreads; public int th32ParentProcessID; public int pcPriClassBase; public int dwFlags; public byte[] szExeFile new byte[260]; Override protected ListString getFieldOrder() { return Arrays.asList( dwSize, cntUsage, th32ProcessID, th32DefaultHeapID, th32ModuleID, cntThreads, th32ParentProcessID, pcPriClassBase, dwFlags, szExeFile); } }JNA里自定义结构体必须实现 getFieldOrder否则运行时会直接报错这是新手最常见的翻车点。szExeFile 是260字节的 char 数组对应C里的 MAX_PATH 长度拿到进程名后要用 trim 去掉结尾的 \0。dwSize 这个字段要在调用前提前赋值为结构体本身的大小Windows API 会用它来判断结构版本忘了赋值 Process32First 会返回 false而且没有任何提示。Pointer snapshot Kernel32.INSTANCE.CreateToolhelp32Snapshot(0x00000002, 0); ProcessEntry entry new ProcessEntry(); entry.dwSize entry.size(); if (Kernel32.INSTANCE.Process32First(snapshot, entry)) { do { String name new String(entry.szExeFile, StandardCharsets.UTF_8).trim(); int pid entry.th32ProcessID; System.out.println(pid pid , name name); } while (Kernel32.INSTANCE.Process32Next(snapshot, entry)); } Kernel32.INSTANCE.CloseHandle(snapshot);CreateToolhelp32Snapshot 第一个参数传0x00000002即 TH32CS_SNAPPROCESS表示只要进程快照如果要连线程或模块一起拿就按位或对应常量。第二个参数传0表示快照所有进程。快照句柄用完后要 CloseHandleGUI工具长时间挂着不释放句柄会一天天涨上去最后进程列表刷新直接变慢。提示snapshot 返回的也是句柄但不需要 OpenProcess 那种权限组合它只是一个系统快照引用记得关闭就行。3.2 打开进程权限组合是第一步拦路虎OpenProcess 本身不复杂但权限写错了表现很隐蔽有的进程能打开但读出来全是0有的直接返回0拿不到句柄。我一般在工具类里固定封装一个打开方法把常用权限组合成常量避免每个调用点各写一份漏字段。public Pointer openProcess(int pid) { int access Kernel32.PROCESS_VM_READ | Kernel32.PROCESS_VM_WRITE | Kernel32.PROCESS_QUERY_INFORMATION; Pointer h Kernel32.INSTANCE.OpenProcess(access, false, pid); if (h null) { int err Kernel32.INSTANCE.GetLastError(); throw new IllegalStateException(OpenProcess失败, pid pid , err err); } return h; }第二个参数 bInheritHandle 表示返回的句柄能否被子进程继承调试器场景一般传 false。这里最容易翻车的是权限不足普通用户 OpenProcess 被保护进程或系统进程时GetLastError 返回5。解决办法是让程序以管理员身份运行或者给当前进程开启 SeDebugPrivilege 调试权限。但要明白边界内存修改器的权限边界就是Windows自身的安全边界OpenProcess 拿不到的进程用户态代码不存在绕过的说法别被网上那些黑科技文章带偏。拿到句柄后我习惯先做一次探测读从目标进程的模块入口附近读几个字节确认句柄真的能用再继续做扫描。很多新手跳过了这一步跑到扫描阶段才发现所有读都返回false排查半天又绕回 OpenProcess。血泪经验每一步都检查返回值不要在错误句柄上继续叠操作。3.3 用VirtualQueryEx过滤内存区域别把整个地址空间扫一遍进程虚拟地址空间里有大量空洞和未提交区域直接从头读到尾会遇到两类问题一是读取未提交的页会直接失败二是不必要的区域白白拖慢扫描速度。正确做法是先用 VirtualQueryEx 把内存区域划分出来只对已提交且可读的区域处理。long address 0; while (address 0x00007FFFFFFF0000L) { MemoryBasicInformation mbi new MemoryBasicInformation(); int len kernel32.VirtualQueryEx(hProcess, new Pointer(address), mbi, mbi.size()); if (len 0) { break; // 走到无效区域通常是权限不够或地址越界 } int state mbi.state.intValue(); int protect mbi.protect.intValue(); if (state MEM_COMMIT isReadable(protect) !isGuard(protect)) { processRegion(hProcess, mbi.baseAddress.longValue(), mbi.regionSize.longValue()); } address mbi.regionSize.longValue(); }核心逻辑是每次循环把 address 加上 regionSize 跳到下一个区域而不是一页一页加4KB否则虚拟地址空间跨度太大循环次数会多到不可接受。过滤条件有三个state 必须是 MEM_COMMIT表示这块内存已经实际提交protect 必须包含可读标志还要排除 PAGE_GUARD这是带一次性保护属性的页读它会触发访问违规异常。isReadable 的判断建议用位运算而不是等于因为 Protect 字段里除了读写执行属性还可能夹杂 PAGE_NOCACHE、PAGE_WRITECOMBINE 这些修饰位。直接用等号判断会漏掉很多本来能扫的区域。这一步过滤结束后真正的扫描域往往只有进程虚拟空间的很小一部分扫描性能的差距从这里就拉开了。4. 核心数值扫描与写入精确值搜索、模糊搜索与地址锁定4.1 精确值扫描把目标值翻译成小端字节序CE用户对“搜索数值”再熟悉不过。原理其实就一句话在可读内存区域里逐块读取字节按目标类型解析成数值与目标值比对相等就记下地址。唯一容易踩的是字节序。x86/x64平台底层是小端存储Java的 ByteBuffer 默认却是大端必须显式指定 LITTLE_ENDIAN不然搜出来全是乱码值。public ListLong searchInt(Pointer hProcess, ListRegion regions, int target) { ListLong hits new ArrayList(); ByteBuffer buffer ByteBuffer.allocate(4 * 1024 * 1024).order(ByteOrder.LITTLE_ENDIAN); for (Region r : regions) { long remaining r.size; long offset 0; while (remaining 0) { int toRead (int) Math.min(buffer.capacity(), remaining); byte[] chunk readMemory(hProcess, r.base offset, toRead); buffer.clear(); buffer.put(chunk); buffer.flip(); while (buffer.remaining() 4) { int value buffer.getInt(); if (value target) { hits.add(r.base offset buffer.position() - 4); } } offset toRead; remaining - toRead; } } return hits; }这个实现里一次读取4MB区块而不是按4字节读。原因很直接ReadProcessMemory 每次调用涉及JNA类型转换和系统调用按4字节读几十万次和按4MB读几十次时间差是数量级的。第一轮扫描候选地址可能成百上千这是正常的因为整个进程内存里等于同一个数值的4字节数据非常多。CE第一轮筛完通常也是几千起步需要靠修改游戏里的值继续缩小范围。buffer.getInt() 返回的是当前位置的4字节int因为前面经历过 put 和 flipposition 已经被推到当前读取点。hits 记录的是绝对地址区域基址加上块内偏移再加上当前 buffer 的 position 减4。减4是因为 getInt 已经把 position 向前移了4而我们要的是这个数值的起始地址。4.2 模糊扫描变大、变小、没变用状态机筛地址模糊扫描是当目标没有固定数值时的唯一出路只告诉程序目标内存“变大了”“变小了”“没变”或“数值发生了变化”。实现上就是保留上一轮扫描的候选值快照下一轮重新读取同一地址与快照比较按用户选择的关系过滤。public ListLong filterByChange(Pointer hProcess, ListLong candidates, MapLong, Integer lastValues, ChangeType type) { ListLong survived new ArrayList(); for (Long addr : candidates) { int current readInt(hProcess, addr); Integer last lastValues.get(addr); if (last null) continue; boolean ok; switch (type) { case INCREASED: ok current last; break; case DECREASED: ok current last; break; case UNCHANGED: ok current last; break; default: ok current ! last; break; } if (ok) { lastValues.put(addr, current); survived.add(addr); } } lastValues.keySet().retainAll(survived); return survived; }模糊扫描的关键是快照怎么维护。lastValues 这个Map里存的是上一轮扫描时该地址的值每轮筛选通过后都要更新成当前值否则下一轮比较失真。被淘汰的地址要立刻从Map里删掉我第一次写的时候漏了 retainAll 这一步扫了几轮之后Map里堆了几十万条死地址程序直接卡死内存占用飙到1GB。模糊扫描不是万能的。如果游戏数值被加密、或者每次变化都叠加随机量这个策略也会失手。这种时候要换数据类型搜索或者直接搜特征字节序列毕竟内存修改器的边界就停在物理内存的表示形式上超过这个边界就是另一套攻防体系了。4.3 写入与锁定WriteProcessMemory加上轮询定时器定位到目标地址之后修改就是最后一击。写4字节int的代码很直白核心是检查实际写入字节数别只看返回值。public void writeInt(Pointer hProcess, long address, int value) { byte[] data ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array(); int[] written new int[1]; boolean ok Kernel32.INSTANCE.WriteProcessMemory(hProcess, new Pointer(address), data, 4, written); if (!ok || written[0] ! 4) { throw new IllegalStateException(写入失败 addr Long.toHexString(address) err Kernel32.INSTANCE.GetLastError()); } }检查 written[0] 是否等于4很容易被忽略。WriteProcessMemory 返回 true 只代表调用本身没出错实际写入了几个字节在 written 数组里。如果只写了一半就返回成功数值会变成一个想不到的怪值而且极难排查。锁定是CE的经典操作游戏每次重算数值会把它重置回来所以要定时把目标地址写回锁定值。GUI场景下用 javax.swing.Timer 放在EDT线程里每150毫秒左右写一次别在按钮事件里做死循环界面会直接卡成白屏。Timer lockTimer new Timer(150, e - { try { writeInt(process, targetAddress, lockedValue); } catch (Exception ex) { ((Timer) e.getSource()).stop(); // 句柄失效就停掉别刷报错弹窗 } }); lockTimer.start();150毫秒是个折中值太快空耗CPU太慢在部分游戏的高频刷新下会被重新计算覆盖。这个参数拿到源码后可以自己调锁定失败时先排查是不是间隔太长再看地址是否已经失效。开关锁定功能时记得 stop 掉 Timer避免切了进程还继续往旧句柄里写数据。5. 避坑清单权限、64位地址与扫描性能的翻车实录5.1 OpenProcess一切正常但ReadProcessMemory总是返回false现象进程列表能显示名字OpenProcess 返回了非空句柄但一调用 ReadProcessMemory 就返回 falseGetLastError 报299或5。原因299 是 ERROR_PARTIAL_COPY常见于读取还驻留在其他进程地址空间里的共享区域更常见的是 OpenProcess 权限里漏了 PROCESS_QUERY_INFORMATION导致后面的 VirtualQueryEx 拿不到正确区域信息所有读取都建立在错误的地址列表上。还有一个隐蔽情况是目标进程挂了句柄变成僵尸读谁都会失败。解决OpenProcess 的权限参数统一带上 PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION 三个一个都不能少。每次 ReadProcessMemory 后检查 lpNumberOfBytesRead 是否等于请求大小不等就按失败处理。另外每次读之前对比一下 pid 进程是否还活着用 Process32Next 重新刷一遍列表避免拿旧句柄读新状态。5.2 扫描出来的地址是负数或者写操作写到了奇怪位置现象扫描结果里有 0xFFFFFFFFFFF00000 这种地址转成 int 后变成负数写入时要么报错要么毫无反应。原因64位进程的地址高位很大代码里如果用 int 接收指针值int 会溢出成负数。JNA 的 lpBaseAddress 参数用了 long 就不会有这个毛病但自己计算偏移时如果拿 int 和 long 做加法一加就悄悄截断。解决全项目地址类型统一用 long不允许混用 int。可以封装一个 AddressUtils 类只提供 long 进出的转换方法地址打印一律用 Long.toHexString。我自己的习惯是所有从API拿到的地址先做一次类型校验如果值的符号位异常或超出目标进程地址空间范围直接抛异常宁可崩溃也不静默写错位置。5.3 能读到的区域很少游戏关键数据搜不到现象VirtualQueryEx 遍历出来的可扫描区域只有几MB或者大量堆区域数据根本没出现在扫描结果里。原因过滤条件漏了 PAGE_EXECUTE_READWRITE 这类可执行内存区域。有些游戏会把动态数据放在带执行属性的页里判断函数里只写了 PAGE_READWRITE 就漏掉一大块。另一个原因是 Protect 字段还带了 PAGE_NOCACHE 这类修饰位用等号判断全都不匹配。解决isReadable 判断要覆盖 PAGE_READONLY、PAGE_READWRITE、PAGE_WRITECOPY、PAGE_EXECUTE_READ、PAGE_EXECUTE_READWRITE、PAGE_EXECUTE_WRITECOPY 六种用位运算检测可读位不要用等于。PAGE_GUARD 单独用位标志排除别混在一起判断。改完之后重新扫描可用区域会大不少。5.4 游戏重启后地址完全变了昨天锁定的地址今天全是无效现象昨天锁定好的地址今天重开游戏全部失效重新搜索也搜不回原来的数值。原因Windows 启用了 ASLR地址空间布局随机化每次进程启动时模块基址和堆位置都会变。你昨天记的是 0x140000000 0x2A3F4今天基址变成 0x7FF700000000偏移对但基址全废了。解决不要记绝对地址记“模块名 模块基址 静态偏移”。用 EnumProcessModules 拿到进程主模块基址目标地址减去模块基址得到偏移重启后再把新模块基址加回去换算新地址。这一步是修改器从玩具走向可用的分水岭具体代码在第6章展开。5.5 第一次扫描要好几秒第二轮又慢又卡现象全量扫描一个内存占用1GB的进程要5秒以上扫描期间GUI完全无响应。原因除了字节解析本身的开销更大的瓶颈是每次 ReadProcessMemory 调用都在做JNA类型转换和内存拷贝而且扫描循环跑在EDT线程里界面自然卡死。解决扫描任务丢到 SwingWorker 或普通线程里进度回传用 SwingUtilities.invokeLater 更新UI。每块读取大小从4KB加到4MB命中区域记录为候选区第二轮只重扫这些候选区附近的内存块速度能提升一个数量级。再进一步优先扫描堆和栈附近区域大多数游戏的动态数值都在那里。6. 进阶与验证定位静态基址、多级偏移与最后的校验6.1 把动态地址换成静态基址一次解决重启漂移动态地址是修改器的头号敌人基址指针结构是解药。记住这个公式目标地址 模块基址 偏移链。CE里选中地址后能看到“绿色地址”说明它直接落在模块基址附近偏移固定如果不是绿色说明藏在多层指针下面。多层指针的意思是某个内存地址里存着另一个地址游戏对象属性往往通过“玩家指针→对象指针→属性偏移”这样两层以上跳转。用代码模拟就是反复执行“先加偏移再读地址”public long resolvePointer(Pointer hProcess, long base, ListLong offsets) { long addr base; for (int i 0; i offsets.size() - 1; i) { addr readLong(hProcess, addr offsets.get(i)); // 读下一层地址 } return addr offsets.get(offsets.size() - 1); } // 用法resolvePointer(process, moduleBase, Arrays.asList(0x1A2CL, 0x0F34L, 0x04D0L))readLong 读的是8字节指针只有64位进程这么用32位进程要换成 readInt。每一层都先加偏移再读最后一层只加偏移不读因为那已经是真正的数据地址。判断偏移值是否合理有个土办法读出来的地址必须落在目标进程已提交的可读区域内并且对齐到4或8字节不符合就说明这一层偏移算错了。6.2 最后的校验改值、重启、再验证拿到源码后我建议先完整走一遍校验流程来验证修改器是否真正可用。第一步启动目标程序搜索一个初始值记录候选地址数第二步手动改变游戏内数值再扫描确认候选数降到个位数第三步记录候选地址与模块基址的偏移退出程序第四步重启程序用“基址偏移”直接定位地址读回数值确认与之前一致。这一整套流程全部通过才说明修改器的地址链是稳定的。很多人写完扫描就收工结果换台电脑、换一个分辨率就全部失效最后归咎于系统版本其实只是没做基址稳定性验证。地址链一旦稳定锁定数值、批量修改、脚本自动化都是顺水推舟的事。早些年我做这类工具时只图快直接用绝对地址写死锁定逻辑当场演示一切正常第二天游戏一更新整个地址链全断脚本跑起来全是无效写入排查了一个下午才发现是ASLR把基址挪了位置。从那以后我每次做完一个定位功能都强制走一遍重启校验校验不过就先追基址再谈其他功能。内存修改器的本质不是改数值而是在不确定的地址空间里找到那个确定不变的东西——这是它最像玄学也最迷人的地方。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网