原文来源:https://www.hypn.za.net/blog/2020/04/19/hacking-unity-games-p...
原文发布日期:2020年4月19日
我在上一篇"破解Unity游戏"的文章探讨了一些用于破解Unity游戏的工具和方法。这些方法都涉及到修改游戏逻辑,要么是在游戏的磁盘文件中,要么是在内存中的代码。游戏的一次更新可能会使所有这些方法失效,因为它们会替换磁盘上的文件或导致内存中搜索的字节/偏移量发生变化。
通过再次使用Frida,我们可以将一些自定义的JavaScript注入到游戏中,并访问Mono(Unity游戏编译时使用的技术)函数以实现更好的破解效果。这是通过使用"frida-inject"注入我们的代码以及"frida-mono-api"包与Mono进行交互来实现的。
我将会再次破解游戏"198X",并重新实现之前的"无敌"破解以及其他一些破解。经过几次迭代的脚本编写来破解迷你游戏,以及数小时的试错、搜索和查看偏移量和内存值,我最终创建了一个JavaScript库来完成一些繁重的工作。
这个名为"enumerator.js"的脚本不太有创意,它用于列举给定的Unity/Mono类的所有函数名称和属性,并提供了"getter"和"setter"方法,以便轻松地操作该类的实例。
要想知道这些类的名称,仍然需要使用 dnSpy(您可能仍然想要使用它来查找您想要破解的游戏逻辑)——这主要是因为"frida-mono-api"库中还没有实现所需的一些Mono功能。
译者:除了 dnSpy 外,还可以使用 ilSpy。
注意:注入脚本的输出(例如:console.log()
)会打印在运行注入器脚本的控制台窗口中。
安装/设置
要想运行我提供的 "Enumerator"脚本 和 破解脚本
- 克隆我的 "unity-frida-hacks" 存储库
安装 NodeJS 13(NodeJS 14 LTS也可以,但NodeJS 15存在问题)
译者:似乎需要用 NodeJS 14.15.0
- 安装 Frida
- 并安装 Npm 库:
npm install frida-mono-api
npm install frida-inject
但是... 还不止这些 :/ 您还需要根据待处理的 “拉取请求(Pull Request)” 修改两个文件(但是你直接使用完整的 “拉取请求(Pull Request)” 分支会出现问题)...
- 将你的 "node_modules\frida-mono-api\src\mono-api.js" 替换为 https://raw.githubusercontent.com/GoSecure/frida-mono-api/ext...
- 将你的 "node_modules\frida-mono-api\src\mono-api-helper.js" 替换为 https://raw.githubusercontent.com/GoSecure/frida-mono-api/ext...
希望做完上面这些步骤后一切正常工作。
如上所述,这一切都是通过将 JavaScript 注入到游戏中来实现的——也就是说,我们需要一个"注入器"脚本,在我的存储库中有一个 injector.js 脚本,支持命令行参数,利于复用。像下面这样一个简单的脚本,将打印出之前文章中关注的 "TakeDamage" 类的所有信息:
import Enumerator from './enumerator.js'
// mono class we want to enumerate
var takeDamage = Enumerator.enumerateClass('TakeDamage');
// print it out
Enumerator.prettyPrint(takeDamage);
(通过 node injector.js 198X.exe enumerator-test.js
来运行)
译者:
如果你在使用 injector.js 进行注入过程中出现问题,包括并不限于以下问题:
- injector.js 相关错误
- frida-inject 相关错误
- frida 相关错误
你可以通过 rollup 将 enumerator-test.js 以 --format iife 打包成 enumerator-test.bundle.js 后
通过 frida 198X.exe -l enumerator-test.bundle.js 来运行。更具体的细节可以参考本人基于原文博主的仓库修改后的方案
然后就会打印出 "TakeDamage" 类的信息:
{
"address": "0xe8733e0",
"methods": {
...
"Damage": {
"address": "0xe887610",
"jit_address": "0x1082bd20"
},
...
},
"fields": {
...
"isPlayerCharacter": {
"address": "0xe8873d0",
"offset": "0x1c",
"type": "boolean"
}
}
}
我们要特别注意这些函数的 jit_address
,因为在之前的文章中,我们都用到了这个值——无论是在 CheatEngine 脚本中,还是在内存中用来搜索特定字节——以便进行修补。现在我们可以用更程序化的、且不需要通过 CheatEngine 的方式来找到这个地址...... 但这里还有改善空间。
我提供的 Enumerator 中的一些代码肯定会存在错误或不可靠,代码中还包含了一些完全基于运气和假设的神奇偏移量——在使用时请小心 :P
破解"Beating Heart"、"Out of the Void"和"Shadowplay"
这三个迷你游戏都使用相同的逻辑,因此我们可以一次性破解。在之前的文章中,我都修改了 if (this.dead ...
逻辑检查,修改了检查的字段,使伤害逻辑被绕过。
与之前修补游戏代码的实现方式不同,而新提供的脚本将动态修改接收伤害的游戏对象,以绕过逻辑。
var takeDamage = Enumerator.enumerateClass('TakeDamage');
MonoApiHelper.Intercept(takeDamage.address, 'Damage', {
onEnter: function(args) {
this.instance = args[0];
// check if the player is receiving damage, and if so then set "dead" flag
// (damage code is skipped if the object receiving it is flagged as "dead")
var playerCheck1 = takeDamage.getValue(this.instance, 'isPlayerCharacter'); // for "beating heart" and "shadowplay"
var playerCheck2 = (takeDamage.getValue(this.instance, 'maxHealth') === 3); // for "out of the void"
if (playerCheck1 || playerCheck2) {
takeDamage.setValue(this.instance, 'dead', true);
this.resetDeadFlag = true; // tell "onLeave" (below) to reset this
}
},
onLeave: function(retval) {
if (this.resetDeadFlag) {
takeDamage.setValue(this.instance, 'dead', false);
}
}
});
这段代码枚举 (enumerates) 了 TakeDamage
类(获取了之前需要从 CheatEngine 中获取的字段偏移和其他信息),然后在 Damage
函数上设置了一个 Frida 的 interceptor
(拦截器)。
在这个拦截器中传递给 Damage
函数的参数可以从本地的 args
变量中获取,而 args[0]
是指向 TakeDamage
类实例的指针。
不幸的是,与常规的 Frida interception
(拦截) 不同,直接修改一个 Mono
函数的 args
值(或者在 onLeave
中 retval
)不仅不能够影响游戏,还会导致错误——所以我们没法这样做。
尽管如此,当对象是玩家的角色时,我们可以通过修改对象的 dead
属性,来绕过游戏逻辑。而这些都是动态完成的,没有硬编码的地址或偏移量,因此这个破解应该能在不太改变逻辑的情况下“经受”住游戏的一般更新。
值得注意的是 this
变量在 onEnter
和 onLeave
函数之间共享,你可以通过它轻松地共享一些状态。
通过 Enumerator
这个类的 getValue()
和 setValue()
函数可以从实例基地址获取相关偏移量以及数据类型,基于此,我们可以轻松读取和写入所需值。
破解 "The Runaway"
这是一个迷你赛车游戏,关注于时间和速度而不是伤害。它也显露了 Enumerator
类 的一个严重缺点...我想修改一个子类 (RoadRenderer.Sprite
) 的属性,但 Enumerator
类 找不到偏移量,所以我不得不走老路,采用硬编码的偏移 :/
在这次破解中,我去掉了车子偏离道路时的减速逻辑,去掉了与其他汽车或障碍物碰撞时的速度损失,还屏蔽了障碍物引发的"失控"。这些破解都基于在 dnSpy
中阅读的游戏源码(前一篇文章中有介绍)。
var carController = Enumerator.enumerateClass('CarController');
MonoApiHelper.Intercept(carController.address, 'SetSpeed', {
onEnter: function(args) {
this.instance = args[0];
// prevent going "off-road" from reducing speed
// (the offRoadDeceleration value is subtracted from current speed)
carController.setValue(this.instance, 'offRoadDeceleration', 0.0);
}
});
MonoApiHelper.Intercept(carController.address, 'OnCollision', {
onEnter: function(args) {
this.instance = args[0];
// prevent collisions with other cars from reducing speed
// (current speed is multiplied by collisionSpeedLoss, set it to 1 to prevent it from changing)
carController.setValue(this.instance, 'collisionSpeedLoss', 1.0);
// prevent collisions with objects form causing a "wipeout"
// (the "shouldCauseWipeout" property of the sprite is checked to determine this, set it to false to prevent wipeouts)
//
// NOTE: a "RoadRenderer.Sprite" object is passed in to "OnCollision"
// this "Enumerator" can't find the nested sprite class, so this has to be done manually...
// the "0x44" offset is from CheatEngine, and we add it to the sprite address to reference "shouldCauseWipeout"
var spriteAddr = parseInt(args[1]);
var wipeoutAddr = spriteAddr + 0x44;
Enumerator.setFieldValue(wipeoutAddr, 'boolean', false);
}
});
注意: 这不会使"不发生一次碰撞完成The Runaway"成就变得更容易,碰撞仍然会发生并被计算,你只是不会减速。
破解 "Kill Screen"
这个迷你游戏是一款RPG地牢探险风格的游戏。玩家受到的伤害是由 RPGController
类中的 EnemyAttack
函数完成。传递给这个函数的第一个参数是正在造成的伤害量,但如前所述,我们不能直接把这个改为零来防止伤害。
我决定不去"阻止"伤害,而是去"撤销"伤害——在受伤之前读取玩家的血量,然后在受伤后将值设置回去。
在onLeave
(EnemyAttack
函数的)中修改血量就能够达到目的,游戏会认为玩家仍然处于满血状态。但在这个函数执行完毕之前,UI已经更新并显示受伤害后的血条。
为了解决这个问题,我选择在UI更新函数(Status
类中的UpdateStatusText
)中重置玩家的生命值...这意味着我不能使用 this
变量来共享数值,因为不同的 onEnter
函数的作用域并不共享它们的 this
变量,所以我使用了全局变量。
var rpgController = Enumerator.enumerateClass('RPGController');
var status = Enumerator.enumerateClass('Status'); // used to update on-screen RGP text (eg: health)
MonoApiHelper.Intercept(rpgController.address, 'EnemyAttack', {
onEnter: function(args) {
this.instance = args[0];
var damage = parseInt(args[1]);
// get the current health value, to set health back to after damage
// (we can't change the incoming damage value to 0 unfortunately)
var health = rpgController.getValue(this.instance, 'health');
// we could set the health back in the "onLeave" for "EnemyAttack", but then the health displayed in-game looks like we took damage
// instead we'll reset the health before the UI (and displayed health) is updated so it can stay at full health
globalState.contollerAddress = this.instance;
globalState.healthWas = health;
globalState.updateHealth = true;
}
});
MonoApiHelper.Intercept(status.address, 'UpdateStatusText', {
onEnter: function(args) {
// make sure we want to update health (NOT during game start or level up)
if (globalState.contollerAddress && globalState.updateHealth) {
// set the health back to what it was previously - before the UI update
// using the RPG Controller's address, rather than this "Status" object's instance address!
var health = rpgController.setValue(globalState.contollerAddress, 'health', globalState.healthWas);
// clear the flag for future level-ups or game restarts
globalState.resetHealth = false;
}
}
});
结论
我上面提到的所有破解都包含在我 GitHub 上的 198X-hacks.js 脚本中。其中还有一个对游戏主菜单的挂钩,用于检测玩家何时开始其中一个游戏:
像这样编写游戏函数的挂钩,使我们能够更方便地编写游戏机器人——它可以监听游戏内发生的事件,并更轻松地访问游戏状态,从而触发机器人逻辑——或者在“速通挑战”过程中,用来检测何时完成了某个关卡(或某个迷你游戏)。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。