Skip to content
Lesson 15.7Defensive Design and Anti-Cheat

Control Gaps in Toy Defenses

Three small defenses show how an apparently valid check can miss the path that produces an effect, and how to repair its invariant.

Lesson 15.7
Read or listenPrint chapter or book
About 32 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 15.7Key ideas, reference and questions

A one-page summary of “Control Gaps in Toy Defenses” you can keep beside your notes. Plain Markdown.

Lesson 15.7 — Control Gaps in Toy Defenses: cheatsheet

  • Remember: Protect the effect you promise to prevent; a familiar name or environmental signal may only be a clue about that effect.
  • Also useful: Group the toy commands by what they change, like sorting tools by their job rather than by the name printed on the handle.

Key terms

  • coverage by spelling: The weakness is coverage by spelling.
  • effect_of: effect_of is now the single place that judgement lives.
  • if: The final if is the call-site fragment inside the lab's command handler: it shows the shared execution boundary where that decision is used.
  • time-of-check/time-of-use: This is a time-of-check/time-of-use gap.
  • telemetry gap: This is a telemetry gap, not a control bypass.
  • confusion matrix: The four-outcome table above is called a confusion matrix.

Review

  • Q: A read-only toy policy blocks WriteMemory but accepts ApplyPatch even though both change state. What is the strongest repair? A: Classify commands by effect and authorize the shared boundary
  • Q: A policy meant to enforce read-only mode blocks only a command named 'write'. A different command named 'patch' that also changes state is requested. What does the policy do? A: Allows it, since the name is not on its list
Download cheatsheet (.md)

The earlier chapters showed how a value, hook, or object layout can change while a game still appears to run normally. The next question is whether a check guards the effect it claims to guard. A perfect module inventory cannot rescue a rule that checks the wrong path. Three deliberately weak rules in a small program make that gap visible before we examine game logic controls and further trust-boundary failures.

Research the failure without targeting a real defense

Section titled “Research the failure without targeting a real defense”

This lesson uses a tiny program with no Windows APIs, drivers, processes, networking, persistence, or security-product interaction. The “attacker” is a test function calling an intentionally bad policy. That lets us study the computer-science shape of an evasion while keeping the recipe useless against a real anti-cheat or endpoint defense. 🧪

toy_evasion_lab.rs

rust-labs/src/bin/ · Rust, 175 lines

Show the codeHide the code
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LabCommand {
    Observe,
    WriteMemory,
    ApplyPatch,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Effect {
    ReadOnly,
    ChangesState,
}

fn effect_of(command: LabCommand) -> Effect {
    match command {
        LabCommand::Observe => Effect::ReadOnly,
        LabCommand::WriteMemory | LabCommand::ApplyPatch => Effect::ChangesState,
    }
}

fn weak_name_based_policy(command: LabCommand) -> bool {
    // ❌ The policy recognizes one spelling instead of the shared effect.
    command != LabCommand::WriteMemory
}

fn effect_based_policy(command: LabCommand, writes_allowed: bool) -> bool {
    match effect_of(command) {
        Effect::ReadOnly => true,
        Effect::ChangesState => writes_allowed,
    }
}

#[derive(Debug)]
struct ToyTarget {
    build_id: u32,
    byte: u8,
}

fn weak_check_then_use(
    target: &mut ToyTarget,
    expected_build: u32,
    replacement: u8,
    between_check_and_use: impl FnOnce(&mut ToyTarget),
) -> Result<(), &'static str> {
    if target.build_id != expected_build {
        return Err("wrong build");
    }

    // 🧪 The lab hook simulates state changing after validation.
    between_check_and_use(target);
    target.byte = replacement;
    Ok(())
}

fn checked_at_the_sink(
    target: &mut ToyTarget,
    expected_build: u32,
    expected_byte: u8,
    replacement: u8,
) -> Result<(), &'static str> {
    // ✅ Validate every value immediately beside the side effect.
    if target.build_id != expected_build {
        return Err("wrong build");
    }
    if target.byte != expected_byte {
        return Err("state changed");
    }
    target.byte = replacement;
    Ok(())
}

#[derive(Debug, PartialEq, Eq)]
enum AuditResult {
    Allowed,
    Denied,
}

fn weak_audit(command: LabCommand, events: &mut Vec<String>) -> AuditResult {
    if !effect_based_policy(command, false) {
        // ❌ This early return creates a telemetry gap.
        return AuditResult::Denied;
    }
    events.push(format!("allowed {command:?}"));
    AuditResult::Allowed
}

fn complete_audit(command: LabCommand, events: &mut Vec<String>) -> AuditResult {
    let result = if effect_based_policy(command, false) {
        AuditResult::Allowed
    } else {
        AuditResult::Denied
    };
    // ✅ Record the decision regardless of which branch it took.
    events.push(format!("{result:?} {command:?}"));
    result
}

fn main() {
    println!("1. Coverage-gap lab");
    println!(
        "weak policy accepts ApplyPatch: {}",
        weak_name_based_policy(LabCommand::ApplyPatch)
    );
    println!(
        "effect policy accepts ApplyPatch in read-only mode: {}",
        effect_based_policy(LabCommand::ApplyPatch, false)
    );
    println!(
        "effect policy accepts Observe in read-only mode: {}",
        effect_based_policy(LabCommand::Observe, false)
    );

    println!("\n2. Check/use timing lab");
    let mut target = ToyTarget {
        build_id: 7,
        byte: 0x90,
    };
    let weak_result = weak_check_then_use(&mut target, 7, 0xcc, |target| {
        target.build_id = 8; // 🔁 Simulate replacement after the check.
    });
    println!("weak result: {weak_result:?}, target: {target:?}");

    let mut target = ToyTarget {
        build_id: 7,
        byte: 0x90,
    };
    target.byte = 0x91; // Simulate state changing before the sink check.
    let strong_result = checked_at_the_sink(&mut target, 7, 0x90, 0xcc);
    println!("strong result: {strong_result:?}, target: {target:?}");

    println!("\n3. Telemetry-gap lab");
    let mut weak_events = Vec::new();
    let _ = weak_audit(LabCommand::WriteMemory, &mut weak_events);
    println!("weak events: {weak_events:?}");

    let mut complete_events = Vec::new();
    let _ = complete_audit(LabCommand::WriteMemory, &mut complete_events);
    println!("complete events: {complete_events:?}");
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn effect_policy_covers_every_state_changing_command() {
        for command in [LabCommand::WriteMemory, LabCommand::ApplyPatch] {
            assert_eq!(effect_of(command), Effect::ChangesState);
            assert!(!effect_based_policy(command, false));
        }
    }

    #[test]
    fn sink_check_rejects_changed_state() {
        let mut target = ToyTarget {
            build_id: 7,
            byte: 0x91,
        };
        assert_eq!(
            checked_at_the_sink(&mut target, 7, 0x90, 0xcc),
            Err("state changed")
        );
        assert_eq!(target.byte, 0x91);
    }

    #[test]
    fn denied_decisions_are_logged() {
        let mut events = Vec::new();
        assert_eq!(
            complete_audit(LabCommand::WriteMemory, &mut events),
            AuditResult::Denied
        );
        assert_eq!(events, ["Denied WriteMemory"]);
    }
}

The buildable source is toy_evasion_lab.rs. Run it with:

Terminal window
cd rust-labs
cargo run --bin toy_evasion_lab
cargo test --bin toy_evasion_lab

Each exercise follows the same research loop:

state the control's promise
find the unchecked assumption
reproduce the gap in the toy
move the check to
the real effect
add a regression test

Recipe 1: test a name-based rule with an equivalent command

Section titled “Recipe 1: test a name-based rule with an equivalent command”

The toy has three commands:

enum LabCommand {
Observe,
WriteMemory,
ApplyPatch,
}

Both WriteMemory and ApplyPatch change state. The weak policy blocks only the command whose name it recognizes:

fn weak_name_based_policy(command: LabCommand) -> bool {
command != LabCommand::WriteMemory
}
  1. Set the lab to read-only mode.
  2. Ask the weak policy about WriteMemory; it says no.
  3. Ask about ApplyPatch; it says yes.
  4. Observe that the second spelling reaches the same kind of state-changing effect the policy claimed to prevent.
A read-only policy that checks names instead of effectsThe toy policy blocks WriteMemory by name. ApplyPatch changes state just as much, and the name check lets it through.
  1. 1 The policy blocks one name

    Policy by name

    WriteMemory
    name: blocked
    changes state
    ApplyPatch
    name: allowed
    changes state

    It lists WriteMemory as forbidden. ApplyPatch is not on the list, so the policy allows it.

  2. 2 Both commands change state

    Policy by name: one gap

    WriteMemory
    name: blocked
    changes state
    ApplyPatch
    name: allowed (wrong)
    changes state

    The invariant says no state changes, so the name check missed one of them.

  3. 3 Repair: classify the effect

    Repaired: policy by effect

    WriteMemory
    effect: blocked
    changes state
    ApplyPatch
    effect: blocked
    changes state

    The repaired invariant looks at what a command does, so any state-changing command is refused.

The toy policy blocks WriteMemory by name. ApplyPatch changes state just as much, and the name check lets it through.

name matches the denylist
name is not on the denylist
WriteMemory
blocked
ApplyPatch
allowed —
but changes state too

The weakness is coverage by spelling. Adding more forbidden names would become an endless denylist. Classify the effect instead:

enum Effect {
ReadOnly,
ChangesState,
}
fn effect_based_policy(command: LabCommand, writes_allowed: bool) -> bool {
match effect_of(command) {
Effect::ReadOnly => true,
Effect::ChangesState => writes_allowed,
}
}

Look at what changed about the question being asked. The weak version asked “which command is this?”, which has as many answers as there are names. The enum asks “what does this command do to state?”, and that has exactly two answers. A question with a fixed number of answers can be answered completely; a question about names cannot.

effect_of is now the single place that judgement lives. Adding a command to the lab means classifying it there, and the match makes that unavoidable — leave the new variant out and the code does not compile. The denylist that would have needed extending forever has become a decision the compiler insists you make once.

The original policy blocks only the command named WriteMemory; every other name passes, even when writes are disabled. The final if is the call-site fragment inside the lab’s command handler: it shows the shared execution boundary where that decision is used.

Before: block only one command name
fn policy(command: LabCommand, writes_allowed: bool) -> bool {
command != LabCommand::WriteMemory // Ignores the setting and other write commands.
}
if policy(command, settings.writes_allowed) {
execute_at_shared_boundary(command)?;
}

Classify the effect of each command once, then apply the setting to every state-changing effect. The enum and exhaustive match keep new command variants from silently falling outside the rule.

Improved code

fn policy(command: LabCommand, writes_allowed: bool) -> bool {
match effect_of(command) {
Effect::ReadOnly => true,
Effect::ChangesState => writes_allowed,
}
}
if policy(command, settings.writes_allowed) {
execute_at_shared_boundary(command)?;
}

Show the diff

fn policy(command: LabCommand, writes_allowed: bool) -> bool {
command != LabCommand::WriteMemory // Ignores the setting and other write commands.
}
fn policy(command: LabCommand, writes_allowed: bool) -> bool {
match effect_of(command) {
Effect::ReadOnly => true,
Effect::ChangesState => writes_allowed,
}
}
if policy(command, settings.writes_allowed) {
execute_at_shared_boundary(command)?;
}

Takeaway: permission checks should follow what an operation does, not just what it is called.

The regression test iterates over every state-changing enum variant. Adding a new variant then forces the programmer to classify it.

Recipe 2: test a change between validation and use

Section titled “Recipe 2: test a change between validation and use”

The weak function checks a target build, calls a lab-only hook that simulates a change, and then writes:

if target.build_id != expected_build {
return Err("wrong build");
}
between_check_and_use(target); // the fixture changes here
target.byte = replacement;
  1. Start with build ID 7 and byte 0x90.
  2. Let the first build check succeed.
  3. In the supplied test hook, replace the build ID with 8.
  4. Observe that the weak function still writes 0xCC because it uses an old decision.
targetweak_toctou_patchtargetweak_toctou_patchbetween_check_and_use(target)changes build_id to 8still writes 0xCC,using the now-stale build checkread build_id (7 == expected_build)target.byte = 0xCC

This is a time-of-check/time-of-use gap. The repair validates the build and expected old byte immediately beside the side effect:

if target.build_id != expected_build {
return Err("wrong build");
}
if target.byte != expected_byte {
return Err("state changed");
}
target.byte = replacement;

For a real game process, Windows can still change the target between two calls. You cannot make another process freeze by wishing. Keep the interval small, compare expected bytes, report mismatch as a normal refusal, and make the operation reversible.

This interactive lab needs JavaScript. The lesson text around it still explains the same idea.

Recipe 3: test a state-changing branch the logger misses

Section titled “Recipe 3: test a state-changing branch the logger misses”

The third toy control logs allowed work but returns early for denied work:

if !effect_based_policy(command, false) {
return AuditResult::Denied; // ❌ no event was recorded
}
events.push(format!("allowed {command:?}"));
  1. Submit WriteMemory while writes are disabled.
  2. Confirm that the policy denies it.
  3. Inspect the event list; it is empty.
  4. A reviewer looking only at the log cannot distinguish “nothing happened” from “a prohibited request was blocked.”
allowed
denied — old code
denied — fixed code
command arrives
effect_based_policy?
events.push(allowed)
return Denied,
no event recorded
events.push(Denied)

This is a telemetry gap, not a control bypass. It still matters because a denial that leaves no event hides repeated mistakes and makes the control difficult to test. The fix computes the decision first and then logs it, whichever way it went:

let result = if effect_based_policy(command, false) {
AuditResult::Allowed
} else {
AuditResult::Denied
};
events.push(format!("{result:?} {command:?}"));

Do not log secrets, raw memory, authentication keys, or giant payloads. A useful event records time, operation category, verified target identity, allow/deny decision, and a bounded reason code.

Published evasion write-ups are usually operational recipes. Underneath the commands, each one describes the same parts, and this lab’s bypass contains every one of them:

Part In this lab
The promise the control made Read-only mode prevents state change
What the control actually checked One command name
The equivalent effect it missed Another command reached the same sink
The observable result The weak policy returned true for ApplyPatch
Where the invariant belongs At the shared state-changing boundary
The regression test that keeps it fixed Every ChangesState variant is denied in read-only mode

Described by its parts, a bypass is a gap between what a control promised and what it checked. That is something a defender can close, and nothing in the description is a step that could be aimed at someone else’s machine.

No program can directly read a person’s intent. A detector measures observable signals—for example a debug flag, an unusual timing gap, a changed byte, an unexpected handle, or a forbidden state transition—and applies a rule. That rule can be wrong in two directions:

Outcome Plain-English meaning
True positive The measured signal and prohibited condition are both present
False positive A harmless tool or environment produces the same signal
True negative Normal behavior is accepted
False negative The prohibited effect occurs without the expected signal

This is why a strong repair protects the important effect boundary instead of trusting one environmental clue. In the toy lab, “state must not change in read-only mode” is an invariant. A process name, timing threshold, or single flag is only a signal that often accompanies a violation; it is not the invariant itself.

The four-outcome table above is called a confusion matrix. Testing a rule against a controlled fixture fills it in one condition at a time: attached and detached runs, slow and fast machines, and the diagnostic tools a legitimate user is expected to run. A rule that fires on a legitimate debugger or profiler lands in the false-positive row, and that is a compatibility bug worth fixing rather than proof that every observer is hostile.

Use signal families as a checklist for honest tests and false-positive review, not as a list of concealment tricks. The goal is to understand what the detector actually measures and then protect the real invariant at the shared boundary.

A name can change, an earlier validation can go stale, and a denied decision can disappear from the record. Authorize the effect at its shared boundary, record both allowed and denied outcomes, and keep the failed case as a regression test. The toy boundary makes each failure observable without conflating it with the behavior of a real game’s defenses.

Knowledge check

Repair the shared effect

A read-only toy policy blocks WriteMemory but accepts ApplyPatch even though both change state. What is the strongest repair?

Finished Lesson 15.7?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 15: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.