Eine Minimal API obfuskieren, ohne die Parameterbindung zu zerstören
ASP.NET-Core-Minimal-APIs binden Route- und Query-Werte über den reflektierten Namen an die Handler-Parameter. Entfernt oder benennt man diese Namen um, schlägt die Bindung lautlos fehl: id kommt als 0 an. Hier ist die Lösung.
Eine Minimal API wirkt wie das Einfachste der Welt zum Obfuskieren. Keine Controller, keine Aktionsnamen-Konventionen, keine [HttpGet]-Attribute, die eine Methode per Reflexion an eine Route binden — nur app.MapGet("/orders/{id}", handler) und eine Lambda. Die Route ist eine Zeichenfolge, der Handler ein Delegat, und keines davon scheint von einem Symbolnamen abzuhängen, den ein Obfuskator anfassen würde. Also aktivieren Sie das Umbenennen, entfernen Metadaten, um das Binary zu verkleinern, veröffentlichen — und die Endpoints liefern weiterhin 200 OK, während sie lautlos den falschen Datensatz ausliefern. GET /orders/42 kommt mit Bestellung null zurück.
Nichts hat eine Ausnahme geworfen. Kein 500, kein 400, keine Logzeile. Die Assembly ist gültig und die Route hat getroffen. Was kaputtging, ist eine bestimmte Sache, die Minimal APIs tun und die Controller verbergen: Sie binden Route- und Query-Werte über den reflektierten Namen des Parameters an Ihre Handler-Parameter, und die Obfuskierung läuft auf den Metadaten, in denen dieser Name lebt. Dies ist eine Anleitung zu dieser Naht: wie genau RequestDelegateFactory eine Anfrage mit Ihrem Delegaten verdrahtet, warum das Entfernen oder Umbenennen eines Parameternamens id = 42 in id = 0 ohne Fehler verwandelt, und wie Sie den gesamten Endpoint-Graphen mit Nebula.NET schützen, sodass die Bindung weiter funktioniert.
Warum eine Minimal API überhaupt obfuskieren
Die Handler-Lambdas sind dünn, aber alles dahinter ist das Produkt: die Preisberechnung, die Berechtigungsregeln, die Fulfilment-Orchestrierung, die Abfragen, die Ihr Datenmodell formen. Ein Minimal-API-Dienst wird als DLL ausgeliefert — oft an die eigene Infrastruktur eines Kunden, eine von ihm kontrollierte Container-Registry oder eine On-Prem-Maschine — und jeder mit Dateizugriff kann sie in einem Decompiler öffnen und Ihre Geschäftslogik als sauberen C#-Code zurücklesen. Die Endpoint-Schicht ist auch dort, wo sich Geheimnisse ballen: interne Dienst-URLs, Signierschlüssel, Fragmente von Verbindungszeichenfolgen, die internen Header-Namen, die Ihr Gateway setzt. Die Kosten dafür zu erhöhen, Ihren eigenen ausgelieferten Code aus diesem Binary zurückzulesen, ist der gewöhnliche, legitime Grund, es zu obfuskieren.
Wie eine Minimal API eine Anfrage an Ihren Delegaten bindet
Hier ein vollkommen normaler Endpoint:
app.MapGet("/orders/{id}", async (
int id, // aus dem Route-Token {id}
bool detail, // aus ?detail=true
IOrderService svc) => // aus der DI, per Typ
{
var order = await svc.Get(id, detail);
return order is null ? Results.NotFound() : Results.Ok(order);
});
Drei Parameter, drei verschiedene Bindungsquellen, und das Framework muss herausfinden, welcher welcher ist. Es tut das einmal, beim Start, wenn RequestDelegateFactory.Create den RequestDelegate für diese Route baut. Konzeptionell macht es für jeden Parameter dies:
foreach (ParameterInfo p in handler.Method.GetParameters())
{
// 1. ein registrierter Service-Typ? -> aus der DI auflösen
if (serviceProviderIsService.IsService(p.ParameterType)) { /* aus DI binden */ }
// 2. enthält das Route-Template ein Token namens p.Name?
else if (routeTemplate.HasParameter(p.Name)) { /* Route-Wert p.Name binden */ }
// 3. sonst als Query-Wert mit Schlüssel p.Name behandeln
else { /* query[p.Name] binden */ }
}
Sehen Sie sich Schritt 2 und 3 an. Das Route-Token ist {id}, und der Parameter bindet daran, weil p.Name == "id". Der Query-Schlüssel ist detail, weil p.Name == "detail". IOrderService bindet per Typ, nicht per Namen, ist also sicher — aber id und detail werden vollständig über die Zeichenfolge zugeordnet, die ParameterInfo.Name zurückgibt. Diese Zeichenfolge ist ein Metadatum, in die Parametertabelle der Methode einkompiliert.
Kompilieren Sie nun diese Lambda. Sie wird zu einer Methode — sagen wir <Program>$.<<Main>$>b__0_0 — die eine Parametertabelle trägt:
.method assembly hidebysig instance class Task
'<<Main>$>b__0_0'(int32 id, bool detail, class IOrderService svc)
// ^^^^^ ^^^^^^
// ParameterInfo.Name von #0 und #1 — die Zeichenfolgen, die die Bindung liest
Die Obfuskierung läuft hier, auf der DLL, nachdem der C#-Compiler fertig ist. Entfernt der Durchlauf die Parameternamen (eine übliche Größen-/Metadaten-Reduktion) oder benennt sie um, wird diese Tabelle zu int32 A_0, bool A_1, ... oder enthält gar keine Namen. Beim Start ruft RequestDelegateFactory GetParameters() auf, findet keinen Parameter namens id und fällt auf den unteren Pfad durch: Es gibt keinen Route-Wert zu binden, also liefert der Binder für einen nicht-nullbaren int default(int) — null — statt zu scheitern. Der Endpoint ist verdrahtet, die Route trifft, und jede Anfrage führt svc.Get(0, false) aus. Die eine Zeichenfolge, die das Framework brauchte, um {id} mit Ihrem Parameter zu verbinden, ist weg, und nichts hat es Ihnen gesagt.
Die Falle des lautlosen Fehlers, genau genommen
Das ist fieser als der übliche Obfuskierungsbruch, weil es nicht fehlschlägt. Ein umbenanntes Reflexionsziel wirft normalerweise eine Ausnahme — ein fehlender Typ, eine MissingMethodException, eine JsonException. Die namensbasierte Parameterbindung degradiert stattdessen zum Standardwert. Ein string-Query-Parameter kommt als null an, ein int als 0, ein bool als false, ein Nullable bleibt null. Der Endpoint liefert 200 mit Inhalt, der aus diesen Standardwerten berechnet wurde. Ihr Smoke-Test, der auf 200 prüft, besteht. Der Bug taucht als „Kunde sagt, Bestellung 42 zeigt die falschen Artikel” auf, Tage später, ohne Stacktrace, der auf die Bindung zeigt. Behandeln Sie jede Minimal API, bei der ein Route- oder Query-Wert nach einer Obfuskierungsänderung lautlos zu seinem Standardwert wird, als Problem eines entfernten Parameternamens, bis das Gegenteil bewiesen ist.
Was tatsächlich von einem Namen abhängt und was nicht
Gehen Sie den Endpoint-Graphen durch, und die Keep-List schreibt sich von selbst.
- Über Route und Query gebundene Parameter —
id,detail. Gebunden überParameterInfo.Name. Diese Namen müssen überleben, oder die Bindung darf nicht von ihnen abhängen (siehe die beiden Lösungen unten). - Die Route-Template-Zeichenfolgen —
"/orders/{id}". Das sind Zeichenfolgenliterale, die Ihr Code anMapGetübergibt; die String-Verschlüsselung entschlüsselt sie zur Laufzeit auf genau denselben Wert, sodass die Zuordnung unberührt bleibt. Nichts zu tun. - Service-Parameter —
IOrderService svc. Gebunden per Typ, den das Umbenennen erhält (die Identität bleibt auch bei geändertem Namen bestehen). Benennen Sie den Service und seine Implementierung frei um. - Der Name der Handler-Methode —
<<Main>$>b__0_0. Das Framework hält einen Delegaten darauf und schlägt sie nie über den Namen nach. Benennen Sie sie um; es spielt keine Rolle. - Mitglieder eines
[AsParameters]-Structs — binden Sie ein Struct mit[AsParameters], bindet das Framework jede öffentliche Eigenschaft über ihren Namen genau wie einen Top-Level-Parameter. Behalten Sie diese Eigenschaftsnamen (oder annotieren Sie sie). Dieselbe Regel für einen aus dem Body als JSON gebundenen Parameter: Die Eigenschaftsnamen des DTOs sind der Wire-Vertrag, den Ihr Serializer liest. [FromKeyedServices("cache")],[FromHeader(Name="...")]und Verwandte — der Schlüssel ist ein Zeichenfolgenliteral, kein Symbol. Überlebt die String-Verschlüsselung. Nichts zu tun.
Die gesamte namenssensible Oberfläche ist also: Route-/Query-Parameternamen, [AsParameters]-Eigenschaftsnamen und die Eigenschaftsnamen des Body-DTOs. Alles andere — die Handler, die Services, das Innere — ist Freiwild.
Lösung eins: die Namen behalten (schnell)
Annotieren Sie den Bereich, der die Handler enthält, um die Parameternamen zu bewahren, und lassen Sie alles andere umbenennen. In nebula.config.json:
{
"schemaVersion": 1,
"inputs": [ "publish/Contoso.Orders.dll" ],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"stripParameterNames": false,
"renaming": {
"rules": [
{ "pattern": "Contoso.Orders.Contracts.*", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Orders.*", "action": "rename" }
]
}
}
stripParameterNames: false weist Nebula an, die Parametertabellen der Methoden intakt zu lassen, sodass RequestDelegateFactory weiterhin einen Parameter namens id findet. Die Keep-Regel fixiert Ihre Request-/Response-DTOs und jedes [AsParameters]-Struct unter Contoso.Orders.Contracts, damit deren Eigenschaftsnamen weiterhin mit den Route-Token und dem JSON-Wire übereinstimmen. Alles andere in der Assembly — Service-Namen, interne Typen, Methodenkörper — wird umbenannt. Das ist eine zweizeilige Änderung und sie funktioniert, lässt die Bindung aber an Metadaten gekoppelt, die Sie sonst loswerden wollten.
Lösung zwei: die Bindung umbenennungssicher machen (robust)
Besser: Holen Sie den Bindungsschlüssel aus dem Parameternamen heraus und in eine Zeichenfolge, die der Obfuskator nicht brechen kann. Explizite Bindungsattribute tun genau das:
app.MapGet("/orders/{id}", async (
[FromRoute(Name = "id")] int id,
[FromQuery(Name = "detail")] bool detail,
IOrderService svc) =>
await svc.Get(id, detail) is { } o ? Results.Ok(o) : Results.NotFound());
Jetzt liest RequestDelegateFactory den Bindungsschlüssel aus "id" und "detail" — Zeichenfolgenliterale, die das Attribut trägt, die die String-Verschlüsselung zur Laufzeit reproduziert und das Umbenennen nie berührt. Der Metadaten-Name des Parameters ist nun für die Bindung bedeutungslos, also können Sie ihn entfernen:
{
"stripParameterNames": true,
"encryptStrings": true,
"controlFlowObfuscation": true
}
Der Vergleich ist die ganze Entscheidung: Namen behalten ist eine Konfigurationszeile und keine Codeänderung, hält aber Metadaten, die ein aggressiver Durchlauf los sein will, und man vergisst es leicht bei einem neuen Endpoint; explizite Attribute sind mehr Tipparbeit am Anfang, lassen Sie aber jeden Parameternamen in der Assembly entfernen und nie wieder über die Bindung nachdenken. Für einen Dienst, den Sie ernsthaft härten, annotieren Sie. Für [AsParameters]-Structs gilt derselbe Zug — setzen Sie [FromRoute(Name = "...")] / [FromQuery(Name = "...")] auf die öffentlichen Eigenschaften, und dann können auch deren Namen umbenannt werden.
Kompilieren Sie über den Request Delegate Generator (Native AOT oder EnableRequestDelegateGenerator), verdrahtet der generierte Bindungscode die Argumente ohne ParameterInfo.Name-Nachschlag zur Laufzeit, sodass Top-Level-Route-/Query-Parameter bereits umbenennungssicher sind — aber [AsParameters] und Body-DTOs binden weiterhin per Eigenschaftsnamen, also behalten Sie diese.
Den Schutz in den Build einbauen
Schützen Sie die veröffentlichte Ausgabe, und nur auf dem Build-Server, damit das lokale dotnet run schnell und debugbar bleibt:
dotnet publish Contoso.Orders -c Release -o publish
nebula protect --config nebula.config.json
Hängen Sie es in der CI nach dem Publish ein, damit das bereitgestellte Artefakt immer das geschützte ist:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Liefern Sie protected/ aus, nicht den rohen Publish-Ordner. nebula protect gibt bei Fehler einen Exit-Code ungleich null zurück, sodass ein kaputter Schutzschritt den Build scheitern lässt, statt ein Binary auszuliefern, dessen Endpoints lautlos alles an null binden.
Das Innere schützen
Den Namen eines Parameters lesbar zu halten, bedeutet nicht, dass die Arbeit des Handlers es ist. Die Bindungsoberfläche bleibt lesbar; alles dahinter wird undurchsichtig. String-Verschlüsselung verbirgt die internen URLs, Schlüssel und Verbindungszeichenfolgen-Fragmente, die sich in der Endpoint-Schicht ansammeln. Die stärksten Transformationen — Code-Virtualisierung und vollständige Methodenverschlüsselung, beide Pro-Funktionen in Nebula.NET — kommen auf die Kronjuwelen: die Preis-, Berechtigungs- oder Abrechnungslogik, die die Handler aufrufen, angewandt auf diese Handvoll Methoden statt auf die ganze Assembly:
public sealed class OrderService : IOrderService
{
[Obfuscation(Feature = "virtualization")] // Pro — der Algorithmus, nicht die Verrohrung
public Task<Order?> Get(int id, bool detail) { /* ... */ }
}
Umbenennen und Kontrollfluss-Obfuskierung laufen breit darunter und verwandeln den Rest des Inneren in bedeutungslose Symbole und verschlungene Verzweigungen, während die Route weiterhin ihren Parameter findet.
Überprüfen, dass es tatsächlich funktioniert
Unit-Tests fangen das nicht ab: Sie rufen die Handler-Lambda direkt mit echten Argumenten auf und laufen nie über RequestDelegateFactory gegen die geschützte DLL. Zwei Prüfungen tun es. Erstens: Führen Sie den geschützten Build aus und treffen Sie eine echte Route mit einem echten Wert — GET /orders/42?detail=true — und prüfen Sie, dass die Antwort 42 und true widerspiegelt, nicht 0 und false. Ein 200, das den Datensatz mit id null zurückgibt, ist die exakte Signatur eines entfernten Parameternamens; prüfen Sie die gebundenen Werte, nicht den Statuscode. Tun Sie das für einen Route-Parameter, einen Query-Parameter und einen über [AsParameters] oder Body gebundenen Endpoint, da jeder über einen anderen Namen bindet.
Zweitens: Öffnen Sie die ausgelieferte Assembly in einem Decompiler und lesen Sie sie, wie ein Angreifer es tun wird. Glass.NET ist der kostenlose, rein statische Begleiter für diesen Durchgang: Laden Sie die geschützte DLL, bestätigen Sie, dass die Bindungsoberfläche, die Sie behalten wollten, vorhanden ist — die Route-Templates, die DTO-Eigenschaftsnamen oder die [FromRoute(Name=...)]-Literale — und bestätigen Sie dann, dass in OrderService.Get und der Logik, die es aufruft, nichts mehr zu lesen ist.
Das Fazit
Eine Minimal API verbindet eine Anfrage mit Ihrem Handler über eine Zeichenfolge pro Parameter — den reflektierten ParameterInfo.Name, den RequestDelegateFactory einem {route}-Token oder einem Query-Schlüssel zuordnet — und diese Zeichenfolge ist ein Metadatum, das ein Obfuskierungsdurchlauf entfernt oder umbenennt. Verschwindet sie, wirft die Bindung keine Ausnahme; sie übergibt Ihrem Handler default(T), sodass GET /orders/42 lautlos Bestellung null mit einem 200 ausliefert. Entscheiden Sie die Bindungsoberfläche bewusst: entweder behalten Sie die Parameter- und DTO-Namen (stripParameterNames: false plus eine Keep-Regel auf Ihren Verträgen) oder — besser — verschieben Sie jeden Bindungsschlüssel in eine Attribut-Zeichenfolge [FromRoute(Name = "...")] / [FromQuery(Name = "...")], damit Sie jeden Namen in der Assembly entfernen und nie wieder eine Route brechen. Übergeben Sie dann das gesamte Innere an Nebula.NET zum Umbenennen, String-Verschlüsseln und Virtualisieren, schicken Sie einen echten Wert durch den geschützten Build, um zu beweisen, dass er bindet, und lesen Sie die ausgelieferte DLL, um zu bestätigen, dass die Logik hinter dem Endpoint verschwunden ist.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.