Custom Attributes obfuskieren: was die Metadaten behalten, was das Umbenennen bricht und welche Reflection weiterhin funktioniert
Ein Custom Attribute ist kein Kommentar, den der Compiler wegwirft — es ist eine Zeile in der CustomAttribute-Metadatentabelle, die per Token auf einen Konstruktor zeigt und einen Blob aus positionalen und benannten Argumenten trägt. Diese Struktur entscheidet exakt, was Obfuskierung anfassen darf und was nicht. Benennen Sie die Attributklasse um, und das tokenbasierte GetCustomAttribute<T>() funktioniert weiter, weil Tokens sich nicht für Namen interessieren; benennen Sie sie um, und das stringbasierte GetCustomAttributes().First(a => a.GetType().Name == "FeatureGate") bricht, weil es einen Namen gelesen hat, den Sie gerade gelöscht haben. Benannte Argumente sind der subtile Teil: Sie referenzieren ein Feld oder eine Property per Namensstring im Blob, sodass ein Renamer, der das eine ohne das andere verschiebt, eine Assembly erzeugt, die beim Laden eine TypeLoadException wirft. Dieser Beitrag liest die echten Metadaten, zeigt, welche Reflection-Muster ein Umbenennen überleben und welche nicht, und erklärt, wie Nebula Attributverwendungen konsistent hält und dabei trotzdem die Namen entfernt, die ein Angreifer lesen würde.
Entwickler neigen dazu, ein Custom Attribute als Metadaten zu betrachten, die einen Typ „beschreiben“, und es dabei zu belassen. Für die Obfuskierung ist das nützliche mentale Modell schärfer: Eine Custom-Attribute-Anwendung ist eine konkrete Zeile in einer Metadatentabelle, die per Token auf einen Konstruktor zeigt und einen serialisierten Blob von Argumenten trägt. Sobald man es so sieht, hat die Frage „bricht Obfuskierung meine Attribute?“ eine präzise Antwort — sie hängt ganz davon ab, ob das, was das Attribut liest, dem Token folgt oder einen Namen liest.
Die CustomAttribute-Tabelle
Wenn Sie [FeatureGate(Tier = "pro")] auf eine Methode schreiben, speichert der C#-Compiler nicht den Text. Er emittiert eine Zeile in der CustomAttribute-Metadatentabelle (0x0C), und diese Zeile hat drei Spalten, die zählen:
CustomAttribute row
Parent -> the thing decorated (a MethodDef token here)
Type -> the attribute ctor (a MemberRef/MethodDef token, NOT a name)
Value -> a blob: prolog 0x0001, positional args, then named args
Das entscheidende Wort ist Token. Die Type-Spalte sagt nicht "FeatureGateAttribute"; sie hält ein numerisches Token, das zur Zeile des Konstruktors in den Metadaten aufgelöst wird. Benennen Sie die Attributklasse um, ändert sich der Name des Konstruktors, aber sein Token — und die CustomAttribute-Zeile, die darauf zeigt — nicht. Deshalb ist das Umbenennen einer Attributklasse weit sicherer, als die Intuition vermuten lässt: Alles, was das Attribut über das Token findet, funktioniert weiter, weil Sie das Ziel nicht verschoben, sondern nur umetikettiert haben.
Der Value-Blob ist der interessante Teil. Positionale Konstruktorargumente werden nur nach Position und Typ serialisiert — in diesem Teil des Blobs gibt es keine Namen, sodass das Umbenennen der Konstruktorparameter für ihn unsichtbar ist. Benannte Argumente sind die Ausnahme und die Quelle fast jedes attributbezogenen Obfuskierungsfehlers.
Benannte Argumente: das Einzige, das Sie im Gleichschritt umbenennen müssen
Ein benanntes Argument — Tier = "pro" — ist syntaktischer Zucker für „konstruiere das Attribut, dann setze dieses Feld oder diese Property“. Die CLR kann die zweite Hälfte nicht ausführen, ohne zu wissen, welchen Member, also speichert der Blob den Membernamen als UTF-8-String. Dieser String ist die Gefahr. Wenn ein Obfuskator die Tier-Property auf der Attributklasse zu b umbenennt, aber im Blob "Tier" stehen lässt, sucht der Loader nach einem Member namens Tier, der nicht mehr existiert, und wirft eine TypeLoadException, sobald das Attribut materialisiert wird. Benennt man den Blob-String um, aber nicht die Property, erhält man das Spiegelbild desselben Absturzes.
public sealed class FeatureGateAttribute : Attribute
{
public FeatureGateAttribute(string feature) => Feature = feature;
public string Feature { get; } // positional — blob has no name for it
public string Tier { get; set; } = ""; // named — blob stores "Tier" as a string
}
[FeatureGate("export", Tier = "pro")] // "export" positional, Tier="pro" named
public void ExportReport() { /* ... */ }
Ein korrekter Renamer behandelt die Property und den String des benannten Arguments als eine Einheit: Er benennt entweder beide um — Property Tier → b und Blob-String "Tier" → "b" — oder keines von beiden. Nebula macht standardmäßig Ersteres und folgt der Bindung des benannten Arguments durch die Metadaten, sodass die beiden Seiten nie auseinanderdriften. Das ist genau die Art von Querverweis, die ein naiver Alles-umbenennen-Durchlauf falsch macht, und der Grund, warum „es hat mein Attribut umbenannt und jetzt lädt die App nicht mehr“ fast immer ein zurückgelassener String eines benannten Arguments ist.
Welche Reflection überlebt
Nun zum praktischen Nutzen. Ob Ihr eigener Code seine Attribute nach der Obfuskierung noch findet, hängt davon ab, wie er sie sucht — per Token oder per Name.
// SURVIVES: generic overload resolves the type to a token.
// Rename FeatureGateAttribute -> a; the token is unchanged, so this still matches.
var gate = method.GetCustomAttribute<FeatureGateAttribute>();
if (gate is not null) Enforce(gate.Tier);
// SURVIVES: typeof(...) is a token reference too, resolved at compile time.
var attrs = method.GetCustomAttributes(typeof(FeatureGateAttribute), inherit: false);
// BREAKS: compares a runtime name string that renaming deleted.
var gate2 = method.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "FeatureGateAttribute"); // -> null after rename
// BREAKS: reads a named-argument value by reflecting a property name string.
var tier = (string)gateType.GetProperty("Tier")!.GetValue(gate)!; // "Tier" is gone
Die Regel ergibt sich sauber: Attribute per Typ finden, nie per Name. GetCustomAttribute<T>() und typeof(T) kompilieren beide zu Token-Referenzen, die der Renamer konsistent umschreibt, sodass sie weiter auf dasselbe (jetzt umbenannte) Attribut zeigen. Jeder Codepfad, der eine Entscheidung aus einem String rekonstruiert — GetType().Name, GetProperty("..."), Enum.Parse, ein Name, der mit einem Konfigurationswert verglichen wird —, liest ein Etikett, dessen Zerstörung das Geschäft der Obfuskierung ist, und wird stillschweigend null oder nichts zurückgeben.
Was Sie bewahren müssen
Ihre eigenen internen Marker-Attribute — die, die Sie nur je mit GetCustomAttribute<T>() lesen — können frei umbenannt werden, und sie umzubenennen ist ein echter Sicherheitsgewinn: Ein Attribut namens LicenseCheckAttribute auf einer Methode ist ein Wegweiser, und es in a zu verwandeln entfernt den Wegweiser. Aber manche Attribute werden von Code gelesen, den Sie nicht kontrollieren, per Name oder per bekanntem Typ, und die müssen ihre Namen behalten:
- Serialisierung:
[JsonPropertyName],[DataMember],[XmlElement]— der Serializer gleicht sie per Typ ab und liest ihre benannten Argumente; bewahren Sie das Attribut und den Member, den es dekoriert (siehe Fallstricke bei der Serialisierung). - Von der Laufzeit interpretiert:
[Serializable],[DllImport],[MarshalAs],[StructLayout]— die CLR selbst reagiert darauf; sie umzubenennen ändert das Laufzeitverhalten, nicht nur Etiketten. - Framework-Discovery: Testattribute (
[Fact],[Theory]), Routing- und Model-Binding-Attribute von ASP.NET Core, per Assembly-Scan gefundene DI-Marker — alle werden durch Suche nach einem bekannten Typ gefunden.
Diese Grenze ziehen Sie in nebula.config.json, indem Sie die framework-seitigen Attribute per Namespace oder Typ bewahren und Ihre internen Marker dem Umbenennen überlassen:
{
"schemaVersion": 1,
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "protected",
"preservePublicApi": true,
"rename": {
"preserveAttributes": [
"System.Text.Json.Serialization.*",
"System.Runtime.InteropServices.*",
"MyApp.Contracts.*"
]
}
}
Oder inline, auf der Attributklasse selbst, mit demselben Ausschlussattribut, das Nebula auch anderswo respektiert:
[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = true)]
public sealed class WireContractAttribute : Attribute { /* read by the serializer */ }
Die Regel
Ein Custom Attribute lebt in den Metadaten als per Token referenzierter Konstruktor plus Werte-Blob, und diese Struktur sagt Ihnen genau, was Obfuskierung tun kann. Tokens sind umbenennungsfest — finden Sie Attribute also per Typ, überleben sie unberührt; Namen im Blob sind es nicht, also muss ein benanntes Argument auf beiden Seiten oder gar nicht umbenannt werden, und alles, was ein Framework per Name auflöst, muss bewahrt werden. Beachten Sie diese beiden Fakten, und Sie können die menschenlesbare Absicht aus Ihren internen Marker-Attributen entfernen — genau das Signal, an dem sich ein Angreifer orientieren würde —, ohne einen einzigen GetCustomAttribute<T>()-Aufruf zu brechen oder den Serializer zu verwirren. Die strukturelle Tatsache, dass ein Attribut angewendet ist, lässt sich nicht verbergen; die Bedeutung, die es lesenswert machte, schon.
Die Behandlung von Attributen ist ein Ausschnitt davon, wie Nebula.NET eine umbenannte Assembly identisch zum Original verhalten lässt. Für den breiteren Fehlermodus, zu dem sie gehört, lesen Sie Fallstricke bei Reflection und Serialisierung; um zu sehen, warum Umbenennen nur die erste Schicht ist, Obfuskierung jenseits des Umbenennens. Die kostenlose Edition benennt um und verifiziert die Ausgabe auf echten Assemblies, sodass Sie das Verhalten Ihrer Attribute vor der Auslieferung bestätigen können.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.