Skip to content

Deep Dive · Nebula.NET

Beating de4dot: when the one-click strip comes back empty

A knowledgeable attacker's first move isn't to read your code — it's to run de4dot and let it undo the obfuscation automatically. If it works, you were never protected. Here's precisely how de4dot's pipeline works, stage by stage, and why Nebula's output survives every stage.

By Delta1 Labs Engineering September 2026 10 min read Deobfuscation

de4dot is the de-facto automatic deobfuscator for .NET, and it is very good. Point it at an assembly protected by a mainstream obfuscator and, in a single command, it recognises the scheme and reverses it — restoring names, decrypting strings inline, rebuilding flattened control flow, and stripping the anti-tamper. Minutes of work, fully automated. Which means the honest test of any obfuscator isn't "does the output look scary" — it's does the output survive de4dot? If a tool folds to de4dot app.dll, it sold you a speed bump.

Nebula is engineered to answer that test. Not by being unrecognised by accident, but by defeating each specific technique de4dot's automatic pass relies on. To see why, you first have to see what de4dot actually does.

How de4dot works

de4dot runs a pipeline. First it fingerprints the assembly against a library of known obfuscators; a match unlocks a scheme-specific un-transform. For anything it doesn't recognise it falls back to generic techniques that work against the common patterns obfuscators use, regardless of vendor. Those generic stages are the interesting ones, because they're what a novel obfuscator has to beat:

de4dot's generic pipeline detectfingerprint control-flowun-flatten stringsdecrypt inline proxiesresolve calls tamperstrip savecleaned Nebula has to beat every generic stage — one recovered layer is a foothold.
Figure 1 — Against an unknown obfuscator, de4dot leans on generic passes that exploit common patterns. Each of the middle stages is what the sections below defeat.

Stage: control flow it can't constant-fold

de4dot un-flattens a method by treating the dispatcher's state variable as a value it can constant-propagate: it walks the switch, computes the next state at each block statically, and rebuilds the original edges. That works when the next state is a literal assignment — which, in most obfuscators, it is:

A dispatcher de4dot CAN foldrecoverable
int state = 0;
while (true) switch (state)
{
    case 0: DoA(); state = 2; break;   // next state is a constant →
    case 2: DoB(); state = 5; break;   // de4dot propagates it and
    case 5: return;                    // rebuilds:  DoA(); DoB();
}

Nebula's flattening masks the selector with a value only known at runtime. The stored states aren't the case labels; the case labels are state ^ key, and key is computed by a loop de4dot can't evaluate statically:

The dispatcher de4dot meets in a Nebula buildholds
long key = ComputeKeyAtRuntime();      // opaque; not a compile-time constant
int state = 0x51ED2704;
while (true) switch (state ^ key)      // selector depends on runtime key
{
    case 0x51ED2701: /* … */ state = 0x51ED2705; break;
    case 0x51ED2705: /* … */ state = 0x51ED27F0; break;
    case 0x51ED27F0: /* decoy: unreachable, but not provably dead */ break;
    // …
}

Because the selector is state ^ key and key isn't a constant, there's nothing to propagate — de4dot's control-flow deobfuscator runs and the method stays flattened. The Enterprise aggressive tier adds decoy states that can't be proven dead and pruned, so even partial reconstruction is polluted.

Constant state → folds state = 0 case 0: … state = 2 case 2: … state = 5 DoA(); DoB(); ✓ rebuilt state ^ runtime key → stuck key = runtime() switch(state ^ key) case 0x51ED2701: … + decoy states no constant to fold ✗
Figure 2 — Constant propagation is the whole trick. Remove the constant — make the selector depend on a runtime key — and the un-flattening pass has nothing to compute.

Stage: string encryption it can't auto-strip

de4dot's headline win is string decryption. It recognises the canonical decrypter — a static method that maps a token to a plaintext string — invokes it directly for every call site, and replaces ldc.i4 token; call Decrypt with the recovered literal. The critical assumption is that the decrypter is a pure function of its argument, so de4dot can call it in isolation:

The decrypter de4dot expects — pure, callablerecoverable
static string Decrypt(int token) { … }      // depends only on 'token'
// de4dot just calls Decrypt(0x1A2B) itself and inlines the result.

Nebula's decryption takes a per-call-site salt. Each site passes its own value, and the routine's result depends on where it was called from — so there's no single pure function de4dot can invoke to reproduce the strings:

Per-call-site decryption in a Nebula buildholds
x.d("\uded4\uded4\ude98…", 52751);   // salt differs per site
x.d("\uf5f7\uf5f3\uf5f9…", 58659);
x.d("\uecf7\uece4\ueced…", 64565);
// no single Decrypt(token) reproduces these; the auto-decrypt pass fails.

That signature isn't the string(int) shape de4dot auto-detects, and calling it blindly with the wrong site context yields garbage — so the literals stay encrypted in the output.

Stage: a call graph it can't resolve

de4dot's proxy-call fixer reverses known delegate/proxy schemes — it recognises the specific way a given obfuscator forwards calls and rewrites the site back to the real target. Nebula's reference obfuscation routes calls through injected, unbranded proxy methods that match no catalogued scheme, so the generic fixer has no pattern to apply. Neither de4dot nor a decompiler can tell which external or BCL method a given site actually invokes.

Stage: methods with no IL at all

Deobfuscation, by definition, operates on obfuscated IL. Code virtualization leaves de4dot nothing to operate on: the method body is a call into an embedded VM, and the logic lives as custom bytecode in a resource. There's no flattened graph to rebuild, no decrypter to invoke, no proxy to resolve — just a language de4dot has never seen.

“A deobfuscator reverses transformations it recognises. Nebula's job is to make sure the automatic pass recognises nothing — and, where it matters, finds no IL at all.”

The proof — run it yourself

We don't ask you to take this on faith; we test it every release. We build a sample, protect it with Nebula, and run it through de4dot — including forcing the dedicated control-flow deobfuscator with --f cflow. The outcome is consistent:

de4dot against a Nebula-protected build
> de4dot sample.dll
Detected Unknown Obfuscator
Cleaning sample.dll
Saving "sample-cleaned.dll"

> ilspycmd sample-cleaned.dll   # inspect the "cleaned" output
  • control flow: still flattened (state ^ key intact)
  • strings:      still encrypted (per-site salts intact)
  • virtualized:  still a VirtualMachine.Run(...) stub

de4dot runs to completion and reports success — but the "cleaned" assembly is functionally as protected as the input. You can reproduce it in three steps: protect your build with nebula --config nebula.config.json, run de4dot yourapp.dll, then open the result in Glass.NET or ILSpy and confirm the layers are intact.

What reaches each edition

Automatic-deobfuscation resistance begins at the licensed (Pro) tier and is strongest at Enterprise:

TechniqueFreeLicensedEnterprise
Non-foldable control-flow dispatch
Per-call-site string encryption
Proxy-call (call-graph) obfuscation
Aggressive tier (decoy states)
Code virtualization

Ship output that survives the one-click strip.

Nebula.NET's layered protection is designed to defeat de4dot's automatic deobfuscation — and we verify it every release.