Thursday, September 24, 2026

Game Hacking in Unity Games on Android

Dio.png

Introduction

This article grew out of a talk I gave at BSides São Paulo 2025. It’s one of the things I’m proudest of having done outside the scope of commercial pentesting, purely out of curiosity and for fun, and I wanted to give it a bit more reach than a talk gets, so I decided to write the whole thing down properly.

The idea is simple: anything that lives on the client side (memory, files, logic, keys) can be read and, with a little patience, modified. I’m going to show that in practice, on a real case: Vampire Survivors for Android, a game built in Unity and compiled with IL2CPP.

Vampire Survivors was just the example I picked for the demo, but it was one of dozens of games that my friend Marzano and I tested with the same approach, and they all played out in a similar way. For that we used the frida-il2cpp-bridge module, which works on any Unity game compiled with IL2CPP on Android.

Before we go any further: everything here was done on a single-player, offline game, on my own emulator, for educational purposes. Don’t use this on online or competitive games, or in any scenario that harms other people or violates a service’s terms of use. And if you enjoy the game, buy it: it’s cheap and the folks who made it deserve it. (Poncle, if you’re reading this, please don’t sue me, it was all done with love <3)

How this started

None of this came out of a neat plan. I started out just wanting to hack an Android game somehow, any game, any way. I had no defined goal and no idea where to begin, just the itch.

It was a conversation with Marzano that gave it direction: he introduced me to frida-il2cpp-bridge and told me he’d been testing it and was already pulling some results out of it. That was the push I needed.

From there I started streaming the process live on my Twitch, and “process” here is an elegant euphemism for a lot of trial and error, a lot of debugging, a lot of scripts that wouldn’t run and a lot of the game freezing in front of everyone. But bit by bit the pieces started fitting together: I began to see the classes, the functions and the parameters, and to understand how the application was put together on the inside.

Twitch.png

Once the structure got clearer, the fun part began. I started hooking functions to change the game’s behavior at runtime: making the character immortal by having the damage function return false, poking at values here and there, seeing what broke.

It was messing around with that that led me to the question this article came from: okay, I can change the game while it’s running, but what about the money? And the locked characters? How does any of that get saved?

To get at those two pieces of data there were three classic routes:

  1. Change values in memory, hunting for the address where the money is stored (the Cheat Engine/GameGuardian style of approach).
  2. Hook the game’s functions at runtime, which was exactly what I’d already been doing: effective, immediate and completely volatile.
  3. Go straight at the game’s “database”, the file where progress is saved, and rewrite the data however I wanted, a change that survives closing the game.

I took the third route, and that’s where things got serious: the save has a checksum that blocks editing the file directly. Instead of reverse engineering the algorithm, I used Frida to find out which function generates that checksum and simply asked the game itself to compute the hash for the save I’d built. And in the end I even found out you can generate that checksum without the game running at all.

Background: Unity, Mono and IL2CPP

Unity games are written in C#. At build time, the developer picks one of two scripting backends, and that choice completely changes how hard our life is going to be:

  • Mono: the C# is compiled to IL (.NET bytecode) and shipped as DLLs (Assembly-CSharp.dll, for example). Those DLLs open up almost like source code in tools such as dnSpy or ILSpy. This is the easy scenario.
  • IL2CPP: the IL is converted to C++ and compiled to native code, which becomes the libil2cpp.so library. Class, method and field names go into a separate metadata file, global-metadata.dat. This is the annoying scenario, and it’s what Vampire Survivors uses.

With IL2CPP, static analysis is a lot more work: instead of readable DLLs, we get one giant ARM/x86 binary. The good news is that the IL2CPP runtime has to know every class and method at runtime, and it exposes functions (il2cpp_domain_get, il2cpp_class_get_methods and friends) to navigate all of it.

That’s where frida-il2cpp-bridge comes in: a Frida module that uses those runtime APIs to list assemblies, classes and methods, trace calls, replace implementations and invoke methods, all at runtime, without needing global-metadata.dat. It turns the opaque binary into something you can actually talk to.

The target and the plan

Vampire.png

Vampire Survivors is a delightfully simple roguelite: you only control your character’s movement, and they attack on their own. Hordes of monsters show up and, as you kill them, you earn experience and coins. With coins you buy upgrades and new characters, which start out locked behind dozens of different quests.

So the two pieces of data I was after were the coin count and the list of unlocked characters. With the question finally turned into a goal, the plan ended up with five steps, and each one becomes a section from here on:

  1. find out where the game saves progress,
  2. pull the save file and find the interesting fields (and the protection),
  3. find the right assembly inside the game,
  4. trace the calls until I find who computes the checksum,
  5. build the modified save and get the game itself to sign it.

Setting up the environment

The setup was on Windows. Here’s a summary of what I used:

Tool Version What for
Windows 11 Host
MEmu Play 9 Android emulator (ships with root)
ADB (platform-tools) latest (Dec 2024) Talking to the emulator
Frida / frida-tools 16.5.9 Dynamic instrumentation
frida-server 16.5.9 (android-x86_64) Frida’s agent inside Android
Node.js (via fnm) 22 TypeScript scripts
frida-il2cpp-bridge 0.9.1 IL2CPP API for Frida
Python 3.x Generating the checksum outside the game (bonus)

I tried quite a few emulators and MEmu was the only one that could handle the game with Frida attached without freezing. Android Studio’s emulator is still great for pentesting “normal” apps (the ones that aren’t games), where debugging runs smooth, but with a game it got way too heavy. MEmu has another advantage: it ships with root, so I didn’t waste time figuring out how to root the image. I don’t know whether it comes with free malware on the side, but I used it anyway, hahaha.

root.png

With the emulator up, it’s time to get frida-server running on it. Download the server from the Frida releases page in the same version as the Frida running on your host and for the emulator’s architecture (on MEmu, x86_64), unpack the .xz and push it to the device:

$ adb push frida-server-16.5.9-android-x86_64 /data/local/tmp/
$ adb shell

Inside the Android shell:

$ cd /data/local/tmp
$ chmod 755 frida-server-16.5.9-android-x86_64
$ ./frida-server-16.5.9-android-x86_64 &

fserver.png

On the host, install Frida at the same version as the server and confirm it can see the device and the apps:

$ pip install "frida==16.5.9" "frida-tools<14"
$ frida-ps -Uai

frida.png

Our target’s identifier is com.poncle.vampiresurvivors. A warning about versions: this article uses Frida 16. Frida 17 changed several APIs (the Java/ObjC bridges stopped being bundled, for one), so if you’re on a newer version, check frida-il2cpp-bridge’s compatibility before you start copying commands.

Finally, the Node side. The bridge’s scripts are written in TypeScript, on Windows I used fnm to manage Node, created the project and installed the dependencies:

> winget install Schniz.fnm
> fnm env --use-on-cd | Out-String | Invoke-Expression
> fnm use --install-if-missing 22
> npm init -y
> npm i -D @types/node @types/frida-gum typescript frida-compile frida-il2cpp-bridge

(If PowerShell complains about the execution policy, Set-ExecutionPolicy -Scope CurrentUser RemoteSigned sorts it out.)

Finding out where the game saves progress

Before touching anything, I needed to understand how and where the game stores its data. And there’s a neat trick here: it doesn’t matter what engine you’re dealing with, at the end of the day every file write on Android goes through libc’s open and write. So you can ignore the game entirely for a moment and just wiretap libc with frida-trace:

$ frida-trace -U -f com.poncle.vampiresurvivors -i open -i write

frida-run.png

Here -U uses the device connected over ADB, -f spawns the app (launching the game already instrumented) and -i includes a function in the trace. On the first run, frida-trace creates the __handlers__/libc.so/ folder with one file per function (open.js and write.js). The default handler prints everything, and a game opens and writes files nonstop, so the output turns into a flood of noise. The fun part is editing those handlers to show only what matters.

In open.js, I filtered for paths containing “vampire”:

defineHandler({
  onEnter(log, args, state) {
    var path = args[0].readCString();
    if (path.toLowerCase().includes("vampire")) {
      log('open(' + path + ')');
    }
  },
  onLeave(log, retval, state) {}
});

And in write.js, I printed the buffer’s contents, ignoring very small writes:

defineHandler({
  onEnter(log, args, state) {
    var buffer = args[1].readCString();
    if (buffer.length > 20) {
      log('write:\nSTART OF BUFFER\n' + buffer + '\nEND OF BUFFER');
    }
  },
  onLeave(log, retval, state) {}
});

(Full disclosure: write(fd, buf, count) doesn’t guarantee the buffer is null-terminated. readCString() works here because the content is text, but the properly correct way would be args[1].readUtf8String(args[2].toInt32()).)

Running the same command again, frida-trace reuses the edited handlers, I went into the game, killed a few monsters and quit the run to force a save. And there it was:

open(/storage/emulated/0/Android/data/com.poncle.vampiresurvivors/files/SaveDataUnity.sav)

Right after that, in write, a huge JSON with the player’s entire progress.

frida-trace.png

Reading the save and finding the protection

With the file located, just pull it to the host:

$ adb pull /storage/emulated/0/Android/data/com.poncle.vampiresurvivors/files/SaveDataUnity.sav .\SaveDataUnity.sav

The save is a single-line JSON. Trimming out the noise, these are the fields that matter:

{
  "Coins": 0.0,
  "BoughtCharacters": ["ANTONIO"],
  "UnlockedCharacters": ["ANTONIO", "IMELDA", "PASQUALINA", "GENNARO"],
  "checksum": "57b012be43595a9a6007172cc565cc236b360b7f9cb36247bb842baf160d15b8",
  "UnlockedSkinsV2": { "ANTONIO": ["DEFAULT", "LEGACY"], "...": ["..."] }
}

Coins is the money, UnlockedCharacters are the characters available on the selection screen (the four starting ones, and only one of them was bought), and checksum is a 64-character hex hash (32 bytes) covering the entire contents of the save.

That checksum is the game’s tamper protection: when loading the save, the game recomputes the hash and compares it against the stored value. If I changed Coins and pushed the file back, the hash wouldn’t match anymore, and the game simply ignores the save and reverts to the previous state. In other words, brute-force editing the file doesn’t work, first I need to know how to sign the result.

A warning worth its weight in gold: do not pretty-print that JSON. The hash is computed over the exact text, byte for byte, so a single extra space or line break changes the result. Edit the values right there in the single line.

Finding the checksum function

To find out who computes that checksum, the first step was figuring out which assemblies (the old C# DLLs) exist in the IL2CPP runtime. That’s first-step.ts:

import "frida-il2cpp-bridge";

console.log("[+] Loaded Vampire Hacking");

Il2Cpp.perform(() => {
    for (var x = 0; x < Il2Cpp.domain.assemblies.length; x++)
        console.log(Il2Cpp.domain.assemblies[x].name)
});
$ frida -U -f com.poncle.vampiresurvivors -l .\first-step.ts

The list is long: there are Unity assemblies, Nintendo Switch support, all sorts of things. The one that matters is the first one, carrying the game’s name: VampireSurvivors.Runtime. That’s where the game’s logic lives.

runtime.png

With the assembly in hand, I used the bridge’s tracer to see which methods get called while I play.

trace.png

Worth pointing out that Il2Cpp.trace(false) shows only the call tree (class::method). Il2Cpp.trace(true) also shows the parameters and the return values.

The access violation when tracing with parameters

When I turned on trace(true), the script died with an error like this:

Error: access violation accessing 0x132
    at tryMethod → method → toString

This is a known problem with the bridge, documented in issue #557. To print each parameter, the tracer converts the value to text, and for objects that means calling the object’s own managed (C#) ToString(), inside the game. Some objects don’t survive that: ToString() touches invalid memory, the exception bubbles up to the hook and takes the whole trace down with it.

The fix I took from the issue’s comments is to replace the toString() method of the Il2Cpp.Object class, in node_modules/frida-il2cpp-bridge/dist/index.js, with a version that doesn’t let the error escape:

solution.png

toString() {
    try {
        return this.isNull() ? "null" : this.method("ToString", 0).invoke().content ?? "null";
    }
    finally {
        return "Failed to get value"
    }
}

The author of that comment calls it a cheap hack himself, and there’s a JavaScript gotcha here worth knowing: a return inside finally always beats the return in try, and on top of that it swallows any exception. In practice, every Il2Cpp.Object starts showing up as Failed to get value, even when ToString() would have worked just fine. In my case that didn’t get in the way, because strings and arrays have their own toString() in the bridge, and that was exactly what I wanted to see (the JSON, the key, the hash bytes). If you want to see the objects’ real values whenever possible, use catch instead of finally, BUT IT GETS WAY MORE VERBOSE AND FREEZES WAY MORE!

toString() {
    try {
        return this.isNull() ? "null" : this.method("ToString", 0).invoke().content ?? "null";
    } catch (e) {
        return "Failed to get value";
    }
}

With that fixed, I put together the following script as second-step.ts, going through the assembly and filtering for classes whose name contains framework:

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    Il2Cpp.trace(true)
        .assemblies(Il2Cpp.domain.assembly("VampireSurvivors.Runtime"))
        .filterClasses(function (x) {
            return x.fullName.toLowerCase().includes("framework")
        })
        .and()
        .attach();
});

And let’s see what happens!

$ frida -U -f com.poncle.vampiresurvivors -l .\second-step.ts

crashed.png

Yikes, looks like the game froze, doesn’t it? That happened because turning on trace(true) unfortunately makes the game SOOO, but SO much slower, at least on my machine hahaha.

To work around that, and a few other things:

  • Frida reloads the script when you save the file. You can launch the game with trace(false), navigate to the part you care about, and only then switch it to true and hit save, without restarting anything.

And it’s worth repeating:

  • Filtering is not optional. Tracing everything produces an absurd amount of output and makes the game unplayable. Here I filtered only the classes whose name contains framework.

After doing that, everything flowed the way it should :)

run.png

I know I just hammered on filtering is not optional, and here’s why: the more verbose you let the debugging get, the more likely this is to happen:

crashed2.png

Spotting the checksum in the call tree

With the trace running, I started a run, picked a character and saved the game. In the middle of the flood of output, this sequence showed up:

hash.png

└─VampireSurvivors.Framework.Saves.SaveSerializer::Serialize = "{...}"
┌─VampireSurvivors.Framework.Saves.SaveUtils::UpdateChecksum(rawData = "{...}")
│ ┌─VampireSurvivors.Framework.Saves.SaveUtils::GenerateChecksum(data = "{...}")
│ │ ┌─VampireSurvivors.Framework.Saves.SaveUtils::ComputeHash(secretKey = "缂", data = "{...}")
│ │ │ ┌─...SaveUtils::ByteArrayToString(ba = [239,20,238,6,185,93,...])
│ │ │ └─...SaveUtils::ByteArrayToString = "ef14ee06b95d2e3b...ff093bd"
│ │ └─...SaveUtils::ComputeHash = "ef14ee06b95d2e3b...ff093bd"
│ └─...SaveUtils::GenerateChecksum = "ef14ee06b95d2e3b...ff093bd"
└─...SaveUtils::UpdateChecksum = "{...}"

Reading top to bottom, the story goes like this:

  1. SaveSerializer::Serialize turns the game state into JSON, still with "checksum":"".
  2. SaveUtils::UpdateChecksum receives that JSON and calls GenerateChecksum.
  3. GenerateChecksum calls ComputeHash(secretKey, data), passing what looks like a secret key along with the JSON.
  4. The hash comes out as 32 bytes, gets turned into hex by ByteArrayToString and travels back up to UpdateChecksum, which slots it into the checksum field.

The key shows up as 缂: a single character, the Simplified Chinese ideograph U+7F02. Now I had everything: the class name, the method name, the key and the exact format of the input data.

You could reach the same place through static analysis, and it’s worth mapping out that route: tools like Il2CppDumper, Cpp2IL or Il2CppInspector cross-reference libil2cpp.so with global-metadata.dat and reconstruct the names, generating dummy DLLs and scripts to label the functions in your disassembler, dnSpy and ILSpy are for browsing those DLLs, and Ghidra or IDA for actually reading the native code. The bridge itself can even produce a C#-style dump at runtime with Il2Cpp.dump(), a lifesaver when global-metadata.dat is encrypted. But I didn’t need any of that: if the game already has a function that computes the right hash, just call it.

Forging the save

I went back to SaveDataUnity.sav and made three changes. First, the coins, with a suitably iconic number:

"Coins":1333337.0

Second, the characters. How do you find every character’s ID? Searching for ANTONIO (the starting one) in the file, I stumbled onto the UnlockedSkinsV2 field, which happens to list every character in the game along with their skins. I copied that object’s keys into UnlockedCharacters:

"UnlockedCharacters":["ANTONIO","IMELDA","PASQUALINA","GENNARO","CIRO","PORTA","LAMA","CAMILLO","GERMANA","DOMMARIO","CROCI","CRISTINA","PUGNALA","GIOVANNA","POPPEA","CONCETTA","MORTACCIO","CAVALLO","MARIA","TATANKA","ASSUNTA","PEPPINO","FINO","NOSTRO","TUPU","SHEMOONITA","SANTA","YOLO","SPACEDUDE","SPACEDUDETTE"]

Third, I wiped the old checksum and left the field empty, exactly the way UpdateChecksum receives the JSON before computing the hash:

"checksum":""

After a quick dig through this project’s Issues… (where else, right? Huge thanks to whoever made this lib, but omg, zero documentation =( ) I found THIS WAY OF CALLING A METHOD, and put together the following implementation in a file called index.ts: it loads the SaveUtils class, grabs the ComputeHash method and invokes it with the key and our JSON:

import "frida-il2cpp-bridge";

console.log("[+] Loaded Vampire Hacking");

Il2Cpp.perform(() => {
    const VampireSurvivors = Il2Cpp.domain.assembly("VampireSurvivors.Runtime");

    const SaveUtils = VampireSurvivors.image.class(
        "VampireSurvivors.Framework.Saves.SaveUtils"
    );
    const ComputeHash = SaveUtils.method("ComputeHash");

    const secretKey = Il2Cpp.string("缂");
    // modified JSON, on a single line, with "checksum":""
    const json = '{"saveDate":"...","Coins":1333337.0,...,"checksum":"",...}';

    const hash = ComputeHash.invoke(secretKey, Il2Cpp.string(json));
    console.log(hash);
});

And let’s see…

$ frida -U -f com.poncle.vampiresurvivors -l .\index.ts

The game opens, the script runs and the console spits out the hash for our save:

ComputeHash.png

I copied that hash into the file’s checksum field. I closed the game before pushing the save back, if it’s open, it overwrites the file on exit and undoes everything, and sent it over:

$ adb push .\SaveDataUnity.sav /storage/emulated/0/Android/data/com.poncle.vampiresurvivors/files/

Reopening the game, the checksum matched, the save was accepted and there they were: 1,333,337 coins. On the selection screen, every character available. To wrap this part up, here’s a screenshot from the moment it all worked during my presentation ^^ Imagine if it hadn’t, right? Which is exactly what happened at another Game Hacking talk of mine, where it crashed and I had to do the whole thing again, and in the end it worked out =)

poc.png

Going further: the checksum is just a SHA-256

The Frida approach has one huge advantage: it works without me knowing what the algorithm is. The game becomes an oracle that signs whatever I hand it. When I first ran into that key and that JSON, absolutely nothing crossed my mind beyond the assumption that the generated hash was supposedly the combination of the key with the content. But once all that euphoria wore off, I looked at that hash again, stopped to analyze it and asked myself: what if this is just a plain generic hash of the JSON? So…

I tested a few combinations against three independent samples:

  1. the call captured in the trace: ComputeHash’s data parameter and the 32 bytes that reached ByteArrayToString (ef14ee06...ff093bd),
  2. the SaveDataUnity.sav I forged above, with checksum 57b012be...160d15b8,
  3. a save from a later session, already with characters bought, with checksum 744279ef...cfddf69d.

The result was the same in all three: the checksum is simply the SHA-256 of the JSON (in UTF-8) with the "checksum":"" field. The HMAC-SHA256 variations using the 缂 key (in both UTF-8 and UTF-16), plus SHA256(key + data) and SHA256(data + key), did not match. In other words, in my tests, that so-called “secret key” makes no difference to the result at all.

Only the decompiled code would say why, the key may simply not be used in this version, or the value the tracer displayed may not be the parameter’s real content. Consider it an open invitation for anyone who wants to crack ComputeHash open in Ghidra.

What matters is the practical consequence: you can generate a valid save without Frida and without the game running. This Python script recomputes the checksum of an edited save:

import hashlib
import re
import sys

path = sys.argv[1]
save = open(path, encoding="utf-8").read()

data = re.sub(r'"checksum":"[0-9a-f]*"', '"checksum":""', save)
checksum = hashlib.sha256(data.encode("utf-8")).hexdigest()

save = data.replace('"checksum":""', f'"checksum":"{checksum}"', 1)
open(path, "w", encoding="utf-8", newline="").write(save)
print(checksum)
$ python fix_checksum.py SaveDataUnity.sav

check1.png

check2.png

Run against the original save, it produces exactly the checksum that was already in the file, which confirms the reproduction. From there the workflow becomes: adb pull, edit the JSON, run the script, adb push. No Frida at all.

So why still use Frida? Beyond the fact that it was essential for understanding the game’s flow in the first place, the algorithm won’t always be this innocent. There could be a real key, a salt, binary serialization or encryption in the mix. And as it happens, everything we tested here was only about changing the database. What if we want to change something at runtime instead? Something like…

Going immortal with a hook

Closing the loop: back at the start I mentioned that, before I’d even thought about the save file, the first thing I got working was making the character immortal. Now that we have the trace in hand, here’s how that was done.

Touching the save is just one of the possibilities. The bridge also lets you swap out a method’s implementation while the game is running, without touching a single file.

Looking at the trace for the PhysicsManager class, the OnPlayerOverlapsEnemy method shows up, called whenever the character touches an enemy, which is the exact moment damage gets applied. If I replace the implementation with a function that never calls the original, the damage logic never runs, and the character becomes immortal:

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    const VampireSurvivors = Il2Cpp.domain.assembly("VampireSurvivors.Runtime");

    const PhysicsManager = VampireSurvivors.image.class(
        "VampireSurvivors.Framework.PhysicsManager"
    );
    const OnPlayerOverlapsEnemy = PhysicsManager.method("OnPlayerOverlapsEnemy");

    // the original implementation is never called: damage is never applied
    OnPlayerOverlapsEnemy.implementation = function () {
        return false;
    };
});

And…

immortal.png

Pitfalls along the way

A roundup of what cost me time, so it doesn’t cost you:

  • Emulators freezing. Only MEmu could handle the game with Frida attached.
  • access violation when tracing with parameters. Apply the patch from issue #557 and, on top of that, narrow the trace with filterClasses/filterMethods and start with trace(false).
  • The game crawls with tracing on. That’s normal, especially with parameters. It works, it just takes patience.
  • A formatted JSON breaks the hash. No pretty-printing the save, any byte out of place changes the SHA-256.
  • The game being open during the push. Close the game before pushing the save back, or it’ll overwrite your file.
  • Frida versions. frida on the host and frida-server on the device must be the same version.

Why this works (and what a dev could do about it)

This case is a neat illustration of a basic rule of game security: the client cannot be trusted. Everything the game needs to validate its own save lives inside the game, and with dynamic instrumentation you don’t even need to understand the algorithm, you just use the game’s own code as an oracle.

Here the situation is even simpler, because the checksum is a SHA-256 with no key at all. That protects against accidental corruption of the file, but not against tampering: anyone who works out the scheme just recomputes the hash and moves on. And even if it were an HMAC with a real key, that key would be embedded in the binary, and Frida would get to it just the same.

For an offline, single-player game, that’s perfectly acceptable, there’s no reason to spend a silver bullet protecting the save of someone who just wants to cheat in their own game. For games with a real economy (purchases, premium currency, leaderboards), though, a few measures help:

  • Validate on the server anything that has value: balances, purchases, unlocks.
  • Obfuscate IL2CPP class and method names and protect global-metadata.dat, which raises the cost of finding functions like SaveUtils::ComputeHash.
  • Detect root, emulators and Frida, knowing full well that this can be bypassed too.

None of these measures prevent the attack. They only make it more expensive. Against someone who controls the device, the client always loses.

Conclusion

With a rooted emulator, Frida and frida-il2cpp-bridge, we went from “I know nothing about this game” to “I have 1,333,337 coins and every character” without ever opening a disassembler:

  1. frida-trace on open/write to find where the save gets written,
  2. adb pull to read the save and find the interesting fields and the protection (checksum),
  3. Il2Cpp.domain.assemblies to find the game’s assembly,
  4. Il2Cpp.trace(true) to discover who computes the checksum, and with which parameters,
  5. method.invoke() to get the game itself to sign our modified save,
  6. and, as a bonus, a 10-line Python script to generate the checksum without the game.

This flow, watch the I/O, locate the logic, reuse the target’s own code, goes far beyond Vampire Survivors. As I said back in the introduction, it was just one among the dozens of games Marzano and I tested, and it applies to practically any Unity game with IL2CPP on Android.

And if there’s one thing I’d want you to take away from this article, it isn’t the hack itself: it’s that none of this came out fully formed. It started with a vague urge to “hack a game”, went through weeks of trial and error live on Twitch, through scripts that wouldn’t run and plenty of debugging before the classes started making sense. I hope this was as fun to read as it was to do, and that it helps someone start messing with game reverse engineering with a little less fear of getting it wrong along the way =)

References

Android , Frida , Game Hacking , IL2CPP , Mobile , Reverse Engineering , Unity