1

原文来源:https://www.hypn.za.net/blog/2020/04/11/hacking-unity-games/
发布日期:2020年4月11日

本翻译也发布在了我的博客园
下一篇:骇入Unity游戏 | Unity游戏破解 | C#注入 (2) - 通过 Frida 控制游戏状态【翻译】

接下来,我要介绍一些破解 Unity游戏 的方法。在 Unity 底层,使用了 Mono,一个用于 DotNet 的跨平台编译器。

在 Unity 中,开发人员可以添加使用 C# 编写的 “脚本”,这些脚本构成部分游戏逻辑——这通常就是我们破解游戏的目标。与“传统”游戏不同,这些“脚本”并不直接编译成一个,(可以让我们)通过查找静态内存偏移进行修改的,.exe 文件。
但「道高一尺、魔高一丈」。

接下来,我会以破解 "198X"(第一部分) 为例,介绍多个破解方法。
这是一个以80年代街机为主题的游戏,包含多个迷你游戏。

1. 关于游戏 | About the game

198X中有几个内置的迷你游戏,我们将对“Beating Heart”和“Shadowplay”进行破解:

img

“Beating Heart”是一款“打击游戏”风格的游戏,具有生命条,当被敌人击中时,你会受到伤害:

img

“Shadowplay”是一款“忍者(跑酷?)”风格的游戏,你有5条生命,与敌人或陷阱碰撞时会受到伤害:

img

这两款游戏恰好使用相同的游戏逻辑来处理伤害,无论是对玩家还是敌人(所以不能简单地将其 NOP 掉)。

2. 使用dnSpy进行探索 | Exploring with dnSpy

如果用不了 dnSpy,比如你使用的是 macOs 或 Linux,那么你可以试一下 ilSpy

dnSpy 是一款“.NET调试器和汇编编辑器”,允许你查看.NET应用程序的源代码。正如前面提到的,Unity游戏是使用Mono编译的,这意味着它们是.NET应用程序。

因为我们对“修改游戏逻辑”感兴趣,而不是去搞乱Unity游戏引擎本身,所以我们要“研究”的是用户的Unity"脚本"。方便的是,这些脚本通常被编译到一个叫做"Assembly-CSharp.dll"或"Assembly-CSharp-firstpass.dll"文件中。在我们正在研究的 198X 游戏中,可以在以下位置找到它们:Steam\SteamApps\common\198X\198X_Data\Managed

在dnSpy中打开“Assembly-CSharp.dll”(选择“File” -> “Open” -> 浏览到“Managed”文件夹并选择文件),然后应该会在左侧的树状视图中添加“Assembly-CSharp.dll”和一些其他UnityEngine项。展开“Assembly-CSharp.dll”,点击“{}”将列出游戏中的类(代码类):

img

从这里开始,我们可以开始查找有用的术语(关键词),以帮助我们找到我们想要破解的内容,例如使用 CTRL+F 搜索 "damage" 会得到一些结果 —— 这里,我们感兴趣的是 "TakeDamage"。

展开树形视图中的 "{}" 会显示所有的类,向下滚动并展开 "TakeDamage" 会显示一个 "Damage(BaseDamage)" 函数... 点击它会显示 "Damage" 函数的代码:

img

3. 骇入游戏 | Hacking the game

1. 使用dnSpy | with dnSpy

注意事项:

这些方法涉及对游戏的"Assembly-CSharp.dll"文件进行更改,以使破解生效——在执行以下操作之前最好备份此文件。这些修改应该会在不同的游戏实例中保留,除非Steam进行更新或修复游戏文件。

您应该能够通过在Steam上右键单击游戏 -> "属性" -> "本地文件" -> "验证游戏文件完整性..."来将文件恢复到其原始状态。

怎么 "Edit Method(编辑方法)":

虽然 dnSpy 确实提供了一些代码编辑功能,但它并不总是有效,并且常常会出现"丢失程序集(Missing assembly)"的错误。在 198X 这个游戏中,它们似乎在.exe中使用了嵌入的Mono,这可能会引起问题。尽管如此,我们仍然可以相当容易地对这个"Damage"函数进行补丁/修改。

在 "Damage" 函数中,它首先执行一些检查,以确定是否应该应用伤害:

if (this.dead || base.invincible || !base.enabled || this.excludeTags.Any((string tag) => damage.CompareTag(tag)))
{
    return;
}

在 dnSpy 的列表中,"Damage" 函数的下面几行有一个名为 "isPlayerCharacter: bool" 的属性——它实际上是一个标志,用于确定是否受到伤害的对象是否是玩家。我们可以通过确保选择了 "Damage" 函数,右键单击右侧代码窗口内部,然后选择"编辑方法(C#)"来修改上面的代码。

在 "if" 的括号内添加 "this.isPlayerCharacter ||",然后点击窗口右下角的 "编译"。现在,"Damage" 函数应该如下所示:

if (this.isPlayerCharacter || this.dead || base.invincible || !base.enabled || this.excludeTags.Any((string tag) => damage.CompareTag(tag)))
{
    return;
}

img

保存更改到文件(File -> Save Module),如果游戏已经打开,重新启动游戏以使更改生效。

怎么 "编辑IL指令":

当更改和编译代码不起作用时(例如,当开发者对代码采取混淆或保护措施时),我们可以通过修改"IL指令"(中间语言操作码)来进行更微妙的低级别更改。要做到这一点,右键单击代码窗口,然后选择"编辑IL指令..."。

img

译者:

ilspy-vscode 中如下图操作
img

AvaloniaILSpy 中如下图操作
img

虽然这个模式下的“视图”更难阅读和操作,但仍然可以识别先前看到的代码中使用的字段,例如在"if"条件中的"this.dead":

6    000E    ldfld    bool TakeDamage::dead

单击单词"dead"会弹出一个包含"Field..."选项的弹出窗口,单击这个选项可以让我们选择一个不同的类属性来替代它。单击"isPlayerCharacter"字段应该会修改代码为:

6    000E    ldfld    bool TakeDamage::isPlayerCharacter

单击"Ok"按钮(右下角)应该会将您返回到代码编辑器,"if"语句将被更改为:

if (this.isPlayerCharacter || base.invincible || !base.enabled || this.excludeTags.Any((string tag) => damage.CompareTag(tag)))
{
    return;
}

这段代码与我们之前的更改不完全相同,但也能会达成我们的目标——赋予我们无敌状态... 尽管存在引入bug的风险,允许"dead"对象受到伤害,希望这不会对游戏产生太严重的影响。

再次强调,您需要保存更改(File -> Save Module)并重新启动游戏以使更改生效。

2. 使用CheatEngine | with CheatEngine

CheatEngine 是一个众所周知的游戏作弊工具,我一直在不断发现它的更多功能。不过它最明显的用途是"内存扫描"(例如:查找内存中"生命值"的内存地址,以允许您设置或冻结它),但它也对 Mono 应用程序/游戏(如使用 Unity 编写的游戏)有一些很好的支持。在安装时要小心,防止安装工具栏或其他可能尝试安装的垃圾软件。

打开 CheatEngine,然后单击 "打开进程(Select a process to open)" 按钮(左侧的第一个按钮,应该会列出所有正在运行的进程。选择 198X 进程,CheatEngine 应该会添加一个新的 "Mono" 菜单项。

img

在这个菜单项下有一个 "分析 mono(Dissect mono)" 选项,它提供了类似于 dnSpy 的功能。
弹出的窗口也提供了一个树形视图,显示了加载的"assemblies"(程序集)——对于我们来说,需要关心的是 "Assembly-CSharp.dll" 程序集/文件。与 dnSpy 一样,程序集包含的 类 应该会被列出... 包括我们之前查看过的 "takeDamage" 类,其中还列出了其"fields"(字段)和"methods"(方法):

img

请注意,在"fields"(字段)下,我们可以看到:

Note that under "fields" we can see:

1c : isPlayerCharacter (type: System.Boolean)
...
29 : dead (type: System.Boolean)

右键单击 "Damage" 函数并从弹出菜单中选择 "Jit" 会显示该函数的汇编代码:

TakeDamage:Damage - 55                    - push ebp
TakeDamage:Damage+1- 8B EC                 - mov ebp,esp
TakeDamage:Damage+3- 53                    - push ebx
TakeDamage:Damage+4- 56                    - push esi
TakeDamage:Damage+5- 83 EC 50              - sub esp,50 { 80 }
TakeDamage:Damage+8- 8B 75 08              - mov esi,[ebp+08]
TakeDamage:Damage+b- C7 04 24  B05CF90F    - mov [esp],0FF95CB0 { (0FF97160) }
TakeDamage:Damage+12- 90                    - nop 
TakeDamage:Damage+13- E8 10A64200           - call System:Object:__icall_wrapper_ves_icall_object_new_specific
TakeDamage:Damage+18- 8B D8                 - mov ebx,eax
TakeDamage:Damage+1a- 8B 4D 0C              - mov ecx,[ebp+0C]
TakeDamage:Damage+1d- 89 48 08              - mov [eax+08],ecx
TakeDamage:Damage+20- 0FB6 46 29            - movzx eax,byte ptr [esi+29]
TakeDamage:Damage+24- 85 C0                 - test eax,eax
TakeDamage:Damage+26- 0F85 5A010000         - jne TakeDamage:Damage+186
...

在 dnSpy 中看过 "Damage" 代码后,我们知道其包含一个 "if" 语句,会去检查 "this.dead",在 "fields"(字段)列表下显示的"dead" 偏移位置是 "29"。而在上面代码列表的倒数第三行我们能看到 "esi+29",那很有可能就是在引用它。

在 CheatEngine 的 "Memory Viewer" 中右键单击代码行 "movzx eax,byte ptr [esi+29]" 并选择 "Assemble(汇编)" 选项,让我们可以修改代码:

img

esi+29 更改为 esi+1c 会使逻辑检查受到伤害的对象是否是玩家,而不是受到伤害的对象是否已死亡(就像我们在上面修改"IL指令"时所做的那样):

...
TakeDamage:Damage+20- 0FB6 46 1c            - movzx eax,byte ptr [esi+1c]
...

这会立即生效(但只是暂时的,等你重新启动游戏就没了)。幸运的是,CheatEngine 支持保存和加载”作弊文件“,以及一个脚本语言来动态查找和更改内存值。

虽然我不会在本文中介绍CheatEngine作弊脚本,但以下脚本可以自动查找并修补"TakeDamage.Damage":

{$STRICT}
define(bytesOn, 0F B6 46 1C)
define(bytesOff,0F B6 46 29)

[ENABLE]
{$lua}
LaunchMonoDataCollector()

{$asm}
TakeDamage:Damage+20:
  db bytesOn

[DISABLE]
TakeDamage:Damage+20:
  db bytesOff

基本上,它启用了 Mono 功能,然后查找 "TakeDamage:Damage" 并改写汇编指令,使用了"dead"或"isPlayerCharacter"的偏移。您可以通过按下 Ctrl+Alt+A 打开"自动汇编"窗口,粘贴脚本,然后选择 "File(文件)" -> "Assign to current cheat table(分配给当前作弊表)" 并关闭窗口,来将脚本添加到 CheatEngine 中。

启用作弊(勾选复选框)应该会修补内存地址,而禁用作弊(取消勾选)应该会恢复原状。双击描述以进行更改,并使用 "File(文件)" -> "Save(保存)" 来创建一个 ".ct" 文件,这样之后,就可以加载和在游戏的其他实例中使用(或分发给其他人使用)。

3. 使用Frida | with Frida

如上所提到的,我习惯使用的更传统的游戏破解方法(使用固定偏移量)在这里不起作用... Unity似乎动态加载"Assembly-CSharp.dll" 文件到通用内存中,而不是将其作为库加载,这使得很难找到要打补丁的偏移位置。

因此,我需要在内存中扫描字节以找到应用补丁的位置,而 Frida 已经提供了这方面的功能。深入去研究 Frida 也是值得的,因为它在破解移动应用程序和游戏(例如 Objection )时非常有用。

还可以使用 Frida 以函数为单位钩住(hook) Mono 程序(请参阅 frida-mono-api,尽管我还没有使它正常工作,没能够像下面的代码一样起作用)。鉴于 Xamarin 也使用 Mono 进行跨平台编译的移动应用程序,这可能特别有用。

以下是一个 Frida 脚本,可以找到并在内存中打补丁的字节:

const invulnerability = {
  pattern: '89 48 08 0F B6 46 ?? 85 C0',  // TakeDamage:Damage+20  -  0F B6 46 1C  -  movzx eax,byte ptr [esi+1C]
  offset: 6,
  disabled: [0x29],
  enabled: [0x1C]
}

const findOffset = function(pattern) {
  // find every memory range
  var ranges = Process.enumerateRanges("r");
  for (var i = 0; i < ranges.length; i++) {
    var range = ranges[i];

    // and check each of them for our pattern
    var results = Memory.scanSync(range.base, range.size, pattern);
    if (results[0]) {
      // convert the (first) returned address to int before adding to it, so JavaScript doesn't contact :facepalm:
      return parseInt(results[0].address, 16);
    }
  }
}

// used for patching memory
const patch = function(pattern, skipBytes, bytes) {
  var offset = findOffset(pattern) + skipBytes;
  var pointer = new NativePointer(offset);
  return pointer.writeByteArray(bytes);
}

const enablePatch = function() {
  var result = patch(invulnerability.pattern, invulnerability.offset, invulnerability.enabled);
  if (result) {
    console.log('Patch enabled! (address ' + result + ')');
  }
}

const disablePatch = function() {
  var result = patch(invulnerability.pattern, invulnerability.offset, invulnerability.disabled);
  if (result) {
    console.log('Patch disabled. (address ' + result + ')')
  }
}

它可以通过命令行加载,使用以下命令:frida 198X.exe -l patch.js,然后在Frida中运行 enablePatch()

img

4. 其他内容 Other

非常感谢@leonjza,他推荐了这个精彩的游戏给我,用他的 Frida 技巧提供了帮助,并一直在鼓励我 :D

虽然上面的方法只破解了 5 个迷你游戏中的 2 个("Beating Heart" 和 "Shadowplay",它们使用相同的 "TakeDamage" 类)的无敌效果,但借助上面的信息,相对容易地破解了(某些)其他游戏:

Out of the Void:这个游戏比较棘手,我的破解方法大部分都出了问题,抱歉
The Runaway:可以从 CarController.OnCollisionCarController.SetSpeed 入手
Kill Screen:可以从 RPGController:EnemyAttack 入手


RDDcoding
151 声望17 粉丝

一心一行