Obfusquer async/await et les itérateurs : pourquoi la machine à états survit au renommage (et le seul lien qu'un renommeur naïf casse)
Décompilez un assembly obfusqué et la chose la plus effrayante que vous voyez est un MoveNext plein de flux de contrôle aplati et des champs nommés <>1__state. On suppose que le renommage a forcément cassé le code async. Ce n'est pas le cas — car chaque lien entre une méthode async, son type de machine à états généré et l'AsyncTaskMethodBuilder est un jeton de métadonnées, pas une recherche par nom, alors renommer le type et ses champs en a et b ne change rien que le runtime lise. Mais il y a une chose qu'un obfuscateur négligent peut réellement casser : MoveNext, SetStateMachine, Current et Dispose sont des méthodes d'interface, et les renommer sans réécrire le lien d'interface (le MethodImpl) laisse le builder appeler une méthode qui n'implémente plus IAsyncStateMachine. Ce billet lit l'abaissement réel d'une méthode async et d'un itérateur, montre quels noms sont libres et quel lien doit être préservé, et explique le compromis du durcissement des métadonnées : retirez l'attribut AsyncStateMachine pour une build de release et sachez que cela coûte le débogage pas-à-pas d'async, pas la correction.
Décompilez un assembly obfusqué qui utilise async/await et la sortie est alarmante. Des méthodes que vous avez écrites comme une séquence ordonnée d”await reviennent comme un type que vous n”avez jamais déclaré — quelque chose comme a avec des champs b, c, d — contenant une seule méthode, MoveNext, qui est un switch aplati sur un champ entier avec des goto sautant entre les cas. On dirait que l”obfuscateur a réduit votre code async en quelque chose qui ne peut pas encore fonctionner.
Ça fonctionne parfaitement. Ce que vous regardez, ce sont deux couches de transformation empilées l”une sur l”autre, et aucune n”a changé le comportement. Le compilateur C# est arrivé en premier : async et yield ne sont pas des fonctionnalités du runtime, ce sont des fonctionnalités du compilateur, et le compilateur a déjà réécrit votre méthode linéaire en une machine à états reprenable avant même que l”obfuscateur ne la voie. L”obfuscateur a ensuite renommé les noms générés par le compilateur et aplati davantage le flux de contrôle. La raison pour laquelle le résultat tourne toujours est la même que celle qui rend le renommage ordinaire sûr : chaque lien dans une méthode async est un jeton de métadonnées, pas une recherche par nom — avec exactement un lien qu”un outil négligent peut couper. Ce billet montre l”abaissement, pointe les noms libres et le lien porteur, et tranche la seule décision réelle que vous devez prendre : conserver ou non les attributs de machine à états.
Ce vers quoi async se compile réellement
Commencez par une méthode aussi simple qu”elles peuvent l”être :
public async Task<int> SumAsync(HttpClient http, string url)
{
string body = await http.GetStringAsync(url);
return body.Length;
}
Le compilateur scinde ceci en deux artefacts. Le premier est un stub qui garde le nom et la signature d”origine de la méthode — c”est la partie à laquelle les appelants se lient — et ne fait presque rien sauf préparer et démarrer une machine à états :
.method public hidebysig instance class [System.Runtime]System.Threading.Tasks.Task`1<int32>
SumAsync(class [System.Net.Http]System.Net.Http.HttpClient http, string url) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [System.Runtime]System.Type)
= { type('SumAsync>d__0') } // <-- points typeof at the state machine
// new <SumAsync>d__0 sm;
// sm.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
// sm.http = http; sm.url = url; sm.<>1__state = -1;
// sm.<>t__builder.Start(ref sm); // generic: Start<TStateMachine>(ref sm)
// return sm.<>t__builder.Task;
}
Le second artefact est le type de machine à états — un struct généré par le compilateur qui contient la logique de la méthode :
.class nested private sealed auto ansi beforefieldinit
'<SumAsync>d__0' extends [System.Runtime]System.ValueType
implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine
{
.field public int32 '<>1__state' // where to resume
.field public valuetype ...AsyncTaskMethodBuilder`1<int32> '<>t__builder'
.field public class [System.Net.Http]...HttpClient http // hoisted parameter
.field public string url // hoisted parameter
.field private string '<body>5__1' // hoisted local
.field private valuetype ...TaskAwaiter`1<string> '<>u__1' // the awaiter
.method private hidebysig newslot virtual final instance void
MoveNext() cil managed { /* the switch-on-<>1__state body */ }
.method private hidebysig newslot virtual final instance void
SetStateMachine(class ...IAsyncStateMachine stateMachine) cil managed { ... }
}
Votre unique await est devenu un switch (<>1__state) à l”intérieur de MoveNext : l”état -1 s”exécute jusqu”à l”await, gare le TaskAwaiter dans <>u__1, met <>1__state = 0 et retourne ; quand la tâche se termine, le builder rappelle MoveNext, le switch saute au point de reprise, lit le résultat et exécute le reste. Votre variable locale body est devenue le champ <body>5__1 parce qu”une locale ne peut pas survivre au retour d”une méthode puis à sa ré-entrée — un champ sur la machine à états, si.
Pourquoi le renommage laisse tout cela intact
Regardez ce qui relie les pièces et vous voyez la même chose à chaque fois : un jeton, pas un nom. Le stub crée <SumAsync>d__0 par un newobj/initobj contre un jeton TypeRef/TypeDef ; il lit et écrit <>1__state et <>t__builder via ldfld/stfld contre des jetons FieldDef ; le Start<TStateMachine>(ref TStateMachine) du builder est générique et appelle MoveNext via la contrainte IAsyncStateMachine. Aucun de ceux-ci n”est une recherche réflexive par chaîne. Le CLR résout des jetons ; il n”épelle jamais "<>1__state" pour trouver le champ.
C”est exactement pourquoi le renommage est sûr ici pour la même raison qu”il l”est partout dans le code managé. Renommez le type de machine à états en a, renommez <>1__state en b, renommez <>t__builder en c, renommez les locales hissées — et chaque ldfld, chaque newobj, chaque call pointe toujours vers le bon jeton parce que l”obfuscateur a réécrit la cible du jeton de façon cohérente. Le corps de MoveNext commute toujours sur b, le builder démarre toujours a, la méthode retourne toujours la bonne Task. La sortie décompilée est hideuse ; le comportement est identique.
Le seul lien qu”un renommeur naïf casse
Il y a un unique danger véritable, et c”est la raison pour laquelle « un bon obfuscateur comprend async » n”est pas du marketing. MoveNext et SetStateMachine ne sont pas des méthodes ordinaires — ce sont l”implémentation d”IAsyncStateMachine. Le Start<TStateMachine> du builder appelle MoveNext à travers l”interface, via la contrainte where TStateMachine : IAsyncStateMachine. Pour que cet appel se lie, le runtime doit pouvoir voir que le MoveNext de la machine à états est la méthode qui implémente IAsyncStateMachine.MoveNext.
Un renommeur qui traite MoveNext comme un nom de plus et le réécrit en a() — sans toucher à l”enregistrement d”implémentation d”interface — coupe ce lien. Le type implémente toujours nominalement IAsyncStateMachine, mais maintenant aucune de ses méthodes ne porte le nom de celles de l”interface, et il n”y a pas d”enregistrement MethodImpl (.override) disant « a est IAsyncStateMachine.MoveNext ». L”appel contraint à l”intérieur de Start ne résout rien, et le tout premier await lève une exception à l”exécution sans avertissement à la compilation. C”est l”origine réelle de « l”obfuscation a cassé mon async », et c”est entièrement évitable.
Un obfuscateur correct fait l”une de deux choses : laisser tranquilles les noms des méthodes qui implémentent des interfaces, ou les renommer et émettre le MethodImpl explicite qui relie a à IAsyncStateMachine.MoveNext. Nebula fait le second là où il renomme, et préserve le lien dans les deux cas — il lit l”implémentation d”interface depuis les métadonnées plutôt que de deviner d”après le nom de la méthode —, alors MoveNext, SetStateMachine et les membres d”itérateur ci-dessous sont renommés aussi agressivement que n”importe quoi d”autre tandis que le contrat qu”ils satisfont reste intact. Vous ne listez vos méthodes async nulle part ; la détection automatique voit les types générés et les interfaces qu”ils implémentent.
Les itérateurs sont la même histoire avec plus de surface d”interface
yield return s”abaisse de la même façon, en un type qui implémente IEnumerable<T>/IEnumerator<T> :
public IEnumerable<int> EvensUpTo(int n)
{
for (int i = 0; i <= n; i += 2)
yield return i;
}
Le <EvensUpTo>d__0 généré porte le familier <>1__state et un champ <>2__current contenant la valeur que Current retourne, et il implémente l”interface d”énumérateur — alors MoveNext, get_Current, Dispose et Reset sont tous des membres d”interface, dont chacun est invoqué par un foreach à travers l”interface. Il a aussi un champ <>l__initialThreadId : le compilateur enregistre le thread sur lequel l”itérateur a été créé afin que l”énumérer sur ce même thread puisse réutiliser l”unique instance plutôt que d”en allouer une nouvelle. Les itérateurs async (IAsyncEnumerable<T>) étendent le motif avec un AsyncIteratorMethodBuilder et un DisposeAsync.
Chaque nom ici est libre de renommage — <EvensUpTo>d__0, <>2__current, <>l__initialThreadId, tout — et la même règle unique s”applique : le lien d”implémentation d”interface pour MoveNext/Current/Dispose doit être préservé, ou un foreach sur l”itérateur obfusqué cesse de résoudre. Nebula traite le type généré de l”itérateur exactement comme celui d”async.
La décision de l”attribut : correction contre confort
Reste le [AsyncStateMachine(typeof(...))] sur le stub (et [IteratorStateMachine(typeof(...))] sur un itérateur). Son typeof pointe vers le type généré, et quand l”obfuscateur renomme ce type, le jeton de l”attribut se met à jour avec lui — alors après le renommage l”attribut nomme toujours le bon type (désormais renommé). La vraie question est de savoir s”il faut le conserver du tout.
Le CLR ne lit pas ces attributs pour exécuter du code async ou itérateur. Ils existent pour l”outillage : un débogueur les utilise pour assembler une pile d”appels async et pour passer d”un await à la continuation, et certains démêleurs de traces de pile les utilisent pour présenter un cadre MoveNext sous le nom de votre méthode d”origine. Pour une build livrée et protégée, les retirer est le bon choix — ce sont une carte que vous tendriez sinon à quiconque attache un débogueur, et les retirer ne coûte rien à l”exécution :
{
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "protected",
"controlFlowObfuscation": true,
"encryptStrings": true,
"metadataHardening": true
}
metadataHardening retire les métadonnées de compilation seulement — les attributs d”annotation nullable, et les attributs de machine à états parmi eux — tout en préservant tout ce que le runtime lie : les interfaces, les enregistrements MethodImpl, les références au builder. Votre code async tourne à l”identique ; un lecteur perd juste le confort d”avoir les machines à états étiquetées. S”il vous faut les étiquettes pour votre propre analyse de plantage, gardez un PDB obfusqué (pdbMode: "obfuscated") pour la build que vous livrez et analysez, plutôt que de laisser les attributs dans le binaire pour tout le monde.
À retenir
Une méthode async obfusquée a l”air cassée et ne l”est pas, parce qu”async était déjà une réécriture du compilateur avant que l”obfuscateur n”y touche, et parce que chaque connexion dans cette réécriture est un jeton que le chargeur résout — pas un nom que quelqu”un épelle à l”exécution. Renommez le type de machine à états, son champ d”état, son builder, ses locales hissées : tout est libre. La seule chose qui n”est pas libre est le lien d”interface sur MoveNext, SetStateMachine, Current et Dispose — renommez-les sans réécrire le MethodImpl et le premier await ou foreach meurt. C”est la ligne entre un obfuscateur qui comprend le CLR et un qui renomme juste des chaînes. Faites-le bien et votre code async sort aussi difficile à lire que le reste de votre assembly et se comporte exactement comme il l”a toujours fait.
Pour la vue du décompilateur de la même machinerie, voir vers quoi async/await se décompile et décompiler yield return ; pour la liste plus large de « ce qui résout par nom et doit être préservé », voir ce que l”obfuscation casse : réflexion et sérialisation. Téléchargez Nebula.NET gratuitement et exécutez votre suite de tests riche en async contre la build obfusquée.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.