In this post I'm going to explore a few ways to hack games written using Unity. Under the hood Unity makes use of "Mono" which is a cross-compiler for DotNet.
Within the Unity engine, developers can add "scripts" (written in C#) which make up some of the game logic - these will often be our target. Unlike more traditionally compiled games, these "scripts" are not simply compiled into the .exe where we can find a static memory offset to patch... but we do have some other options.
The game we're going to hack is called "198X" (part 1), an 80s-arcade themed game with several mini-games.
- About the game
- Exploring with dnSpy
- Hacking the game
- Other
1. About the game
198X has several mini games built in, we're going to be hacking "Beating Heart" and "Shadowplay":
"Beating Heart" is a "beat-em-up" style game with health bars and you take damage when hit by enemies:
"Shadowplay" is a "ninja (runner?)" style game in which you have 5 lives and take damage when colliding with enemies or traps:
Both happen to use the same game logic for dealing with damage, for both the player and enemies (so can't just be NOP'ed out)
2. Exploring with dnSpy
dnSpy is a ".NET debugger and assembly editor", which allows you to view the source of .NET applications. As mentioned above, Unity games are compiled with Mono, meaning they're .NET apps.
Because we're interested in cheating the game's logic, and not necessarily messing with the Unity game engine itself, we're after the user's Unity "scripts". Conveniently these are typically compiled in to a "Assembly-CSharp.dll" or "Assembly-CSharp-firstpass.dll" file. In the case of the game 198X we're looking at, they can be found in: Steam\SteamApps\common\198X\198X_Data\Managed
Opening "Assembly-CSharp.dll" in dnSpy (File -> Open -> browse to the "Managed" folder and select the file), should then add "Assembly-CSharp.dll" and a few other UnityEngine items to the treeview on the left. Expanding "Assembly-CSharp.dll" to, and clicking on, "{}" will list the (code) classes in the game:
From here we can start looking for useful terms that might help us find what we want to hack, for example searching (CTRL+F) for "damage" has a few results - the one we are interested in is "TakeDamage".
Expanding the "{}"" in the treeview gives a list of all the classes, scrolling down to and expanding "TakeDamage" reveals a "Damage(BaseDamage)" function... clicking on it shows the "Damage" function's code:
3.1. Hacking with dnSpy
Notes:
These methods involve making changes to the game's "Assembly-CSharp.dll" file in order for the hacks to work - it's probably a good idea to back up this file before doing any of the below. These modifications should persist through different instances of the games unless Steam updates or repairs the game files.
You should be able to revert the file to its original state in Steam by right clicking on the game -> "Properties" -> "Local Files" -> "Verify Integrity of Game Files..."
The "Edit Method" route:
While dnSpy does provide some code editing functionality, it doesn't always work and can often have "Missing assembly" errors. In the case of 198X they seem to be using Mono embedded in the .exe, which can cause problems. That said, we can still patch this "Damage" function fairly easily.
The first thing the "Damage" function does is perform a few checks to determine if damage should be applied:
if (this.dead || base.invincible || !base.enabled || this.excludeTags.Any((string tag) => damage.CompareTag(tag)))
{
return;
}
In the dnSpy listing, a few lines below the "Damage" function, is a "isPlayerCharacter: bool" property - literally a flag as to whether the object taking damage is the player or not. We can modify the above code by making sure the "Damage" function is selected, right clicking inside the code window on the right, and choosing "Edit Method (C#)".
Add this.isPlayerCharacter ||
inside the brackets of the "if" and click "Compile" (bottom right of the window). The damage function should now look like this:
if (this.isPlayerCharacter || this.dead || base.invincible || !base.enabled || this.excludeTags.Any((string tag) => damage.CompareTag(tag)))
{
return;
}
Save the changes to file (File -> Save Module), and restart the game if open, for them to take effect.
The "Edit IL Instructions" route:
When changing and compiling the code doesn't work (eg: when obfuscation or protections have been put in place), we can make more subtle lower-level changes by modifying the "IL Instructions" (intermediate language opcodes). To do this right click in the code window and choose "Edit IL Instructions..."
While this view is much harder to read and work with, it's possible to identify fields used in the previously seen code, such as the "this.dead" in the "if" condition:
6 000E ldfld bool TakeDamage::dead
Left clicking on the word "dead" should give a popup with a "Field..." option, and clicking this lets us pick a different class property to use in its place. Clicking the "isPlayerCharacter" field should modify the code to:
6 000E ldfld bool TakeDamage::isPlayerCharacter
Clicking the "Ok" button (bottom right) should return you to the code editor with the "if" statement changed to:
if (this.isPlayerCharacter || base.invincible || !base.enabled || this.excludeTags.Any((string tag) => damage.CompareTag(tag)))
{
return;
}
This code is not quite the same as our previous change, but should have the same result - granting us invulnerability... at the risk of introducing a bug allowing "dead" objects to take damage, which hopefully won't affect anything too serious.
Once again, you'll need to save the changes (File -> Save Module) and restart the game for them to take effect.
3.2. Hacking the game with CheatEngine
CheatEngine is a well known game-cheating tool, which I keep discovering more and more functionality in. While it's most obvious use case is "memory scanning" (eg: to find the memory address of a "health" value in memory, allowing you to set or freeze it) it also has some great support for Mono apps/games (like those written in Unity). When installing just watch out for toolbars or other crapware it may try and get you to install.
Opening CheatEngine and then clicking the "Select a process to open" button (first button on the left) should list all running processes. Select the 198X process, and CheatEngine should add a new "Mono" menu item.
Under this menu item is a "Dissect mono" option, which gives us some dnSply-like functionality. The window that pops up also gives a treeview, showing the loaded "assemblies" - we're concerned with the "Assembly-CSharp.dll" assembly/file. As with dnSpy, the classes should be listed... including the "takeDamage" class we looked at previously, with its "fields" and "methods" also listed:
Note that under "fields" we can see:
1c : isPlayerCharacter (type: System.Boolean)
...
29 : dead (type: System.Boolean)
Right clicking on the "Damage" function and choosing "Jit" from the popup menu shows the assembly code for that function:
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
...
Having seen the "Damage" code in dnSpy, we know there's an "if" which looks at "this.dead", which is shown at offset "29" in the field list. We can also see "esi+29" in the 3rd last line of the code listing above, which likely references it.
Right clicking on the "movzx eax,byte ptr [esi+29]" line of code in CheatEngine's "Memory Viewer" and choosing "Assemble" lets us modify the code:
Changing the esi+29
to esi+1c
makes the logic check if the object taking damage is the player, rather than if the object is dead (as we did when modifying the "IL Instructions" above):
...
TakeDamage:Damage+20- 0FB6 46 1c - movzx eax,byte ptr [esi+1c]
...
This takes effect immediately (but only temporarily, until you restart the game). Luckily CheatEngine supports saving and loading cheat files, as well as a scripting language to dynamically find and change memory values.
While I won't be covering CheatEngine cheat scripts in this post, the following script automatically finds and patches the "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
Basically it enables the Mono features, then finds "TakeDamage:Damage" and re-writes the assembly instruction using either the "dead" or "isPlayerCharacter" offset. It can be added to CheatEngine by pressing Ctrl+Alt+A to open the "Auto assemble" window, pasting it in, then "File" -> "Assign to current cheat table" and closing the window.
Enabling the cheat (checking the box) should patch the memory address, and disabling (unchecking) it should revert it. Double click the description to change it, and use "File" -> "Save" to create a ".ct" file you can load and use for other instances of the game (or to distribute).
3.3. Hacking the game with Frida
As vaguely mentioned above, the more traditional game hacking methods I'm used to (working with fixed offsets) don't work here... Unity seems to load the "Assembly-CSharp.dll" file dynamically in to general memory, rather than loading it as a library, making it tricky to find the offset to patch.
As a result I needed to scan for bytes in memory to find where to apply the patch, which Frida already provides functionality for. It's also worth exploring Frida as it has many uses when hacking mobile applications and games (eg: Objection).
It is also possible to hook Mono functionality with Frida (see frida-mono-api, though I haven't gotten it working properly, or to a more usable place than the code below, yet). This could be particularly useful given that Xamarin is used for cross-compiling mobile applications using Mono.
Below is a Frida script capable of finding and patching bytes in memory:
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 + ')')
}
}
It can be loaded from the command like with frida 198X.exe -l patch.js
, and then running enablePatch()
in Frida:
4. Other
Big thanks to @leonjza for telling me about this great game, helping with his Frida wisdom, and nudging me on :D
While the above only provides invulnerability for 2 of the 5 mini games ("Beating Heart" and "Shadowplay" which use the same "TakeDamage" class), with the above info it's relatively easy to hack (some) of the remaining games:
- Out of the Void: this one was more tricky and my hacks mostly bugged out, sorry
- The Runaway: look at
CarController.OnCollision
andCarController.SetSpeed
- Kill Screen: look at
RPGController:EnemyAttack