Skip to content
← Alle Beiträge
· Delta1 Labs Nebula.NETObfuskierung.NETTiefenanalyse

async/await und Iteratoren obfuskieren: warum die Zustandsmaschine das Umbenennen überlebt (und das eine Binding, das ein naiver Umbenenner bricht)

Dekompilieren Sie eine obfuskierte Assembly und das Erschreckendste, was Sie sehen, ist ein MoveNext voller abgeflachtem Kontrollfluss und Feldern namens <>1__state. Man nimmt an, das Umbenennen müsse den async-Code zerstört haben. Hat es nicht — denn jedes Binding zwischen einer async-Methode, ihrem generierten Zustandsmaschinen-Typ und dem AsyncTaskMethodBuilder ist ein Metadaten-Token, keine Namenssuche, also ändert das Umbenennen des Typs und seiner Felder zu a und b nichts, was die Laufzeit liest. Doch es gibt eine Sache, die ein nachlässiger Obfuskator wirklich brechen kann: MoveNext, SetStateMachine, Current und Dispose sind Schnittstellenmethoden, und sie umzubenennen, ohne das Schnittstellen-Binding (das MethodImpl) umzuschreiben, lässt den Builder eine Methode aufrufen, die IAsyncStateMachine nicht mehr implementiert. Dieser Beitrag liest die echte Absenkung einer async-Methode und eines Iterators, zeigt, welche Namen frei sind und welches Binding erhalten bleiben muss, und erklärt den Kompromiss der Metadaten-Härtung: Entfernen Sie das AsyncStateMachine-Attribut für einen Release-Build und wissen Sie, dass es async-Einzelschrittdebugging kostet, nicht Korrektheit.

Dekompilieren Sie eine obfuskierte Assembly, die async/await benutzt, und die Ausgabe ist alarmierend. Methoden, die Sie als saubere Folge von await geschrieben haben, kommen als ein Typ zurück, den Sie nie deklariert haben — so etwas wie a mit den Feldern b, c, d —, der eine einzige Methode enthält, MoveNext, die ein abgeflachter switch über ein Integer-Feld mit gotos zwischen den Fällen ist. Es sieht aus, als hätte der Obfuskator Ihren async-Code in etwas verstümmelt, das unmöglich noch funktionieren kann.

Er funktioniert einwandfrei. Was Sie betrachten, sind zwei Transformationsschichten, übereinandergestapelt, und keine hat das Verhalten geändert. Der C#-Compiler war zuerst da: async und yield sind keine Laufzeitfunktionen, es sind Compiler-Funktionen, und der Compiler hat Ihre lineare Methode bereits in eine fortsetzbare Zustandsmaschine umgeschrieben, bevor der Obfuskator sie überhaupt sah. Der Obfuskator benannte dann die vom Compiler generierten Namen um und flachte den Kontrollfluss weiter ab. Der Grund, warum das Ergebnis immer noch läuft, ist derselbe, aus dem gewöhnliches Umbenennen sicher ist: jedes Binding in einer async-Methode ist ein Metadaten-Token, keine Namenssuche — mit genau einem Binding, das ein nachlässiges Werkzeug kappen kann. Dieser Beitrag zeigt die Absenkung, weist auf die freien Namen und das tragende Binding hin und entscheidet die einzige echte Entscheidung, die Sie treffen müssen: ob die Zustandsmaschinen-Attribute bleiben.

Wozu async tatsächlich kompiliert

Beginnen Sie mit einer Methode, so einfach wie sie nur sein kann:

public async Task<int> SumAsync(HttpClient http, string url)
{
    string body = await http.GetStringAsync(url);
    return body.Length;
}

Der Compiler teilt dies in zwei Artefakte. Das erste ist ein Stub, der den ursprünglichen Namen und die Signatur der Methode behält — das ist der Teil, an den sich Aufrufer binden — und fast nichts tut, außer eine Zustandsmaschine einzurichten und zu starten:

.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;
}

Das zweite Artefakt ist der Zustandsmaschinen-Typ — ein vom Compiler generiertes Struct, das die Logik der Methode enthält:

.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 { ... }
}

Ihr einziges await wurde zu einem switch (<>1__state) innerhalb von MoveNext: Zustand -1 läuft bis zum await, parkt den TaskAwaiter in <>u__1, setzt <>1__state = 0 und kehrt zurück; wenn die Aufgabe abgeschlossen ist, ruft der Builder MoveNext erneut auf, der switch springt zum Fortsetzungspunkt, liest das Ergebnis und führt den Rest aus. Ihre lokale Variable body wurde zum Feld <body>5__1, weil eine lokale Variable nicht überleben kann, dass eine Methode zurückkehrt und wieder betreten wird — ein Feld auf der Zustandsmaschine schon.

Stub — öffentlicher Name bleibtSumAsync(http, url)erzeugt die Zustandsmaschinebuilder.Start(ref sm)return builder.Task[AsyncStateMachine(typeof(…))]im Release entfernen · nur DebugZustandsmaschinen-Typ → umbenannt zu a<>1__state → b (resume point)<>t__builder → c (the builder)<body>5__1 → d (hoisted local)Feld- und Typnamen: frei umbenanntMoveNext() : IAsyncStateMachineSetStateMachine() : IAsyncStateMachineumbenennbar NUR wenn das MethodImpl(Schnittstellen-Binding) auch umgeschrieben wird

Warum das Umbenennen all dies intakt lässt

Sehen Sie sich an, was die Teile verbindet, und Sie sehen jedes Mal dasselbe: ein Token, kein Name. Der Stub erzeugt <SumAsync>d__0 per newobj/initobj gegen ein TypeRef/TypeDef-Token; er liest und schreibt <>1__state und <>t__builder per ldfld/stfld gegen FieldDef-Tokens; das Start<TStateMachine>(ref TStateMachine) des Builders ist generisch und ruft MoveNext über die IAsyncStateMachine-Einschränkung auf. Keines davon ist eine reflektive, über Zeichenketten gesteuerte Suche. Die CLR löst Tokens auf; sie buchstabiert nie "<>1__state", um das Feld zu finden.

Genau deshalb ist das Umbenennen hier aus demselben Grund sicher, aus dem es überall im verwalteten Code sicher ist. Benennen Sie den Zustandsmaschinen-Typ zu a um, benennen Sie <>1__state zu b um, benennen Sie <>t__builder zu c um, benennen Sie die hochgezogenen lokalen Variablen um — und jedes ldfld, jedes newobj, jedes call zeigt weiterhin auf das richtige Token, weil der Obfuskator das Ziel des Tokens konsistent umgeschrieben hat. Der Körper von MoveNext schaltet weiterhin über b, der Builder startet weiterhin a, die Methode gibt weiterhin die richtige Task zurück. Die dekompilierte Ausgabe ist scheußlich; das Verhalten ist identisch.

Das eine Binding, das ein naiver Umbenenner bricht

Es gibt eine einzige echte Gefahr, und sie ist der Grund, warum „ein guter Obfuskator versteht async” kein Marketing ist. MoveNext und SetStateMachine sind keine gewöhnlichen Methoden — sie sind die Implementierung von IAsyncStateMachine. Das Start<TStateMachine> des Builders ruft MoveNext über die Schnittstelle auf, via die Einschränkung where TStateMachine : IAsyncStateMachine. Damit dieser Aufruf bindet, muss die Laufzeit sehen können, dass das MoveNext der Zustandsmaschine die Methode ist, die IAsyncStateMachine.MoveNext implementiert.

Ein Umbenenner, der MoveNext als bloß einen weiteren Namen behandelt und es zu a() umschreibt — ohne den Schnittstellen-Implementierungseintrag anzurühren —, kappt dieses Binding. Der Typ implementiert nominell weiterhin IAsyncStateMachine, aber nun heißt keine seiner Methoden wie die der Schnittstelle, und es gibt keinen MethodImpl-Eintrag (.override), der sagt „a ist IAsyncStateMachine.MoveNext”. Der eingeschränkte Aufruf innerhalb von Start löst zu nichts auf, und das allererste await wirft zur Laufzeit eine Ausnahme ohne Warnung zur Compile-Zeit. Das ist der wahre Ursprung von „die Obfuskierung hat mein async zerstört”, und er ist vollständig vermeidbar.

Ein korrekter Obfuskator tut eines von zwei Dingen: die Namen der schnittstellenimplementierenden Methoden in Ruhe lassen, oder sie umbenennen und das explizite MethodImpl ausgeben, das a erneut an IAsyncStateMachine.MoveNext bindet. Nebula tut Letzteres, wo es umbenennt, und erhält das Binding in jedem Fall — es liest die Schnittstellenimplementierung aus den Metadaten, statt sie aus dem Methodennamen zu raten —, also werden MoveNext, SetStateMachine und die Iterator-Mitglieder weiter unten so aggressiv umbenannt wie alles andere, während der Vertrag, den sie erfüllen, intakt bleibt. Sie listen Ihre async-Methoden nirgends auf; die automatische Erkennung sieht die generierten Typen und die Schnittstellen, die sie implementieren.

Iteratoren sind dieselbe Geschichte mit mehr Schnittstellenfläche

yield return senkt genauso ab, in einen Typ, der IEnumerable<T>/IEnumerator<T> implementiert:

public IEnumerable<int> EvensUpTo(int n)
{
    for (int i = 0; i <= n; i += 2)
        yield return i;
}

Das generierte <EvensUpTo>d__0 trägt das vertraute <>1__state und ein Feld <>2__current, das den Wert hält, den Current zurückgibt, und es implementiert die Enumerator-Schnittstelle — also sind MoveNext, get_Current, Dispose und Reset alle Schnittstellenmitglieder, von denen jedes ein foreach über die Schnittstelle aufruft. Es hat außerdem ein Feld <>l__initialThreadId: Der Compiler vermerkt den Thread, auf dem der Iterator erzeugt wurde, damit das Enumerieren auf eben diesem Thread die einzige Instanz wiederverwenden kann, statt eine neue zu allozieren. Async-Iteratoren (IAsyncEnumerable<T>) erweitern das Muster um einen AsyncIteratorMethodBuilder und ein DisposeAsync.

Jeder Name hier ist frei umbenennbar — <EvensUpTo>d__0, <>2__current, <>l__initialThreadId, alles — und dieselbe einzige Regel gilt: Das Schnittstellen-Implementierungs-Binding für MoveNext/Current/Dispose muss erhalten bleiben, sonst löst ein foreach über den obfuskierten Iterator nicht mehr auf. Nebula behandelt den generierten Typ des Iterators genau wie den von async.

Die Attributentscheidung: Korrektheit gegen Komfort

Bleibt das [AsyncStateMachine(typeof(...))] auf dem Stub (und [IteratorStateMachine(typeof(...))] auf einem Iterator). Sein typeof zeigt auf den generierten Typ, und wenn der Obfuskator diesen Typ umbenennt, aktualisiert sich das Token des Attributs mit — also nennt das Attribut nach dem Umbenennen weiterhin den richtigen (nun umbenannten) Typ. Die eigentliche Frage ist, ob man es überhaupt behält.

Die CLR liest diese Attribute nicht, um async- oder Iterator-Code auszuführen. Sie existieren fürs Werkzeug: Ein Debugger benutzt sie, um einen async-Aufruf-Stack zusammenzufügen und um von einem await in die Fortsetzung zu springen, und manche Stacktrace-Entwirrer benutzen sie, um einen MoveNext-Frame unter dem Namen Ihrer ursprünglichen Methode darzustellen. Für einen ausgelieferten, geschützten Build ist das Entfernen die richtige Wahl — sie sind eine Karte, die Sie sonst jedem reichen, der einen Debugger anhängt, und sie zu entfernen kostet zur Laufzeit nichts:

{
  "inputs": ["bin/Release/net8.0/MyApp.dll"],
  "outputDirectory": "protected",
  "controlFlowObfuscation": true,
  "encryptStrings": true,
  "metadataHardening": true
}

metadataHardening entfernt reine Compile-Zeit-Metadaten — die Nullable-Annotationsattribute und die Zustandsmaschinen-Attribute darunter — und erhält dabei alles, woran die Laufzeit bindet: die Schnittstellen, die MethodImpl-Einträge, die Builder-Referenzen. Ihr async-Code läuft identisch; ein Leser verliert nur den Komfort, die Zustandsmaschinen beschriftet zu haben. Wenn Sie die Beschriftungen für Ihre eigene Absturzanalyse brauchen, behalten Sie ein obfuskiertes PDB (pdbMode: "obfuscated") für den Build, den Sie ausliefern und analysieren, statt die Attribute für alle im Binary zu belassen.

Das Fazit

Eine obfuskierte async-Methode sieht kaputt aus und ist es nicht, weil async schon eine Compiler-Umschreibung war, bevor der Obfuskator sie anrührte, und weil jede Verbindung in dieser Umschreibung ein Token ist, das der Loader auflöst — kein Name, den irgendwer zur Laufzeit buchstabiert. Benennen Sie den Zustandsmaschinen-Typ, sein Zustandsfeld, seinen Builder, seine hochgezogenen lokalen Variablen um: alles frei. Das Einzige, was nicht frei ist, ist das Schnittstellen-Binding auf MoveNext, SetStateMachine, Current und Dispose — benennen Sie sie um, ohne das MethodImpl umzuschreiben, und das erste await oder foreach stirbt. Das ist die Linie zwischen einem Obfuskator, der die CLR versteht, und einem, der nur Zeichenketten umbenennt. Machen Sie es richtig, und Ihr async-Code kommt so schwer lesbar heraus wie der Rest Ihrer Assembly und verhält sich genau so, wie er es immer tat.

Für die Sicht des Dekompilierers auf dieselbe Maschinerie siehe wozu async/await dekompiliert und yield return dekompilieren; für die breitere Liste von „was über den Namen auflöst und erhalten bleiben muss” siehe was die Obfuskierung bricht: Reflexion und Serialisierung. Laden Sie Nebula.NET kostenlos herunter und lassen Sie Ihre async-lastige Testsuite gegen den obfuskierten Build laufen.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.