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 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:
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:
.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:
"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.
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:
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.