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

Code obfuskieren, der dynamic verwendet: der Laufzeit-Binder sucht Member nach Namen, also ist eine umbenannte Property eine RuntimeBinderException

Ein statischer Aufruf überlebt das Umbenennen, weil er über ein Metadaten-Token aufgelöst wird und der Obfuskator das Token zusammen mit dem Member umschreibt. Ein dynamic-Aufruf überlebt nichts: der C#-Compiler senkt settings.Timeout in eine Call-Site ab, deren Binder den String "Timeout" erhält, und zur Laufzeit durchsucht Microsoft.CSharp den tatsächlichen Typ des Empfängers nach einem Member mit exakt diesem Namen. Benennen Sie Timeout in a um, und die Site fragt weiter nach "Timeout", die Suche scheitert, und die App wirft RuntimeBinderException auf einer Zeile, die sauber kompilierte und auf dem ungeschützten Build jeden Test bestand. Dieser Beitrag liest die IL einer dynamic-Site, um zu zeigen, wo der Name lebt (ein ldstr, das Binder.GetMember und Binder.InvokeMember in einem versteckten <>o__-Container speist), erklärt, warum String-Verschlüsselung weder hilft noch schadet, warum kein Renamer das sicher automatisch beheben kann (der Typ des Empfängers ist erst bekannt, wenn der Aufruf läuft), welche dynamic-Operationen nach Namen binden und welche nicht (Indexer über Item, Operatoren über op_-Namen, ExpandoObject-Schlüssel nie), das Double-Dispatch-per-dynamic-Muster, das stillschweigend einen ganzen Visitor zerlegt, und die drei Korrekturen in Reihenfolge der Präferenz: dynamic durch ein Interface ersetzen, nur die Member fixieren, die eine Site erreicht, mit [Obfuscation(Exclude = true)], oder ein exclude-Eintrag in nebula.config.json — plus der eine Test, der es auf dem geschützten Build fängt.

Hier ist eine Methode, die auf jedem Build funktioniert außer auf dem, den Sie ausliefern:

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

Auf dem Debug-Build tut Configure(new Settings()), was es sagt. Auf dem obfuskierten Build wirft es:

Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
  'a' does not contain a definition for 'Timeout'

Am Obfuskator ist nichts falsch. Der Typ wurde in a umbenannt, seine Property wurde umbenannt, und die dynamic-Site — die von beidem nie wusste — fragte weiter nach Timeout per Name. Um zu sehen, warum das unvermeidlich ist und was man dagegen tut, lesen Sie die IL, die der Compiler für diese eine Zeile erzeugt hat.

Wo der Name lebt

Ein normales Lesen einer Property, int timeout = settings.Timeout; mit settings als Settings typisiert, kompiliert zu einem callvirt gegen ein Metadaten-Token:

callvirt instance int32 Acme.Settings::get_Timeout()

Das Token zeigt auf eine Zeile in der Methodentabelle. Wenn Nebula get_Timeout in a umbenennt, schreibt es die Zeile und jedes Token um, das sie referenziert — die Aufrufstelle und der Member ziehen gemeinsam um, und der String Timeout hört auf, irgendwo im Assembly zu existieren. Deshalb ist das Umbenennen für gewöhnlichen Code unsichtbar.

Die dynamic-Version kompiliert zu etwas völlig anderem. Der Compiler erzeugt eine versteckte geschachtelte Klasse, die eine zwischengespeicherte Call-Site hält, und die Site wird mit einem Binder initialisiert, dem der Member-Name als String übergeben wird:

// 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)

In dieser ganzen Sequenz gibt es kein Token für Timeout. Die einzige Erwähnung des Members ist ldstr "Timeout", ein String-Literal, das an Binder.GetMember übergeben wird. Zur Laufzeit nimmt der C#-Laufzeit-Binder den tatsächlichen Typ des Objekts, das in settings ankommt, durchsucht ihn nach einem Member namens Timeout — dieselbe Suche, die Reflection durchführt — baut eine Regel für diesen Typ, speichert sie in der Site und führt sie aus. Die Zuweisung kompiliert auf dieselbe Weise über Binder.SetMember(..., "Endpoint", ...); ein Methodenaufruf über Binder.InvokeMember(..., "Save", ...).

Nach dem Umbenennen hat der Typ einen Member a, und die Site sagt weiterhin "Timeout". Der Binder sucht, findet nichts und wirft. Die Verknüpfung war ein Name, und der Renamer, der nur Tokens bewegt, hatte keinen Grund, davon zu wissen.

Token-gebunden vs. namensgebunden: was ein Renamer verfolgen kannstatisch: Settings s; s.Timeoutcallvirt → token 0x06000012(MethodDef-Zeile von get_Timeout)nach Umbenennen → aZeile umbenannt; Token umgeschriebencallvirt → a()löst auf: weiterhin verknüpftdynamic: dynamic s; s.Timeoutldstr "Timeout"→ Binder.GetMember(…)nach Umbenennen → aMember umbenannt; String bleibtBinder sucht "Timeout"RuntimeBinderExceptionwarum keine Auto-KorrekturDie Site weiß nicht,welcher Typ ankommt: IhrSettings, ein Expando, einCOM-Proxy, ein Typ aus einemanderen Assembly. Der Stringist ein später aufzulösenderName — also können nur Siesagen, welchen Member erweiter erreichen muss.Selbst wo es weiter funktioniert, ist das ldstr ein Leck: ein Klartext-"Timeout" neben einem Typ, dessen Member jetzt a, b, c heißen,verrät einem Leser, wie a früher hieß. String-Verschlüsselung versteckt den Wegweiser; an der Bindung ändert sie nichts.

Warum String-Verschlüsselung am Thema vorbeigeht

Der Reflex beim Anblick von ldstr "Timeout" ist zu denken, Nebulas String-Verschlüsselung werde sich darum kümmern. Sie wird es verschlüsseln — und der entschlüsselte Wert, der zur Laufzeit an Binder.GetMember übergeben wird, ist dasselbe "Timeout", das auf dem umbenannten Typ nach wie vor nicht existiert. Die Verschlüsselung ändert nichts an der Bindung.

Was sie ändert, ist das Leck. In einem unverschlüsselten Build ist jede dynamic-Site ein Klartext-Wegweiser im User-String-Heap: ein Leser, der das Assembly öffnet, sieht "Timeout", das an einen Binder übergeben wird, direkt neben einem Typ, dessen Member jetzt a, b, c heißen, und hat gratis einen Originalnamen wiedergewonnen. Das sind die leiseren Kosten von dynamic in einem geschützten Assembly — wo es nicht bricht, erzählt es trotzdem. Mit aktiviertem encryptStrings ist der Wegweiser aus der statischen Ansicht verschwunden; die Suche ist exakt so kaputt wie zuvor.

Welche Operationen nach Namen binden

Nicht jede dynamic-Operation ist eine Namenssuche, und zu wissen, welche es sind, sagt Ihnen, wo Sie hinsehen müssen:

  • Member-Zugriff, Zuweisung, Aufruf — s.Timeout, s.Endpoint = …, s.Save() — gehen über GetMember, SetMember, InvokeMember mit dem Member-Namen als String. Sie brechen, wenn der Member umbenannt wird.
  • Indizierung — s[0] — geht über GetIndex/SetIndex, das den Standard-Member des Typs auflöst ([DefaultMember("Item")], die Item-Property des Indexers). Benennen Sie den Indexer um, und die dynamische Indizierung bricht auf dieselbe Weise.
  • Operatoren und Konvertierungen — a + b, (int)d — gehen über BinaryOperation/Convert, die benutzerdefinierte Operatoren über ihre speziellen Namen auflösen, op_Addition, op_Implicit. Das sind specialname-Member, auf die sich die Laufzeit selbst verlässt, und Nebula benennt sie nicht um, sodass dynamische Arithmetik über Ihre Typen weiter funktioniert.
  • Aufruf eines Delegate-Werts — d(x), wobei d einen Delegate hält — geht über Invoke, kein Name, kein Problem.
  • ExpandoObject und jeder IDynamicMetaObjectProvider — die „Member” sind zur Laufzeit gelieferte Dictionary-Schlüssel, keine CLR-Member. Umbenennen rührt nie Daten an, also funktioniert expando.Timeout in jedem Fall weiter. Dasselbe gilt für ein dynamic über einen COM-IDispatch-Proxy: die Namen werden vom COM-Server aufgelöst, nicht von Ihren Metadaten.

Der gefährliche Fall ist der erste Punkt, angewandt auf Typen, die Ihnen gehören, und es gibt ein Muster, das ihn besonders gut versteckt.

Das Muster, das einen ganzen Visitor auf einmal zerlegt

Double Dispatch per dynamic ist eine beliebte Abkürzung für einen Visitor ohne den Accept-Boilerplate:

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

Das kompiliert zu Binder.InvokeMember(..., "Compute", typeof(Area), ...), wobei der Laufzeit-Binder die Überladung nach dem tatsächlichen Typ des Arguments auswählt. Benennen Sie Compute in b um, und jede Form scheitert beim ersten Aufruf — nicht eine Property, der gesamte Dispatch — mit 'Area' does not contain a definition for 'Compute'. Weil die Überladungen private sind, referenziert nichts anderes im Assembly sie durch irgendetwas außer diesem einen String, sodass es zur Kompilierzeit keine Spur gibt, dass sie überhaupt erreicht werden. Die ungeschützte Testsuite ist grün; der geschützte Build kann keine einzige Fläche berechnen.

Korrekturen, in Reihenfolge der Präferenz

1. Ersetzen Sie dynamic durch ein Interface oder eine virtuelle Methode. Das ist weit öfter die richtige Antwort, als es sich anfühlt. Duck Typing zwischen Typen, die Ihnen gehören, ist genau das, wofür Interfaces da sind, und der Interface-Aufruf ist ein token-gebundenes callvirt, das das Umbenennen überlebt, ohne Binder läuft und vom Compiler geprüft wird:

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

Der Visitor wird zu einem gewöhnlichen virtuellen Accept oder einem IHasArea; der Settings-Leser nimmt Settings oder ein ISettings. Wenn das dynamic nur existierte, um das Schreiben eines Typs zu vermeiden, ist das Schreiben des Typs die Korrektur.

2. Fixieren Sie nur die Member, die eine Site erreicht. Wo dynamic wirklich erforderlich ist — ein Scripting-Host, der in Ihre Objekte hineinruft, eine dynamische Sprachlaufzeit, eine Plugin-Grenze, die Sie nicht kontrollieren — ist die Menge der Member, die es berührt, klein und bekannt. Markieren Sie genau diese mit dem Standardattribut [Obfuscation], das Nebula über autoDetect respektiert:

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 auf einem Member behält den Namen dieses Members und sonst nichts; der Typ wird weiterhin umbenannt, die anderen Member werden weiterhin umbenannt. Oder halten Sie es ganz aus dem Quellcode heraus mit der exclude-Liste in nebula.config.json, die Namespace.Type.Member für einen einzelnen Member akzeptiert:

{
  "inputs": ["bin/Release/net8.0/Acme.Studio.dll"],
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "exclude": [
    "Acme.Settings.Timeout",
    "Acme.Settings.Endpoint",
    "Acme.Area.Compute"
  ]
}

Was Sie nicht tun sollten, ist Acme.Settings pauschal auszuschließen. Das bewahrt jeden Member — die, die der Binder erreicht, und das Dutzend, das er nie erreichen wird — um zwei Properties zu schützen, und es ist der Typ, den ein Angreifer am liebsten lesen will.

3. Finden Sie jede Site, bevor Sie ausliefern. Dynamic-Sites kündigen sich in der IL an: eine Referenz auf Microsoft.CSharp.RuntimeBinder.Binder, versteckte <>o__-Container und ldstr-Literale, die sie speisen. nebula inspect --strings listet jedes Literal im Assembly auf, und diejenigen, die zugleich Member-Namen Ihrer Typen sind, sind die, die Sie ansehen sollten — Timeout, Endpoint, Compute stechen neben echten benutzerseitigen Strings sofort hervor. Gleichen Sie das mit einer Suche nach dynamic im Quellcode ab, und Sie haben die vollständige Liste der zu fixierenden Member.

Der Test, der es fängt

Was auch immer Sie wählen, prüfen Sie es auf dem geschützten Build, denn der Fehler existiert nur dort. Eine dynamic-Site wirft bei der ersten Verwendung, sodass ein Test, der jede Site ausübt, ein Test ist, der laut fehlschlägt, wenn eine Fixierung fehlt:

[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 }));
}

Lassen Sie das in CI gegen die Ausgabe des Obfuskierungsschritts laufen — an derselben Stelle, an die ein Serialisierungs-Round-Trip-Test gehört — und eine RuntimeBinderException wird zu einem roten Build statt zum Absturzbericht eines Kunden.

Das Prinzip ist dasselbe, das Reflection und Serialisierung regiert: Ein Name, der zur Laufzeit aufgelöst wird, ist ein Vertrag, und ein Renamer kann keinen Vertrag sehen, der als String geschrieben ist. dynamic ist das C#-Feature, das einen gewöhnlichen Member-Zugriff in genau diese Art von String verwandelt. Nebula.NET benennt alles um, was Sie ihm erlauben; Ihre Aufgabe ist sicherzustellen, dass der eine Name, den ein Binder per Text abfragen wird, entweder nicht mehr abgefragt wird oder bewusst beibehalten wird. Für die umfassendere Karte dessen, was Laufzeit-Namenssuchen zerbrechen, siehe was Obfuskierung zerbricht.

Nebula.NET testen

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