# 前言

之前不是提到最近在玩一个游戏《无限世界》—— 其实是十几年前的游戏了。
然后里面有个副本有比较好的奖励,然后需要花费大量时间去进行操作、跑路,非常累。
另外之前实现的 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

这样,我们根据对应基址和偏移,就可以一路取到该数据在应用当前运行时的真实地址了。

按照上述规则,取实际地址代码如下:

s
/// <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 类型数据,则可以如此解析:

s
/// <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 类,用于改写内存的处理逻辑层,大约代码如下:

s
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);
}

接着,在界面进行调用测试:

s
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 位 即可。

最终效果如下:

# 完整代码

这里仅展示核心代码,示例所用就不贴了:

s
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# 制作游戏修改器的最佳做法