Skip to content
← Alle Beiträge
· Delta1 Labs Nebula.NETObfuskierung.NETSource-Generatoren

Source-generierten Code obfuskieren: warum System.Text.Json-Kontexte, [GeneratedRegex] und [LoggerMessage] das Umbenennen überleben — und der eine Name, den sie preisgeben

Source-Generatoren laufen innerhalb des Compilers und emittieren gewöhnliches C# in Ihre Assembly, sodass die von ihnen erzeugte IL nicht von Code zu unterscheiden ist, den Sie selbst getippt haben. Die für die Obfuskierung nützliche Konsequenz: Generatoren lösen Namen zur Compile-Zeit auf. Ein System.Text.Json-Kontext hält "Name" als String-Literal fest und erreicht die Eigenschaft über einen Delegaten, der Konfigurations-Binder verzweigt über literale Schlüssel, und ein GeneratedRegex ist reine Typidentität. Benennen Sie alles um, und JSON, gebundene Optionen und Regex funktionieren weiter — anders als ihre reflexionsbasierten Gegenstücke. Was Generatoren zurücklassen, sind Literale: das Regex-Muster, das Log-Nachrichten-Template und ein nameof(), das Ihren ursprünglichen Methodennamen in eine EventId einbrennt. Hier erfahren Sie, was jeder Generator emittiert, was Nebula.NET sicher umbenennt und was Sie verschlüsseln oder ausschließen sollten.

Ein Source-Generator ist ein Compiler-Plugin, das Ihren Code liest und weiteren Code in dieselbe Kompilierung emittiert. Wenn der C#-Compiler fertig ist, sind die generierten Dateien schlicht Teil Ihrer Assembly — dieselben Metadatentabellen, dieselbe IL, kein Marker in der Ausgabe, der eine von Ihnen geschriebene Methode von einer vom Generator geschriebenen unterscheidet. Das wirft für einen Obfuskator die naheliegende Frage auf: Wenn Nebula Ihre Typen und Member umbenennt, was passiert mit Code, der um sie herum generiert wurde?

Die Antwort ist besser, als Sie erwarten würden, mit einem Haken. Generatoren lösen Namen zur Compile-Zeit auf, und genau diese Eigenschaft macht Code umbenennungssicher. Aber sie tun es, indem sie Ihre Namen als String-Literale niederschreiben, und diese Literale überleben das Umbenennen wortwörtlich — und verraten einem Angreifer gelegentlich, wie eine Methode früher hieß.

Laufzeit-Lookup versus Compile-Zeit-Erfassung

Der ganze Grund, warum Obfuskierung Dinge bricht, ist der Namens-Lookup zur Laufzeit. Type.GetProperty("Name"), JsonSerializer.Serialize(obj) ohne Kontext, IConfiguration.Bind(options) — jeder davon durchläuft zur Laufzeit die Metadaten, findet ein Member über seinen String und findet es nicht mehr, sobald das Member umbenannt ist. Deshalb bewahrt autoDetect Member mit Serialisierungsattributen, und deshalb raten die Docs, Ihre Settings-Klassen auszuschließen.

Ein Generator führt den Lookup zur Compile-Zeit durch und emittiert stattdessen zwei Dinge: eine Token-Referenz auf das Member (ein direktes call/ldfld oder einen Delegaten) und, wo zur Laufzeit ein Name gebraucht wird, ein aus Ihrem Quellcode kopiertes Literal. Das Umbenennen schreibt jede Token-Referenz um und lässt Literale in Ruhe.

Reflection: lookup at runtimeGetProperty("Name") → metadataCustomer.Name renamed to a"Name" not found → breaksGenerator: capture at compile timePropertyName = "Name" (literal)Getter = o => o.Name (token)Customer.Name renamed to aGetter → o.a, wire name "Name" → works

Die Regel für generierten Code ist also die Umkehrung der Regel für Reflexion: Das Member kann sicher umbenannt werden; das Literal ist das, worüber Sie nachdenken müssen. Sehen wir uns an, was vier verbreitete Generatoren tatsächlich emittieren.

System.Text.Json: Der Kontext erfasst den Namen und den Accessor

Nehmen Sie ein DTO ganz ohne Serialisierungsattribute und einen source-generierten Kontext:

public sealed class Customer
{
    public string Name  { get; set; } = "";
    public int    Tier  { get; set; }
}

[JsonSerializable(typeof(Customer))]
internal partial class AppJsonContext : JsonSerializerContext { }

Aktivieren Sie einmal die Ausgabe generierter Dateien — <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> im Projekt — und lesen Sie, was der Generator für Customer erzeugt hat. Der Metadaten-Initialisierer sieht so aus (gekürzt):

private static JsonPropertyInfo[] CustomerPropInit(JsonSerializerOptions options)
{
    var properties = new JsonPropertyInfo[2];

    var info0 = new JsonPropertyInfoValues<string>
    {
        IsProperty    = true,
        DeclaringType = typeof(global::Customer),
        Getter        = static obj => ((global::Customer)obj).Name,
        Setter        = static (obj, value) => ((global::Customer)obj).Name = value!,
        PropertyName  = "Name",          // captured at generation time
        JsonPropertyName = null,
    };
    properties[0] = JsonMetadataServices.CreatePropertyInfo<string>(options, info0);
    // ... Tier ...
    return properties;
}

Zwei Dinge fallen auf. Getter und Setter sind Lambdas, die die Eigenschaft direkt berühren — in IL ist das ein ldftn auf eine compilergenerierte Methode, deren Rumpf callvirt Customer::get_Name() ist. Der Name auf der Leitung ist ein ldstr "Name". Nachdem Nebula Customer in a und Name in b umbenannt hat, wird der Lambda-Rumpf zu callvirt a::get_b() — per Token umgeschrieben, wie alle Aufrufstellen —, während das ldstr weiterhin "Name" sagt. Serialisieren Sie einen Customer, und Sie erhalten {"Name":"…","Tier":3} genau wie zuvor.

Im Fast-Path-Serialisierungsmodus tut der Generator dasselbe mit einem vorkodierten Namen — private static readonly JsonEncodedText PropName_Name = JsonEncodedText.Encode("Name"); — und einem handgeschriebenen writer.WriteString(PropName_Name, value.Name). Gleiche Form: Literal plus direkter Member-Zugriff.

Das ist strikt besser als Reflexion, und es bedeutet, dass das DTO kein [JsonPropertyName]-Sicherheitsnetz und keinen exclude-Eintrag braucht. Die Eigenschaft kann umbenannt werden, und der Vertrag hält, weil der Vertrag erfasst wurde, als der Generator lief.

Der eine Weg, auf dem es trotzdem bricht, ist ein Codepfad, der den Kontext nicht verwendet. JsonSerializer.Serialize(customer) ohne Optionen oder eine Resolver-Kette, die in DefaultJsonTypeInfoResolver endet, löst Namen zur Laufzeit aus den Metadaten auf und erzeugt nach dem Umbenennen {"b":"…"}. Das wollen Sie nicht im obfuskierten Build entdecken. Schalten Sie den Reflexionspfad auf Projektebene ab:

<PropertyGroup>
  <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>

Jetzt wirft jeder Aufruf, der stillschweigend auf Reflexion zurückgefallen wäre, bereits in der Entwicklung, und jeder Serialisierungspfad, der die Produktion erreicht, ist einer, den der Generator erfasst hat.

[GeneratedRegex]: reine Typidentität

public static partial class Parsers
{
    [GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$")]
    private static partial Regex IsoDate();
}

Der Generator füllt IsoDate() so aus, dass es ein zwischengespeichertes Singleton einer generierten Klasse zurückgibt — ein sealed-Typ, der von Regex ableitet, dessen Konstruktor pattern, roptions und factory setzt, mit einer verschachtelten RunnerFactory, die einen verschachtelten Runner erzeugt, der Scan, TryFindNextPossibleStartingPosition und TryMatchAtCurrentPosition implementiert. Nichts davon löst irgendetwas per Name auf. Der Runner wird über das factory-Feld erreicht, das die Basisklasse ohnehin erwartet; der Aufruf des Benutzers ist call Parsers::IsoDate() → ldsfld der Instanz. Benennen Sie die Methode, die generierte Klasse, die Factory und den Runner um, und die Regex matcht weiterhin.

Was übrig bleibt, ist das Muster-Literal, das im generierten Konstruktor zugewiesen wird (base.pattern = "^\d{4}-\d{2}-\d{2}$"). Das ist ein gewöhnlicher String in IL, und encryptStrings verbirgt ihn im Ruhezustand wie jeden anderen.

Die eigentliche Überlegung hier betrifft die Performance, nicht die Korrektheit. TryMatchAtCurrentPosition ist die kompilierte Matching-Schleife — genau die Art von engem, verzweigungsreichem Code, den die Kontrollfluss-Abflachung messbar langsamer macht. Läuft die Regex auf einem heißen Pfad, halten Sie die Abflachung davon fern. Die generierten Typen tragen compiler-verstümmelte Namen; ermitteln Sie die exakten Methodennamen also mit nebula inspect --methods und fügen Sie sie zu controlFlowExclude hinzu, oder — einfacher — beschränken Sie die Abflachung mit controlFlowInclude auf die Methoden, die Sie tatsächlich schützen müssen.

[LoggerMessage]: Der Generator schreibt nameof() für Sie

public static partial class Log
{
    [LoggerMessage(EventId = 12, Level = LogLevel.Warning,
                   Message = "Order {OrderId} rejected: {Reason}")]
    public static partial void OrderRejected(ILogger logger, int orderId, string reason);
}

Die generierte Hälfte ist ein zwischengespeicherter LoggerMessage.Define-Callback:

private static readonly Action<ILogger, int, string, Exception?> __OrderRejectedCallback =
    LoggerMessage.Define<int, string>(
        LogLevel.Warning,
        new EventId(12, nameof(OrderRejected)),            // ← your method name, as a string
        "Order {OrderId} rejected: {Reason}",
        new LogDefineOptions { SkipEnabledCheck = true });

public static partial void OrderRejected(ILogger logger, int orderId, string reason)
{
    if (logger.IsEnabled(LogLevel.Warning))
        __OrderRejectedCallback(logger, orderId, reason, null);
}

Dieses nameof(OrderRejected) wertet der C#-Compiler aus. In der IL ist es ldstr "OrderRejected" — es bleibt keine Referenz auf die Methode übrig, nur die Zeichen. Nebula benennt Log.OrderRejected in a.b um; die EventId trägt weiterhin den Namen "OrderRejected". Zwei Konsequenzen:

  • Ihr Logging funktioniert weiter wie entworfen. Dashboards und Alarme, die auf den Ereignisnamen abstellen, sehen vor und nach der Obfuskierung denselben Namen. Gut.
  • Die Binärdatei enthält jetzt einen Map-Eintrag. Ein Decompiler zeigt eine Methode b, deren statischer Initialisierer "OrderRejected" sagt. Bei einer Klasse voller Log-Methoden ist das ein lesbarer Index dessen, was jede umbenannte Methode tut. Es ist derselbe Mechanismus, der in wenn nameof Ihre umbenannten Member preisgibt beschrieben ist, hier erzeugt von einem Generator statt von Ihrem eigenen Code.

encryptStrings ist die Lösung, und zwar die richtige: Das Literal wird in der Assembly verschlüsselt und entschlüsselt, wenn der statische Initialisierer läuft, sodass Ihre Logs weiterhin OrderRejected sagen, die Datei auf der Platte aber nicht. Das Nachrichten-Template wird genauso behandelt, was zugleich die Parameternamen {OrderId}/{Reason} verbirgt — eine Kleinigkeit, aber sie beschreiben die Argumente der Methode.

Der Konfigurations-Binding-Generator: literale Schlüssel statt eines Metadaten-Durchlaufs

Der reflexionsbasierte ConfigurationBinder steht aus gutem Grund auf jeder „Das bewahren“-Liste: Bind ordnet Konfigurationsschlüssel Eigenschaftsnamen zu, indem es zur Laufzeit per Reflexion über den Options-Typ läuft. .NET 8 hat einen source-generierten Binder hinzugefügt:

<PropertyGroup>
  <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>

Ist er aktiv, wird builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp")) abgefangen und auf generierten Code dieser Form umgeleitet:

public static void BindCore(IConfiguration configuration, ref SmtpOptions instance, BinderOptions? binderOptions)
{
    if (configuration["Host"] is string value0)
        instance.Host = value0;                                  // direct assignment, literal key

    if (configuration["Port"] is string value1)
        instance.Port = ParseInt(value1, configuration.GetSection("Port").Path);
}

"Host" und "Port" sind ldstr; instance.Host = … ist callvirt SmtpOptions::set_Host. Benennen Sie SmtpOptions.Host in a.c um, und der generierte Binder liest weiterhin Smtp:Host aus appsettings.json und ruft weiterhin den umbenannten Setter auf. Der exclude-Eintrag für Ihre Settings-Klassen, den der Reflexions-Binder Ihnen aufgezwungen hat, wird für das Binding nicht mehr gebraucht.

Prüfen Sie, was diese Klassen sonst noch per Name liest, bevor Sie den Ausschluss entfernen: ValidateDataAnnotations meldet Member-Namen in seinen Fehlermeldungen (kosmetisch), und jeder verbliebene Bind-Aufruf, der ohne den Generator kompiliert wurde, verwendet weiterhin Reflexion. Der Ordner mit den generierten Dateien verrät Ihnen, welche Aufrufe abgefangen wurden.

Eine Konfiguration, die zu generiertem Code passt

Alles zusammengesetzt für einen typischen Dienst, der alle vier Generatoren nutzt:

{
  "inputs": ["out/Acme.Service.dll"],
  "outputDirectory": "out/obf",
  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "controlFlowIntensity": "normal",
  "controlFlowExclude": [
    "Acme.Service.Parsers.<RegexGenerator_g>F…__IsoDate_0.Runner.TryMatchAtCurrentPosition"
  ]
}

encryptStrings ist hier die Zeile, die am meisten leistet: Sie deckt das Regex-Muster, die Log-Templates und die nameof-Literale in einer Einstellung ab. controlFlowExclude hält die Abflachung von der inneren Schleife des Regex-Runners fern (fügen Sie den exakten verstümmelten Namen aus nebula inspect --methods ein). Nichts in der Konfiguration bewahrt das DTO, die Options-Klasse oder die Log-Methoden, weil nichts das braucht.

Die Checkliste für jeden Generator, dem Sie noch nicht begegnet sind, ist dieselbe, die dieser Beitrag viermal angewendet hat. Geben Sie die generierten Dateien aus, öffnen Sie sie und stellen Sie zu jedem Member-Zugriff zwei Fragen: Ist er ein Token (ein direkter Aufruf, ein typeof, ein Delegat) oder ein String (GetProperty("…"), Type.GetType("…"))? Tokens sind umbenennungssicher. Sehen Sie sich dann jedes Literal an und fragen Sie, ob es ein Leitungsvertrag ist, der unverändert bleiben muss (in Ordnung — er bleibt unverändert), oder eine Kopie eines Namens, den Sie eigentlich verbergen wollten (verschlüsseln Sie sie). Generatoren, die ihre Arbeit zur Compile-Zeit erledigen, sind fast schon konstruktionsbedingt der am leichtesten zu obfuskierende Code in Ihrer Assembly. Siehe Ihre Anwendung lauffähig halten für die reflexionsseitigen Regeln, die diese Generatoren Sie lockern lassen.

Nebula.NET kostenlos herunterladen und Ihre Serialisierungstests gegen den obfuskierten Build laufen lassen.

Nebula.NET testen

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