Eine ASP.NET-Core-App obfuskieren: Controller, DI, Model Binding und was öffentlich bleiben muss
ASP.NET Core findet fast alles zur Laufzeit über Namen: Controller-Typen über ihr Suffix, Aktionen über den Methodennamen, Routen aus den Token [controller] und [action], Query- und Formularparameter über Parameternamen, JSON-Eigenschaften über Eigenschaftsnamen, Hub-Methoden über Zeichenketten, Options-Klassen über Konfigurationsschlüssel. Benenne eines davon um und die App kompiliert weiter, startet weiter und liefert 404 oder ein leeres DTO. Dieser Beitrag kartiert jede reflexionsgesteuerte Naht einer ASP.NET-Core-App — MVC, Minimal APIs, Razor-Views und Tag Helper, SignalR, das Options-Pattern, Hosted Services —, zeigt, wie umbenannte Metadaten tatsächlich aussehen, und baut eine nebula.config.json, die die Services und die Domäne umbenennt und härtet, während sie genau die Oberfläche fixiert, die das Framework beobachtet.
ASP.NET Core ist auf Reflexion gebaut, wie es die meisten Anwendungs-Frameworks nicht sind. Es nutzt Reflexion nicht nur zum Konstruieren von Dingen; es nutzt Namen — Typnamen, Methodennamen, Parameternamen, Eigenschaftsnamen — als Quelle der Wahrheit für Routing, Binding, Serialisierung und Konfiguration. Das macht es zum Worst Case für einen Obfuskator, der aggressiv umbenennt, und zu einem sehr lehrreichen Fall für einen, der präzise umbenennt, denn die Naht zwischen „was das Framework über Namen liest” und „was niemand außerhalb des Prozesses je sieht” ist scharf und klar definiert, sobald man weiß, wo man hinschauen muss.
Dieser Beitrag geht die ganze Naht ab. Für jede Framework-Funktion zeigen wir, was sie über Namen entdeckt, was ein naives Umbenennen ihr antut und was zu fixieren ist. Dann bauen wir eine nebula.config.json zusammen, die die beobachtete Oberfläche in Ruhe lässt und alles dahinter härtet — die Services, die Domäne, die Lizenzprüfung, die Integrationslogik — wo der schützenswerte Code tatsächlich lebt.
MVC: Controller und Aktionen werden über Namen gefunden und über Namen geroutet
Ein MVC-Controller wird entdeckt, weil sein Typ öffentlich, nicht abstrakt ist und entweder auf Controller endet oder [Controller]/[ApiController] trägt. Seine Aktionen werden entdeckt, weil sie öffentliche Instanzmethoden sind. So weit ist es nur Sichtbarkeit, die das Umbenennen bewahrt. Das Problem ist, was danach geschieht: das Framework baut die Routen aus den Namen.
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orders;
public OrdersController(IOrderService orders) => _orders = orders;
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(int id) => ...;
[HttpGet("[action]")]
public async Task<ActionResult<PagedResult<OrderDto>>> Search([FromQuery] int page = 1,
[FromQuery] string? customer = null) => ...;
}
Die Routenvorlage api/[controller] wird beim Start aufgelöst, indem der Controller-Typname genommen, das Controller-Suffix entfernt und ersetzt wird: api/Orders. Das [action]-Token ersetzt den Methodennamen: api/Orders/Search. Beide Ersetzungen geschehen bei der ApplicationModel-Konstruktion aus TypeInfo.Name und MethodInfo.Name — reine Metadaten-Lesevorgänge. Benenne den Typ in b und die Methode in c um und die URLs werden zu api/b und api/b/c. Die App startet sauber. Jeder Client bekommt 404.
Die [FromQuery]-Parameter haben dasselbe Problem eine Ebene tiefer. Ohne expliziten Name nutzt der Binder den Metadaten-Namen des Parameters: ?page=2&customer=acme. Ein Obfuskator, der Parameternamen umschreibt, bricht das Binding still — der Parameter bleibt einfach auf seinem Standardwert, der für page 1 und für customer null ist, und niemand sieht einen Fehler.
Drei Dinge halten das am Laufen, und du solltest alle drei unabhängig von der Obfuskierung tun, weil sie auch deinen API-Vertrag explizit machen:
- Bevorzuge explizite Routenvorlagen gegenüber Token:
[Route("api/orders")],[HttpGet("search")]. Eine Zeichenkettenliteral in einem Attribut ist kein Name, den der Obfuskator als Member sieht; es überlebt. - Benenne deine gebundenen Parameter, wo der Name Teil des Vertrags ist:
[FromQuery(Name = "page")]. Dieselbe Überlegung. - Behalte die Typ- und Aktionsnamen der Controller mit einer Regel, für die Stellen, an denen du 1 und 2 nicht getan hast.
preservePublicApideckt das für öffentliche Controller ab; eine Musterregel deckt die internen ab.
Nebulas Umbenennung schreibt Typen und Member um. Sie lässt Methoden-Parameternamen an ihrem Platz, also ist Schritt 2 eher Tiefenverteidigung als Notwendigkeit — aber du willst ihn trotzdem, denn an dem Tag, an dem ein Kollege page zu pageNumber refaktoriert, ändert sich deine öffentliche API, ohne dass es jemand bemerkt, und ein expliziter Name ist, was das verhindert.
Model Binding und JSON: Eigenschaftsnamen sind das Drahtformat
Die Eigenschaftsnamen eines DTO sind sein JSON. System.Text.Json baut seinen Vertrag aus PropertyInfo.Name (camelCase durch die Standard-Weboptionen), und das Binding von Formular-POSTs und Query-Strings nutzt dieselben Namen. Benenne OrderDto.CustomerName in a um und der Antwortkörper ist {"a":"Acme"}.
public sealed class OrderDto
{
public int Id { get; set; }
public string CustomerName { get; set; } = "";
public decimal Total { get; set; }
}
Du hast zwei korrekte Optionen. Die erste ist, jede serialisierte Eigenschaft mit [JsonPropertyName("customerName")] zu dekorieren, woraufhin der Membername für das Drahtformat irrelevant ist und frei umbenannt werden kann. Es ist auch eine gute Gewohnheit: es macht den Vertrag explizit und überlebt jedes künftige Umbenennen, ob durch Obfuskator oder Mensch. Die zweite ist, die DTO-Membernamen mit einer Regel zu behalten. Für eine große API ist die Regel weniger Tipparbeit; für eine kleine sind die Attribute robuster. In jedem Fall darf eines nicht passieren: ein DTO, das weder attributiert noch behalten ist.
Beachte die Asymmetrie zum Domänen-Modell. Deine Entität Order, PricingPolicy, DiscountRule und der Rest werden nie auf den Draht serialisiert — sie werden per Code in DTOs gemappt, über Eigenschaftszugriffe, die der Compiler zu Member-Token auflöste, die das Umbenennen konsistent aktualisiert. Die können umbenannt werden, und dort ist die wertvolle Logik.
Minimal APIs: Lambdas, Closures und die Parameternamen des Handlers
Minimal APIs sehen aus, als hätten sie gar keine Namen, und das stimmt zur Hälfte. Der Handler ist ein Lambda, das der Compiler zu einer Methode auf einer Closure-Klasse absenkt — <>c.<<Main>$>b__0_0 oder Program.<>c__DisplayClass0_0.<<Main>$>b__0, falls es etwas eingefangen hat — und nichts an diesen Namen zählt fürs Routing, weil die Route die Zeichenkettenliteral ist, die du an MapGet übergeben hast.
app.MapGet("/api/orders/search",
async (int page, string? customer, IOrderService orders, CancellationToken ct)
=> TypedResults.Ok(await orders.SearchAsync(page, customer, ct)));
Die RequestDelegateFactory des Frameworks inspiziert die MethodInfo des Lambdas und entscheidet für jeden Parameter, woher der Wert kommt: ein Routenwert, falls der Name einem Routenvorlagen-Token entspricht, ein Service, falls der Typ registriert ist, der Body, falls es ein komplexer Typ ist, und sonst der Query-String über den Parameternamen. Also werden page und customer aus ?page= und ?customer= gebunden, rein wegen der Benennung der Lambda-Parameter, und orders wird aus der DI gebunden, rein wegen seines Typs. Die Parameternamen sind der Vertrag; der Name der Closure-Klasse ist Rauschen.
Das stellt Minimal APIs in eine gute Lage: kein Typ- oder Membername wird beobachtet, nur Parameternamen, die Nebula nicht umschreibt. Mach sie trotzdem explizit — [FromQuery(Name = "page")] int page — aus demselben Grund der Vertragsstabilität wie oben, und lass die Closure-Klassen sich umbenennen; sie haben schon Namen, die kein Mensch liest.
Razor-Views und Tag Helper: die Model Expression liest MemberInfo.Name
Razor-Views kompilieren zu C#-Klassen — in .NET 6+ standardmäßig in deine Hauptassembly unter AspNetCoreGeneratedDocument, jede getaggt mit [RazorCompiledItem(typeof(Views_Orders_Index), "mvc.1.0.view", "/Views/Orders/Index.cshtml")]. Das Attribut referenziert den Typ über typeof, also ist das Umbenennen der View-Klasse sicher: das Token wird aktualisiert, die Bezeichner-Zeichenkette (der Pfad) ist, was die View-Engine nachschlägt, und es ist ein Literal.
Bei Tag Helpern lecken die Namen. Nimm ein Formular:
<form asp-action="Create" asp-controller="Orders" method="post">
<input asp-for="CustomerName" />
<span asp-validation-for="CustomerName"></span>
<input asp-for="Total" />
</form>
Drei Dinge gehen vor sich, jedes mit einer anderen Antwort:
asp-action="Create" asp-controller="Orders"sind Zeichenkettenliterale, die beim Rendern gegen die Routentabelle aufgelöst werden — dieselbe Tabelle, deren Einträge aus Controller- und Aktionsnamen kamen. Wurden diese Namen behalten (vorheriger Abschnitt), funktioniert das; wurden sie umbenannt, liefert die Linkgenerierung ein leeresaction-Attribut und das Formular POSTet an die aktuelle URL.asp-for="CustomerName"kompiliert zuModelExpressionProvider.CreateModelExpression(ViewData, __model => __model.CustomerName). Beim Rendern durchläuft der Tag Helper diesen Ausdrucksbaum, liestMemberInfo.Nameund gibtname="CustomerName" id="CustomerName"aus. Wird die Eigenschaft inaumbenannt, sagt das ausgegebene HTMLname="a"— und weil das Formular an einen Model Binder zurück-POSTet, der ebenfalls den umbenannten Namen nutzt, funktioniert das Binding weiterhin. Was bricht, ist alles, was das Feld von außen referenzierte: dein JavaScript mitdocument.getElementById("CustomerName"), dein CSS, deine End-to-End-Tests und die clientseitigen Validierungsregeln vonasp-validation-for, die auf demselben Namen schlüsseln.- Das View-Modell selbst ist eine serialisierungsähnliche Grenze: es wird über Ausdrücke per Name gelesen, und seine Namen erreichen den Browser.
Die praktische Regel: behandle View- und Page-Modelle genau wie DTOs. Behalte ihre Membernamen (oder, bei Razor Pages, behalte die [BindProperty]-Eigenschaften des PageModel), benenne die View-Klassen frei um, und referenziere nie einen umbenannten Domänentyp aus einer View — Views sollten mit View-Modellen sprechen, was du ohnehin wolltest.
SignalR: Hub-Methoden werden über Zeichenketten aufgerufen
Ein SignalR-Client ruft connection.invoke("PlaceOrder", dto) auf und der Server löst "PlaceOrder" gegen die öffentlichen Methoden des Hubs über den Namen auf. Die Zeichenkette des Clients wird vom Obfuskator nicht angefasst; die Methode des Servers schon. Benenne sie um und jede Invokation scheitert mit Method does not exist. Client-zu-Server-Namen müssen behalten werden — preservePublicApi deckt öffentliche Hub-Methoden ab — und Server-zu-Client-Aufrufe (Clients.All.SendAsync("OrderUpdated", ...)) sind Zeichenkettenliterale, die von selbst überleben. Stark typisierte Hubs (Hub<IOrderClient>) lösen die Client-Methodennamen aus den Member-Namen der Schnittstelle auf, also muss die Schnittstelle ebenfalls behalten werden.
Options, Konfiguration und die Dinge, bei denen du vergessen hast, dass sie über Namen gebunden werden
services.Configure<SmtpOptions>(config.GetSection("Smtp")) befüllt SmtpOptions, indem es Konfigurationsschlüssel über ConfigurationBinder, der PropertyInfo.Name mit einem Vergleich ohne Groß-/Kleinschreibung nutzt, an Eigenschaftsnamen abgleicht. Benenne SmtpOptions.Host in a um und der gebundene Wert ist null. Nichts wirft; deine E-Mails hören einfach in der Produktion auf zu versenden. Options-Klassen sind die am häufigsten vergessene Naht bei der Web-App-Obfuskierung, weil sie internal sind (also ignoriert sie preservePublicApi) und wie schlichte POCOs ohne irgendetwas Attributwürdiges aussehen.
Dasselbe gilt für alles andere, das die Konfiguration per Konvention liest: [FromKeyedServices("...")] mit einem nameof, aus Typnamen abgeleitete Health-Check-Namen, ILogger<T>-Kategorienamen (diese werden zu a.b in deinen Logs — harmlos, aber hässlich; die Umbenennungskarte löst es auf) und IHostedService-Implementierungen (per Typ registriert — sicher).
Alles zusammensetzen: die Config
Hier eine nebula.config.json für eine typische geschichtete Web-App: MyApp.Web (Controller, DTOs, View-Modelle, Hubs, Options, Start), MyApp.Application (Services), MyApp.Domain (Entitäten und Regeln), MyApp.Infrastructure (EF Core, Integrations-Clients). Die Web-Schicht erhält selektive keeps; die drei inneren Schichten werden vollständig umbenannt und gehärtet.
{
"schemaVersion": 1,
"inputs": [
"bin/Release/net8.0/publish/MyApp.Web.dll",
"bin/Release/net8.0/publish/MyApp.Application.dll",
"bin/Release/net8.0/publish/MyApp.Domain.dll",
"bin/Release/net8.0/publish/MyApp.Infrastructure.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "MyApp.Web.Controllers.*", "action": "keep" },
{ "pattern": "MyApp.Web.Hubs.*", "action": "keep" },
{ "pattern": "MyApp.Web.Contracts.*", "action": "keep" },
{ "pattern": "MyApp.Web.ViewModels.*", "action": "keep" },
{ "pattern": "MyApp.Web.Pages.*Model", "action": "keep" },
{ "pattern": "MyApp.Web.Options.*", "action": "keep" },
{ "pattern": "AspNetCoreGeneratedDocument.*", "action": "rename" },
{ "pattern": "MyApp.Web.*", "action": "rename" },
{ "pattern": "MyApp.Application.*", "action": "rename" },
{ "pattern": "MyApp.Domain.*", "action": "rename" },
{ "pattern": "MyApp.Infrastructure.*", "action": "rename" }
]
},
"exclude": [
"MyApp.Infrastructure.Persistence.Entities.*"
]
}
Von oben nach unten gelesen: preservePublicApi ist aus, denn in einer Web-App ist „öffentlich” meist ein Zufall der Projektvorlage, und wir wollen explizit sein. Die ersten sechs Regeln fixieren genau die Oberfläche, die das Framework beobachtet — Controller, Hubs, Request/Response-Verträge, View-Modelle, Page-Modelle, Options. Generierte Razor-Dokumente und alles andere in der Web-Schicht werden umbenannt. Die drei inneren Assemblies werden vollständig umbenannt, mit String-Verschlüsselung und Kontrollfluss-Obfuskierung auf alle angewandt — dort leben das Lizenz-Gate, die Preis-Engine und die Integrationsgeheimnisse. Der EF-Core-Entitäten-Namespace ist aus dem Umbenennen ausgeschlossen, aus den Gründen, die der Entity-Framework-Beitrag durchgeht (der Model Builder und der Migrations-Snapshot referenzieren Member über Namen); alles andere in Infrastructure, einschließlich der Integrations-Clients, ist Freiwild.
Wenn du Attribute Mustern vorziehst, lassen dich [JsonPropertyName] auf jeder DTO-Eigenschaft und explizite Routenvorlagen auf jeder Aktion die Contracts- und Controllers-keeps vollständig weglassen, und die Config schrumpft entsprechend.
Den geschützten Build verifizieren
Unit-Tests fangen nichts davon, denn sie referenzieren das Projekt und führen die obfuskierte Assembly nie aus. WebApplicationFactory<Program> ist dasselbe: es baut deine App in-process aus der Referenz des Testprojekts, nicht aus protected/. Was es fängt, ist, die veröffentlichte, geschützte Ausgabe als Prozess auszuführen und sie über HTTP zu steuern.
# CI, after obfuscation
dotnet protected/MyApp.Web.dll --urls http://localhost:5199 --environment Smoke &
sleep 3
dotnet test tests/MyApp.Smoke --filter Category=Protected -- BaseUrl=http://localhost:5199
Die Smoke-Suite ist bewusst klein und bewusst langweilig: ein Request je Route, der Status und Form prüft, ein JSON-Hin-und-Rückweg je DTO, der bestätigt, dass jede Eigenschaft unter ihrem erwarteten Namen vorhanden ist, ein Formular-POST je Razor-Seite, der bestätigt, dass das Modell gebunden hat, eine Hub-Methoden-Invokation und eine Startbehauptung, dass jede Options-Klasse einen Nicht-Standardwert aus der Smoke-Konfiguration gebunden hat. Führe sie in derselben Pipeline, die das Artefakt erzeugt, gegen den geschützten Build aus, und das umbenannte Routen-Token oder die vergessene Options-Klasse lässt den Build scheitern statt das Deployment eines Kunden. Zusammen damit, Entwickler-Builds sauber zu halten und nur auf dem Build-Server zu obfuskieren, ist das die ganze Schleife.
Das Fazit
ASP.NET Core liest Namen aus einer kurzen, klar definierten Liste von Orten: Routen-Token, gebundene Parameternamen, serialisierte Eigenschaftsnamen, Hub-Methodennamen, Razor-Model-Expressions und aus der Konfiguration gebundene Options. Jeder davon ist entweder ein Literal, das du explizit machen kannst, oder ein Member, den du mit einer Regel behältst, und keiner davon ist dort, wo dein wertvoller Code ist. Fixiere diese Oberfläche, mach den Vertrag mit Attributen explizit, wo du kannst, und lass den Obfuskator die Services, die Domäne, die Lizenzprüfung und die Integrationen dahinter umbenennen, verschlüsseln und verflachen. Dann führe den geschützten Build, nicht den Quellcode, durch eine Smoke-Suite, denn das Framework wird dir nicht sagen, was es nicht gefunden hat — es wird einfach 404 liefern.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.