HOME

Using Frida as a scriptable cheat engine

header image

Frida has been on my list of tools to look into for quite a while now but I've never really gotten around to it. Either because it didn't quite fit the needs of the projects I was working on or because I was working under deadlines that didn't really allow for taking the time to learn a new tool.
So a couple of days ago I decided that since that situation probably isn't going to change any time soon, now is as good a time as any to throw in an quick, semi-useful weekend project to get my hands dirty with Frida. After all having another tool under your belt is always a good idea as you never know when it might come in handy.

So, who is this Frida anyways ?

Frida is a dynamic binary instrumentation framework similar to Intel PIN or DynamoRIO.
The basic idea is that it lets you inject code into another binary without having to recompile so that you can e.g. observe memory allocations to check for leaks, observe branching behavior to create coverage statistics or to dynamically change program state and variables to fix bugs, develop exploits or, as we are going to see, cheat in video games ;).

While I have used DynamoRIO on a couple of occasions in the past, what got me interested in Frida in particluar is its focus on portability and scriptability with bindings for a decent range of languages (here we will use a Python script to inject JavaScript code into the target binary), as well as its seamless integration with radare.

Finding a worthwhile target

While I could just have read through a couple of tutorials and getting started guides and tried to replicate the instructions I always find that I retain new information much better when applying it in a way that has some practical use for me.
Therefore I came up with the idea to use Frida as a scriptable cheat engine for SnowRunner.

Currently I am on my second playthrough of SnowRunner and while I do like the game a lot the system of ranks and experience points always seemed kind of arbitrary to me. In particular on the one hand the game allows and even encourages switching between the various regions (Michigan, Alaska, Russia, ...) at any time and playing them in an non-linear order. On the other hand, though, chained tires (without which Alaska is practically unplayable) only become available once you reach rank 10-15 (depending on the vehicle).
So what better way to take Frida for a spin than using it to increase my rank so I can buy some winter tires ^^.

The battle plan

Now that we have a goal, lets see how we can get there.
Even though all we want to do is increase a current rank of e.g. 5 to, say, 16, finding a particular single digit value in the several gigabytes of memory that the SnowRunner executable maps is probably not going to work without some additional information.

image of profile before update

Some more recognizable values might be the account balance (112500 in this case) or the player name. We'll assume that since all those values have something to do with the state of the active player profile they will be stored somewhat close together in memory. So if we are able to find a not too large area of memory where all of those values occur it will be safe to assume that these are our target variables and we can subsequently change them to our desired values.

Implementing the plan

Note: For the sake of readability I will only include the most relevant code snippets in the following text. The complete script can be found at gitlab and github.

Before we can start looking for our target values in memory we will have to get a list of the memory ranges that the process maps. This is exactly what Process.enumerateRanges() does and since we are interested in data, not code, we can filter out executable regions by setting the protection string to 'rw-':

var ranges = Process.enumerateRanges('rw-');
This gives us a list of memory ranges that should look approximately like this:
[ { "base": "0x557b101b2000", "size": 4096, "protection": "rw-", }, { "base": "0x557b1058c000", "size": 135168, "protection": "rw-" }, { "base": "0x7fd1a8990000", "size": 8388608, "protection": "rw-" }, ... ]

Next we will take a look at each of these ranges to check if they contain our values.
We will start by searching for our current account balance and then refine our search in only those ranges that contain this value.
For this we will use Memory.scanSync(address, size, pattern).
The parameters address and size we can take directly from the data that enumerateRanges() returned. pattern needs to be a string of hexadecimal values representing the data in memory we are looking for.
To scan for our account balance we first convert 112500 to hex which yields 1B774. And because we are running on a little endian machine we will have to reverse the individual bytes which gives us a scan pattern of 74 B7 01 00.
Of course we don't have to do this conversion manually. After all one of the hallmarks of Frida is its scriptability. And we don't even have to do it in the JavaScript code that is going to run in the target process (which would not be a big deal in this particular case but could become an issue if we needed to make more complicated calculations but did not want to introduce too much delay because e.g. timing of our operations might be important). Instead we can simply do the conversion in the Python script that is going to inject our JavaScript code into the target application.

Putting all of that together our script now looks like this:

def int_to_scan_string(num): num_int = int(num) return '%02x %02x %02x %02x' % ( num_int & 0xff, (num_int >> 8) & 0xff, (num_int >> 16) & 0xff, (num_int >> 24) & 0xff, ) session = frida.attach("snowrunner.exe") script = session.create_script(""" var ranges = Process.enumerateRanges('rw-'); for (var i in ranges) { var results = Memory.scanSync(ranges[i].base, ranges[i].size, '%s'); ... }""" % (int_to_scan_string(112500)) script.load()

This gives us a list of memory addresses that contain our target value:

[ { "address":"0x56287c6d02a0", "size":4 }, ... ]

Most of these results are going to be false positives as 112500 is not a particular unique value and is bound to occur in memory in several places simply by random chance. Therefore we are now going to look at the areas around those preliminary results and see if we find a place where the values of our current account balance, rank and profile name occur in relative proximity.

To do that we don't have to rescan each whole range but we will use the results of our first scan as reference points and only scan a few hundrded bytes before and after them:

var new_base = (parseInt(results[j].address, 16) - 384).toString(16), var search_size = 768; var rank = Memory.scanSync(ptr(new_base), search_size, '%s'); for (var k in rank) { addr_rank = rank[k].address; }

We will do this for both our rank and profile name and save the results if we find all those values in a single regions:

if (addr_balance && addr_rank && addr_name) { candidates.push({'rank': addr_rank, 'balance': addr_balance, 'name': addr_name}); }

Ideally we will end up with exactly one set of addresses for our target values that we can now write our new values to:

if (candidates.length == 1) { Memory.writeInt(candidates[0].money, new_balance); Memory.writeInt(candidates[0].rank, new_rank); }

Technically that's all there is to it. In this particular case, though, it turns out we will need to take care of one more thing or we will end up with the following weird result: image of negative XP

What we did not consider until now is that while our goal only was to increase our rank, this value is coupled to the profile's experience points whose absolute value is not displayed on the profile page but only the number of points relative to those required to reach the current rank.
So by updating the rank but leaving the XP untouched our relative experience level is now negative and as soon as we trigger any action in the game that awards us additional points our rank will be reset to the appropriate one for our total XP.

Essentially what that means is that it is not enough to set our desired rank but we will also have to locate our experience points in memory and set them to a value appropriate for our target rank.
So we will just add another set of scan and write calls to our Frida script. No big deal. But how do we obtain the value that we need to scan for ?
While it would not be too hard to figure that out based on the relative XP requirements displayed in the profile, fortunately someone has already done that work for us here.
From that table we can see that at our current rank 5 with 100/1300 XP the absolute value we are looking for will be 4200 and if our goal is to get promoted to rank 16 setting our XP to 25100 should do the trick.
So lets poke those values into memory and see if that works out!

image after successful update

Now we are really done and can finally buy chained tires and explore Alaska :D.

Where to go from here

While the goal for this particular project is reached, depending on our use case there are some improvements that could be made to our Frida script.

Currently we are scanning the whole process memory on every run of the script.
That was necessary on the first run as we had to figure out where our target values were located in memory without any prior knowledge. Now that we know where the variables are located relative to each other and relative to the load address of the program binary, we could simply hard-code those offsets and do away with the searching altogether.
That would greatly simplify the script and speed up further runs.

The problem with this approach, though, is that the relative addresses are likely to change with future updates of the game or even among different installations.

Also if our goal was not to cheat in video games but for example to use a similar approach to exploit security vulnerabilities we might not necessarily be able to determine a precise location in memory that we want to target but would want to scatter a certain value across multiple potential target areas. In such a case it might be acceptable to occasionally write to a wrong area that causes the target process to crash if in turn that gives us on average a higher likelihood of success. So instead of making the search and write algorithm more specific we might want to make it a little more fuzzy.

As you can see there are many possible applications for Frida and I hope this article helped you getting started exploring them.


P.S.: Since someone is certainly going to mention it; yes I am aware that the same goal could be achieved by simply editing the save data that are stored as JSON in a plain text file. But then again, where would be the fun in that!