Translation: Este artigo também está disponível em Português
Introduction
The other day, I was reading updates on the website of a tool I follow called Frida, which is, as they describe it, a "Greasemonkey for native programs." In other words, it’s a toolkit with its own API to analyze, interact, and manipulate running programs, supporting multiple platforms.
Anyway, I came across this September 2024 update that not only announced some new features but also showcased an interesting use case. It involved the game "Doom + Doom II," which was released recently. In the example, they demonstrated a tool that scans memory to locate where the ammo count is stored and, with remarkable ease, creates a sort of Cheat Engine to maintain infinite ammo. I loved how practical this example was and decided to recreate it while detailing every step here.
Installing Frida
As I mentioned earlier, Frida is available for multiple platforms. Here, I’m using Windows and already have Python installed, so I’ll simplify the installation process by using pip:
pip install frida-tools
Usage Example
The frida-tools package comes with several utilities to interact with the Frida ecosystem (you can learn more by visiting the official documentation).
A practical example is using frida
to attach directly to a running program. For instance:
frida -n CalculatorApp.exe
This opens Frida and attaches it to the process called CalculatorApp.exe
, which is the Windows calculator.
From this new prompt, you can use several built-in functions and call JavaScript directly to access the application’s memory, registers, interrupts, etc.
It’s great for quick tests, but the real power comes from creating your own scripts, known as "agents."
Choosing the API Language
You can write these agent scripts in various languages. Frida offers support for Python, JavaScript, C, Go, Swift, and others.
For this example, I’ll go with JavaScript—or even better, TypeScript, which provides autocomplete and quick access to documentation, as shown below:
Setting Up the Project Structure
Frida’s documentation for JavaScript suggests cloning the following repository to start developing your "agent": (https://github.com/oleavr/frida-agent-example). So, I’ll clone it, install the dependencies, and open it in VS Code:
git clone https://github.com/oleavr/frida-agent-example.git doom_example
cd .\doom_example\
npm install
code .
The repository simplifies programming in TypeScript and compiling JavaScript to be used by Frida. It comes with some scripts in package.json
, like pnpm watch
, which watches and compiles the ./agent/index.ts
file automatically.
However, in the latest versions of Frida, it can directly read our TypeScript, so we don’t need to compile to
.js
.
One More Thing Before We Continue
It’s likely, as was the case during my tests, that the repository has some outdated dependencies. Don’t forget to run npm update --save
to update the dependencies, especially the typings.
Basic Agent
Let’s start with the same example from the original post, adding some comments, typings, and adapting it for TypeScript:
Important note: If you use plain JavaScript for the agent, you can directly call any function or variable declared in the script. However, when using TypeScript, functions and variables are not added to the global object. You must export them to the
globalThis
object. I’m making this export at the end of the script.
let matches: NativePointer[] = [];
// Scans the process memory for a pattern
function scan(pattern: string | MatchPattern) {
const locations = new Set<string>();
for (const r of Process.enumerateMallocRanges()) {
for (const match of Memory.scanSync(r.base, r.size, pattern)) {
locations.add(match.address.toString());
}
}
matches = Array.from(locations).map(ptr);
console.log('Found', matches.length, 'matches');
}
// Further filters results for those containing a specific value
function reduce(val: number) {
matches = matches.filter(location => location.readU32() === val);
console.log('Filtered down to:');
console.log(JSON.stringify(matches));
}
// Converts a numeric value into a pattern for a 32-bit unsigned integer
function patternFromU32(val: number) {
return new MatchPattern(ptr(val).toMatchPattern().slice(0, 11));
}
globalThis['scan'] = scan;
globalThis['reduce'] = reduce;
globalThis['patternFromU32'] = patternFromU32;
Attaching Frida to the Game
Now, let’s run Frida and attach it to the game.
frida -n doom_gog.exe -l agent/index.ts
Make sure the game is running so frida
can locate the process using the -n
option. If you’re unsure about the process name, use frida-ps
to list all running processes.
Finding a Value in Memory (a needle in a haystack)
In the original post, the author searches for the memory location where the ammo count is stored. But let’s switch things up and look for where our health (or "HP," "life," or whatever you call it) is stored.
In the open Frida console, we’ll execute our scan
function with a pattern for the number 100 as an argument.
[Local::doom_gog.exe ]-> scan(patternFromU32(100))
Found 8073 matches
Whoa, 8073 matches for the number 100! That seems like a lot, but not too bad. We need to narrow it down. First, let’s take some damage in the game:
Now that our health has dropped to 67%, let’s further filter our results using the reduce
function we created in the script:
[Local::doom_gog.exe ]-> reduce(67)
Filtered down to:
["0x179b96a0d0c","0x179f5499984"]`
We’re left with two possibilities. Which one is it? Hang on, I’ll figure it out…
Now that our health is at 68%, let’s filter the results again:
[Local::doom_gog.exe ]-> reduce(68)
Filtered down to:
["0x179b96a0d0c","0x179f5499984"]
Hmm, we still have two addresses left. It’s possible that health is stored in two different memory locations or represents two separate update states. Maybe I shouldn’t have tested this by both losing and then regaining health, but oh well—let’s move on with the experiment.
Creating Dynamic Watchpoints
Let’s proceed with the article by creating a helper function to set watchpoints. This function will take the memory address, the size of the stored data, and the break condition type as arguments. We’ll use w
to specify that it should only trigger on write
conditions.
Don’t forget to register the function with globalThis
at the end.
function installWatchpoint(address: NativePointerValue, size: number | UInt64, conditions: HardwareWatchpointConditions) {
const thread = Process.enumerateThreads()[0];
Process.setExceptionHandler(e => {
console.log(`\n=== Handler got ${e.type} exception at ${e.context.pc}`);
if (Process.getCurrentThreadId() === thread.id &&
['breakpoint', 'single-step'].includes(e.type)) {
thread.unsetHardwareWatchpoint(0);
console.log('\tDisabled hardware watchpoint');
return true;
}
console.log('\tPassing to application');
return false;
});
thread.setHardwareWatchpoint(0, address, size, conditions);
console.log('Ready');
}
globalThis['installWatchpoint'] = installWatchpoint;
Identifying the Code Location (via Watchpoints)
Now the goal is to find where in the game’s code the damage calculation is executed. Let’s start by registering a watchpoint for the first address:
[Local::doom_gog.exe ]-> installWatchpoint(ptr('0x179b96a0d0c'), 4, 'w')
Ready
After inserting the watchpoint, I tried taking more damage, but nothing happened. However, when I picked up a potion, the code was triggered:
[Local::doom_gog.exe ]->
=== Handler got single-step exception at 0x7ff6332f7b08
Disabled hardware watchpoint
This indicates that the first address we located is likely used in the context of health recovery. Since we’re more interested in the damage context, I tested the other address. This time it triggered when I took damage in the game:
[Local::doom_gog.exe ]-> installWatchpoint(ptr('0x179f5499984'), 4, 'w')
Ready
[Local::doom_gog.exe ]->
=== Handler got single-step exception at 0x7ff6332fafcc
Disabled hardware watchpoint
Finding the Exact Code Execution Location
With the address where the value change occurs, 0x179f5499984
, let’s find its relative offset to the program’s base.
[Local::doom_gog.exe ]-> healthCode = ptr('0x7ff6332fafcc')
"0x7ff6332fafcc"
[Local::doom_gog.exe ]-> healthModule = Process.getModuleByAddress(healthCode)
{
"base": "0x7ff633020000",
"name": "doom_gog.exe",
"path": "F:\\GOG Games\\DOOM + DOOM II\\doom_gog.exe",
"size": 15450112
}
[Local::doom_gog.exe ]-> offset = healthCode.sub(healthModule.base)
"0x2dafcc"
Now we know the address is in the doom_gog.exe
module and its position relative to the program’s base is 0x2dafcc
.
Checking the Address in a Disassembler
We can use any debugger with disassembler support to analyze this segment and determine its functionality. You can use tools like radare2, IDA, or GHidra. In this example, I used x64dbg and navigated to the correct address by pressing Ctrl+G and entering the expression:
doom_gog.exe+0x2dafcc
... which produced the following disassembly:
Here, we see the instruction executed immediately after the health value decreases. We can hypothesize that [rsi+24]
holds our health value and r15d
represents the damage received. Let’s focus on the damage received for now.
Creating Interceptors to Dynamically Receive Data
Following the example from the original article, let’s create an interceptor for this instruction to capture the amount of damage received.
Since this function is executed directly in the script, we don’t need to add it to
globalThis
.
Interceptor.attach(Module.getBaseAddress('doom_gog.exe').add(0x2dafcc), function () {
const context = this.context as X64CpuContext;
const damageReceived = context.r15;
console.log(`Damage Received: ${damageReceived}`);
});
After saving the script, if we continue to take damage in the game, the Frida console will print messages showing the amount of damage:
Now, the Ultimate Manipulation!
If we want, as in the original example, to completely ignore the damage, we just need to move the interceptor to the previous instruction (address 0x2dafc8
) and dynamically set the register value to zero!
Interceptor.attach(Module.getBaseAddress('doom_gog.exe').add(0x2dafc8), function () {
const context = this.context as X64CpuContext;
const damageReceived = context.r15;
console.log(`Damage Received: ${damageReceived.toUInt32()}, but we will just ignore it`);
context.r15 = ptr(0);
});
Conclusion
This exercise was fantastic for exploring what a tool like Frida can offer. This dynamic way of handling data is also incredibly interesting for learners. The example with Doom was perfect due to its simplicity. Keep in mind that creating a game cheat like this is merely "scratching the surface" of possibilities. Tools like Frida can—and should—be extensively used for tasks like malware analysis, software behavior studies, security assessments, and more.
Top comments (0)