What Actually Survives a Memory Scanner

Scott Davis · October 2026

A follow-up to Securing Game Data in the Agentic Age.

Building it, then attacking it

In the last post I argued that client-side protection only slows attackers down, and that “AI is making the slowdown barely matter.” I ended with a sketch of a small masked type that re-keys on every write and moves its storage around.

I built it. I also built a memory scanner and pointed it at my own game.

How cheats spread, and why offsets don’t carry over

Most cheats aren’t written by the people who use them. One person finds where a value lives and traces the chain of pointers from a fixed spot in the game’s memory down to it. They write that chain down as a base address plus a list of offsets and publish it as a cheat table, or bundle it into a trainer. Everyone else downloads it, attaches it to the game, and it works, because the game lays out memory the same way every time it runs. When a patch shifts things, one person updates the offsets and everyone gets the new version.

That model needs a value you can find by scanning and a location that means the same thing in every session. The masking takes away both:

  • The usual way to discover an offset is to search for a known number, change it in game, and search again. A masked value never sits in memory as that number, so the search has nothing to follow.
  • A masked value moves to new storage as it’s written, so an address that held it a moment ago holds something else now.
  • Part of every key comes from a table filled with fresh random data at every launch. A key or masked value recorded in one session means nothing in the next, so a shared table has nothing to carry over.

A cheat for this game can’t be a list of offsets. It has to be a program that understands the masking, finds the key table in each session, and decodes values as they move.

What I have built

I wrapped every value a cheat would go after in a masked C# type, unless the game already shows it on screen. That covers movement (run, crouch and sprint speed, jump height and gravity) and every entity’s health, max health and level. It also covers the spawn table, the positions of anything in flight, resource nodes, and the snapshot and interpolation buffers.

The last post promised “a few cheap operations.” Measured in a Release build, median of three runs:

Operation Plain (ns) Masked (ns)
float read 2.63 4.07
float write 0.34 3.3
Vector read (3 floats) 2.62 17.0
Spawn entry read — 43.5

A masked read takes about 4 ns, 1.4 ns more than a plain read. Writes were slower at first, around 30 ns, because each one called Random.Shared three times. Now a write gets its key half and table slot from one call to a small, fast generator, which brings it down to about 3.3 ns. In game, the movement system runs 140 wandering NPCs in 2.3 ms out of a 34 ms frame.

A memory scanner in CI

Poking private fields with reflection would only prove the code does what I wrote. I wanted to know what an attacker sees.

I built a small trainer-style memory scanner. It attaches to the running process, walks every committed region of its address space, reads it all, and goes looking for my values.

It attacks the masking the way a trainer would:

  • Value scan: set a known value and search all of memory for it.
  • Next scan: change the value, search again, and keep only the addresses that followed the change.
  • Rewrite scan: rewrite a value hundreds of times and check whether it ever shows up in the clear.

The scanner runs the same kind of automated attack the first post warned about, against my own game, and it comes up empty: it has never found a masked value in memory. It runs as a test on every build, so a change that made a value findable would fail the build before it shipped.

Version one: two keys per value

My first version stored each value in two masked cells, each under its own key, with no plaintext anywhere. If someone edited the main cell, the game restored it from the spare.

It passed every scanner test. A value scan found nothing in my storage, a next scan followed zero addresses, and a value rewritten 750 times never appeared in the clear.

Each key sat whole in the same object as the value it protected, which is the “key a few bytes away” pattern from the first post. A scanner can’t find a random key, because there’s no known value to search for. One object held everything needed to read the value, so for the next version I stopped storing whole keys.

A key nobody keeps

The current design splits every key in two. A masked value keeps only its own half and a slot number. The other half lives in a shared table. The real key is own ^ KeyTable[slot], put back together on every read and write and never stored anywhere.

Each read rebuilds the key just long enough to unmask the value One masked read in four steps, on a loop old storage wiped Masked value own half of the key slot cell = value ⊕ key Shared key table fresh random data every launch … table part at this slot key = own ⊕ part exists only while in use value = cell ⊕ key handed to the caller 1. The read loads the own half and the slot. Nothing is written. 2. The key is rebuilt from the own half and the table part at that slot. 3. The cell is unmasked into a plain value for the caller. The key is dropped. 4. Every so often a read also moves the value to new storage under a new key.
Each write rebuilds the key, uses it once, and throws it away One masked write in four steps, on a loop old storage wiped old table array wiped Masked value own half of the key slot cell = value ⊕ key Shared key table fresh random data every launch … table part at this slot key = own ⊕ part exists only while in use 1. The write draws one random number: a new own half and a new slot. 2. The key is rebuilt from the own half and the table part at that slot. 3. The value is masked into the cell. The key is not kept anywhere. 4. Every so often the value moves to new storage and the table to a new array.

The idea comes from white-box cryptography, the technique DRM systems use when the attacker owns the machine running the decryption. A white-box implementation never holds its key as one value. It spreads the key across lookup tables mixed with random encodings, so reading memory turns up pieces that mean nothing on their own. My version is much lighter.

The table is filled from the OS secure random generator, fresh every launch. Every so often it is copied to a new array and the old one is wiped. Values also move their own storage on a schedule of their own. Both halves of every key keep moving, and a dump of a single object decodes to nothing.

The key halves stored beside each value come from that fast generator. It is seeded from the OS secure generator at launch and reseeded every time the table moves. A cheat that finds the generator could predict the next halves, but each half already sits next to its value, so predicting it gives nothing away. The secret is the table, and the speedup doesn’t touch it. A table of pre-made whole keys would have been weaker: keys would repeat, and it wouldn’t be any faster.

A simplified sketch (not the shipped code):

static class KeyTable {
    public static int[] Parts = Fresh();   // secure RNG, per launch
    static ulong _state = SecureSeed();    // reseeded each time Parts moves

    public static ulong Next() { /* small, fast generator */ }
}

struct MaskedInt {
    int _cell;   // value ^ key
    int _own;    // this value's half of the key
    int _slot;   // which shared half to use

    public int Get() => _cell ^ (_own ^ KeyTable.Parts[_slot]);

    public void Set(int v) {
        ulong r = KeyTable.Next();   // one call: own half + slot
        _own  = OwnPart(r);
        _slot = Slot(r);
        _cell = v ^ (_own ^ KeyTable.Parts[_slot]);
    }
}

The split-key design keeps neither the whole key nor the value anywhere a copy of the object can use.

The results

Attack Two-key design Split-key design
Value scan (search for the float) Nothing in my storage Nothing in my storage
Next scan (change, then intersect) Follows 0 addresses Follows 0 addresses
Rewrite 750 times, with relocations Never appears in the clear Never appears in the clear
Does a dump of one object decode? Yes, the key is in the object No, it needs the table

What the design removes is anything stable: no fixed address, no stable key, no layout you work out once and reuse. The first post said an attacker has to “solve it again on every read, every write, and every move.” Here is what that looks like in the shipped code. Every write picks a new key. Every n write moves the value to a new address. Every n writes the key table itself moves to a new array. The next-scan test checks the result: after a value changes, zero addresses still hold it, so any scan result is stale after a single write.

Cost per frame

I measured frame cost in a test scene with 140 wandering NPCs, with the C# built in Debug. The system that moves the NPCs takes 2.3 ms of a 34 ms frame. Rendering takes most of the rest: 15.5 ms of CPU time and 23.9 ms on the GPU.

That movement system took 3.2 ms a frame before masking and 3.4 ms after, so masking added about 0.2 ms. That was measured when a masked write still cost 30 ns. Writes now cost about 3.3 ns, and the system has since dropped to 2.3 ms because of other changes.

The scanner attacks the masking on every build. It has never found a masked value, and any change that made one findable would fail the build before it shipped.