Rename everything in a .NET assembly — every type, every method, every field — and decompile the result, and most of it collapses into the unreadable wall you wanted: a.b(c, d). Then you get to an enum, and the decompiler calmly prints LicenseTier.Pro. The name you were trying to hide is right there, in a Release build, with no PDB in sight.
This is not a bug in your obfuscator and it is not a decompiler reading symbols that should not be there. It is a consequence of what an enum actually is in the metadata — and understanding it tells you both why the names are so visible and, more importantly, why renaming them is often the one transform you must not apply.
What an enum compiles to
An enum is syntactic sugar over a class. This:
public enum LicenseTier
{
Free = 0,
Trial = 1,
Pro = 2,
}
compiles to a sealed class deriving from System.Enum, with one public const literal field per member plus a special instance field that holds the underlying value. Decompiled literally, it is:
public sealed class LicenseTier : System.Enum
{
public int value__; // the instance field — the actual integer
public const LicenseTier Free = (LicenseTier)0;
public const LicenseTier Trial = (LicenseTier)1;
public const LicenseTier Pro = (LicenseTier)2;
}
Two different things are stored here, and the distinction is the whole post. The value of each member — 0, 1, 2 — is a constant, stored in the Constant metadata table and attached to the field. The name of each member — Free, Trial, Pro — is stored exactly like any other field name: a row in the Field table pointing at an entry in the #Strings heap. In ILDASM the field rows read like this:
.field public static literal valuetype LicenseTier Free = int32(0x00000000)
.field public static literal valuetype LicenseTier Trial = int32(0x00000001)
.field public static literal valuetype LicenseTier Pro = int32(0x00000002)
.field public specialname rtspecialname int32 value__
The names Free, Trial, Pro are string literals in the metadata, as durable and as readable as a type name. A decompiler prints them for the same reason it prints LicenseTier itself, and it needs no symbols to do it. So far this is just "enum names are metadata," which is why a renamer can reach them. The reason it usually shouldn't is what the runtime does with those names.
The names are load-bearing at runtime
Enum values are compared as integers — a switch on an enum is a switch on value__, and tier == LicenseTier.Pro is ldc.i4.2; ceq. None of that touches the name. If your code only ever compared and switched, the names would be dead weight you could rename freely.
But the moment a value becomes text, or text becomes a value, the runtime reaches for the names. Consider the single most common thing people do with enums:
Console.WriteLine($"Current tier: {tier}"); // calls tier.ToString()
Enum.ToString() with no format does not have a lookup table baked in by the compiler. It reflects over the enum type at runtime: it enumerates the literal fields, reads each one's constant value out of metadata, finds the field whose value equals the boxed instance, and returns that field's name. The IL at the call site is nothing but a boxed virtual call —
ldloca.s tier
constrained. LicenseTier
callvirt instance string [System.Runtime]System.Object::ToString()
— and all the work happens inside Enum.ToString, walking the same Field rows the decompiler read. So if you renamed Pro to a, this line now prints Current tier: a. Your logs, your diagnostics, your support tickets all now say a. You didn't break compilation; you broke the output.
The reverse direction is worse, because it fails instead of just looking wrong:
var tier = Enum.Parse<LicenseTier>(config["tier"]); // config says "Pro"
Enum.Parse matches the supplied text against the field names. If the field is now called a, then parsing "Pro" throws ArgumentException — and parsing "a" succeeds. So a configuration file, a saved document, a database column, or an API request that carries the string "Pro" — written by a previous version of your own app, or by a client you don't control — stops parsing the instant you rename the member. The name is not decoration; it is the wire format.
Where it bites hardest
Three patterns turn this from a footnote into a shipped bug.
[Flags] formatting. A flags enum formats a combined value by decomposing it into set bits and joining the matching names with ", ":
[Flags] public enum Caps { None = 0, Read = 1, Write = 2, Admin = 4 }
(Caps.Read | Caps.Admin).ToString(); // "Read, Admin"
Rename the members and that becomes "a, c". If anything persists or parses that string — and flags enums are often persisted exactly because they pack neatly — the rename silently changes your format.
String enum serialization. System.Text.Json with JsonStringEnumConverter writes and reads the member name, not the number:
var opts = new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } };
JsonSerializer.Serialize(LicenseTier.Pro, opts); // "\"Pro\""
Rename Pro and your API now emits "a", and every client that sends "Pro" gets a deserialization failure. Config binders, appsettings.json, YAML, and most serializers that favor readable enums behave the same way. The name is part of your public contract whether you declared it one or not.
Enum.GetNames / GetValues driven UI and validation. Any code that builds a dropdown, a CLI help listing, or a validation message from Enum.GetNames<LicenseTier>() will now show a, b, c to a human. The values still work; the words are gone.
The rule: scope the rename to the value, never the contract
The useful thing to protect about an enum is almost never the spelling of its members — it is the logic its values gate. An attacker reading LicenseTier.Pro learns nothing they couldn't guess; what you don't want them to follow easily is the method that decides what Pro unlocks and the control flow that enforces it. And the thing that breaks under renaming is precisely the name round-trip. Those two facts point at one rule:
Rename enum members only where the name never leaves the process and is never reconstructed from text. Exclude from renaming any enum whose names cross a boundary — serialized, parsed, string-matched, or shown to a person.
This is exactly the kind of scoping Nebula is built around. You leave the name-dependent enums readable so ToString(), Enum.Parse and your JSON contract keep working, while the methods and control flow that consume those enums — the tier gate, the capability check, the seat math — get the full renaming, control-flow and string-encryption treatment. In a Nebula config that looks like excluding the members of a contract enum from renaming while leaving everything else in scope:
<Rules>
<!-- Keep the names of enums that cross a boundary; their text is a contract. -->
<Rule pattern="type MyApp.Contracts.LicenseTier" rename="false" />
<Rule pattern="type MyApp.Contracts.Caps" rename="false" />
<!-- Everything else, including the logic that reads these enums, is renamed as usual. -->
</Rules>
The payoff is that nothing an attacker cares about got easier to read. The enum name was never the secret — Pro versus a tells them nothing about how Pro is enforced, and leaving the name intact costs you no protection on the code that matters. What it buys you is a build that still serializes, parses and logs correctly, instead of one that compiles clean and then fails the first time a saved "Pro" comes back through Enum.Parse.
The one-line test
Before you let a renamer touch an enum, ask one question of each member: does its name ever become text, or come from text, anywhere this process can reach — a log, a config file, a serialized payload, a UI string, a parsed input, a stored value you read back? If yes, the name is part of your contract and must stay. If genuinely no — the enum is private, compared only by value, never stringified — then rename away; it is as safe as any other field. The mistake is never renaming an enum; it is renaming one whose names were quietly holding your wire format together.
Top comments (0)