原文来源: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”进行破解:
“Beating Heart”是一款“打击游戏”风格的游戏,具有生命条,当被敌人击中时,你会受到伤害:
“Shadowplay”是一款“忍者(跑酷?)”风格的游戏,你有5条生命,与敌人或陷阱碰撞时会受到伤害:
这两款游戏恰好使用相同的游戏逻辑来处理伤害,无论是对玩家还是敌人(所以不能简单地将其 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”,点击“{}”将列出游戏中的类(代码类):
从这里开始,我们可以开始查找有用的术语(关键词),以帮助我们找到我们想要破解的内容,例如使用 CTRL+F 搜索 "damage" 会得到一些结果 —— 这里,我们感兴趣的是 "TakeDamage"。
展开树形视图中的 "{}" 会显示所有的类,向下滚动并展开 "TakeDamage" 会显示一个 "Damage(BaseDamage)" 函数... 点击它会显示 "Damage" 函数的代码:
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;
}
保存更改到文件(File -> Save Module),如果游戏已经打开,重新启动游戏以使更改生效。
怎么 "编辑IL指令":
当更改和编译代码不起作用时(例如,当开发者对代码采取混淆或保护措施时),我们可以通过修改"IL指令"(中间语言操作码)来进行更微妙的低级别更改。要做到这一点,右键单击代码窗口,然后选择"编辑IL指令..."。
译者:
在
ilspy-vscode
中如下图操作在
AvaloniaILSpy
中如下图操作
虽然这个模式下的“视图”更难阅读和操作,但仍然可以识别先前看到的代码中使用的字段,例如在"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" 菜单项。
在这个菜单项下有一个 "分析 mono(Dissect mono)" 选项,它提供了类似于 dnSpy 的功能。
弹出的窗口也提供了一个树形视图,显示了加载的"assemblies"(程序集)——对于我们来说,需要关心的是 "Assembly-CSharp.dll" 程序集/文件。与 dnSpy 一样,程序集包含的 类 应该会被列出... 包括我们之前查看过的 "takeDamage" 类,其中还列出了其"fields"(字段)和"methods"(方法):
请注意,在"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(汇编)" 选项,让我们可以修改代码:
将 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()
:
4. 其他内容 Other
非常感谢@leonjza,他推荐了这个精彩的游戏给我,用他的 Frida 技巧提供了帮助,并一直在鼓励我 :D
虽然上面的方法只破解了 5 个迷你游戏中的 2 个("Beating Heart" 和 "Shadowplay",它们使用相同的 "TakeDamage" 类)的无敌效果,但借助上面的信息,相对容易地破解了(某些)其他游戏:
Out of the Void:这个游戏比较棘手,我的破解方法大部分都出了问题,抱歉
The Runaway:可以从 CarController.OnCollision
和 CarController.SetSpeed
入手
Kill Screen:可以从 RPGController:EnemyAttack
入手
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。