Decompiling and Recompiling .NET Apps — and How to Protect Your Binaries

I ship a lot of .NET. Desktop apps, libraries, the occasional licensed component. And every so often
someone asks me a question that sounds simple but isn't: "If I send a customer this DLL, can they
see my code?"
The honest answer is yes — most of it. Not because you did anything wrong, but because of how
.NET is built. A C# assembly isn't a sealed box of machine instructions; it's a remarkably readable
package that carries your program's structure right on its surface. That property is a gift when
you're debugging or learning, and a liability when you're trying to protect intellectual property or
a license check.
This is the post I wish I could hand to people when that question comes up. First, why .NET is so
easy to crack open. Then a concrete decompile → patch → recompile loop so it stops being abstract.
And finally the real menu of protections — what each one buys you, and the uncomfortable truth about
where the ceiling is.
Why .NET hands over your source
When you build a C# project, the compiler does not produce native machine code for a specific CPU.
It produces IL — Intermediate Language (you'll also see it called MSIL or CIL). IL is a compact,
stack-based instruction set that the .NET runtime compiles to real machine code at runtime, with
the JIT compiler, on the machine where the app actually runs. That's the whole reason a single build
can run on x64, Arm64, Windows, Linux, and macOS.
Here's the catch. To make all of that work, the assembly has to carry a complete description of itself
— the metadata. Every type, every method, every field, every parameter, every property, with their
original names and signatures. The runtime needs that information to do its job. So your .dll
contains, in plain structured form:
- the full type and member layout, with names like
LicenseManager.IsActivated()intact, - the IL body of every method,
- and (unless you stripped them) often debugging hints too.
A decompiler's job is almost mechanical: read the metadata, read the IL, and run the C# compiler's
transformations in reverse — turn IL branches back into if/while, turn stack operations back
into expressions, reattach the names from the metadata. Because the names survived, the output reads
like real source. Contrast that with a C++ binary, where the compiler threw the names away and baked
everything down to registers and addresses — there, a decompiler can only guess at structure and
invents names like sub_401000.
So the thing that makes .NET pleasant — portable, self-describing, reflection-friendly — is exactly
the thing that makes it transparent. Worth saying clearly: this is not a bug, and there is no flag
that turns it off. It's the design.
The tools, briefly
The ecosystem here is mature and mostly free:
- ILSpy — the open-source workhorse decompiler. Open a
.dll, browse the tree, read clean C#. There's a standalone app, a VS Code extension, and a CLI
(ilspycmd). - dnSpy / dnSpyEx — a decompiler that's also a debugger and
an editor. This is the one that makes people's eyes widen: you can edit a method in C# or in IL
and save the modified assembly back out. The original dnSpy is archived; dnSpyEx is the
maintained fork. - dotPeek — JetBrains' free decompiler; can present a
decompiled assembly as a navigable pseudo-source project. - ILDASM / ILASM — Microsoft's own disassembler and assembler, shipped with the SDK.
ildasm
turns an assembly into a.iltext file;ilasmturns that.ilback into an assembly. This
round-trip is the "official" version of recompiling. - Reflexil — a patcher that plugs into ILSpy/Reflector for
surgical IL edits.
None of this is shady. I use ILSpy constantly to understand a third-party library when the source
isn't handy, or to check what a NuGet package actually does. The same tools that help you cross the
line into someone else's licensing logic also help you do legitimate, everyday engineering. Which is
the whole tension of this post.
A note on ethics and the law before we go further: decompiling your own code, or doing it to
learn and interoperate, is normal engineering. Cracking software you don't own, defeating a license
you didn't pay for, or redistributing someone's patched binary is a different thing entirely — often
a breach of the license agreement and, depending on where you live, illegal. Everything below is so
you can defend your software, with a clear picture of what the attacker on the other side can do.
A concrete loop: decompile, patch, recompile
Let me make it real with the smallest possible example — the kind of naive license gate I've seen in
production more than once. Imagine the app ships with this:
public static class LicenseManager
{
public static bool IsActivated()
{
// talk to nobody, just check a local flag
return ReadLicenseFlagFromDisk();
}
}
// elsewhere
if (!LicenseManager.IsActivated())
{
Console.WriteLine("Trial expired. Please purchase a license.");
return;
}
RunTheGoodStuff();
The author thinks the secret is safe because it's "compiled." Here's what an attacker actually sees
after dropping the DLL into a decompiler — essentially the source above, names and all. Now the
attack. The method returns a bool. At the IL level, return true is almost nothing:
ldc.i4.1 // push the integer 1 (true) onto the stack
ret // return it
In dnSpyEx, you don't even need to think in IL. You right-click IsActivated, choose Edit
Method (C#), change the body to return true;, hit compile, and then File → Save Module. dnSpy
rewrites the assembly with your patched method baked in. The license check now passes unconditionally,
the file still loads and runs, and nothing about the rest of the app changed. That's the entire
attack — minutes, no source, no rebuild of the original project.
The Microsoft-blessed version of the same idea is the ildasm/ilasm round-trip:
# 1. disassemble the assembly to editable IL text
ildasm MyApp.dll /out=MyApp.il
# 2. edit MyApp.il — find IsActivated, replace its body with
# ldc.i4.1 / ret (return true)
# 3. reassemble back into a working DLL
ilasm MyApp.il /dll /output=MyApp.patched.dll
Same outcome. The takeaway isn't "dnSpy is dangerous" — it's that a bool gate sitting inside the
binary you handed the customer is not protecting anything. The decision is made on the attacker's
machine, with the attacker holding every byte. So what can you do?
The protection menu
Think of this as defense in depth. No single item is a wall; each one raises the cost and time of an
attack, and stacking them is the point. I'll go roughly from cheapest to most effective.
1. Obfuscation
Obfuscation rewrites your assembly so it still runs identically but is miserable to read. The
common transformations:
- Renaming —
LicenseManager.IsActivated()becomesa.b(), every field becomesa,b,c.
The decompiler still works, but you're now reading soup with no semantic hints. - Control-flow obfuscation — the linear logic is shredded into a state machine with bogus
branches, so the decompiler can't reconstruct cleanif/whileand often emitsgotospaghetti. - String encryption — literals like
"Trial expired"or an API endpoint are encrypted in the
binary and decrypted at runtime, so you can't just grep for the telling string to find the gate. - Anti-tamper — a checksum the assembly computes over itself at startup, so a patched binary
refuses to run. - Anti-debug — checks that trip when a debugger like dnSpy is attached.
Tools in this space: ConfuserEx (free, open source, still
the go-to for hobby and small commercial work), .NET Reactor, Eazfuscator.NET, Babel, and
PreEmptive Dotfuscator (a community edition ships in Visual Studio). Honesty check: obfuscation
slows humans down, it doesn't stop them. There are deobfuscators (de4dot and friends) tuned for
the popular packers, and a determined attacker with time will still get there. Renaming buys the most
per unit of effort; control-flow and anti-tamper raise the bar further. Just don't mistake "unreadable"
for "secure."
2. Strong naming is not protection (a common myth)
People reach for strong naming thinking it stops tampering. It doesn't. A strong name is a
cryptographic identity — it lets the runtime tell "this is genuinely Joche's assembly v2.1" from an
impostor, and historically it mattered for the GAC and binding. But an attacker who patches your DLL
simply re-signs it with their own key, or strips the strong name entirely, and the app runs. Use
strong naming for identity and versioning. Do not file it under anti-piracy.
3. Native compilation — the real shift
Here's the one that actually changes the physics, and it's matured a lot in modern .NET.
NativeAOT (ahead-of-time)
compiles your app straight to native machine code — no IL shipped, no JIT at runtime, much of the
metadata gone. The result is a self-contained native executable. Now the attacker is back in
C++-decompiler territory: a disassembler that sees registers and addresses, not LicenseManager. IsActivated(). It is dramatically harder to read and patch than IL. It's not magic — native
binaries can still be reverse-engineered by people who do that for a living — but you've moved from
"afternoon with dnSpy" to "specialist with IDA Pro and real time on their hands."
The trade-offs are real and you should know them: NativeAOT restricts reflection and runtime code
generation, needs the trimming-friendly bits of your dependency graph, targets a specific platform per
build, and doesn't fit every app (notably the classic reflection-heavy desktop and plugin frameworks).
Its lighter cousin ReadyToRun (R2R) precompiles IL to native for startup speed but still ships the
IL alongside, so it's a performance feature, not a protection one. If protecting the binary is a hard
requirement and your app can live within the constraints, NativeAOT is the strongest lever on this
list.
4. Move the secret off the client entirely
This is the one I push hardest, because it's the only one with no ceiling. Code you don't ship can't
be decompiled. If a piece of logic is genuinely valuable — a pricing algorithm, a proprietary
calculation, the actual license validation — put it behind an API and run it on a server you control.
The client calls in; the secret never leaves your machine.
Apply it directly to our example: don't decide IsActivated() locally with a bool. Have the client
ask your license server, over TLS, signing the request, and have the server return a signed,
time-limited token that the client verifies with a public key. Now patching the client doesn't help —
there's nothing local to flip, and forging the server's signature is the actual hard cryptographic
problem you wanted the attacker to face all along. You can't always move everything server-side
(offline apps exist), but move what you can, and design the local checks to fail toward calling the
server rather than toward a local boolean.
5. Licensing, done so it actually resists patching
If you must validate locally, lean on cryptography instead of conditionals. Issue licenses as data
signed with your private key; the app verifies the signature with the embedded public key.
The attacker can read your verification code all they like — they still can't mint a valid license
without your private key. Yes, they can try to patch out the verification call itself (back to the
bool problem), which is exactly why you combine this with anti-tamper and, ideally, a server check.
Layers.
So where does that leave you?
Let me be blunt, because the worst outcome is false confidence. You cannot make a .NET binary that a
motivated, skilled attacker with physical possession of it cannot eventually defeat. Anything that
runs on a machine you don't control is, in the end, reverse-engineerable. That's not defeatism — it's
the correct threat model, and it tells you where to spend effort.
What you can do is make the cost of attacking exceed the value of the prize, for the attackers you
actually care about. A reasonable stack, in order of payoff:
- Move the crown jewels server-side. Unbeatable, when feasible.
- NativeAOT if your app fits — it changes the whole game on the client.
- Cryptographic licensing (signed, server-validated) instead of local booleans.
- Obfuscation (renaming + control-flow + anti-tamper) to slow the rest down.
- Use strong naming for identity — and don't expect it to stop anyone.
The mistake I see most often isn't picking the wrong tool — it's the bool in the binary, the belief
that "compiled" means "hidden." It doesn't. In .NET, compiled means portable and self-describing,
which is wonderful right up until you need it not to be. Now you know why, you've seen the attack, and
you've got the menu to push back. Build with that in mind from the start, and you'll spend your
protection budget where it actually moves the needle.