Renaming is the first thing most .NET obfuscators do and the cheapest win they offer: Billing.InvoiceService.Charge becomes a.b.c, and a decompiler no longer hands a reader your architecture for free. The catch shows up weeks later, the first time a real crash comes back from the field. The stack trace is there, the line is right — but every frame reads a.b(c), and you cannot tell what broke. The instinct is to turn renaming off. The right move is to keep the renaming map and learn to read through it.
Why the trace is scrambled but not lost
A .NET stack trace is built at throw time from metadata that is in the shipped assembly: the method tokens on the call stack, resolved to their names. Obfuscation rewrote those names before shipping, so the trace faithfully reports the names that are actually in the binary — the obfuscated ones. Nothing is corrupted. The exception type, the message, the call order, and (if you kept symbols) the line numbers are all genuine. The only thing missing is the dictionary that maps the shipped names back to yours.
That dictionary is the renaming map, and a good obfuscator emits one per build. It is not shipped with the app — that would hand an attacker the exact inverse of the obfuscation. It is a build artifact you archive next to the PDBs for that version.
What a map entry actually contains
It is tempting to picture the map as a flat table of oldName → newName, but that falls apart immediately, because obfuscators reuse short names aggressively. a might be a hundred unrelated methods; b a dozen types. Scope is what keeps them apart, so each map entry is fully qualified — declaring type plus full signature — not just a leaf name. Conceptually a few rows look like this:
# original (fully qualified) => obfuscated
Billing.InvoiceService::Charge(Customer, Money) => a.b::c(a.d, a.e)
Billing.InvoiceService::.ctor(IClock) => a.b::.ctor(a.f)
Core.Money::FromMinor(Int64, String) => a.e::a(System.Int64, System.String)
The real file is usually XML or a tool-specific format, but the shape is the same: an original element identified by namespace, type, member and signature, paired with its obfuscated form. Because the key is the full frame, A::a(int) and B::a(string) are different rows even though both leaves are a. A deobfuscator that matches only on the short name will mistranslate; one that matches on the qualified frame is exact.
Deobfuscating a trace, frame by frame
Here is an obfuscated trace as it might arrive in a crash report:
System.InvalidOperationException: Sequence contains no elements
at a.e.a(Int64 A_0, String A_1)
at a.b.c(a.d A_0, a.e A_1)
at a.g.b(a.d A_0)
at X.<>c__DisplayClass4_0.a()
Reading it back with the map for that build turns each frame into its original identity:
System.InvalidOperationException: Sequence contains no elements
at Core.Money.FromMinor(Int64 minor, String currency)
at Billing.InvoiceService.Charge(Customer customer, Money amount)
at Billing.BatchRunner.Run(Customer customer)
at Billing.BatchRunner.<Run>b__4_0() // lambda in Run
Two details matter here. First, compiler-generated frames — the <>c__DisplayClass closure, the b__ lambda — are usually left untouched by renaming because the compiler, not you, named them; a good map still resolves the containing method so you know the lambda lived in Run. (If this part looks unfamiliar, how lambdas become display classes covers the shape.) Second, parameter names like A_0 appear because the obfuscator stripped the originals; if you need them back, keep parameter names in the map or disable parameter renaming for the frames you care about.
The translation itself is mechanical: parse the trace into frames, and for each frame look up (declaring type, member, signature) in the map and substitute the original. The one real subtlety is matching — you must normalize the obfuscated signature the same way the map stored it (fully qualified parameter types, including the already-obfuscated type names), or the lookup misses.
Keeping the map safe and findable
A map is only useful if, months after a build shipped, you can find the exact map for that build — and only you can. Three practices make that reliable:
-
Archive one map per build, keyed by version and module. Store it beside the PDBs as a build artifact, named by assembly version and, ideally, the module
Mvid(the GUID baked into each compiled module). When a crash arrives, the report tells you the version; that selects the map with no guessing. - Never let the map near the client. It does not belong in the installer, the app directory, a NuGet package, or a public symbol server. Treat it like a signing key: internal storage, access-controlled. A shipped map is a deobfuscated app.
- Deobfuscate server-side, on intake. Wire the translation into wherever crash reports land so incoming obfuscated traces are resolved automatically against the matching archived map. Your dashboards then show real names; the user never sees a map and never needs one.
When you want a frame left readable
Full renaming plus a private map is the right default, but sometimes you deliberately exempt a few members from renaming — a public SDK surface callers bind to by name, a plugin contract, or a handful of top-level entry points you want legible even without a map. Those frames come through a trace readable as-is, and everything beneath them stays obfuscated. Nebula lets you scope renaming with include/exclude rules exactly for this: protect the internals aggressively, keep the deliberately-public names, and emit a map that covers the rest.
The through-line: an obfuscated stack trace is not a lost stack trace. The crash is real and the information is intact — it is written in the names that actually shipped. Keep the per-build map, resolve traces on your side, and you get the full protection of renaming with none of the debugging tax. Disable renaming to make traces readable and you have simply handed the reader your source map for free; keep the map instead, and only you can read it.
Top comments (0)