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

Wenn nameof und [CallerMemberName] Ihre umbenannten Member verraten

Sie haben jeden Typ und jede Methode umbenannt, dann das obfuskierte Binary geöffnet und Ihre Originalnamen in reinen String-Literalen gefunden. Der Schuldige ist kein Fehler im Obfuscator – es ist der Compiler. nameof(OrderService) backt das Literal "OrderService" zur Compile-Zeit in die IL, lange bevor die Umbenennung läuft, und [CallerMemberName] macht dasselbe mit dem ursprünglichen Methodennamen als Standardwert eines Parameters. Benennen Sie den Member um, und das Literal sagt weiterhin den alten Namen, sodass ein Decompiler Ihre Zuordnung gratis liest und jede Reflection, die den Laufzeitnamen erwartet, still bricht. Hier erfahren Sie, woher diese Literale in der IL genau stammen, warum die Umbenennung sie allein nicht anfassen kann und wie die String-Verschlüsselung und die umbenennungsbewusste Behandlung von Nebula.NET das Leck schließen.

Sie haben die Umbenennung eingeschaltet, neu gebaut und das Ergebnis in einem Decompiler geöffnet, um die Wand aus a, b und c zu bewundern, wo früher Ihre Typen standen. Dann haben Sie sie trotzdem gefunden – OrderService, CustomerName, ValidateCoupon –, in reinen String-Literalen, gratis serviert an der einen Stelle, die die Umbenennung nie berührt hat. Nichts ist kaputt. Das ist das Werk des Compilers, nicht des Obfuscators, und zu verstehen warum, ist der Unterschied zwischen einem geschützten Build und einem, der leise seine eigene Umbenennungszuordnung veröffentlicht.

Zwei C#-Features legen Ihre Originalnamen als Daten in die Assembly, bevor die Umbenennung überhaupt läuft: nameof, das einen Namen zur Compile-Zeit in ein Literal backt, und [CallerMemberName], das den Namen des aufrufenden Members an jeder Aufrufstelle als Standardwert eines Parameters einfriert. Die Umbenennung durchläuft Typ- und Member-Referenzen – ein nackter String ist keines von beiden, also bleibt der alte Name im String-Heap. Dieser Beitrag zeigt genau, woher diese Literale in der IL stammen, warum die Umbenennung sie allein nicht anfassen kann, wann das Umschreiben richtig ist und wann es etwas brechen würde, und wie Nebula.NET das Leck mit String-Verschlüsselung und umbenennungsbewusster Behandlung schließt.

Der Quellcode

Eine Klasse, die beide Features so nutzt, wie echter Code es tut – Logging-Kategorien, Argumentvalidierung und INotifyPropertyChanged:

public sealed class OrderService : INotifyPropertyChanged
{
    private string _customerName = "";

    public string CustomerName
    {
        get => _customerName;
        set { _customerName = value; OnPropertyChanged(); }   // [CallerMemberName] fills in "CustomerName"
    }

    public void ValidateCoupon(string code)
    {
        if (code is null)
            throw new ArgumentNullException(nameof(code));     // nameof -> literal "code"
        _logger.BeginScope(nameof(OrderService));              // nameof -> literal "OrderService"
    }

    public event PropertyChangedEventHandler? PropertyChanged;
    private void OnPropertyChanged([CallerMemberName] string? name = null) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

Wo die Namen in der IL tatsächlich liegen

nameof(OrderService) referenziert den Typ im erzeugten Code nicht. Der Compiler ersetzt es während der Kompilierung durch ein schlichtes String-Literal – ein einzelnes ldstr:

// ValidateCoupon body — the names are DATA, not references:
ldstr      "code"                      // nameof(code)        -> literal
newobj     instance void [System.Runtime]System.ArgumentNullException::.ctor(string)
...
ldstr      "OrderService"              // nameof(OrderService) -> literal
callvirt   instance class ... Microsoft.Extensions.Logging.ILogger::BeginScope<...>(...)

[CallerMemberName] ist derselbe Trick auf einem Parameter. OnPropertyChanged() wird im Quellcode ohne Argument aufgerufen, aber der Compiler füllt den Standardwert des optionalen Parameters an der Aufrufstelle mit dem Namen des Aufrufers als Literal:

// CustomerName setter — the compiler injected the name at the CALL SITE:
ldarg.0
ldstr      "CustomerName"              // injected by [CallerMemberName] at compile time
call       instance void OrderService::OnPropertyChanged(string)

Beide Namen sind nun ldstr-Operanden, die in den #US-Heap (User-Strings) der Assembly zeigen. Es sind schlichte Daten ohne Metadaten-Verbindung zurück zu dem Typ oder der Property, aus denen sie abgeleitet wurden.

Warum die Umbenennung sie allein nicht anfassen kann

Die Umbenennung schreibt Referenzen um: eine TypeRef auf OrderService, eine MethodDef für ValidateCoupon, jedes call und ldfld, das einen Member benennt. Den Inhalt von Strings lässt sie bewusst in Ruhe, weil ein String keine Referenz ist und das standardmäßige Ändern von String-Semantik jedes Programm brechen würde, das einen vergleicht, serialisiert oder anzeigt. Wenn der Renamer also OrderService in b.c verwandelt, wird das ldstr "OrderService" nicht mitgenommen – nichts verbindet das Literal mit dem Typ. Das Ergebnis ist ein umbenanntes Binary mit einem Klartext-Index seiner eigenen Originalnamen.

Compile: nameof /CallerMemberName#US-String-Heapldstr "OrderService"Renamer schreibt REFS umOrderService → b.c...aber nicht den HeapHeap UNVERÄNDERT"OrderService" ← Leckalter Name im KlartextString-VerschlüsselungWert im Ruhezustand verborgenentschlüsselt bei Nutzung

Den Wert verschlüsseln – die Vertraulichkeitslösung

Das Leck ist zuallererst ein Informationsleck: Die Originalnamen sind lesbar. Die String-Verschlüsselung schließt das. Nebula ersetzt jedes ldstr durch einen Aufruf eines entschlüsselnden Accessors, sodass der Klartextname nicht mehr im Heap liegt und ein Decompiler einen Aufruf einer obfuskierten Routine sieht, nicht den alten Namen Ihres Typs:

{
  "inputs": ["bin/Release/net8.0/OrderService.dll"],
  "outputDirectory": "bin/Release/net8.0/obf",
  "renameIdentifiers": true,
  "encryptStrings": true
}

Nach diesem Durchlauf wird aus dem ldstr "OrderService" etwas wie ldstr <cipher> / call string <decrypt>(...), und der String-Heap verkündet nicht mehr OrderService, CustomerName oder ValidateCoupon. Für den häufigen Fall – nameof für eine Log-Kategorie oder einen Exception-Argumentnamen – ist das die ganze Lösung: Der Wert entschlüsselt zur Laufzeit weiterhin zum richtigen String, und nichts hing davon ab, dass der Name lesbar ist.

Den Wert umschreiben – die Korrektheitslösung, wo sie greift

Verschlüsselung verbirgt das Literal; sie ändert nicht, wozu es entschlüsselt. Das ist in zwei Richtungen relevant, und sie ziehen in entgegengesetzte Richtungen:

  • Das Literal treibt Reflection gegen einen umbenannten Member. typeof(T).GetProperty(nameof(SomeProp)) erwartet den Laufzeitnamen. Wurde SomeProp umbenannt, sagt das entschlüsselte Literal weiterhin SomeProp, und die Suche scheitert. Hier ist die richtige Lösung, das Literal der Umbenennung folgen zu lassen oder diesen Member von der Umbenennung auszunehmen, damit der Name stabil bleibt.
  • Das Literal ist ein externer Vertrag. Ein serialisierter Property-Name, ein [CallerMemberName], das in INotifyPropertyChanged fließt, ein Data-Binding-Pfad, den auch das XAML benennt – der String auf der anderen Seite wird nicht umbenannt, also würde das Umschreiben Ihres Literals die Übereinstimmung brechen. Hier dürfen Sie nicht umschreiben und oft auch den Member nicht umbenennen.

Nebulas sicherer Standard ist Verschlüsseln (Verbergen) statt Umschreiben (Umdeuten), weil die IL allein nicht immer eine reflektive von einer vertraglichen Verwendung unterscheiden kann. Die reflektiven Fälle markieren Sie explizit – nehmen Sie die Member aus, deren Namen per String nachgeschlagen werden, damit die Umbenennung sie stabil lässt und das nameof-Literal weiterhin passt:

{
  "inputs": ["bin/Release/net8.0/OrderService.dll"],
  "outputDirectory": "bin/Release/net8.0/obf",
  "renameIdentifiers": true,
  "encryptStrings": true,
  "excludeMembers": ["OrderService.CustomerName"]   // bound by name in XAML + INPC — keep it stable
}

Der INotifyPropertyChanged-Fall ist die Lehrbuchfalle. OnPropertyChanged() übergibt "CustomerName" per [CallerMemberName], und das WPF/XAML-Binding löst die Property zur Laufzeit über diesen Namen auf. Verschlüsseln Sie das Literal, funktioniert das Binding weiterhin (es entschlüsselt zu "CustomerName"). Benennen Sie die Property in b um, ohne sie auszunehmen, sagt das Literal nun CustomerName, während die Property b heißt – das Binding hört still auf zu aktualisieren. Die Lösung ist, gebundene Properties von der Umbenennung auszunehmen, genau wie in der Konfiguration oben, damit der Name, den das Binding braucht, der Name bleibt, den die Property hat.

Verifizieren, dass das Leck geschlossen ist

Bestätigen Sie nach dem Schützen, dass die Namen aus dem String-Heap verschwunden sind – die direkte Prüfung, dass nameof/[CallerMemberName] sie nicht mehr offenlegen:

# Dump the user-string heap of the protected assembly; your type names must not appear.
ikdasm /metadata=US obf/OrderService.dll | grep -i "OrderService\|CustomerName\|ValidateCoupon" || echo "clean"

Ein clean-Ergebnis bedeutet, dass die Literale verschlüsselt wurden; ein Treffer bedeutet, dass ein String einen Durchlauf überlebt hat – üblicherweise einer, der von der Verschlüsselung ausgenommen war, was einen zweiten Blick wert ist, wenn er auch einen Namen trägt, den Sie verbergen wollten.

Was Sie mitnehmen sollten

Die Umbenennung schützt Referenzen; sie schützt nicht die Namen, die der Compiler bereits in Daten verwandelt hat. nameof und [CallerMemberName] frieren Ihre ursprünglichen Typ- und Member-Namen zur Compile-Zeit in String-Literale ein, sodass ein umbenanntes Binary einem Leser weiterhin seine eigene Zuordnung aushändigen kann. Schließen Sie das Informationsleck mit String-Verschlüsselung, damit die Klartextnamen den Heap verlassen. Behandeln Sie dann die zwei Fälle, die Verschlüsselung nicht lösen kann: Wo ein Literal Reflection gegen einen umbenannten Member treibt, halten Sie den Namen dieses Members stabil, indem Sie ihn von der Umbenennung ausnehmen; wo ein Literal ein externer Vertrag wie ein INotifyPropertyChanged-Binding ist, nehmen Sie den Member aus, damit der Name, den die andere Seite erwartet, sich nicht bewegt. Verschlüsseln für Vertraulichkeit, ausnehmen für Korrektheit, und verifizieren, dass der Heap sauber ist – und Ihr umbenannter Build hört auf, die Namen zu veröffentlichen, die Sie umbenannt haben.

Nebula.NET testen

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