# 前言
之前不是提到最近在玩一个游戏《无限世界》—— 其实是十几年前的游戏了。
然后里面有个副本有比较好的奖励,然后需要花费大量时间去进行操作、跑路,非常累。
另外之前实现的 AHK 自动战斗,在设置不同分辨率后也有写问题,毕竟识图的方式实现逻辑,比较受外部因素影响,于是想找找其它提升精确度和便捷性的方式。
经过观察, 发现这个游戏的移动方式,应当是采用前端本地移动,服务器转发实现。
这样,如果可以修改到本地的移动数据,那么岂不是就可以实现瞬移之类的了?
于是就研究了几天,其它先不论,本文主要说说,在取得对应数据基址之后,使用 C# 改写值的方式。
# 简介
这里虽说是使用 C#,但实际上,也是调用系统函数来实现。
有个术语叫 PInvoke,好像也叫 Win32 调用。
其效果类似于:
[DllImport("Kernel32.dll")] | |
private static extern void CloseHandle(IntPtr hObject); |
以前我看到这种, 都是感觉非常非常麻烦的,因为此前也是也没用过,不理解对面所需的参数,跟我们这边定义的类型关联是什么?
例如上述方法,可以在微软 API CloseHandle 看到函数介绍,然而想使用,如何转化为上述代码?
昨天研究的时候,偶然在一个个文章链接上,找到了一个专门搜集 C# PInvoke 函数的网站:
pinvoke.net
上面不但有索引、搜索,每个函数还都列出来了 C# 这边调用方式。
想使用的,甚至只需要复制下来,然后按照正常方法调用就行了!
昨天试了一下,立刻惊为天人,加入了收藏列表。
# 实现
好了,回到主题,有了系统函数可供我们调用之后,就可以找到几个跨进程操作内存的方法了:
- 一个读取 ReadProcessMemory
- 一个写入 WriteProcessMemory
有了这两个,然后对面的应用又没有作加密的话,再找到一个对应数据的地址,就可以使用了。
找基址一般可以用 CE、OD 据说也可以,暂时不在本文讨论范围。
找到基址一般格式如下:
模块名 + 基础偏移 + 指针偏移
例如:
IWClient.exe+00042184->1C->8->40->110->4C
这样,我们根据对应基址和偏移,就可以一路取到该数据在应用当前运行时的真实地址了。
按照上述规则,取实际地址代码如下:
/// <summary> | |
/// 得到实际操作地址 | |
/// </summary> | |
/// <param name="BaseADDR"> 基址 & lt;/param> | |
/// <param name="Deviations"> 指针偏移 & lt;/param> | |
/// <returns></returns> | |
public int Get_RealityADDR(string BaseADDR, string[] Deviations) | |
{ | |
var RealityADDR = Read_MemoryInt(BaseADDR); | |
for (int i = 0; i < Deviations.Length - 1; i++) | |
{ | |
RealityADDR = Read_MemoryInt(AddAddress(RealityADDR, Deviations[i])); | |
} | |
RealityADDR = CalcAddressFromString(AddAddress(RealityADDR, Deviations.Last())); | |
return RealityADDR; | |
} |
在取真实地址最后一位之前中断,是因为最后一个真实地址直接就是地址 + 偏移量,此时该地址已经不是指向下一个地址的指针,其值直接就是数据,数据值留待后续获取,因此直接返回前一个地址值 (指针)+ 偏移即可。
在取得真实地址之后,由于 ReadProcessMemory 获取到的数据是一个字节数组,因此我们需要根据对应地址值进行读取 —— 相信都到这里了,指定地址存放的值的类型,应该都是有数的。
例如,若地址为指针或 Int 类型数据,则可以如此解析:
/// <summary> | |
/// 读取内存内容(以 Int 类型) | |
/// </summary> | |
/// <param name="BaseADDR"> 内存地址 & lt;/param> | |
public int Read_MemoryInt(int ADDR) | |
{ | |
if (Read_MemoryValue(ADDR, ref _bufferLittle)) | |
{ | |
return BitConverter.ToInt32(_bufferLittle, 0); | |
} | |
return -1; | |
} |
其它类型则同理。
# 测试
为了测试方便,这里直接拿 CE 的示例吧。
在菜单选择步骤二,即第一关:
经过简单的查找,我们可以找到第一关的存放血量地址为:
Tutorial-x86_64.exe+325A70->7F8
如图所示:
其模块名为 Tutorial-x86_64.exe,基础偏移为 325A70,后续偏移一次为 7F8
如此,根据上述接口,就可以写一个简单的读取及改写的代码了。
首先,这里定义一个 Adapter 类,用于改写内存的处理逻辑层,大约代码如下:
private Memory _memery; | |
private IntPtr _valueAddr; | |
public InfinityWorldAdapter() | |
{ | |
_memery = new Memory(); | |
_memery.BindProcess("Tutorial-x86_64"); | |
_valueAddr = _memery.Get_RealityADDR("Tutorial-x86_64.exe", "325A70", new string[] { "7F8" }); | |
} | |
public int ReadHP() | |
{ | |
return _memery.Read_MemoryInt(_valueAddr); | |
} | |
public void WriteHP(int hp) | |
{ | |
_memery.Write_MemoryValue(_valueAddr, hp); | |
} |
接着,在界面进行调用测试:
private void OnUpdate(object sender, EventArgs e) | |
{ | |
hpLabel.Content = InfinityWorldAdapter.GetInstance.ReadHP(); | |
} | |
private void Button_Click(object sender, RoutedEventArgs e) | |
{ | |
InfinityWorldAdapter.GetInstance.WriteHP(InfinityWorldAdapter.GetInstance.ReadHP() + 1); | |
} | |
private void Button_Click_1(object sender, RoutedEventArgs e) | |
{ | |
InfinityWorldAdapter.GetInstance.WriteHP(InfinityWorldAdapter.GetInstance.ReadHP() - 1); | |
} |
需要注意的是,你可能会碰到如下错误:
这是因为我们的程序进程可能与目标不匹配,例如,我们打开的是 CE 64 位示例,然后我们的应用勾选了『首选 32 位』就会出现不匹配导致的报错问题。
若出现如上情况,可以在项目设置中,将平台目标是修改为与其一致的 32 位 / 64 位 即可。
最终效果如下:
# 完整代码
这里仅展示核心代码,示例所用就不贴了:
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Text; | |
using System.Threading.Tasks; | |
using System.Runtime.InteropServices; | |
using System.Diagnostics; | |
//******************************************************************** | |
// Author:CWHISME | |
// QQ:785300468 | |
// 描述:个人用跨进程内存操作类 | |
// 参考: | |
// https://www.pinvoke.net/default.aspx/user32/ReadProcessMemory.html | |
// https://www.seeull.com/archives/80.html | |
// https://zhuanlan.zhihu.com/p/35005383 | |
//******************************************************************** | |
/// <summary> | |
/// 读写内存 | |
/// </summary> | |
public class Memory | |
{ | |
/// <summary> | |
/// 从内存中读取字节集 | |
/// </summary> | |
/// <param name="hProcess"> 进程句柄 & lt;/param> | |
/// <param name="lpBaseAddress"> 内存基址 & lt;/param> | |
/// <param name="lpBuffer"> 读取到缓存区的指针 & lt;/param> | |
/// <param name="nSize"> 缓存区大小 & lt;/param> | |
/// <param name="lpNumberOfBytesRead"> 读取长度 & lt;/param> | |
/// <returns></returns> | |
[DllImport("kernel32.dll", SetLastError = true)] | |
public static extern bool ReadProcessMemory( | |
IntPtr hProcess, | |
IntPtr lpBaseAddress, | |
byte[] lpBuffer, | |
Int32 nSize, | |
out IntPtr lpNumberOfBytesRead); | |
/// <summary> | |
/// 从内存中写入字节集 | |
/// </summary> | |
/// <param name="hProcess"> 进程句柄 & lt;/param> | |
/// <param name="lpBaseAddress"> 内存地址 & lt;/param> | |
/// <param name="lpBuffer"> 需要写入的数据 & lt;/param> | |
/// <param name="nSize"> 写入字节大小,比如 int32 是 4 个字节 & lt;/param> | |
/// <param name="lpNumberOfBytesWritten"> 写入长度 & lt;/param> | |
/// <returns></returns> | |
[DllImport("kernel32.dll")] | |
static extern bool WriteProcessMemory( | |
IntPtr hProcess, | |
IntPtr lpBaseAddress, | |
byte[] lpBuffer, | |
Int32 nSize, | |
out IntPtr lpNumberOfBytesWritten | |
); | |
//// 以现有进程获取句柄 | |
//[DllImport("kernel32.dll")] | |
//private static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); | |
// 关闭句柄 | |
//[DllImport("Kernel32.dll")] | |
//private static extern void CloseHandle(IntPtr hObject); | |
// 缓冲区大小 | |
static byte[] _bufferLittle = new byte[8]; | |
private Process _currentProcess; | |
public Process CurrentProcess { get { return _currentProcess; } } | |
public void BindProcess(string pName) | |
{ | |
_currentProcess = GetProcess(pName); | |
} | |
/// <summary> | |
/// 得到模块基址 | |
/// </summary> | |
/// <param name="DllName"> 动态链接库名 & lt;/param> | |
/// <returns></returns> | |
public IntPtr GetModuleHandle(string DllName) | |
{ | |
try | |
{ | |
for (int i = 0; i < CurrentProcess.Modules.Count; i++) | |
{ | |
if (CurrentProcess.Modules[i].ModuleName == DllName) | |
{ | |
return CurrentProcess.Modules[i].BaseAddress; | |
} | |
} | |
} | |
catch | |
{ | |
} | |
return (IntPtr)0; | |
} | |
// 以进程名得到进程 ID | |
public static Process GetProcess(string PName) | |
{ | |
try | |
{ | |
var process = Process.GetProcessesByName(PName); | |
return process[0]; | |
} | |
catch | |
{ | |
return null; | |
} | |
} | |
/// <summary> | |
/// 得到实际操作地址 | |
/// </summary> | |
/// <param name="moduleName"> 模块名 & lt;/param> | |
/// <param name="BaseADDR"> 基址 & lt;/param> | |
/// <param name="Deviations"> 指针偏移 & lt;/param> | |
/// <returns></returns> | |
public IntPtr Get_RealityADDR(string moduleName, string BaseADDR, string[] Deviations) | |
{ | |
return Get_RealityADDR(AddAddress(GetModuleHandle(moduleName), BaseADDR), Deviations); | |
} | |
/// <summary> | |
/// 得到实际操作地址 | |
/// </summary> | |
/// <param name="BaseADDR"> 基址 & lt;/param> | |
/// <param name="Deviations"> 指针偏移 & lt;/param> | |
/// <returns></returns> | |
public IntPtr Get_RealityADDR(string BaseADDR, string[] Deviations) | |
{ | |
var RealityADDR = Read_MemoryInt(BaseADDR); | |
for (int i = 0; i < Deviations.Length - 1; i++) | |
{ | |
RealityADDR = Read_MemoryInt(AddAddress(RealityADDR, Deviations[i])); | |
} | |
return (IntPtr)CalcAddressFromString(AddAddress(RealityADDR, Deviations.Last())); | |
} | |
/// <summary> | |
/// 16 进制字符串转 long | |
/// </summary> | |
/// <param name="addr"></param> | |
/// <returns></returns> | |
private Int64 CalcAddressFromString(string addr) | |
{ | |
return Convert.ToInt64(addr, 16); | |
} | |
/// <summary> | |
/// 16 进制字符串转 int | |
/// </summary> | |
/// <param name="addr"></param> | |
/// <returns></returns> | |
private int CalcAddressOffset(string addr) | |
{ | |
return Convert.ToInt32(addr, 16); | |
} | |
private string AddAddress(string baseAddr, string offset) | |
{ | |
return (CalcAddressFromString(baseAddr) + CalcAddressOffset(offset)).ToString("X"); | |
} | |
private string AddAddress(IntPtr baseAddr, string offset) | |
{ | |
return (baseAddr + CalcAddressOffset(offset)).ToString("X"); | |
} | |
private string AddAddress(Int64 baseAddr, string offset) | |
{ | |
return (baseAddr + CalcAddressOffset(offset)).ToString("X"); | |
} | |
#region Read | |
/// <summary> | |
/// 读取内存内容(以 Int 类型) | |
/// </summary> | |
/// <param name="BaseADDR"> 内存地址 & lt;/param> | |
public int Read_MemoryInt(string ADDR) | |
{ | |
return Read_MemoryInt((IntPtr)CalcAddressFromString(ADDR)); | |
} | |
/// <summary> | |
/// 读取内存内容(以 Int 类型) | |
/// </summary> | |
/// <param name="BaseADDR"> 内存地址 & lt;/param> | |
public int Read_MemoryInt(IntPtr ADDR) | |
{ | |
return BitConverter.ToInt32(Read_MemoryValue(ADDR), 0); | |
} | |
/// <summary> | |
/// 读取内存内容(以 long 类型) | |
/// </summary> | |
/// <param name="BaseADDR"> 内存地址 & lt;/param> | |
public Int64 Read_MemoryLong(string ADDR) | |
{ | |
return Read_MemoryLong((IntPtr)CalcAddressFromString(ADDR)); | |
} | |
/// <summary> | |
/// 读取内存内容(以 long 类型) | |
/// </summary> | |
/// <param name="BaseADDR"> 内存地址 & lt;/param> | |
public Int64 Read_MemoryLong(IntPtr ADDR) | |
{ | |
return BitConverter.ToInt64(Read_MemoryValue(ADDR), 0); | |
} | |
/// <summary> | |
/// 读取内存内容(以 Float 类型) | |
/// </summary> | |
public float Read_MemoryFloat(string ADDR) | |
{ | |
return Read_MemoryFloat((IntPtr)CalcAddressFromString(ADDR)); | |
} | |
/// <summary> | |
/// 读取内存内容(以 Float 类型) | |
/// </summary> | |
public float Read_MemoryFloat(IntPtr ADDR) | |
{ | |
return BitConverter.ToSingle(Read_MemoryValue(ADDR), 0); | |
} | |
/// <summary> | |
/// 读取内存内容,需求其值为 8 位或以下 | |
/// </summary> | |
/// <param name="BaseADDR"> 内存地址 & lt;/param> | |
public byte[] Read_MemoryValue(IntPtr ADDR) | |
{ | |
if (Read_MemoryValue(ADDR, ref _bufferLittle)) | |
{ | |
return _bufferLittle; | |
} | |
Debug.Print(string.Format("读取地址 {0} 数据失败!", ADDR)); | |
Array.Clear(_bufferLittle, 0, _bufferLittle.Length); | |
return _bufferLittle; | |
} | |
/// <summary> | |
/// 读取内存内容 | |
/// </summary> | |
/// <param name="ADDR"> 地址 & lt;/param> | |
/// <param name="buff"> 缓冲数组 & lt;/param> | |
/// <returns > 是否读取成功 & lt;/returns> | |
public bool Read_MemoryValue(IntPtr ADDR, ref byte[] buff) | |
{ | |
IntPtr length; | |
var Rs = ReadProcessMemory(CurrentProcess.Handle, ADDR, buff, buff.Length, out length); | |
return Rs; | |
} | |
#endregion | |
#region Write | |
#region 写入 int 数据 | |
/// <summary> | |
/// 将数值写入内存地址 | |
/// </summary> | |
/// <param name="ADDR"> 内存地址 & lt;/param> | |
/// <param name="Value"> 待写入数值 & lt;/param> | |
/// <returns></returns> | |
public bool Write_MemoryValue(string ADDR, int Value) | |
{ | |
return Write_MemoryValue((IntPtr)CalcAddressFromString(ADDR), Value); | |
} | |
public bool Write_MemoryValue(Int64 ADDR, int Value) | |
{ | |
return Write_MemoryValue((IntPtr)ADDR, Value); | |
} | |
public bool Write_MemoryValue(IntPtr ADDR, int Value) | |
{ | |
return Write_MemoryValue(ADDR, BitConverter.GetBytes(Value)); | |
} | |
#endregion | |
#region 写入 float 数据 | |
public bool Write_MemoryValue(Int64 ADDR, float Value) | |
{ | |
return Write_MemoryValue((IntPtr)ADDR, Value); | |
} | |
public bool Write_MemoryValue(IntPtr ADDR, float Value) | |
{ | |
return Write_MemoryValue(ADDR, BitConverter.GetBytes(Value)); | |
} | |
#endregion | |
/// <summary> | |
/// 最终写入方法 | |
/// </summary> | |
/// <param name="ADDR"> 地址 & lt;/param> | |
/// <param name="bytes"> 字节数据 & lt;/param> | |
/// <returns></returns> | |
public bool Write_MemoryValue(IntPtr ADDR, byte[] bytes) | |
{ | |
IntPtr wLength; | |
var Rs = WriteProcessMemory(CurrentProcess.Handle, ADDR, bytes, bytes.Length, out wLength); | |
return Rs; | |
} | |
#endregion | |
} |
# 结语
一通研究和实际操作下来,明白的东西还是不少,因此决定记录一下。
在上述操作中, 64 位进程的起始地址,可能是超出 32 位 int 上限的,一开始我使用 int 作为指针地址,然后在测试读写 CE 64 这个示例就出现了问题,修修改改,将其统一为了 64 位 Int。
修改为 64 位 是因为猜测 (并在控制台进行了简单测试) 64 位 读写方式其实也支持 32 位进程的,并在后续实际测试了 CE 32 位 示例,确认了该猜测。
下一步,就是应用在游戏中实际体验下了。
参考文章:
pinvoke.net
C# 跨进程内存读写 (PVZ 修改器简单实例)
用 C# 制作游戏修改器的最佳做法