Obfusquer les attributs personnalisés : ce que les métadonnées conservent, ce que le renommage casse, et la réflexion qui continue de fonctionner
Un attribut personnalisé n'est pas un commentaire que le compilateur jette — c'est une ligne de la table de métadonnées CustomAttribute qui pointe, par jeton, vers un constructeur, et transporte un blob d'arguments positionnels et nommés. Cette structure décide précisément de ce que l'obfuscation peut toucher ou non. Renommez la classe d'attribut et le GetCustomAttribute<T>() basé sur le jeton continue de fonctionner, car les jetons se moquent des noms ; renommez-la et le GetCustomAttributes().First(a => a.GetType().Name == "FeatureGate") à clé textuelle casse, car il lisait un nom que vous venez de supprimer. Les arguments nommés sont la partie subtile : ils référencent un champ ou une propriété par son nom sous forme de chaîne dans le blob, de sorte qu'un renommeur qui déplace l'un sans l'autre produit un assembly qui lève TypeLoadException au chargement. Cet article lit les vraies métadonnées, montre quels motifs de réflexion survivent à un renommage et lesquels non, et explique comment Nebula garde les usages d'attributs cohérents tout en retirant les noms qu'un attaquant lirait.
Les développeurs ont tendance à considérer un attribut personnalisé comme une métadonnée qui « décrit » un type, sans aller plus loin. Pour l’obfuscation, le modèle mental utile est plus tranchant : une application d’attribut personnalisé est une ligne concrète dans une table de métadonnées qui pointe, par jeton, vers un constructeur, et transporte un blob sérialisé d’arguments. Une fois vu sous cet angle, la question « l’obfuscation casse-t-elle mes attributs ? » a une réponse précise — cela dépend entièrement de savoir si ce qui lit l’attribut suit le jeton ou lit un nom.
La table CustomAttribute
Lorsque vous écrivez [FeatureGate(Tier = "pro")] sur une méthode, le compilateur C# ne stocke pas le texte. Il émet une ligne dans la table de métadonnées CustomAttribute (0x0C), et cette ligne a trois colonnes qui comptent :
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
Le mot critique est jeton. La colonne Type ne dit pas "FeatureGateAttribute" ; elle contient un jeton numérique qui se résout vers la ligne du constructeur dans les métadonnées. Renommez la classe d’attribut et le nom du constructeur change, mais son jeton — et la ligne CustomAttribute qui pointe vers lui — ne change pas. C’est pourquoi renommer une classe d’attribut est bien plus sûr que l’intuition ne le suggère : tout ce qui retrouve l’attribut en suivant le jeton continue de fonctionner, car vous n’avez pas déplacé la cible, vous l’avez seulement réétiquetée.
Le blob Value est l’endroit où cela devient intéressant. Les arguments positionnels du constructeur sont sérialisés uniquement par position et par type — il n’y a aucun nom dans cette partie du blob, de sorte que renommer les paramètres du constructeur lui est invisible. Les arguments nommés sont l’exception, et la source de presque tous les bugs d’obfuscation liés aux attributs.
Arguments nommés : la seule chose à renommer de concert
Un argument nommé — Tier = "pro" — est du sucre syntaxique pour « construire l’attribut, puis affecter ce champ ou cette propriété ». Le CLR ne peut pas faire la seconde moitié sans savoir quel membre, de sorte que le blob stocke le nom du membre sous forme de chaîne UTF-8. Cette chaîne est le danger. Si un obfuscateur renomme la propriété Tier sur la classe d’attribut en b mais laisse le blob dire "Tier", le chargeur cherche un membre nommé Tier qui n’existe plus et lève TypeLoadException dès que l’attribut est matérialisé. Renommez la chaîne du blob mais pas la propriété et vous obtenez l’image miroir du même plantage.
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() { /* ... */ }
Un renommeur correct traite la propriété et la chaîne d’argument nommé comme une seule unité : soit il renomme les deux — la propriété Tier → b et la chaîne du blob "Tier" → "b" — soit il ne renomme aucun des deux. Nebula fait le premier par défaut, en suivant la liaison d’argument nommé à travers les métadonnées pour que les deux côtés ne dérivent jamais. C’est exactement le genre de référence croisée qu’une passe naïve de renommage-tout rate, et la raison pour laquelle « il a renommé mon attribut et maintenant l’application ne se charge plus » est presque toujours une chaîne d’argument nommé laissée derrière.
Quelle réflexion survit
Passons maintenant à la conséquence pratique. Que votre propre code retrouve encore ses attributs après obfuscation dépend de la façon dont il les cherche — par jeton ou par nom.
// 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
La règle en découle proprement : retrouvez les attributs par type, jamais par nom. GetCustomAttribute<T>() et typeof(T) compilent tous deux en références de jeton que le renommeur réécrit de façon cohérente, de sorte qu’ils continuent de pointer vers le même attribut (désormais renommé). Tout chemin de code qui reconstruit une décision à partir d’une chaîne — GetType().Name, GetProperty("..."), Enum.Parse, un nom comparé à une valeur de configuration — lit une étiquette que l’obfuscation a pour métier de détruire, et il renverra silencieusement null ou rien.
Ce que vous devez préserver
Vos propres attributs marqueurs internes — ceux que vous ne lisez qu’avec GetCustomAttribute<T>() — peuvent être renommés librement, et les renommer est un vrai gain de sécurité : un attribut nommé LicenseCheckAttribute sur une méthode est un panneau indicateur, et le transformer en a retire le panneau. Mais certains attributs sont lus par du code que vous ne contrôlez pas, par nom ou par type bien connu, et ceux-là doivent garder leur nom :
- Sérialisation :
[JsonPropertyName],[DataMember],[XmlElement]— le sérialiseur les fait correspondre par type et lit leurs arguments nommés ; préservez l’attribut et le membre qu’il décore (voir pièges de la sérialisation). - Interprétés par le runtime :
[Serializable],[DllImport],[MarshalAs],[StructLayout]— le CLR lui-même agit sur eux ; les renommer change le comportement à l’exécution, pas seulement les étiquettes. - Découverte par les frameworks : attributs de test (
[Fact],[Theory]), attributs de routage et de liaison de modèle ASP.NET Core, marqueurs DI balayés par assembly — tous retrouvés en cherchant un type connu.
Vous tracez cette frontière dans nebula.config.json, en préservant les attributs tournés vers les frameworks par espace de noms ou par type, tout en laissant vos marqueurs internes être renommés :
{
"schemaVersion": 1,
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "protected",
"preservePublicApi": true,
"rename": {
"preserveAttributes": [
"System.Text.Json.Serialization.*",
"System.Runtime.InteropServices.*",
"MyApp.Contracts.*"
]
}
}
Ou en ligne, sur la classe d’attribut elle-même, avec le même attribut d’exclusion que Nebula honore ailleurs :
[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = true)]
public sealed class WireContractAttribute : Attribute { /* read by the serializer */ }
La règle
Un attribut personnalisé vit dans les métadonnées sous la forme d’un constructeur référencé par jeton plus un blob de valeur, et cette structure vous dit exactement ce que l’obfuscation peut faire. Les jetons résistent au renommage, donc retrouvez les attributs par type et ils survivent intacts ; les noms dans le blob n’y résistent pas, donc un argument nommé doit être renommé des deux côtés ou d’aucun, et tout ce qu’un framework résout par nom doit être préservé. Respectez ces deux faits et vous pouvez retirer l’intention lisible de vos attributs marqueurs internes — précisément le signal qu’un attaquant suivrait — sans casser un seul appel GetCustomAttribute<T>() ni dérouter le sérialiseur. Le fait structurel qu’un attribut est appliqué ne peut pas être caché ; le sens qui le rendait digne d’être lu, si.
La gestion des attributs est une tranche de la façon dont Nebula.NET garde un assembly renommé au comportement identique à l’original. Pour le mode de défaillance plus large auquel elle appartient, lisez pièges de la réflexion et de la sérialisation ; pour voir pourquoi le renommage n’est que la première couche, l’obfuscation au-delà du renommage. L’édition gratuite renomme et vérifie la sortie sur de vrais assemblies pour que vous puissiez confirmer le comportement de vos attributs avant de livrer.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.