# Runtime patching: the attack your file-integrity check never sees


Most .NET anti-tamper does one thing: at startup it hashes the assembly and checks the hash against a known-good value, so if someone edited the DLL to delete a license check, the app notices and refuses to run. That is a real and useful defence — against *file* tampering. But there is a whole class of attack it cannot see, because it never touches the file. The method gets rewritten in memory, after the runtime has already loaded and JIT-compiled it. The bytes on disk are identical; the behaviour is not.

## The method the hash check never inspects

A .NET method goes through two forms. On disk it is IL inside the assembly — that is what a file hash covers. At runtime the JIT compiles that IL to native machine code in the process's memory, and *that* native code is what the CPU actually executes. In-memory patching targets the second form. It lets the method load and JIT normally — passing any load-time file check — and then rewrites the compiled method, or redirects its entry point, so calls land somewhere else.

The most popular managed tool for this is **Harmony**. It is a legitimate and excellent library (game mods rely on it), but the same capability that lets a mod change game behaviour lets an attacker neutralise a license check. A Harmony patch of your gate looks like this:

```csharp
// Attacker's patch assembly, loaded into your process (via a mod loader,
// an injected AppDomain, or a wrapper exe):
[HarmonyPatch(typeof(LicenseGate), nameof(LicenseGate.IsLicensed))]
static class Bypass
{
    // A "prefix" runs before your method and can skip it entirely.
    static bool Prefix(ref bool __result)
    {
        __result = true;   // force IsLicensed() to return true
        return false;      // skip the original method body
    }
}

var harmony = new Harmony("bypass");
harmony.PatchAll();        // detours LicenseGate.IsLicensed in memory
```

After `PatchAll()`, every call to `LicenseGate.IsLicensed()` returns `true` without your code running. Harmony did this by rewriting the JIT-compiled method with a detour to the prefix — **no file was modified**. Your Authenticode signature is still valid. Your SHA-256 of the DLL still matches. The load-time integrity check passes, and the gate is wide open.

<figure class="my-8"><svg viewBox="0 0 640 150" role="img" aria-label="A file-integrity check hashes the on-disk DLL and passes; meanwhile the JIT-compiled method in memory is detoured to the attacker's replacement, which the file hash never sees" style="width:100%;height:auto"><defs><style>.d{fill:#eef4ff;stroke:#8eb4ff}.m{fill:#fff1f2;stroke:#fda4af}.ok{fill:#e6fffb;stroke:#2dd4bf}.t{font:600 12px system-ui;fill:#1547e0}.s{font:11px system-ui;fill:#475569}.a{stroke:#8eb4ff;stroke-width:2;fill:none}.x{stroke:#fb7185;stroke-width:2;fill:none}</style><marker id="h" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#8eb4ff"/></marker><marker id="hx" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#fb7185"/></marker></defs><rect class="d" x="6" y="20" width="150" height="40" rx="6"/><text class="t" x="18" y="40">DLL on disk</text><text class="s" x="18" y="55">IL · unchanged</text><path class="a" d="M158 40 h50" marker-end="url(#h)"/><rect class="ok" x="210" y="20" width="160" height="40" rx="6"/><text class="t" x="222" y="40" style="fill:#0d9488">Hash check: PASS</text><text class="s" x="222" y="55">file is byte-identical</text><rect class="d" x="6" y="96" width="150" height="40" rx="6"/><text class="t" x="18" y="116">JIT'd method</text><text class="s" x="18" y="131">in process memory</text><path class="x" d="M158 116 h50" marker-end="url(#hx)"/><text class="s" x="160" y="108" style="fill:#be123c">detour</text><rect class="m" x="210" y="96" width="200" height="40" rx="6"/><text class="t" x="222" y="116" style="fill:#be123c">Attacker's replacement</text><text class="s" x="222" y="131">IsLicensed() → true</text><text class="s" x="440" y="120" style="fill:#be123c">never seen by the file hash</text></svg></figure>

## Detect the tool, not the file

If you cannot catch the change by hashing the file, catch the *conditions the attack needs*. In-memory rewriting in .NET relies on one of two things being present in your process, and both are observable at startup with ordinary managed code.

**A CLR profiler.** The runtime exposes a profiling/instrumentation API that can rewrite IL as it's JIT-compiled — the sanctioned, powerful way to detour managed methods. The runtime enables it through environment variables, so their presence is a strong signal that something is positioned to rewrite your methods:

```csharp
static bool ProfilerAttached() =>
    Environment.GetEnvironmentVariable("CORECLR_ENABLE_PROFILING") == "1" ||
    Environment.GetEnvironmentVariable("COR_ENABLE_PROFILING") == "1";
```

**A patching framework loaded in-process.** Harmony has to be *in* your process to patch it, and it ships as a specific assembly (`0Harmony`). You don't need to enumerate every assembly — ask the runtime whether its entry type resolves:

```csharp
static bool HarmonyLoaded() =>
    Type.GetType("HarmonyLib.Harmony, 0Harmony", throwOnError: false) != null
    || Type.GetType("Harmony.HarmonyInstance, 0Harmony", throwOnError: false) != null; // legacy 1.x
```

Run both at startup (in the module initializer, so they fire before your own code) and react the way you react to any other tampering signal — throw, exit, or hand off to your own handler. Neither check is expensive, and crucially they are **managed and cross-platform**, so the same guard works on Windows, Linux, macOS and the WASM runtime.

## What this does and does not buy you

Be honest about the threat model, because overselling a defence is how you get a false sense of security. These checks **raise the cost** of a casual detour bypass — the attacker who grabs Harmony and writes a ten-line prefix now has to also defeat a detector before their patch even runs. That stops the opportunistic majority. They do **not** defeat a determined attacker: someone who controls the environment can rename the patcher assembly, patch out your detector first, or attach a profiler the check doesn't look for. There is no managed-code check that a sufficiently motivated attacker with full control of the machine cannot eventually remove — the same caveat that applies to every client-side protection.

The right posture is layering. File-integrity anti-tamper catches the patched-binary attack; profiler and patcher detection catches the in-memory-detour attack; and the thing both are protecting — your license gate — should itself be hard to *find* and hard to *read*, so an attacker can't easily locate the method to patch in the first place. A gate buried inside [control-flow-flattened](https://delta1labs.com/blog/control-flow-obfuscation-dotnet) and [virtualized](https://delta1labs.com/articles/code-virtualization) code, returning a value that feeds into real logic rather than a tidy `bool IsLicensed()`, is far harder to detour cleanly than a single well-named method. Detection tells you an attack tool is present; obfuscation makes the target it's aiming at expensive to acquire.

## Putting it together

If your anti-tamper story is "I hash the DLL at startup," you are defending one door and leaving another open. The in-memory patch is the door that modern license bypasses and cheats actually walk through, precisely because it leaves the file — and its signature — pristine. Add startup detection for the two things an in-memory patch needs (a profiler hook, a loaded patcher), react on your terms, and combine it with obfuscation that makes the patched target hard to find. [Nebula](https://delta1labs.com/products/nebula) injects both the file-integrity check and the profiler/patcher detection for you, so the protected build watches the door the hash check can't see — without you writing any of the detection code by hand.

