Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETAnalyse approfondie

Obfusquer les génériques : renommer les paramètres de type, et les contraintes et la réflexion qui fixent les noms

Un paramètre de type générique comme TResult est un nom dans les métadonnées — une ligne de la table GenericParam avec une chaîne dans #Strings — donc un renommeur peut le transformer en T0 et un décompilateur affichera fidèlement T0, ce qui est exactement le résultat inoffensif que vous voulez. Mais les génériques sont l'endroit où un renommage naïf casse discrètement des choses, parce que le CLR identifie une instanciation par position et par arité, pas par nom, alors que votre code de réflexion identifie un paramètre de type par son nom. Cet article lit ce qu'une méthode et une classe génériques compilent réellement — les tables GenericParam et GenericParamConstraint, le MethodSpec et le TypeSpec qui enregistrent une instanciation par index — montre pourquoi renommer le paramètre est gratuit mais renommer un type de contrainte ne l'est pas, et parcourt les appels de réflexion (Type.GetGenericArguments().Name, MakeGenericType, GetMethod sur un générique ouvert) qui relisent les noms et cassent quand un renommeur touche la mauvaise chose.

Renommez un assembly et décompilez une méthode non générique, et la signature s’effondre joliment : int a.b(string c). Faites la même chose avec une méthode générique et vous voyez souvent quelque chose qui a l’air à moitié fini — TResult a.b<TResult, TState>(TState c). Le nom de la méthode est parti, le nom du paramètre est parti, mais les noms des paramètres de type sont toujours là entre chevrons, parfaitement lisibles.

Ce n’est pas un échec d’obfuscation, et le pourchasser est une erreur. Les noms de paramètres de type sont les noms les plus inoffensifs de tout l’assembly, et l’histoire intéressante de l’obfuscation des génériques est l’inverse : ces noms sont si sûrs à renommer que le vrai risque est de renommer au-delà d’eux, dans les deux choses que portent les génériques et qui sont réellement porteuses — les types de contrainte, et les noms que la réflexion relit à l’exécution. Pour voir où est la ligne, il faut regarder ce qu’un générique compile réellement.

Ce qu’un générique compile

Prenez une petite méthode générique avec quelques contraintes :

public static TResult Map<TState, TResult>(TState state, Func<TState, TResult> f)
    where TState : notnull
    where TResult : class
{
    return f(state);
}

Les deux paramètres de type, TState et TResult, ne sont pas stockés là où vous pourriez l’attendre — ils ne font pas partie du nom de la méthode et ne sont pas cuits dans son IL. Ce sont des lignes d’une table de métadonnées dédiée, GenericParam, une par paramètre, et chaque ligne porte trois choses qui comptent : un Number (la position à partir de zéro — TState est 0, TResult est 1), un propriétaire (la méthode ou le type auquel ils appartiennent) et un Name, un pointeur dans le tas #Strings contenant le texte littéral TState ou TResult. Dans ILDASM, l’en-tête de méthode se lit :

.method public hidebysig static !!1 Map<(class) TState, class TResult>(
        !!0 state,
        class [System.Runtime]System.Func`2<!!0, !!1> f) cil managed

Lisez attentivement le corps de cette signature, parce que c’est tout l’argument. Les paramètres sont désignés par !!0 et !!1 — par index, pas par nom. L’argument Func<TState, TResult> est Func2<!!0, !!1>: encore positionnel. Le type de retour!!1signifie « le second paramètre de type de méthode », quel que soit son nom. Les nomsTStateetTResultapparaissent à exactement un endroit — les chaînesGenericParam.Name` — et nulle part dans le code qui les utilise.

Les noms vivent dans une seule table ; chaque usage se fait par indexTable GenericParamNumber=0 Name="TState"Number=1 Name="TResult"Signature + corps (usages)arg: Func`2<!!0, !!1>ret: !!1Renommer "TState"→"A", "TResult"→"B"Seules les deux chaînes Name changent. !!0 et !!1 sont des index — intacts.Le CLR lie les arguments par emplacement, donc chaque instanciation se résout encore. Coût du renommage : zéro.

Ainsi, quand un renommeur change TState en A et TResult en B, il réécrit deux chaînes dans #Strings et ne touche à rien d’autre. Chaque !!0 et !!1 dans la signature et l’IL continue de pointer vers le bon emplacement, parce que les emplacements sont numérotés, pas nommés. La méthode lie ses arguments exactement comme avant. C’est pourquoi renommer les paramètres de type est le renommage le plus sûr de la boîte à outils — plus sûr que renommer une méthode privée, car un nom de méthode privée peut encore fuiter via une trace de pile, alors qu’un nom de paramètre de type n’a aucun consommateur à l’exécution dans du code normal.

L’instanciation est positionnelle aussi

La même histoire positionnelle vaut au site d’appel, l’autre endroit qui pourrait inquiéter un renommeur naïf. Quand vous écrivez Map<string, int>(...), le compilateur n’émet pas les noms string et int face à des paramètres appelés TState et TResult. Il émet un MethodSpec — un blob de métadonnées qui dit « instancie la méthode Map avec cette liste ordonnée de types d’arguments » — et la liste est positionnelle : [string, int] signifie que l’emplacement 0 est string, l’emplacement 1 est int. De même, un type construit comme List<string> est un blob TypeSpec portant la définition générique et une liste ordonnée d’arguments.

Nulle part dans un MethodSpec ou un TypeSpec il n’y a de nom de paramètre. La liaison est « l’argument i remplit le paramètre i ». C’est pourquoi vous pouvez renommer chaque paramètre de type de votre assembly en la même lettre unique — ou en rien de signifiant — et chaque instanciation générique de tout le programme se résout encore correctement. La machinerie de résolution n’a jamais eu les noms entre les mains.

Là où le renommage mord vraiment : les contraintes

Maintenant la partie qui n’est pas gratuite. Notre méthode avait where TState : notnull et where TResult : class. Ces contraintes sont de vraies métadonnées, et un obfuscateur doit les préserver — supprimez-les et l’assembly peut échouer à la vérification ou changer la résolution de surcharge. Elles vivent à deux endroits : les drapeaux de contrainte spéciale sur la ligne GenericParam (les sortes class/struct/new() et notnull) et, pour les contraintes de type, des lignes dans la table GenericParamConstraint.

Une contrainte notnull ou class ne nomme aucun type, donc il n’y a rien à renommer. Mais dès qu’une contrainte nomme un type —

public sealed class Handler<T> where T : IPlugin, IDisposable, new()
{
    // ...
}

— la contrainte devient une référence à IPlugin et IDisposable, stockée sous forme de lignes GenericParamConstraint pointant vers ces types. Et voici la distinction clé : la relation de contrainte est structurelle et survit, mais le type qu’elle nomme obéit aux règles de renommage ordinaires. IDisposable est un type du framework — intouchable, jamais renommé. IPlugin est à vous : s’il est interne et que rien ne le cherche par nom, le renommer en a.b est sans danger et la contrainte pointe simplement vers le type renommé. Mais si IPlugin fait partie d’un contrat public que vos clients implémentent, ou si quelque chose le résout par nom, alors le renommer casse ce contrat — et le casse indépendamment du fait que la référence se trouve apparaître dans une contrainte générique. La contrainte est une fausse piste ; ce qui compte, c’est la renommabilité du type nommé, jugée par le même test que vous appliquez partout ailleurs.

Là où le renommage mord plus fort : la réflexion sur les arguments de type

Le second danger est celui qui surprend vraiment, parce qu’il n’échoue qu’à l’exécution. Les noms de paramètres de type n’ont aucun consommateur dans du code normal — mais la réflexion peut les relire :

Type t = typeof(Dictionary<,>);          // open generic
foreach (Type p in t.GetGenericArguments())
    Console.WriteLine(p.Name);            // "TKey", "TValue" — or "A", "B" after renaming

GetGenericArguments() sur un générique ouvert renvoie les paramètres de type, et .Name lit la chaîne GenericParam.Name même qu’un renommeur réécrit. La plupart du code s’en moque — il utilise les objets Type positionnellement. Mais le code qui journalise, sérialise ou branche sur ces noms verra désormais le texte renommé. Si l’un d’eux franchit une frontière — un nom de paramètre écrit dans un diagnostic qu’un client lit, ou dans une clé de cache, ou comparé à une chaîne codée en dur — le renommage a changé le comportement en silence.

La version plus tranchante consiste à chercher un membre générique par nom puis à le construire :

// Find an open generic method by name, then instantiate it.
MethodInfo open = typeof(Repo)
    .GetMethod("LoadAll", BindingFlags.Public | BindingFlags.Instance)!;
MethodInfo closed = open.MakeGenericMethod(typeof(Customer));

La chaîne vulnérable ici n’est pas le paramètre de type — c’est "LoadAll", le nom de la méthode passé à GetMethod. Renommez LoadAll et ce GetMethod renvoie null, et la ligne suivante lève une exception. C’est le même problème « la réflexion casse le renommage » qui s’applique à n’importe quel membre, mais les génériques le concentrent : le code générique lourd en réflexion (fabriques, sérialiseurs, conteneurs DI qui résolvent des génériques ouverts, dispatch via MakeGenericType/MakeGenericMethod) a tendance à chercher les membres par nom puis à les fermer sur des types d’exécution. Les arguments de type sont sûrs ; les noms de membres alimentant GetMethod, GetType(string) et consorts ne le sont pas.

Comment Nebula délimite tout cela

La règle qui découle des métadonnées est nette, et c’est exactement celle que Nebula applique. Les paramètres de type sont renommés agressivement, parce que leurs noms n’ont aucun rôle à l’exécution et que les renommer ne coûte rien — Map<TState, TResult> devenant Map<A, B> dans le décompilateur est un pur gain. Les types de contrainte sont jugés par le même test de renommabilité que n’importe quel autre type : internes et non référencés par nom, ils sont renommés ; contrat public ou résolus par nom, ils sont exclus. Et les noms de membres que la réflexion reconstruit à partir de chaînes sont conservés, de sorte qu’une fabrique qui fait GetMethod("LoadAll") trouve encore sa méthode.

Dans une configuration Nebula, la seule chose spécifique aux génériques que vous faites habituellement est de protéger la surface de réflexion — les types et méthodes génériques ouverts que quelque chose cherche par nom — tout en laissant les paramètres de type et les types de contrainte internes être renommés normalement :

<Rules>
  <!-- A factory resolves these open generics by method name at runtime: keep the names. -->
  <Rule pattern="method MyApp.Data.Repo::LoadAll" rename="false" />
  <Rule pattern="type MyApp.Plugins.IPlugin" rename="false" />
  <!-- Type parameters everywhere are renamed by default; nothing to configure. -->
</Rules>

Le résultat se lit comme vous le voulez quand vous le décompilez : les méthodes génériques sont aussi opaques que tout le reste, leurs paramètres de type réduits à des lettres sans signification, la logique interne renommée et son flot de contrôle obfusqué — tandis que la poignée de noms qui ont réellement un rôle à l’exécution, parce que la réflexion ou un contrat public les lit, restent debout pour que le code fonctionne encore.

Le test, en une ligne

Les noms de paramètres de type sont libres à renommer ; ils n’ont aucun consommateur à l’exécution hormis la réflexion que vous contrôlez. Avant de laisser un renommage toucher quoi que ce soit d’autre qu’un générique référence, posez la question que vous posez à chaque nom : ce nom est-il relu — par le CLR par nom (jamais ; il utilise des index), par la réflexion, ou à travers une frontière de contrat ? Pour un paramètre de type la réponse est presque toujours non, donc renommez-le. Pour le type qu’une contrainte nomme, ou le membre vers lequel pointe une chaîne GetMethod, la réponse peut être oui — et c’est là, pas dans les chevrons, que l’attention doit se porter.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.