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:
// 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.
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:
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:
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 and virtualized 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 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.
Top comments (0)