Skip to content
← Alle Beiträge
· Delta1 Labs ObfuscationNebula

Stark typisierte Konfiguration obfuskieren, ohne die IOptions<T>-Bindung zu zerstören

Der Konfigurationsbinder ordnet die Schlüssel aus appsettings.json und Umgebungsvariablen per Reflexion den öffentlichen Eigenschaftsnamen Ihres Options-POCO zu. Benennen Sie diese Eigenschaften in einem Obfuskierungsdurchlauf um, werden die gebundenen Werte zur Laufzeit stillschweigend null oder der Standardwert — kein Kompilierfehler, keine Ausnahme, nur falsches Verhalten. Dieser Artikel liest, wie `services.Configure<T>` Schlüssel auf Eigenschafts-Setter auflöst, was der quellgenerierte Binder von .NET 8+ daran ändert und was nicht, wie Sie eine Keep-List in nebula.config.json aufbauen, die die öffentlichen Eigenschaftsnamen und den parameterlosen Konstruktor Ihrer Options-Klassen fixiert, während alles andere umbenannt wird, und wie Sie in Glass.NET bestätigen, dass die Options-Oberfläche überlebt hat.

Eine stark typisierte Options-Klasse sieht aus wie das gewöhnlichste C# in Ihrer Projektmappe: ein POCO mit öffentlichen Eigenschaften und nichts Raffiniertem. Doch sobald Sie sie an services.Configure<T> übergeben, hören einige ihrer Namen auf, C#-Namen zu sein, und werden zu Schlüsseln außerhalb Ihres Programms: Zeichenfolgen in appsettings.json, Zeichenfolgen in Umgebungsvariablen, Zeichenfolgen auf der Befehlszeile. Der Konfigurationsbinder verbindet diese Zeichenfolgen per Reflexion zur Laufzeit mit Ihren Eigenschaftsnamen, und der Compiler sieht es nie. Das macht die Eigenschaftsnamen genau zu jener tragenden Oberfläche, die ein blinder Umbenennungsdurchlauf zerstört.

Der Fehler ist der stille. Benennen Sie MaxRetries zu a um, und Ihre Anwendung kompiliert weiterhin, startet weiterhin, läuft weiterhin. Aber der Binder, der eine Eigenschaft namens MaxRetries sucht, um den Wert aus der Konfiguration aufzunehmen, findet nichts und belässt die Eigenschaft auf ihrem Standardwert. Es wird keine Ausnahme geworfen, denn ein nicht zugeordneter Konfigurationsschlüssel ist kein Fehler. Sie liefern ein Options-Objekt voller Nullen und Nullwerte und eine Wiederholungsrichtlinie aus, die niemals wiederholt.

appsettings.json"ConnectionString""MaxRetries""TimeoutSeconds"Schlüssel sind Zeichenfolgenvon außenKONFIGURATIONSBINDERActivator.CreateInstancedann GetProperties()ordnet jeden Schlüssel einerEigenschaft NACH NAMEN zuReflexion, keine PrüfungDIESE NAMEN BEHALTENConnectionStringMaxRetries, TimeoutSeconds() parameterloser Ktorgebunden → exakt fixierenFREI ZUM UMBENENNENder Klassenname selbstprivate Felder, Helferkonsumierende Dienste, Logiknie als Schlüssel gelesen

Wie der Binder einen Schlüssel einer Eigenschaft zuordnet

Beginnen Sie mit einer unauffälligen Options-Klasse und dem Abschnitt, aus dem sie bindet:

public sealed class EmailOptions
{
    public string ConnectionString { get; set; } = "";
    public int MaxRetries { get; set; } = 3;
    public int TimeoutSeconds { get; set; } = 30;
    public bool UseTls { get; set; } = true;
}
{
  "Email": {
    "ConnectionString": "smtp://mail.internal:587",
    "MaxRetries": 5,
    "TimeoutSeconds": 15,
    "UseTls": true
  }
}

Sie verdrahten die beiden im Kompositionswurzelpunkt:

builder.Services.Configure<EmailOptions>(
    builder.Configuration.GetSection("Email"));

Beachten Sie, welche Zeichenfolge wo lebt. Der Name des Abschnitts — "Email" — ist ein Zeichenfolgenliteral in Ihrem Code, das an GetSection übergeben wird. Nebula lässt Zeichenfolgenliterale standardmäßig unangetastet (und verschlüsselt sie, statt sie umzubenennen, wenn encryptStrings aktiviert ist), sodass der Abschnittsname selten der fragile Teil ist. Der fragile Teil ist das andere Ende: die Schlüssel innerhalb des Abschnitts — ConnectionString, MaxRetries —, die nur in appsettings.json existieren und zu Eigenschaftsnamen passen müssen, die nur in Ihrer kompilierten Assembly existieren.

Der Reflexionsbinder schließt diese Lücke zur Laufzeit. Konzeptionell tut ConfigurationBinder.Bind das Äquivalent davon:

// Simplified from Microsoft.Extensions.Configuration.ConfigurationBinder.
object instance = Activator.CreateInstance(typeof(EmailOptions))!; // needs a parameterless ctor
foreach (PropertyInfo prop in typeof(EmailOptions)
             .GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
    if (!prop.CanWrite) continue;
    IConfigurationSection child = section.GetSection(prop.Name); // key == property NAME
    if (child.Value is not null)
        prop.SetValue(instance, Convert(child.Value, prop.PropertyType));
}

Zwei Dinge sind nun tragend. Erstens benötigt Activator.CreateInstance einen zugänglichen parameterlosen Konstruktor. Zweitens wird jeder Schlüssel prop.Name zugeordnet — dem lebendigen, reflektierten Namen der Eigenschaft. Benennen Sie die Eigenschaft um, und prop.Name ändert sich mit ihr; der Schlüssel in appsettings.json nicht. Die Zuordnung schlägt fehl, SetValue wird nie aufgerufen, und die Eigenschaft behält ihren CLR-Standardwert. Der Compiler ist an all dem nicht beteiligt, weshalb der Build grün bleibt, während das Verhalten fehlerhaft wird.

Das Konsumieren der Options ist unabhängig davon gleich:

public sealed class EmailSender(IOptions<EmailOptions> options)
{
    private readonly EmailOptions _cfg = options.Value;

    public async Task SendAsync(/* ... */)
    {
        for (int attempt = 0; attempt <= _cfg.MaxRetries; attempt++) { /* ... */ }
    }
}

Wenn MaxRetries an seinen Standardwert 0 gebunden wurde, weil der Schlüssel nicht mehr passte, unternimmt SendAsync genau einen Versuch und gibt auf — ein Defekt, der in einem Unit-Test, der EmailOptions direkt konstruiert, nie auftaucht, sondern nur in der Produktion, wo der Wert durch die Konfiguration kommt.

Der quellgenerierte Binder ändert den Mechanismus, nicht die Abhängigkeit

.NET 8 führte einen Quellgenerator für den Konfigurationsbinder ein, den Sie über eine Projekteigenschaft aktivieren:

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

Mit aktiviertem Generator wird der Aufruf Configure<EmailOptions> zur Kompilierzeit abgefangen und die obige Reflexion durch generierten, stark typisierten Code ersetzt — gut für Native AOT und Trimming, da es keinen Reflexionsdurchlauf mehr zu bewahren gibt. Aber sehen Sie sich an, was der Generator ausgibt:

// Generated binder (shape). The key is a string LITERAL taken from the property name.
if (section["ConnectionString"] is string s0) instance.ConnectionString = s0;
if (section["MaxRetries"] is string s1) instance.MaxRetries = ParseInt(s1);

Der Schlüssel "MaxRetries" ist im generierten Code als Literal eingefroren, abgeleitet aus dem Eigenschaftsnamen zur Kompilierzeit. Die Obfuskierung nach der Kompilierung benennt das Eigenschafts-Token und die generierte Zuweisung gemeinsam um — sie bleiben konsistent —, aber das eingefrorene Schlüsselliteral und Ihre appsettings.json müssen weiterhin übereinstimmen, und dieses Literal bleibt der ursprüngliche Name MaxRetries. Der Quellgenerator stutzt also die Reflexion, ohne die Anforderung zu beseitigen, dass der Konfigurationsschlüssel dem Eigenschaftsnamen entspricht. Sie fixieren die Namen weiterhin; Sie erhalten obendrein AOT-Tauglichkeit. (Und da andere Pfade — Validierung, IConfiguration-Diagnostik, Options-Änderungsverfolgung — den echten Namen noch reflektiv lesen können, ist das Behalten der Namen die sichere Standardwahl bei beiden Bindern.)

Überprüfen, dass die Bindung tatsächlich stattgefunden hat

Es lohnt sich, das Problem des stillen Fehlers auch von der Anwendungsseite her zu schließen, unabhängig von der Obfuskierung. Die Options-Validierung verwandelt einen stillschweigend ungebundenen Wert in einen lautstarken Startfehler:

public sealed class EmailOptions
{
    [Required] public string ConnectionString { get; set; } = "";
    [Range(1, 10)] public int MaxRetries { get; set; } = 3;
    [Range(1, 120)] public int TimeoutSeconds { get; set; } = 30;
    public bool UseTls { get; set; } = true;
}
builder.Services.AddOptions<EmailOptions>()
    .Bind(builder.Configuration.GetSection("Email"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Für Regeln, die Attribute nicht ausdrücken können, implementieren Sie IValidateOptions<T> — oder verwenden Sie den Quellgenerator [OptionsValidator], der den Validator ohne Reflexion für Sie erzeugt:

[OptionsValidator]
public sealed partial class ValidateEmailOptions : IValidateOptions<EmailOptions> { }

Mit ValidateOnStart schlägt ein MaxRetries, das als 0 zurückkam, weil sein Schlüssel nicht gebunden wurde, nun bei [Range(1, 10)] fehl, und der Host weigert sich mit einer präzisen Meldung zu starten, anstatt mit einer kaputten Wiederholungsrichtlinie zu laufen. Das verwandelt genau die Obfuskierungsregression, um die es in diesem Artikel geht, in ein schnelles Scheitern beim Start — ein günstiges Sicherheitsnetz über einer korrekten Keep-List, kein Ersatz dafür.

Die Keep-List in nebula.config.json

Nebula errät nicht, welche Eigenschaften aus der Konfiguration gebunden werden. Sie deklarieren die Options-Typen als keep-Regeln, begrenzt auf ihre öffentliche Oberfläche, und lassen einen Auffangeintrag alles andere umbenennen:

{
  "schemaVersion": 1,
  "inputs": [ "bin/Release/net8.0/MyApp.dll" ],
  "outputDirectory": "protected",

  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "MyApp.Configuration.*Options", "action": "keep", "members": "public" },
      { "pattern": "MyApp.*", "action": "rename" }
    ]
  }
}

Die erste Regel fixiert jeden Options-Typ unter MyApp.Configuration und seine öffentlichen Mitglieder — die gebundenen Eigenschaften ConnectionString, MaxRetries und die übrigen behalten ihre Namen, und da ein öffentlicher parameterloser Konstruktor Teil der öffentlichen Oberfläche ist, wird auch er bewahrt, sodass Activator.CreateInstance weiterhin einen Konstruktor zum Aufrufen hat. Private Felder, der Sicherungszustand und alle privaten Helfer innerhalb des Options-Typs werden weiterhin umbenannt, denn members: "public" begrenzt das Behalten auf die öffentliche Oberfläche. Der Name der Klasse selbst ist hier öffentlich, also wird er behalten; wenn Sie den Typnamen lieber umbenennen lassen, behalten Sie nur die Eigenschaften, indem Sie die Mitglieder explizit zuordnen — der Binder liest nie den Typnamen, nur die Eigenschaftsnamen und den Konstruktor.

Der zweite rename-Auffangeintrag fegt alles hinter der Options-Oberfläche — die Dienste, die IOptions<EmailOptions> injizieren, die Rümpfe Ihrer Validatoren, die Geschäftslogik, die DTO-Interna und die DI-Verdrahtung — in die vollständige Umbenennung, zuzüglich Zeichenfolgenverschlüsselung und Kontrollflussobfuskierung. Das ist der schützenswerte Code, und nichts davon wird von außen nach Namen gelesen.

Für konstruktorgebundene Options (Records oder ein Typ mit einem einzigen nicht standardmäßigen Konstruktor) ordnet der Binder die Namen der Konstruktor-Parameter den Schlüsseln zu, also fixieren Sie auch diese — das Behalten der öffentlichen Mitglieder des Records deckt seine positionellen Eigenschaften ab, und Nebula bewahrt die Parameternamen an einem behaltenen Mitglied.

Wenn Sie den genauen Typ lieber annotieren, als ein Namespace-Muster zuzuordnen, lebt das Standardattribut [Obfuscation] direkt neben dem Code:

[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = true)]
public sealed class EmailOptions { /* ... */ }

Führen Sie den Schutz als Teil des Release-Builds aus, niemals auf Entwicklermaschinen:

<Target Name="NebulaProtect" AfterTargets="Publish"
        Condition="'$(CI)' == 'true'">
  <Exec Command="nebula protect --config nebula.config.json" />
</Target>

Oder rufen Sie die CLI direkt gegen einen veröffentlichten Build auf:

nebula protect --config nebula.config.json

Die Oberfläche mit Glass.NET überprüfen

Eine Keep-List ist eine Behauptung darüber, welche Namen überlebt haben; überprüfen Sie die Behauptung, statt ihr zu vertrauen. Öffnen Sie die geschützte Assembly in Glass.NET, dem kostenlosen .NET-Decompiler von Delta1 Labs, und sehen Sie sich zwei Dinge an.

Erstens die gebundene Oberfläche. Ihr Typ EmailOptions sollte weiterhin öffentliche Eigenschaften namens ConnectionString, MaxRetries, TimeoutSeconds und UseTls offenlegen, mit einem vorhandenen öffentlichen parameterlosen Konstruktor. Das sind die exakten Namen, mit denen Ihre appsettings.json-Schlüssel abgeglichen werden; wurde einer davon zu a umbenannt, belässt der Binder diesen Wert in der Produktion auf seinem Standardwert, und Ihr Keep-List-Muster hat ihn verfehlt.

Zweitens alles hinter der Oberfläche. Die Dienste, die die Options konsumieren, die Rümpfe der Validatoren, Ihre privaten Helfer und die DTO-Interna sollten alle umbenannte Bezeichner zeigen und — mit aktiviertem encryptStrings — undurchsichtige Zeichenfolgenkonstanten. Das ist der Schutz, für den Sie gekommen sind, intakt genau auf dem schützenswerten Code.

Die Regel, die all dem zugrunde liegt, ist jene, die jedes reflexionsgesteuerte Framework befolgt: Ein Name ist genau dann tragend, wenn etwas außerhalb der CLR-eigenen Token-Auflösung ihn als Zeichenfolge liest. Für stark typisierte Konfiguration ist diese Menge klein und erkennbar — die öffentlichen Eigenschaftsnamen Ihrer Options-POCOs plus ein parameterloser Konstruktor —, und sobald Sie genau diese fixiert haben, benennt Nebula die Konsumenten, die Validatoren und die Interna frei um und schützt Ihre eigene ausgelieferte Anwendung, ohne je einen Namen zu verschieben, von dem der Konfigurationsbinder abhängt.

Nebula.NET testen

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