Skip to content
← Alle Beiträge
· Delta1 Labs Nebula.NETObfuskierung.NETTiefenanalyse

Enums obfuskieren: warum die Namen das Umbenennen überleben und wie ToString() und switch sie verraten

Benenne einen Enum und seine Member um, und ein Decompiler druckt immer noch LicenseTier.Pro, nicht a.b — denn ein Enum ist eine versiegelte Klasse, deren Member öffentliche const-Literalfelder sind, und sobald dein Code ToString() oder Enum.Parse aufruft, liest die Laufzeit diese Feldnamen zur Laufzeit direkt aus den Metadaten. Sie in a, b, c umzubenennen ist legal und kostet dich nichts außer dem Einen, das du wolltest: Ein Hin-und-Zurück über den Namen bricht, weil "Pro" nicht mehr zu einem Feld namens a passt. Dieser Beitrag liest das IL, zu dem ein Enum kompiliert, zeigt, wo die Namen leben (die Field-Tabelle und der #Strings-Heap, nicht die Werte), geht genau durch, was beim Umbenennen bricht — ToString, Enum.Parse, [Flags]-Formatierung, String-JSON-Serialisierung, Enum.GetNames — und wie Nebula die Enum-Behandlung eingrenzt, sodass die Werte, die deine Logik steuern, geschützt sind, während die Namen, von denen dein Protokoll abhängt, intakt bleiben.

Benenne alles in einem .NET-Assembly um — jeden Typ, jede Methode, jedes Feld — und decompiliere das Ergebnis, und fast alles stürzt in die unlesbare Wand, die du wolltest: a.b(c, d). Dann kommst du zu einem Enum, und der Decompiler druckt seelenruhig LicenseTier.Pro. Der Name, den du verbergen wolltest, steht genau dort, in einem Release-Build, ohne PDB in Sicht.

Das ist kein Fehler deines Obfuskators und auch nicht der Decompiler, der Symbole liest, die nicht da sein sollten. Es ist eine Folge dessen, was ein Enum in den Metadaten tatsächlich ist — und das zu verstehen sagt dir sowohl, warum die Namen so sichtbar sind, als auch, wichtiger, warum ihr Umbenennen oft die eine Transformation ist, die du nicht anwenden darfst.

Wozu ein Enum kompiliert

Ein Enum ist syntaktischer Zucker über einer Klasse. Dies:

public enum LicenseTier
{
    Free = 0,
    Trial = 1,
    Pro = 2,
}

kompiliert zu einer versiegelten Klasse, die von System.Enum erbt, mit einem public const-Literalfeld pro Member plus einem speziellen Instanzfeld, das den zugrunde liegenden Wert hält. Wörtlich decompiliert ist es:

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;
}

Hier werden zwei verschiedene Dinge gespeichert, und die Unterscheidung ist der ganze Beitrag. Der Wert jedes Members — 0, 1, 2 — ist eine Konstante, gespeichert in der Metadaten-Tabelle Constant und an das Feld gebunden. Der Name jedes Members — Free, Trial, Pro — wird genau wie jeder andere Feldname gespeichert: eine Zeile in der Field-Tabelle, die auf einen Eintrag im #Strings-Heap zeigt. In ILDASM lesen sich die Feldzeilen so:

.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__

Die Namen Free, Trial, Pro sind String-Literale in den Metadaten, so dauerhaft und so lesbar wie ein Typname. Ein Decompiler druckt sie aus demselben Grund, aus dem er LicenseTier selbst druckt, und braucht keine Symbole dafür. Bis hierher ist das nur „Enum-Namen sind Metadaten”, weshalb ein Umbenenner sie erreichen kann. Der Grund, warum er es gewöhnlich nicht sollte, ist das, was die Laufzeit mit diesen Namen tut.

Die Namen sind zur Laufzeit tragend

Enum-Werte werden als ganze Zahlen verglichen — ein switch über einen Enum ist ein switch über value__, und tier == LicenseTier.Pro ist ldc.i4.2; ceq. Nichts davon berührt den Namen. Würde dein Code nur vergleichen und switchen, wären die Namen totes Gewicht, das du frei umbenennen könntest.

Doch sobald ein Wert zu Text wird oder Text zu einem Wert, greift die Laufzeit nach den Namen. Betrachte das Häufigste, was man mit Enums tut:

Console.WriteLine($"Current tier: {tier}");   // calls tier.ToString()

Enum.ToString() ohne Format hat keine vom Compiler eingebackene Nachschlagetabelle. Es reflektiert zur Laufzeit über den Enum-Typ: Es zählt die Literalfelder auf, liest den konstanten Wert jedes einzelnen aus den Metadaten, findet das Feld, dessen Wert der geboxten Instanz gleicht, und gibt den Namen dieses Feldes zurück. Das IL an der Aufrufstelle ist nichts als ein geboxter virtueller Aufruf —

ldloca.s   tier
constrained. LicenseTier
callvirt   instance string [System.Runtime]System.Object::ToString()

— und die ganze Arbeit passiert in Enum.ToString, indem es dieselben Field-Zeilen durchläuft, die der Decompiler gelesen hat. Hast du also Pro in a umbenannt, druckt diese Zeile nun Current tier: a. Deine Logs, deine Diagnosen, deine Support-Tickets sagen jetzt a. Du hast nicht die Kompilierung gebrochen; du hast die Ausgabe gebrochen.

Die Gegenrichtung ist schlimmer, weil sie scheitert, statt nur schlecht auszusehen:

var tier = Enum.Parse<LicenseTier>(config["tier"]);   // config says "Pro"

Enum.Parse vergleicht den gelieferten Text mit den Feldnamen. Heißt das Feld jetzt a, dann wirft das Parsen von "Pro" eine ArgumentException — und das Parsen von "a" gelingt. Also hört eine Konfigurationsdatei, ein gespeichertes Dokument, eine Datenbankspalte oder eine API-Anfrage, die den String "Pro" trägt — geschrieben von einer früheren Version deiner eigenen App oder von einem Client, den du nicht kontrollierst — in dem Moment auf zu parsen, in dem du den Member umbenennst. Der Name ist keine Dekoration; er ist das Wire-Format.

Enum-MemberWert 2 · Name "Pro"nutzt den WERT (int)== · switch · bitweise &/| · Arithmetiknutzt den NAMEN (#Strings)ToString · Parse · GetName/GetNamessicher umzubenennen in a, b, c[Flags]-Format · String-JSON-Enum · ConfigUmbenennen bricht das Hin-und-Zurück

Wo es am härtesten beißt

Drei Muster machen daraus aus einer Fußnote einen ausgelieferten Fehler.

[Flags]-Formatierung. Ein Flags-Enum formatiert einen kombinierten Wert, indem es ihn in gesetzte Bits zerlegt und die passenden Namen mit ", " verbindet:

[Flags] public enum Caps { None = 0, Read = 1, Write = 2, Admin = 4 }

(Caps.Read | Caps.Admin).ToString();   // "Read, Admin"

Benenne die Member um, und daraus wird "a, c". Persistiert oder parst irgendetwas diesen String — und Flags-Enums werden oft gerade deshalb persistiert, weil sie sich sauber packen — ändert das Umbenennen still dein Format.

String-Enum-Serialisierung. System.Text.Json mit JsonStringEnumConverter schreibt und liest den Namen des Members, nicht die Zahl:

var opts = new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } };
JsonSerializer.Serialize(LicenseTier.Pro, opts);   // "\"Pro\""

Benenne Pro um, und deine API gibt nun "a" aus, und jeder Client, der "Pro" sendet, bekommt einen Deserialisierungsfehler. Konfigurations-Binder, appsettings.json, YAML und die meisten Serialisierer, die lesbare Enums bevorzugen, verhalten sich genauso. Der Name ist Teil deines öffentlichen Vertrags, ob du ihn so erklärt hast oder nicht.

Durch Enum.GetNames/GetValues gesteuerte UI und Validierung. Jeder Code, der aus Enum.GetNames<LicenseTier>() ein Dropdown, eine CLI-Hilfeliste oder eine Validierungsmeldung baut, zeigt einem Menschen nun a, b, c. Die Werte funktionieren noch; die Worte sind weg.

Die Regel: grenze das Umbenennen auf den Wert ein, nie auf den Vertrag

Das Schützenswerte an einem Enum ist fast nie die Schreibweise seiner Member — es ist die Logik, die seine Werte steuern. Ein Angreifer, der LicenseTier.Pro liest, lernt nichts, was er nicht erraten könnte; was er nicht leicht verfolgen können soll, ist die Methode, die entscheidet, was Pro freischaltet, und der Kontrollfluss, der es durchsetzt. Und was beim Umbenennen bricht, ist genau das Hin-und-Zurück über den Namen. Diese zwei Tatsachen weisen auf eine Regel:

Benenne Enum-Member nur dort um, wo der Name den Prozess nie verlässt und nie aus Text rekonstruiert wird. Schließe jeden Enum vom Umbenennen aus, dessen Namen eine Grenze überschreiten — serialisiert, geparst, per String verglichen oder einem Menschen gezeigt.

Genau diese Art von Eingrenzung ist das, worum Nebula gebaut ist. Du lässt die namensabhängigen Enums lesbar, damit ToString(), Enum.Parse und dein JSON-Vertrag weiter funktionieren, während die Methoden und der Kontrollfluss, die diese Enums verbrauchen — das Tier-Gate, die Fähigkeitsprüfung, die Platz-Arithmetik — die volle Behandlung aus Umbenennen, Kontrollfluss und String-Verschlüsselung erhalten. In einer Nebula-Konfiguration sieht das so aus, dass du die Member eines Vertrags-Enums vom Umbenennen ausschließt und alles andere im Geltungsbereich lässt:

<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>

Der Gewinn ist, dass nichts, was einen Angreifer interessiert, leichter zu lesen wurde. Der Enum-Name war nie das Geheimnis — Pro gegenüber a sagt ihm nichts darüber, wie Pro durchgesetzt wird, und den Namen intakt zu lassen kostet dich keinen Schutz am Code, der zählt. Was es dir bringt, ist ein Build, der noch korrekt serialisiert, parst und loggt, statt eines, der sauber kompiliert und dann beim ersten Mal scheitert, wenn ein gespeichertes "Pro" durch Enum.Parse zurückkommt.

Der Ein-Zeilen-Test

Bevor du einen Umbenenner einen Enum anfassen lässt, stelle jedem Member eine Frage: Wird sein Name jemals zu Text, oder kommt er aus Text, irgendwo, das dieser Prozess erreichen kann — ein Log, eine Konfigurationsdatei, eine serialisierte Nutzlast, ein UI-String, eine geparste Eingabe, ein gespeicherter Wert, den du zurückliest? Wenn ja, ist der Name Teil deines Vertrags und muss bleiben. Wenn wirklich nein — der Enum ist privat, nur per Wert verglichen, nie zu String gemacht — dann benenne nach Belieben um; es ist so sicher wie jedes andere Feld. Der Fehler ist nie, einen Enum umzubenennen; er ist, einen umzubenennen, dessen Namen still dein Wire-Format zusammengehalten haben.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.