Obfuscation answers "how does this work?" by making the answer expensive. It does nothing about a different attack: someone who doesn't care how your licence check works, only that they can flip one branch. A single byte — turning a brfalse into a brtrue, or NOPping a call — can turn "if (!licensed) Exit()" into "always run." You don't need to understand code you can patch.
Anti-tamper is the countermeasure. It doesn't hide logic; it makes a modified assembly detect its own modification and refuse to run normally. Here's how Nebula.NET implements it, what the runtime check actually looks like, and — because this matters more than the marketing — where it stops.
The idea: hash at build, verify at run
The assembly on disk is just bytes. If you hash the bytes that make up a method's body at build time and store that hash, then recompute it at runtime and compare, a patched byte produces a different hash. The method knows it's been changed.
The naive version is easy to defeat, so the details are everything. Nebula.NET, during its build pass:
- Computes a hash over the IL bytes of the protected methods (not the whole file — that would break the moment the CLR rewrites anything, and would be trivially skipped).
- Encrypts those expected hashes and threads them through the assembly's metadata and data streams, not as one neat table a tool can locate and zero out.
- Injects a small runtime integrity verifier that, on a protected method's first call, reads that method's current IL via the runtime's own reflection surface, hashes it, and compares.
Conceptually, a protected method gains a guard that runs once:
// What Nebula weaves in (illustrative — real injection is inlined and obfuscated).
static class Guard
{
static int _state; // 0 = unchecked, 1 = ok
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Verify(int methodToken, ulong expected)
{
if (Volatile.Read(ref _state) == 1) return; // amortized: check once per run
var mod = typeof(Guard).Module;
var mb = mod.ResolveMethod(methodToken)!;
byte[] il = mb.GetMethodBody()!.GetILAsByteArray()!;
ulong actual = Fnv1a(il) ^ Salt(methodToken); // keyed, not a bare hash
if (actual != expected)
Tamper(); // react — see below
Volatile.Write(ref _state, 1);
}
}
The real version doesn't look like that — the token and expected value aren't sitting in a clean call, the hash is mixed with string-encryption-style key handling, and the verifier itself is a control-flow-flattened target so you can't just find and neuter one tidy function. But the mechanism is exactly this: resolve the method, read its IL, hash it, compare to the sealed expected value.
Reacting without saying "tamper detected"
The weakest thing anti-tamper can do is throw new SecurityException("tamper detected") — you've just handed the attacker a string to breakpoint and a stack trace pointing at your check. Good tamper reaction is indirect and delayed:
- Corrupt, don't announce. Instead of branching to an exit, let the mismatch poison a value the real logic depends on — a decryption key derived partly from the expected hash, so a patched method silently decrypts to garbage and fails downstream, far from the check.
- Delay the effect. A check that reacts three method-calls later, in unrelated code, is far harder to trace back than one that dies on the spot.
-
Don't centralize. One
Tamper()everyone calls is one function to stub. Nebula scatters the reaction so there's no single choke point.
That's why anti-tamper pairs naturally with string encryption: the integrity value is used as key material, so defeating the check and decrypting the strings become the same problem.
The costs, honestly
-
First-call latency. Reading a method's IL and hashing it costs microseconds, done once per method per run (the
_stateamortization above). Scope it to the methods that matter — licence logic, crypto, the handful of functions whose patching actually hurts — not every getter. - It doesn't survive a debugger alone. An attacker who lets the check pass, then patches behavior at runtime in memory, isn't modifying the on-disk IL you hashed. Anti-tamper raises the cost of static patching; it is not a substitute for anti-debug or, ultimately, for not trusting the client.
- The verifier is itself a target. If an attacker can find and NOP your verifier, the checks never run. That's why the verifier must be obfuscated, inlined, scattered, and wired into values the program actually needs — so removing it breaks the program too.
Where it fits in the free/Pro line
The Free edition doesn't inject anti-tamper — it's a Pro/Enterprise protection, because it only earns its cost on code with real IP or licensing value. In the Pro edition you scope it per method or namespace and it runs as one pass alongside renaming, string encryption and control-flow flattening, so the integrity checks, the hidden expected values, and the encrypted strings all reinforce each other instead of sitting as separable layers.
The honest framing, same as always: anti-tamper does not make your assembly unmodifiable — nothing on a machine you don't control does. It makes a casual patch (flip a branch, NOP a call) stop working, and a determined patch a slow, uncertain job of untangling checks from the logic they protect. For the single-byte licence bypass that most "cracks" actually are, that's the difference between a five-minute job and one most attackers abandon.
Turn it on for the methods that matter: install the CLI with dotnet tool install --global Delta1Labs.Nebula.Tool, add a method to your anti-tamper scope, then patch a byte in the output and watch it fail. See the Nebula.NET docs to get started.
Top comments (0)