Skip to content

Deep Dive · Nebula.NET

Can .NET code be decompiled?

Yes — and far more completely than most developers expect. A managed assembly isn't a compiled black box; it's a fully-indexed description of your program. Here's exactly what a decompiler pulls out of a DLL, why the runtime keeps that information around, and what actually takes it back.

By Delta1 Labs Engineering September 2026 9 min read Reverse engineering

Ship a C++ or Rust binary and a reverse engineer starts from machine code: no names, no types, no method boundaries, just registers and jumps — a hard, lossy climb. Ship a .NET assembly and they start from something extraordinarily friendlier. C# doesn't compile to machine code at all; it compiles to CIL, a portable, fully-typed, stack-based instruction set, and it wraps that IL in a rich metadata directory the runtime needs to load, verify and JIT it. A free tool reads both back and reconstructs C# that often mirrors what you wrote.

That surprises people, so this article is deliberately concrete: what's recoverable, why the CLR keeps it, and the specific things you can do to strip it back out without breaking your program.

A DLL is a labelled directory, not a black box

A managed assembly is a PE file whose payload is a CLI image: a set of metadata tables plus the IL method bodies they point at. The tables are the giveaway. TypeDef and MethodDef hold your type and method names; Param holds parameter names; Field holds field names; the #Strings heap stores every one of those identifiers as UTF-8, and the #US (user-string) heap stores every string literal in your code. The runtime needs all of it — to resolve a Type.GetMethod("Foo"), to bind an overload, to serialize by property name, to print a stack trace. A decompiler just reads the same directory.

What ships inside Your.dll PE + CLI header entry point, table directory Metadata tables TypeDef → Pricing, Order MethodDef → Discount, IsVip Param → o, key, total Field → _rate, _customer Heaps #Strings → identifier names #US → "VIP rule", API keys… IL method bodies ldarg.1 callvirt get_Total ldc.i4 1000 bgt.s IL_0021 ldstr "VIP rule" ... typed, structured, exception regions intact
Figure 1 — The metadata tables name every type, method, field and parameter; the heaps hold your identifiers and string literals; the IL bodies keep types, structure and exception regions. A decompiler reads the directory the runtime itself relies on.

What a decompiler actually recovers

Point ILSpy, dnSpy, or our free Glass.NET at an ordinary release build and you don't get an approximation — you get your program. Take a small, representative pricing rule:

Pricing.cs — what you wrote
public decimal Discount(Order o)
{
    if (o.Total > 1000m && o.Customer.IsVip)
        return o.Total * 0.15m;      // VIP rule
    return o.Total * 0.05m;
}

The compiler lowers that to IL. Even at the IL level, before any decompilation, the logic is legible — the names, the constant 1000, the property calls and the branch are all right there:

Discount — IL as read by any disassembler
.method public hidebysig instance valuetype System.Decimal
        Discount(class Order o) cil managed
{
  ldarg.1
  callvirt   instance valuetype System.Decimal Order::get_Total()
  ldc.i4     1000
  ...
  callvirt   instance bool Customer::get_IsVip()
  brfalse.s  IL_0021          // skip VIP branch if not VIP
  ldarg.1
  callvirt   Order::get_Total()
  ldc.r8     0.15             // the VIP rate
  ...
}

Feed that to a decompiler and it reconstructs the control flow into real if/return, folds the constants back, restores the names from metadata, and hands you source that recompiles. The output is essentially your original — business rule, rate and all. Worse, string literals sit in the #US heap in plain view, so an embedded connection string or API key is a single search away:

What "Find strings" turns up in an unprotected buildexposed
"Server=prod-sql;Database=Billing;User Id=svc;Password=…"
"sk_live_9f2a…"                       // an API key compiled into the DLL
"VIP rule"                            // even your comments-turned-messages
“A .NET DLL isn't a locked box. It's a labelled, indexed manual for your software — unless you take the labels off.”

Why the CLR keeps all of this

It's not an oversight; it's the contract. IL is JIT-compiled to machine code on the target machine, so the assembly must carry enough typed structure for the JIT to verify and compile it. Reflection, serialization, dependency injection, data binding and stack traces all resolve members by name at runtime — so the names must survive. That richness is exactly what a decompiler consumes. Which points straight at the remedy: obfuscation is the practice of degrading what the file reveals — renaming, restructuring, encrypting — without breaking how the runtime loads and runs it.

What actually takes it back

You can't make client-side code unreadable forever, but you can climb a ladder that turns "open the DLL and read it" into "spend weeks and probably give up." Each rung removes something the decompiler depended on.

Each layer removes something the decompiler needs Renaming strips identifiers → a.b(c) Control-flow flattening destroys if/for structure String encryption empties the #US heap of secrets Anti-tamper / anti-debug closes the patch & step-through path Code virtualization removes the IL itself — nothing to read
Figure 2 — Protection is subtractive. Each rung takes away a specific input the decompiler relied on; virtualization removes the IL entirely.

Run the same Discount method through renaming, control-flow flattening and string encryption and the decompiler no longer shows a tidy rule — it shows an opaque state machine with the names gone and the rate no longer a visible literal:

Discount — decompiled after renaming + flattening + string encryptionprotected
public decimal a(b o)
{
    int s = 3;
    while (true)
        switch (s ^ k)            // runtime-keyed dispatcher; not constant-foldable
        {
            case 0x2F1A: /* … */ s = 0x77C4; break;
            case 0x77C4: /* … opaque blocks; 0.15m masked; "VIP rule" decrypted lazily … */
            // de4dot can't rebuild the original blocks; the rate isn't a plain literal
        }
}

And for the handful of methods that genuinely are the product — a license check, a key-derivation step, a pricing kernel — code virtualization goes further still: it removes the IL of the method entirely, replacing it with a call into an embedded VM that runs a custom bytecode. There is no IL left for a decompiler to read.

See it on your own binary first

The fastest way to appreciate the exposure is to look at it. Open one of your own release DLLs in the free Glass.NET decompiler and read a method you consider proprietary — the names, the constants, the structure will all be there. Then protect the build with Nebula.NET and open it again. The difference between a bare build and a hardened one, on the exact methods you care about, is the whole argument.

See what your DLL reveals — then close it.

Open your build in the free Glass.NET decompiler to see exactly what's exposed, then strip it back out with Nebula.NET.