Obfusquer du code qui utilise dynamic : le binder d'exécution cherche les membres par nom, donc une propriété renommée est une RuntimeBinderException
Un appel statique survit au renommage parce qu'il est résolu par jeton de métadonnées et que l'obfuscateur réécrit le jeton avec le membre. Un appel dynamic ne survit à rien : le compilateur C# abaisse settings.Timeout en un call site dont le binder reçoit la chaîne "Timeout", et à l'exécution Microsoft.CSharp parcourt le type réel du récepteur à la recherche d'un membre portant exactement ce nom. Renommez Timeout en a et le site demande toujours "Timeout", la recherche échoue, et l'application lève RuntimeBinderException sur une ligne qui compilait bien et passait tous les tests sur le build non protégé. Ce billet lit l'IL d'un site dynamic pour montrer où vit le nom (un ldstr alimentant Binder.GetMember et Binder.InvokeMember dans un conteneur caché <>o__), explique pourquoi le chiffrement des chaînes n'aide ni ne nuit, pourquoi aucun renommeur ne peut le corriger automatiquement en toute sécurité (le type du récepteur n'est pas connu avant l'exécution de l'appel), quelles opérations dynamic se lient par nom et lesquelles non (indexeurs via Item, opérateurs via les noms op_, clés d'ExpandoObject jamais), le schéma de double dispatch par dynamic qui casse silencieusement un visiteur entier, et les trois correctifs par ordre de préférence : remplacer dynamic par une interface, épingler uniquement les membres qu'un site atteint avec [Obfuscation(Exclude = true)], ou une entrée exclude dans nebula.config.json — plus le seul test qui l'attrape sur le build protégé.
Voici une méthode qui fonctionne sur tous les builds sauf celui que vous livrez :
public sealed class Settings
{
public int Timeout { get; set; } = 30;
public string Endpoint { get; set; } = "https://api.example";
}
static void Configure(dynamic settings)
{
int timeout = settings.Timeout; // compiles; passes tests; dies when obfuscated
settings.Endpoint = "https://api.example/v2";
}
Sur le build de débogage, Configure(new Settings()) fait ce qu’il dit. Sur le build obfusqué, il lève :
Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
'a' does not contain a definition for 'Timeout'
Rien ne cloche dans l’obfuscateur. Le type a été renommé en a, sa propriété a été renommée, et le site dynamic — qui n’a jamais eu connaissance de l’un ni de l’autre — a continué de demander Timeout par nom. Pour voir pourquoi c’est inévitable, et quoi faire, lisez l’IL que le compilateur a émis pour cette seule ligne.
Où vit le nom
Une lecture de propriété normale, int timeout = settings.Timeout; avec settings typé Settings, compile en un callvirt contre un jeton de métadonnées :
callvirt instance int32 Acme.Settings::get_Timeout()
Le jeton pointe vers une ligne de la table des méthodes. Quand Nebula renomme get_Timeout en a, il réécrit la ligne et chaque jeton qui la référence — le site d’appel et le membre bougent ensemble, et la chaîne Timeout cesse d’exister où que ce soit dans l’assembly. C’est pourquoi le renommage est invisible pour le code ordinaire.
La version dynamic compile en quelque chose de tout autre. Le compilateur génère une classe imbriquée cachée pour contenir un call site mis en cache, et le site est initialisé avec un binder auquel on remet le nom du membre sous forme de chaîne :
// hidden container the compiler generates for Configure
.class nested private '<>o__0'
{
.field public static class [System.Runtime]System.Runtime.CompilerServices.CallSite`1<
class [System.Runtime]System.Func`3<CallSite, object, object>> '<>p__0'
}
// first use of the site: build the binder, with the member NAME as data
IL_0007: ldc.i4.0 // CSharpBinderFlags.None
IL_0008: ldstr "Timeout" // <-- the binding, as a string
IL_000d: ldtoken Acme.Program // calling context
IL_0012: call class Type Type::GetTypeFromHandle(RuntimeTypeHandle)
IL_0017: ldc.i4.1
IL_0018: newarr CSharpArgumentInfo
...
IL_002a: call CallSiteBinder Binder::GetMember(CSharpBinderFlags, string, Type, IEnumerable`1<CSharpArgumentInfo>)
IL_002f: call CallSite`1<...> CallSite`1<...>::Create(CallSiteBinder)
IL_0034: stsfld ... '<>o__0'::'<>p__0'
// every use: invoke the site's delegate with the receiver
IL_0039: ldsfld '<>o__0'::'<>p__0'
IL_003e: ldfld !0 CallSite`1<...>::Target
IL_0043: ldsfld '<>o__0'::'<>p__0'
IL_0048: ldarg.0 // settings (object)
IL_0049: callvirt instance !2 Func`3<CallSite, object, object>::Invoke(!0, !1)
Il n’y a aucun jeton pour Timeout dans toute cette séquence. La seule mention du membre est ldstr "Timeout", un littéral de chaîne passé à Binder.GetMember. À l’exécution, le binder C# prend le type réel de l’objet qui arrive dans settings, le fouille à la recherche d’un membre nommé Timeout — la même recherche que fait la réflexion — construit une règle pour ce type, la met en cache dans le site, et l’exécute. L’affectation compile de la même façon via Binder.SetMember(..., "Endpoint", ...) ; un appel de méthode via Binder.InvokeMember(..., "Save", ...).
Après le renommage, le type a un membre a et le site dit toujours "Timeout". Le binder cherche, ne trouve rien, et lève. Le lien était un nom, et le renommeur, qui ne déplace que des jetons, n’avait aucune raison de le savoir.
Pourquoi le chiffrement des chaînes est hors sujet
Le réflexe, en voyant ldstr "Timeout", est de penser que le chiffrement des chaînes de Nebula va s’en charger. Il le chiffrera — et la valeur déchiffrée remise à Binder.GetMember à l’exécution est le même "Timeout", qui n’existe toujours pas sur le type renommé. Le chiffrement ne change rien à la liaison.
Ce qu’il change, c’est la fuite. Dans un build non chiffré, chaque site dynamic est un panneau en clair dans le tas de chaînes utilisateur : un lecteur qui ouvre l’assembly voit "Timeout" passé à un binder juste à côté d’un type dont les membres sont désormais a, b, c, et a récupéré un nom d’origine gratuitement. C’est le coût plus discret de dynamic dans un assembly protégé — là où il ne casse pas, il raconte encore. Avec encryptStrings activé, le panneau disparaît de la vue statique ; la recherche est exactement aussi cassée qu’avant.
Quelles opérations se lient par nom
Toute opération dynamic n’est pas une recherche par nom, et savoir lesquelles le sont vous dit où regarder :
- Accès à un membre, affectation, invocation —
s.Timeout,s.Endpoint = …,s.Save()— passent parGetMember,SetMember,InvokeMemberavec le nom du membre comme chaîne. Elles cassent quand le membre est renommé. - Indexation —
s[0]— passe parGetIndex/SetIndex, qui résout le membre par défaut du type ([DefaultMember("Item")], la propriétéItemde l’indexeur). Renommez l’indexeur et l’indexation dynamic casse de la même façon. - Opérateurs et conversions —
a + b,(int)d— passent parBinaryOperation/Convert, qui résolvent les opérateurs définis par l’utilisateur par leurs noms spéciaux,op_Addition,op_Implicit. Ce sont des membresspecialnamesur lesquels le runtime lui-même s’appuie, et Nebula ne les renomme pas, donc l’arithmétique dynamic sur vos types continue de fonctionner. - Invocation d’une valeur déléguée —
d(x)oùdcontient un délégué — passe parInvoke, sans nom, sans problème. ExpandoObjectet toutIDynamicMetaObjectProvider— les « membres » sont des clés de dictionnaire fournies à l’exécution, pas des membres CLR. Le renommage ne touche jamais aux données, doncexpando.Timeoutcontinue de fonctionner quoi qu’il arrive. Il en va de même pour undynamicsur un proxy COMIDispatch: les noms sont résolus par le serveur COM, pas par vos métadonnées.
Le cas dangereux est le premier point appliqué à des types que vous possédez, et il y a un schéma qui le cache particulièrement bien.
Le schéma qui casse un visiteur entier d’un coup
Le double dispatch par dynamic est un raccourci populaire pour un visiteur sans le code répétitif d’Accept :
public abstract class Shape { }
public sealed class Circle : Shape { public double R { get; init; } }
public sealed class Rect : Shape { public double W { get; init; } public double H { get; init; } }
public static class Area
{
public static double Of(Shape s) => Compute((dynamic)s); // picks the overload at runtime
private static double Compute(Circle c) => Math.PI * c.R * c.R;
private static double Compute(Rect r) => r.W * r.H;
}
Cela compile en Binder.InvokeMember(..., "Compute", typeof(Area), ...) avec le binder d’exécution sélectionnant la surcharge selon le type réel de l’argument. Renommez Compute en b et toutes les formes échouent au premier appel — pas une propriété, le dispatch entier — avec 'Area' does not contain a definition for 'Compute'. Comme les surcharges sont private, rien d’autre dans l’assembly ne les référence autrement que par cette unique chaîne, donc il n’y a aucune trace à la compilation qu’elles soient atteintes. La suite de tests non protégée est verte ; le build protégé ne peut pas calculer une seule aire.
Correctifs, par ordre de préférence
1. Remplacez dynamic par une interface ou une méthode virtuelle. C’est la bonne réponse bien plus souvent qu’il n’y paraît. Le duck typing entre des types que vous possédez est précisément ce à quoi servent les interfaces, et l’appel d’interface est un callvirt lié par jeton qui survit au renommage, s’exécute sans binder, et est vérifié par le compilateur :
public interface IHasArea { double Area(); }
public sealed class Circle : Shape, IHasArea { … public double Area() => Math.PI * R * R; }
public static double Of(Shape s) => s is IHasArea a ? a.Area() : 0; // no binder, no name
Le visiteur devient un Accept virtuel ordinaire ou un IHasArea ; le lecteur de paramètres prend Settings ou un ISettings. Si le dynamic n’existait que pour éviter d’écrire un type, écrire le type est le correctif.
2. Épinglez uniquement les membres qu’un site atteint. Là où dynamic est réellement requis — un hôte de script qui appelle vos objets, un runtime de langage dynamique, une frontière de plugin que vous ne contrôlez pas — l’ensemble des membres qu’il touche est petit et connu. Marquez exactement ceux-là avec l’attribut standard [Obfuscation], que Nebula honore via autoDetect :
public sealed class Settings
{
[Obfuscation(Exclude = true)] public int Timeout { get; set; } = 30;
[Obfuscation(Exclude = true)] public string Endpoint { get; set; } = "";
public string InternalCacheKey { get; set; } = ""; // still renames
private int _retries; // still renames
}
Exclude = true sur un membre conserve le nom de ce membre et rien d’autre ; le type se renomme toujours, les autres membres se renomment toujours. Ou gardez cela hors du code source entièrement avec la liste exclude de nebula.config.json, qui accepte Namespace.Type.Member pour un seul membre :
{
"inputs": ["bin/Release/net8.0/Acme.Studio.dll"],
"encryptStrings": true,
"controlFlowObfuscation": true,
"exclude": [
"Acme.Settings.Timeout",
"Acme.Settings.Endpoint",
"Acme.Area.Compute"
]
}
Ce que vous ne devez pas faire, c’est exclure Acme.Settings en bloc. Cela préserve chaque membre — ceux que le binder atteint et la douzaine qu’il n’atteindra jamais — pour protéger deux propriétés, et c’est le type qu’un attaquant veut le plus lire.
3. Trouvez chaque site avant de livrer. Les sites dynamic s’annoncent dans l’IL : une référence à Microsoft.CSharp.RuntimeBinder.Binder, des conteneurs cachés <>o__, et des littéraux ldstr qui les alimentent. nebula inspect --strings liste chaque littéral de l’assembly, et ceux qui sont aussi des noms de membres de vos types sont ceux à examiner — Timeout, Endpoint, Compute ressortent immédiatement à côté des véritables chaînes destinées à l’utilisateur. Croisez avec une recherche de dynamic dans le code source et vous avez la liste complète des membres à épingler.
Le test qui l’attrape
Quel que soit votre choix, vérifiez-le sur le build protégé, parce que la défaillance n’existe que là. Un site dynamic lève à la première utilisation, donc un test qui exerce chaque site est un test qui échoue bruyamment quand un épinglage manque :
[Fact]
public void Dynamic_sites_still_bind_on_the_protected_build()
{
var s = new Settings();
Configure(s); // GetMember "Timeout", SetMember "Endpoint"
Assert.Equal("https://api.example/v2", s.Endpoint);
Assert.Equal(Math.PI, Area.Of(new Circle { R = 1 }), precision: 10); // InvokeMember "Compute"
Assert.Equal(6.0, Area.Of(new Rect { W = 2, H = 3 }));
}
Exécutez-le contre la sortie de l’étape d’obfuscation en CI — le même endroit où va un test aller-retour de sérialisation — et une RuntimeBinderException devient un build rouge au lieu du rapport de plantage d’un client.
Le principe est le même que celui qui régit la réflexion et la sérialisation : un nom résolu à l’exécution est un contrat, et un renommeur ne peut pas voir un contrat écrit sous forme de chaîne. dynamic est la fonctionnalité C# qui transforme un accès à un membre ordinaire en exactement ce genre de chaîne. Nebula.NET renommera tout ce que vous le laissez renommer ; votre travail est de vous assurer que le seul nom qu’un binder demandera par texte n’est soit plus demandé, soit délibérément conservé. Pour la carte plus large de ce que cassent les recherches de noms à l’exécution, voir ce que l’obfuscation casse.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.