DEV Community

Cover image for [EN] Introduction to Frida: Real-Time Memory Manipulation with TypeScript (and DOOM)
StealthC
StealthC

Posted on

[EN] Introduction to Frida: Real-Time Memory Manipulation with TypeScript (and DOOM)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This opens Frida and attaches it to the process called CalculatorApp.exe, which is the Windows calculator.

Image demonstrating Frida execution

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:

Image showing TypeScript documentation support in VS Code

Documentation and autocomplete—some of the best things about modern IDEs (until AI came along)

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 .
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Image demonstrating Frida execution


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.

Gameplay image of Doom

Let’s hope this 100% is an integer...

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
Enter fullscreen mode Exit fullscreen mode

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:

The player shoots at a barrel, causing it to explode

Take that, barrel filled with mysterious green liquid!

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"]`
Enter fullscreen mode Exit fullscreen mode

We’re left with two possibilities. Which one is it? Hang on, I’ll figure it out…

The player picks up a blue potion

Drinking this blue potion I found on the ground...

Now that our health is at 68%, let’s filter the results again:

[Local::doom_gog.exe ]-> reduce(68)
Filtered down to:
["0x179b96a0d0c","0x179f5499984"]
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

... which produced the following disassembly:

Address displayed in x64dbg

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}`);
});
Enter fullscreen mode Exit fullscreen mode

After saving the script, if we continue to take damage in the game, the Frida console will print messages showing the amount of damage:

Damage displayed in the console


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);
});
Enter fullscreen mode Exit fullscreen mode

Animation of the player taking damage but the health value doesn't decrease

Who needs IDDQD?

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)