hui-shao 发表于 2022-8-25 23:30

保卫萝卜PC版内存修改器分析、制作详细过程

本帖最后由 hui-shao 于 2022-8-25 23:38 编辑

### 0x00 前言



近期看到 [@空竹](home.php?mod=space&uid=979293) 大佬分享了基于 `Cheat Engine` 的 [保卫萝卜破解教程](https://www.52pojie.cn/thread-1663348-1-1.html) ,欲初探一下逆向知识,并锻炼 `Win32` 编程技能,故有此篇。

文章尽可能详细记录了各个过程,既是自己学习路上的一点经验记录,又是一篇简单教程,期望能对一些像我一样的入门小白有所帮助。


### 0x01 游戏内存分析

*(这一部分我写得详细些方便小白入门)*

首先运行游戏,然后打开 `CE` - 打开 `LuoBo.exe` ,点击 `Attach debugger to process` 附加调试器,然后随便开一个关卡,把游戏暂停。

我们看到游戏中的金币数量是 450 。因此在右侧,填入当前金币数450 ,并使用 `First Scan` 进行初次扫描。可以看到出现了一大堆地址。



接下来种一个炮塔,让金币发生变化,然后暂停游戏,此时金币值是 350。点击 `Next Scan` 筛选出变化后的金币值。

看到左侧列表中只有一个内存地址了,我们先右键把它加入地址列表。



因为当前获取到的是金币的动态地址,每次打开游戏,这个值都是变化的,无法制作成修改器。所以下面我们来寻找基址(手动),通过静态的指针实现对这个内存区域的稳定访问。

对金币的内存地址右键-`Find Out what accesses this address`,然后继续游戏,打一个怪。这时暂停游戏,可以发现窗口中出现了一些汇编指令。



很明显,这个 `add` 指令就是用于增加金币的,而且金币的地址是 `esi+0x74` 。我们往下拉一下,可以看到 `esi` 的地址。我们把它选中复制下来。



回到 `CE` 主窗口,点一下 `New Scan` ,勾选 `Hex` 复选框,把刚才复制的粘贴进去,点击 `First Scan` 开始新的扫描。

可以看到,左边的地址中出现了相应结果。好消息是,列表中出现了绿色字样的 `Luobo.exe+105E68` ,这表明这是个静态地址,至此,金币基址的寻找工作告一段落。



接下来我们手动添加指针,尝试访问金币所在的内存区域,作为验证。

点击右下方 `Add Address Manaually` 按钮-勾选 `Pointer` ,在最下方填入 `Luobo.exe+105E68` ,倒数第二个框填写偏移量 `74`,观察到最上方的内存地址中,运算出的值为 `364`,恰好为金币数,说明没有问题。*(有兴趣可以退掉游戏重开下,发现该指针仍可以访问到金币,而之前的动态地址不行)*



按照前文提到的大佬的分析,金币是有校验的。我们需要破坏这个校验,防止程序闪退。第一步还是需要找到校验指令所在内存区域的基址。

在小窗口中选中 `add` 指令,点击右侧 `Show disassembler` 查看汇编与内存。



可以发现,金币在增加之后,有如下一段指令:

```asm
mov eax,
dec eax
mov ,eax
```

在其他金币减少的过程中,这段指令也出现,所以高度怀疑是验证代码。

上述代码实现了这样的功能:

1. 把金币的数量(``)存入 `eax`
2. 使用 `dec` 指令使得 `eax` 减一
3. 将 `eax` 的值存入 ``

可见,检测的机制大致就是比较两个地址上的金币相差是否为1.

所以接下来,只需要对 `` 查访问就行了。

在主窗口下方,将金币的指针复制一份,把偏移量改成 `EC`,右键查访问,选择 `Find what accesss the address pointed at by this pointer` ,接着再随意打怪或者升级下炮塔,然后暂停游戏。





从图中可以发现有 `mov` 指令把这个校验值读取到了 `ecx`,选中这条指令,与之前相似,在右侧点击“查看汇编”按钮。



汇编有如下指令:

```asm
mov ecx,
mov eax,00000001
add ecx,eax
cmp ecx,
je Luobo.exe+245F2
```

这些代码实现的是“将校验值加一之后与金币值比较,如果相等则跳转到 `Luobo.exe+245F2`”。(对应操作码:`0x74 0x70`)

我们双击 `je` 所在的那一行指令,将指令改为 `jmp` 即可实现无条件跳转。(对应操作码:`0xEB 0x70`)





至此,金币修改就完全被破解了。随意修改金币,都不会造成游戏闪退。

这里,我们留意记下这条 `je` 指令的静态地址为 `Luobo.exe+24580` ,为后续编写内存修改器做准备。

### 0x02 修改器程序流程构建

#### 总体思路



#### 具体一点点

和函数名做一个对应。



### 0x03 修改器代码实现

*(模块化叙述,可能有不准确之处,具体代码见开源工程)*

#### CMakeLists

管理项目的 `CMakeLists.txt`:

比较重要的是添加上 UAC 权限的请求。

```cmake
cmake_minimum_required(VERSION 3.23)
project(LuoBo_Mod C)
add_executable(LuoBo_Mod main.c)

# 添加UAC请求管理员权限
set_target_properties(LuoBo_Mod PROPERTIES LINK_FLAGS " /MANIFESTUAC:\"level='requireAdministrator' uiAccess='false'\" ")
```

#### Headers

普普通通头文件,win32编程的必备。

```c
#include <windows.h>
#include <psapi.h>
#include "print.h"
```
#### 查找窗体

先来个全局变量用于储存句柄信息:

```c
HWND hwnd;
```

然后开始查找游戏窗体,以便后续获取 `PID`:

```c
void findWindow(void)
{
    hwnd = FindWindow(NULL, "保卫萝卜Beta");
    if (hwnd == NULL)
    {
      printf("无法获取窗口句柄,请检查进程是否存在!Code: %lu\n", GetLastError());
      exit(0);
    }
}
```

上面的代码加入了判断,如果不存在就报错。

#### 获取PID

拿到句柄以后,可以据此来找到 `PID` ,方便后续访问进程。

还是来个全局变量储存下 `PID`:

```c
DWORD pid;
```

接下来是函数:

```c
void getPID(void)
{
    GetWindowThreadProcessId(hwnd, &pid); // 获取 pid
    if (pid == 0)
    {
      printf("无法获取PID!Code: %lu\n", GetLastError());
      exit(0);
    }
    printf("PID: %lu\n", pid);
}
```

#### 访问进程

接下来根据 `PID` 去访问进程,先定义个全局变量:

```c
HANDLE hProcess;
```

接下来编写对应函数:

```c
void openProcess(void)
{
    hProcess = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION | PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, pid);
    if (hProcess == NULL)
    {
      printf("无法获取句柄ID!Code: %lu\n", GetLastError());
      exit(0);
    }
    printf("hProcess ID: %lu\n", (unsigned long) hProcess);
}
```

值得注意的是,在调用 `OpenProcess` 函数时,传入参数中 `dwDesiredAccess` 项需要添加 `PROCESS_QUERY_INFORMATION` ,否则虽然在 Windown10 上测试正常,但在 Windows7 上会出现 `ErrorCode: 5` 错误,即“拒绝访问”。

其它信息可参考官方文档:(https://docs.microsoft.com/en-us/windows/desktop/ProcThread/process-security-and-access-rights)

#### 内存地址获取与计算

打开进程后,需要进行内存地址读取计算。

##### 模块基址

前文提到的地址都是 `Luobo.exe+XXXXX` 形式,但实际使用中,我们并不知道 `Luobo.exe` 的内存地址,因此第一步就是获取模块基址。

先定义一个结构体用来保存信息:

```c
struct
{
    DWORD moduleBase;
    DWORD module;
    DWORD coinNumBase;
    DWORD coinCheckBase;
    DWORD jumpCheckBase;
} Address;
```

获取模块基址的方法很多,这里主要用到 `EnumProcessModulesEx` 函数,读取出来就是 CE 中的 `Luobo.exe `,是一个指针。

```c
...
HMODULE hModule = {0};
DWORD dwRet = 0;
int bRet = EnumProcessModulesEx(hProcess, (HMODULE *) (hModule), sizeof(hModule), &dwRet, LIST_MODULES_ALL);
if (bRet == 0)
{
    printf("EnumProcessModules Failed.Code: %lu\n", GetLastError());
    exit(0);
}

Address.moduleBase = (DWORD) hModule;// 数组首元素即基地址
...
```

##### 各变量基址

金币基址获取如下。注意,金币的基址本身是一个指针,其值(即金币变量所在的地址)为 `Luobo.exe+0x105E68` 所指向的内容加上 `0x74`。

```c
...
ReadProcessMemory(hProcess, (LPCVOID) (Address.moduleBase + 0x105E68), &Address.coinNumBase, 4, NULL);
Address.coinNumBase += 0x74;
printf("金币基址: 0x%p\n", (void *) Address.coinNumBase);
...
```

`je` 指令的基址则简单些,直接把模块基址加上偏移量就行。

```c
...
Address.jumpCheckBase = Address.moduleBase + 0x24580;
printf("跳转校验基址: 0x%p\n", (void *) Address.jumpCheckBase);
...
```

注:`getAddress();` 函数完整代码请见源码。

#### 内存修改

基址也已经到手,现在“万事俱备,只欠东风”。先把金币的校验破坏掉,就可以随意修改金币了。

##### 金币校验修改

先访问下基址,读取看看是不是找到了关键的那条 `je` 指令。`ReadProcessMemory` 函数将内存 `Address.jumpCheckBase` 处的数值读出,并保存到 `tempBuf` 之中。

```c
void modifyJumpCheck(void)
{
    static const BYTE originalCode[] = {0x74, 0x70};// je 未修改时操作码
    static const BYTE targetCode[] = {0xEB, 0x70};// jmp 修改后的操作码
    BYTE tempBuf = {0};
    ReadProcessMemory(hProcess, (LPCVOID) Address.jumpCheckBase, tempBuf, sizeof(tempBuf), NULL);
    printf("Previous Code: %x %x\n", tempBuf, tempBuf);

...未完
```
接下来是比较,如果 `tempBuf` 的内容和 `originalCode` 的内容一样,说明金币校验还没有被修改,这时我们就可以调用 `WriteProcessMemory` 把 `targetCode` 的内容写入进去。

```c
...接上一代码段
      if (tempBuf == originalCode && tempBuf == originalCode)
    {
      printf("CoinNumCheck patch point found. Trying to patch.\n");
      WriteProcessMemory(hProcess, (LPVOID) Address.jumpCheckBase, targetCode, sizeof(targetCode), NULL);
    } else if (tempBuf == targetCode && tempBuf == targetCode)
    {
      printf("CoinNumCheck has already been patched.\n");
    } else
    {
      printf("Unknown CoinNumCheck patch state.\n");
    }
}
```

至此,金币校验就被破坏了。由于该内存区域在游戏运行过程中不会被再次修改,所以这里在游戏开始运行时修改一次即可达成目的,可谓“一劳永逸”。

##### 金币数量修改

接下来就是修改金币数了。先读取一下当前金币数,如果比目标值(666666)小 1000,则把金币修改为目标值。

```c
void modifyCoinNum(void)
{
    DWORD coinNum_previous = 0;
    ReadProcessMemory(hProcess, (LPCVOID) Address.coinNumBase, &coinNum_previous, 4, NULL);
    printf("Previous Coin Num: %lu\n", coinNum_previous);
    DWORD coinNum_target = 666666;
    if (coinNum_previous < coinNum_target - 1000)
    {
      WriteProcessMemory(hProcess, (LPVOID) Address.coinNumBase, &coinNum_target, 4, NULL);
      printf("Set Coin Num to: %lu\n", coinNum_target);
    }
}
```

由于金币数实时变动,届时可以把该函数放入循环中,实现不间断的监测和修改。

#### main 函数

依次调用上述函数即可:

```c
int main()
{
    findWindow();
    getPID();
    openProcess();
    getAddress();
    modifyJumpCheck();
    while (1)
    {
      modifyCoinNum();
      Sleep(5 * 1000);
    }
    return 0;
}
```



### 0x04 修改器细节优化

截至当前,修改器的基本功能已经完成,接下来是一些细节上的完善,以得到“锦上添花”的效果。

#### SIGNAL捕获

在用户按下 `Ctrl+C` 以及尝试关闭程序、关机、注销等时刻,系统会发送不同的信号导致进程终止,此时我们仍有些打开的句柄没有释放,因此我们可以捕获此类信号,添加自定义的处理流程。

我们可以定义一个函数用来关闭句柄,清理内核对象。(后来了解到,实际上整个进程结束后,内核也会回收这些资源,这里就留作记录)

```c
void sweep(void)
{
    if (hProcess != NULL)
    {
      CloseHandle(hProcess);
      hProcess = NULL;
    }
}
```

然后声明一下自定义的处理流程,在流程中调用上述函数。

```c
BOOL WINAPI CtrlHandler(DWORD fdwCtrlType)
{
    switch (fdwCtrlType)
    {
      // Handle the CTRL-C signal.
      case CTRL_C_EVENT:
            pr_warn("Ctrl-C event\n\n");
            Beep(750, 300);
            loopContinueFlag = FALSE;
            return TRUE;

            // CTRL-CLOSE: confirm that the user wants to exit.
      case CTRL_CLOSE_EVENT:
            Beep(600, 200);
            pr_warn("Ctrl-Close event\n\n");
            sweep();
            _exit(0);

            // Pass other signals to the next handler.
      case CTRL_BREAK_EVENT:
            Beep(900, 200);
            pr_warn("Ctrl-Break event\n\n");
            sweep();
            _exit(0);

      case CTRL_LOGOFF_EVENT:
            Beep(1000, 200);
            pr_warn("Ctrl-Logoff event\n\n");
            sweep();
            _exit(0);

      case CTRL_SHUTDOWN_EVENT:
            Beep(750, 500);
            pr_warn("Ctrl-Shutdown event\n\n");
            sweep();
            _exit(0);

      default:
            return FALSE;
    }
}
```

声明完以后并不是万事大吉,不要忘记将其注册。注册通常放在 `main` 函数之中。

```c
SetConsoleCtrlHandler(CtrlHandler, TRUE);
```

提示:如需取消注册自定义的处理流程,将上一行代码的 `TRUE` 改为 `FALSE` 执行一遍即可。

#### 为程序添加图标

在 `CMakeLists.txt` 所在目录新建 `res` 资源文件夹,在文件夹中放入图标 `logo.ico` ,并新建资源描述文件 `logo.rc`,编写以下内容:

```c
IDI_ICON1 ICON DISCARDABLE "logo.ico"
```

然后在 CMake 中将其添加到目标中。

```cmake
add_executable(LuoBo_Mod main.c res/logo.rc)
```

#### 日志分级与彩色文字

在调试与发布的工程中,日志分级能带来很大的便利。彩色文字则让不同级别的输出更加明显,界面更加美观。

主要过程就是引入一对 `.c/.h` 文件,并添加到 CMake 目标,设置好宏参数,然后把 `printf` 按照所需等级对应替换为 `pr_info`、`pr_warn`、`pr_err`、`pr_bug` 等。(注意,该方法实现的彩色在 Windows7 系统上并不奏效,故可以通过宏参数控制编译出无色彩版本。)

详细过程可以参靠我之前写过的 [这篇文章](https://hui-shao.cn/c-console-print-level "泾渭分明——改造C语言printf启用日志分级输出") ,这里就不详述了。


#### 条件编译控制

CMake 是一个强大的工具,通过更改 CMake 配置文件,我们就可以实现刚才提到的一些条件选项。

首先设置 “彩色版” 和 “无色版” 两个编译目标。

```cmake
add_executable(LuoBo_Mod_Colorful main.c main.h main.h print.h print.c res/logo.rc)# 彩色版本
add_executable(LuoBo_Mod_NoColor main.c main.h main.h print.h print.c res/logo.rc)   # 无色版本
```

接下来就可以根据构建的类型(`CMAKE_BUILD_TYPE`)配置输出等级,根据构建目标(`target`)设置是否启用色彩。

```cmake
# 根据目标配置颜色类型
target_compile_definitions(LuoBo_Mod_Colorful PRIVATE PRINT_COLORFUL=1)
target_compile_definitions(LuoBo_Mod_NoColor PRIVATE PRINT_COLORFUL=0)

# 根据编译类型选择日志等级
if (CMAKE_BUILD_TYPE AND (CMAKE_BUILD_TYPE STREQUAL "Debug"))
    add_compile_definitions(PRINT_LEVEL=LEVEL_DEBUG)
else ()
    add_compile_definitions(PRINT_LEVEL=LEVEL_INFO)
endif ()
```

#### 优化后流程图

添加上细节的优化之后,程序的工作流程如下图。



### 0x05 效果展示

放几张效果图(在 Windows Terminal 运行效果最佳)

#### 游戏界面



#### Debug 彩色版



#### Release 彩色版



#### Release 无色版



### 0xFE 下载地址

项目在 Github 开源:(https://github.com/hui-shao/LuoBo_Mod.git)

对应游戏在我们 52 上就能找到:[【怀旧游戏】保卫萝卜 Beta 绿化版](https://www.52pojie.cn/thread-1257230-1-1.html)

### 0xFF 结束

首次尝试逆向分析和内存修改器制作,若有不对之处恳请指正,不尽感激。

首发于 [个人博客](https://hui-shao.cn) 及 (https://www.52pojie.cn/) 论坛,转载请注明。

hui-shao 发表于 2022-8-26 15:29

xiboliyalang 发表于 2022-8-26 14:56
乌5,太长了,我选择看着你玩

长是因为想写的详细点哈哈。
难度倒是不大。
况且,最后也有成品链接。
打不开的话,这里:https://gitee.com/hui-shao/LuoBo_Mod/releases

iawyxkdn8 发表于 2022-8-26 11:02

真报了你,写这么长,我这懒人,看都不想看!

m4wayne 发表于 2022-9-7 10:09

文章很好,特别是CE部分,讲的很详细{:1_893:}

296250 发表于 2022-8-26 10:44

感谢分享

gzfa 发表于 2022-8-26 10:50

感谢分享。

yx159247 发表于 2022-8-26 11:04

感谢分享!

hui-shao 发表于 2022-8-26 11:04

iawyxkdn8 发表于 2022-8-26 11:02
真报了你,写这么长,我这懒人,看都不想看!

主要还是记录一下,方便学习:lol

sprite2061 发表于 2022-8-26 11:10

感谢分享,写的太详细了

固相膜 发表于 2022-8-26 11:17

物是人非 再也不见当年玩友了

SuPer丨豆子 发表于 2022-8-26 11:17

分析十分详细,感谢楼主分享{:301_974:}

注册个id 发表于 2022-8-26 11:56

修改游戏的文章先收藏再学习
页: [1] 2 3 4 5 6 7 8 9 10
查看完整版本: 保卫萝卜PC版内存修改器分析、制作详细过程