The Game Believes Whatever Is in Memory

Inspired by Linux memory monitoring (allocations Vs usage) by gary at n0derunner. His post takes a tiny C program, changes one thing about how it touches memory, and watches the same process through top, free, pmap and gdb. I borrowed his method: build a target you fully control, inspect it with standard Linux tools, then point the same tools at something real. His target was malloc. Mine is a game.

The game believes whatever is in memory.

That is the whole trick. Your health in Terraria is an integer at an address inside the game process. The game reads it to draw the hearts. It writes it when a zombie lands a hit. It never asks whether the number is legitimate, because nothing else on the machine is supposed to touch it. Something else can. If that something runs as your user, the kernel will hand it a file that maps straight onto the game’s memory, readable and writable.

Is that a vulnerability? In single player, no. It is your save, your machine, your process. It is still the cleanest small demonstration of a trust boundary I know, and it teaches three things: how a process address space is laid out, how to find one value among hundreds of millions of bytes, and what you pay when the client is the authority on its own state.

We do it in the same order n0derunner did. A toy where you already know the answer. Then the tools, one at a time. Then the real target.

  • toygame.c: a fake player with life, mana and coins, so we can check every claim
  • memscan.py: about 80 lines of Python, no dependencies, Linux only
  • gdb: the independent witness, as in his post
  • Terraria: the same procedure at a bigger scale

Single player only, and back up your character first. On Linux the files live in ~/.local/share/Terraria/Players/. A wrong write can corrupt a save, and you will not enjoy learning that at 2 a.m.

Two files do all the work

Every Linux process exposes its address space under /proc/<pid>/. You need two files.

maps lists the regions. Each line is an address range, permissions, an offset, a device, an inode and, for file-backed regions, a path. Here is the heap of the toy game:

55fa9f70a000-55fa9f72b000 rw-p 00000000 00:00 0                          [heap]

mem is the memory itself. Seek to an address, read four bytes, and you have the integer stored there. Open it for writing, seek, write four bytes, and you have changed it. No injection, no debugger, no shellcode. Just open, lseek, read, write.

Why is this allowed? Because of what the kernel treats as a boundary. proc(5) gates /proc/<pid>/mem behind a ptrace access check, and the classic rule is that a process running as the same user passes it. The Yama documentation states the consequence bluntly: “a single user is able to examine the memory and running state of any of their processes.” Yama’s ptrace_scope setting exists to shrink that. At scope 1, only a parent may attach to its descendants, unless you hold CAP_SYS_PTRACE.

I need to be straight about my test setup. I ran everything below as root in a container with no Yama sysctl present, so I did not observe the blocked case myself. On a desktop distribution with ptrace_scope at 1, expect to run the scanner with sudo, or launch the game from the scanner as its parent.

The toy game

The target is a struct on the heap, three ints in a row. The starting values are deliberately sloppy: life and coins are both 100.

$ ./toygame
pid 498, player at 0x55fa9f70a2a0, life at 0x55fa9f70a2a0
life=100 mana=20 coins=100  [enter = take 13 damage]

The player lives at 0x55fa9f70a2a0. That sits inside the [heap] range above, which is not a coincidence. Small malloc calls come from the heap.

Now the scan. The scanner reads maps, keeps regions that are writable and not file-backed, reads each one in chunks, and records every 4-byte-aligned offset that holds the value you asked for.

$ python3 memscan.py 498 first 100
2 candidates
0x55fa9f70a2a0, 0x55fa9f70a2a8

Two hits. One is life. One is coins. The scanner cannot tell which, and neither can you, because a value alone carries no meaning. This is the central fact of memory scanning: you are not searching for a variable, you are searching for a number, and numbers repeat.

So you change the world and ask again. Take a hit, life becomes 87, and filter the survivors.

$ python3 memscan.py 498 next 87
1 candidates
0x55fa9f70a2a0

The coin counter did not move, so it drops out. One candidate left. Take another hit, filter for 74, and the same address survives. That is the loop: scan, change one thing, filter. Candidates after scan 3 < candidates after scan 1, and the drop is the whole point. Each filter is a hypothesis test, and false positives fall away geometrically.

Check it with gdb

n0derunner used gdb to dump memory and od to read it, confirming what pmap implied. Same idea here. Never trust a tool you cannot cross-check with a second one.

$ gdb -p 549 -batch -ex "x/3dw 0x55c15c1432a0"
0x55c15c1432a0: 100 20  100
[Inferior 1 (process 549) detached]

That is a second run, so the address is different. ASLR moved everything, which is why you cannot hard-code an address between launches. The three words are life, mana and coins, adjacent in memory, in struct order. That is a useful trick on its own. Once you find one field, x/8dw around it shows its neighbors, and neighbors tell you what the object is.

Manipulation

Reading is reconnaissance. Writing is the attack.

$ python3 memscan.py 498 write 9999
1 candidates

The next line of the game reads life=9986. It accepted 9999, then subtracted 13. The game did not object because the toy has no rules. Terraria does, and I will come back to that.

One write is fragile. Any code that runs every frame will overwrite your value. So you freeze it: rewrite the value in a loop.

$ python3 memscan.py 498 freeze 500
life=87 ... life=487 ... life=487 ... life=487

Why 487 and not 500? Because the loop writes 500, the game subtracts 13 when you press enter, and the display shows the result before the next write lands. The frozen value is always one hit below what you wrote. That gap is a real limitation. The sleep between writes is 50 ms. A game ticking at 60 updates per second runs a tick every 16.7 ms, so 50 ms is about three ticks. A big enough hit inside that window kills you before your next write. If you freeze a value, shorten the sleep.

Now Terraria

Same procedure. Different scale. Terraria is a C# game, and a fresh character starts with 100 life and 20 mana. That gives you your first value.

  1. Start a single-player world with a throwaway character. Find the process with pgrep -a Terraria and note the PID.
  2. Scan for your current life: python3 memscan.py <pid> first 100.
  3. Take damage. Fall from a height, or let a zombie find you at night. Read the new value off the hearts.
  4. Filter: python3 memscan.py <pid> next <new value>. Repeat until the candidate list is short.
  5. Write your max life back, or freeze it.

[REPLACE: paste your first-scan candidate count and the count after each filter here. I have not run this against Terraria myself, so I will not invent numbers. A screenshot of the hearts next to the terminal output would sell it.]

Expect the first scan to return many more hits than the toy did. The value 100 is everywhere in a running game. Do not be discouraged by that. Three or four filters is usually enough, because damage values are irregular and few unrelated ints will track them.

Now the honest part: why might this not work?

The runtime moves things. Terraria is managed code. On Linux it has historically run on Mono, which means objects sit on a garbage-collected heap. The collector is allowed to relocate objects. Player state is long-lived and tends to settle, but if your frozen value suddenly stops working, rescan. An address is a fact about one moment, not a property of the variable.

The game has rules. In the toy, 9999 life was fine. In Terraria the player object has a statLife field and a maximum, and I would expect the game to clamp life to that maximum on update. Test it. Write your max life, not an absurd number, and see what the hearts do.

The value might not be an int. Life is an integer here, but plenty of game values are floats or doubles, and some games obfuscate them on purpose by storing the value XOR a key, or twice in two forms. My scanner searches for a signed 32-bit int only. If a scan finds nothing, the type is the first thing to question.

Yama. Covered above. If the scanner cannot open /proc/<pid>/mem, this is the likely cause.

What this says about defense

You are the adversary in this post, and you had very little to overcome. Same UID, so no privilege boundary. Client-side state, so no authority to argue with. A value stored in plain form, so no obfuscation. The blast radius is one save file.

Now change the setting. Suppose that number were your account balance in an online game. The fix is not a cleverer hiding place for the integer. It is moving authority to the server, so that the client’s memory is only a cache of a decision made elsewhere. Obfuscation raises the price of the attack. Server authority removes the attack. Those are different kinds of defense, and mixing them up is how studios end up in an arms race they lose by design.

The game believes whatever is in memory. The only question worth asking is whether it should.

Code

toygame.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

struct player { int life; int mana; int coins; };

int main(void) {
    struct player *p = malloc(sizeof *p);
    p->life = 100; p->mana = 20; p->coins = 100;
    printf("pid %d, player at %p, life at %p\n", getpid(), (void *)p, (void *)&p->life);
    fflush(stdout);
    for (;;) {
        printf("life=%d mana=%d coins=%d  [enter = take 13 damage] ", p->life, p->mana, p->coins);
        fflush(stdout);
        if (getchar() == EOF) break;
        p->life -= 13;
    }
    return 0;
}

Build with gcc -O0 -o toygame toygame.c.

memscan.py

#!/usr/bin/env python3
"""Minimal Linux memory scanner. Single-player, your own processes only.

  memscan.py PID first 100      # scan all writable anon regions for int32 == 100
  memscan.py PID next 87        # keep only candidates that now read 87
  memscan.py PID show           # print surviving candidates
  memscan.py PID write 500      # write int32 to every surviving candidate
  memscan.py PID freeze 500     # rewrite it every 50 ms until Ctrl-C
"""
import json, os, struct, sys, time

CHUNK = 16 * 1024 * 1024  # multiple of 4, so aligned hits never straddle a chunk


def regions(pid):
    with open(f"/proc/{pid}/maps") as f:
        for line in f:
            parts = line.split(None, 5)
            span, perms = parts[0], parts[1]
            path = parts[5].strip() if len(parts) > 5 else ""
            if perms[:2] != "rw":
                continue
            if path and path != "[heap]":
                continue  # skip file-backed maps, [stack], [vvar] and friends
            lo, hi = (int(x, 16) for x in span.split("-"))
            yield lo, hi


def first(pid, value):
    needle = struct.pack("<i", value)
    hits = []
    with open(f"/proc/{pid}/mem", "rb", 0) as mem:
        for lo, hi in regions(pid):
            for base in range(lo, hi, CHUNK):
                try:
                    mem.seek(base)
                    buf = mem.read(min(CHUNK, hi - base))
                except OSError:
                    break  # region vanished or is unreadable
                i = buf.find(needle)
                while i != -1:
                    if i % 4 == 0:
                        hits.append(base + i)
                    i = buf.find(needle, i + 1)
    return hits


def nxt(pid, value, cands):
    needle = struct.pack("<i", value)
    keep = []
    with open(f"/proc/{pid}/mem", "rb", 0) as mem:
        for a in cands:
            try:
                mem.seek(a)
                if mem.read(4) == needle:
                    keep.append(a)
            except OSError:
                pass  # page is gone
    return keep


def write(pid, value, cands):
    data = struct.pack("<i", value)
    with open(f"/proc/{pid}/mem", "r+b", 0) as mem:
        for a in cands:
            mem.seek(a)
            mem.write(data)


if __name__ == "__main__":
    pid, cmd = sys.argv[1], sys.argv[2]
    state = f"/tmp/memscan-{pid}.json"
    cands = json.load(open(state)) if os.path.exists(state) else []
    if cmd == "first":
        cands = first(pid, int(sys.argv[3]))
    elif cmd == "next":
        cands = nxt(pid, int(sys.argv[3]), cands)
    elif cmd == "write":
        write(pid, int(sys.argv[3]), cands)
    elif cmd == "freeze":
        try:
            while True:
                write(pid, int(sys.argv[3]), cands)
                time.sleep(0.05)
        except KeyboardInterrupt:
            pass
    json.dump(cands, open(state, "w"))
    print(f"{len(cands)} candidates")
    if cmd == "show" or len(cands) <= 8:
        print(", ".join(hex(a) for a in cands))

One caveat on the scanner: the region filter keeps anonymous writable mappings and [heap], which is where a managed runtime’s heap normally lives. If a scan finds nothing, print the regions and check that yours is in the list.

Takeaways

  1. A same-UID process can read and write another process’s memory through /proc/<pid>/mem unless Yama or a stricter policy stops it.
  2. You do not search for a variable, you search for a value, then change the world and filter until one address survives.
  3. Confirm any address with a second tool such as gdb before you write to it, and expect it to change on every launch.
  4. Freeze loops race the game’s own update tick, so keep the write interval well under one tick.
  5. If a scan finds nothing, suspect the data type first, then the garbage collector, then the ptrace policy.
  6. Obfuscation raises the cost of this attack; moving authority to the server removes it.

Published September 27, 2026