Einen .NET-Worker-Service obfuskieren: einen kopflosen Hintergrund-Worker schützen
Ein .NET-Worker-Service ist die am leichtesten aggressiv zu obfuskierende Art von App und die, deren Obfuskierung man am leichtesten ganz vergisst. Er wird als DLL auf den Server eines Kunden ausgeliefert, läuft kopflos als Windows-Dienst oder systemd-Unit und trägt Ihre Integrationsgeheimnisse und Ihre Geschäftslogik bei fast keiner zu schützenden Reflexionsoberfläche — was bedeutet, dass Sie nahezu alles umbenennen können. Dieser Beitrag behandelt, warum ein Hintergrund-Worker ein verlockendes Ziel für Reverse Engineering ist, die wenigen Nähte des Generic Host, die noch Namen lesen (Options-Binding, Assembly-Scanning, serialisierte DTOs), und wie Sie Nebula.NET in den Publish-Schritt einklinken, damit das ausgelieferte Binary umbenannt, seine Strings verschlüsselt und dort virtualisiert ist, wo es zählt.
Ein Worker-Service ist der stille Fall im Gespräch über Obfuskierung. Er hat keine Controller, keine Views, keine öffentliche API, von der Aufrufer abhängen — er ist ein BackgroundService an einem Timer oder ein IHostedService, der eine Queue leert, gebaut auf dem Generic Host und installiert als Windows-Dienst oder systemd-Unit. Weil er keine Oberfläche hat, sind zwei Dinge zugleich wahr: Er ist die am leichtesten aggressiv zu obfuskierende Art von .NET-App, weil fast nichts in ihm über Namen beobachtet wird, und er ist am leichtesten zu vergessen, weil sich „es ist ja nur ein Hintergrundjob auf dem Server unseres Kunden” so anfühlt, als wäre er nicht exponiert. Er ist es. In diesem Beitrag geht es darum, einen kopflosen Worker als das zu behandeln, was er ist — eine DLL in fremden Händen — und ihn mit dem Strich des Generic Host zu schützen statt gegen ihn.
Warum ein kopfloser Worker das Rückentwickeln lohnt
Was einen Worker zu einem verlockenden Ziel macht, ist genau das, was ihn sicher erscheinen lässt: Er hat kein Frontend. In einer Desktop- oder Web-App ist ein Großteil des Codes Präsentation, Verrohrung und Framework-Klebstoff, und die wertvolle Logik ist nur ein Bruchteil davon. Ein Worker ist fast ausschließlich wertvolle Logik. Er existiert, um unbeaufsichtigt etwas zu tun — Hauptbücher nachts abzugleichen, einen Katalog nach Zeitplan neu zu bewerten, eine Partner-API abzufragen und die Ergebnisse zu transformieren, Abrechnungen an eine Clearingstelle zu schieben. Dieser Geschäftsprozess ist das Produkt, und in einem kopflosen Dienst ist er das ganze Assembly.
Er trägt auch tendenziell die sensibelsten Konstanten, die Sie ausliefern. Ein Worker authentifiziert sich bei den Systemen, mit denen er sich integriert, also hält er Endpoint-URLs, API-Schlüssel, Mandanten-Kennungen, Header-Namen und Fragmente von Verbindungszeichenfolgen — oft in der Konfiguration, aber ebenso oft im Code als Standardwerte, Fallbacks oder die Form einer Anfrage. Und weil er auf dem Server eines Kunden läuft, häufig auf einem, den der Kunde administriert, hat der Angreifer gemächlichen physischen Zugriff auf das Binary. Er läuft keinem laufenden Prozess hinterher; er kopiert Contoso.Worker.dll von der Maschine und liest sie in aller Ruhe. Ungeschützt übergibt ihm ein Decompiler Ihre Planungslogik, Ihre Preismathematik und Ihr Integrationshandbuch als sauberes C#.
Der Worker und wohin seine Namen gehen
Hier ist ein gewöhnlicher Worker: ein BackgroundService, der einen Preis-Feed in einem Intervall abfragt und einen Katalog neu bewertet. Nichts daran ist ungewöhnlich, und das ist der Punkt.
public sealed class Worker : BackgroundService
{
private readonly ILogger<Worker> _log;
private readonly IPricingFeed _feed;
private readonly WorkerOptions _options;
public Worker(ILogger<Worker> log, IPricingFeed feed, IOptions<WorkerOptions> options)
{
_log = log;
_feed = feed;
_options = options.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_options.PollInterval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var quotes = await _feed.PullAsync(stoppingToken);
foreach (var quote in quotes)
PricingEngine.Reprice(quote); // proprietär — der Daseinsgrund dieses Workers
}
}
}
Und seine Registrierung auf dem Generic Host:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<WorkerOptions>(builder.Configuration.GetSection("Worker"));
builder.Services.AddSingleton<IPricingFeed, PricingFeed>();
builder.Services.AddHostedService<Worker>();
builder.Services.AddWindowsService(o => o.ServiceName = "Contoso Pricing Worker");
builder.Services.AddSystemd();
builder.Build().Run();
Verfolgen Sie nun jeden Namen und fragen Sie, ob das Framework ihn zur Laufzeit liest:
AddHostedService<Worker>()registriert den Worker über seinen Typ, und der Host zählt dieIHostedService-Instanzen aus dem Container über den Typ auf. Benennen SieWorkerinaum, und die RegistrierungAddHostedService<a>()zeigt weiterhin auf denselben Typ. Sicher.ExecuteAsyncist eine Überschreibung vonBackgroundService.ExecuteAsync. Der Host ruft sie über den virtuellen Slot der Basisklasse auf, den die Umbenennung konstruktionsbedingt bewahrt — der Slot ist Identität, nicht der Name. Sicher.- Die Konstruktorinjektion (
ILogger<Worker>,IPricingFeed,IOptions<WorkerOptions>) wird über den Typ aufgelöst, genau wie in jedem DI-Container. Die Umbenennung bewahrt die Typidentität, die Injektion bleibt also unberührt. (Das einzige DI-Muster, das bricht, ist die Registrierung per Konvention — siehe unten.) ServiceName = "Contoso Pricing Worker"und der Name der systemd-Unit sind String-Literale. Der Name, unter dem der Dienststeuerungs-Manager den Dienst kennt, wird hier gesetzt und mitsc.exeregistriert; er hat nichts mit Ihren Typnamen zu tun. Sicher.ILogger<Worker>leitet seine Kategorie vom vollständigen Typnamen ab, also lesen Ihre Logs nach der UmbenennungCategory: a.bstattContoso.Worker.Worker. Für den Betrieb harmlos, aber wenn Sie log-basierte Alarmierung haben, die auf der Kategorie-Zeichenkette fußt, behalten Sie den Typ bei oder setzen Sie eine explizite Kategorie. Das ist kosmetisch, kein Bruch.
Das ist der gesamte Audit eines schlichten Workers, und fast alles landet bei „sicher umzubenennen”. Vergleichen Sie das mit eine ASP.NET-Core-App obfuskieren, wo Routing, Model Binding, Serialisierung und Razor allesamt Namen lesen: Ein Worker hat einen Bruchteil der Oberfläche, und genau deshalb können Sie bei ihm aggressiv vorgehen.
Die drei Nähte, die noch Namen lesen
Drei Dinge in einem Worker werden doch über Namen aufgelöst, und jedes hat dieselbe Behebung wie überall sonst.
Aus der Konfiguration gebundene Options. services.Configure<WorkerOptions>(config.GetSection("Worker")) befüllt die Klasse, indem es Konfigurationsschlüssel über ConfigurationBinder, der PropertyInfo.Name liest, mit den Eigenschaftsnamen abgleicht. Benennen Sie WorkerOptions.PollInterval in a um, und der Binder findet keinen Schlüssel PollInterval, belässt den Standardwert, und Ihr Worker läuft in der Produktion stillschweigend mit seinem Fallback-Intervall. Nichts wirft eine Ausnahme.
public sealed class WorkerOptions
{
public TimeSpan PollInterval { get; set; } = TimeSpan.FromMinutes(5);
public string FeedEndpoint { get; set; } = "";
public string ApiKey { get; set; } = "";
}
Behalten Sie die Mitgliedsnamen der Options-Klasse bei — entweder mit einer Umbenennungsregel (unten) oder indem Sie jede Eigenschaft mit [ConfigurationKeyName("pollInterval")] dekorieren, was den Schlüssel als Literal fixiert und das Mitglied frei umbenennen lässt. Options-Klassen sind die am häufigsten vergessene Naht bei der Worker-Obfuskierung, weil sie meist internal-POCOs ohne etwas Attributwürdiges sind, und preservePublicApi sie daher ignoriert.
Serialisierte DTOs. Wenn der Worker aus einer Queue liest, eine Partner-API aufruft oder in ein Topic schreibt, haben die DTOs, die er (de)serialisiert, Eigenschaftsnamen, die ein Wire-Vertrag sind. System.Text.Json baut diesen Vertrag aus PropertyInfo.Name; benennen Sie PriceQuote.Symbol in a um, und das ausgehende JSON wird zu {"a":...} und die eingehende Deserialisierung findet Symbol nicht mehr. Fixieren Sie diese mit [JsonPropertyName(...)] an jeder Eigenschaft oder behalten Sie den DTO-Namespace bei. Die allgemeine Fassung dieser Falle behandelt Obfuskierung, Reflexion und Serialisierungsfallen.
Konventionsbasierte Registrierung. Wenn Sie Hosted Services automatisch registrieren, indem Sie das Assembly scannen — eine Schleife über Typen, deren Name auf Job endet, oder eine Bibliothek, die dasselbe tut — dann treibt der Name die Registrierung, und die Umbenennung löscht die Konvention, sodass nichts registriert wird. Die Behebung besteht darin, explizit zu registrieren (AddHostedService<T>() pro Worker) oder nach Marker-Interface/-Attribut statt nach Namen zu scannen. Das ist das einzige DI-Muster in einem Worker, das unter Umbenennung wirklich bricht, und es hat seine eigene Abhandlung: Assembly-Scanning und Dependency Injection obfuskieren.
Eine Anmerkung zu Job-Schedulern: Wenn Sie ein Framework verwenden, das den Typnamen und den Methodennamen eines Jobs in den Speicher persistiert (um geplante Arbeit über Neustarts hinweg zu rehydrieren), behandeln Sie diese Typ- und Methodennamen als Vertrag und behalten Sie sie bei — sonst kann ein in Arbeit befindlicher Job, der vor einer Umbenennung serialisiert wurde, danach nicht aufgelöst werden.
Den Innenraum schützen: zuerst die Strings, dann die Kronjuwelen
Alles, was nicht auf dieser kurzen Liste steht, ist Freiwild, und der Innenraum eines Workers ist dort, wo der Wert sitzt. Zwei Transformationen tragen den Großteil des Gewichts.
String-Verschlüsselung für die Geheimnisse. Die Integrationskonstanten eines Workers — FeedEndpoint, API-Schlüssel, Header-Namen, jede als Standard eingebettete URL oder jedes Token — sind genau das, wonach ein Angreifer zuerst grept. String-Verschlüsselung speichert sie verschlüsselt und entschlüsselt sie erst am Verwendungspunkt, sodass eine Suche im Binary nichts zurückgibt. (Das ist keine Magie: Der Wert wird zur Laufzeit im Speicher entschlüsselt, ein Debugger kann ihn also weiterhin bergen. Was Sie beseitigen, ist der triviale Angriff ohne Aufwand.)
Die stärksten Transformationen auf den wenigen Methoden, die zählen. PricingEngine.Reprice, Ihr Planungskern und das Lizenztor sind die Kronjuwelen. Reservieren Sie Code-Virtualisierung und vollständige Methodenverschlüsselung — beides Pro-Features in Nebula — für diese Handvoll Methoden, nicht für das ganze Assembly: Virtualisierung schreibt eine Methode in Bytecode für eine eingebettete VM um, sodass keine IL zum Dekompilieren übrig bleibt, und Methodenverschlüsselung speichert die ursprüngliche IL verschlüsselt und rekonstruiert sie zur Laufzeit. Sie wählen die Ziele explizit, was die heiße Schleife schnell hält, während die sensible Mathematik unlesbar wird:
public static class PricingEngine
{
[Obfuscation(Feature = "virtualization")] // Pro — der schützenswerte Algorithmus
public static PriceResult Reprice(PriceQuote quote) { /* ... */ }
}
Umbenennung und Kontrollfluss-Obfuskierung laufen breit unter beiden und verwandeln den Rest des Innenraums in bedeutungslose Symbole und verworrene Verzweigungen.
Nebula in den Build einklinken
Schützen Sie die veröffentlichte Ausgabe, und tun Sie es mit einer Konfiguration, die nur die drei Nähte fixiert. Für einen Worker, aufgeteilt in Contoso.Worker (Host, Options, DTOs) und Contoso.Pricing (der Algorithmus), sieht nebula.config.json so aus:
{
"schemaVersion": 1,
"inputs": [
"bin/Release/net8.0/publish/Contoso.Worker.dll",
"bin/Release/net8.0/publish/Contoso.Pricing.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Contoso.Worker.Configuration.*", "action": "keep" },
{ "pattern": "Contoso.Worker.Contracts.*", "action": "keep" },
{ "pattern": "Contoso.Worker.*", "action": "rename" },
{ "pattern": "Contoso.Pricing.*", "action": "rename" }
]
}
}
preservePublicApi ist aus, weil nichts außerhalb des Prozesses die öffentliche Oberfläche dieses Assemblys nutzt — „öffentlich” ist hier ein Zufall der Projektvorlage. Die beiden keep-Regeln fixieren die Options-Klassen und die serialisierten DTOs; alles andere in beiden Assemblys wird umbenannt, seine Strings verschlüsselt und sein Kontrollfluss abgeflacht, mit Virtualisierung angewandt auf die von Ihnen markierten Methoden.
Treiben Sie sie vom Publish-Schritt aus an. Der CLI-Befehl ist nebula:
dotnet publish Contoso.Worker -c Release -o publish
nebula protect --config nebula.config.json
Oder klinken Sie sie ins Projekt ein, damit sie nach dem Publish auf dem Build-Server automatisch läuft — und nur auf dem Build-Server, damit das lokale F5-Debugging schnell und lesbar bleibt:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Entwicklungs-Builds unobfuskiert zu halten und nur in der CI zu obfuskieren verdient seine eigene Disziplin; die Begründung steht in nur auf dem Build-Server obfuskieren.
Den geschützten Worker verifizieren
Unit-Tests fangen nichts davon ab — sie referenzieren das Projekt und führen protected/ nie aus. Zwei Prüfungen tun es. Erstens: Führen Sie den geschützten Worker als Prozess gegen eine Staging-Konfiguration aus, lassen Sie ihn einen oder zwei Zyklen vollenden und prüfen Sie sein Verhalten: Er hat seine Options gebunden (Poll-Intervall und Endpoint weichen vom Standard ab, was beweist, dass keine Option stillschweigend auf ihren Fallback zurückfiel), er hat die Integration erreicht, er hat eine bekannte Eingabe zur erwarteten Ausgabe transformiert, und er hat einen sauberen Start und ein anmutiges Herunterfahren bei Strg+C / SIGTERM protokolliert. Zweitens: Öffnen Sie das ausgelieferte Assembly in einem Decompiler und lesen Sie es so, wie es ein Angreifer tun wird — bestätigen Sie, dass die internen Namen verschwunden sind, die Integrations-Strings nicht mehr im Klartext erscheinen und Reprice einen VM-Aufruf statt Ihres Algorithmus zeigt. Glass.NET ist der kostenlose, rein statische Begleiter für genau diese Prüfung; das vollständige Vorgehen steht in hat Ihre Obfuskierung wirklich funktioniert?.
Das Fazit
Ein Worker-Service ist der Obfuskierungsfall, in dem das Framework Ihnen am wenigsten Widerstand leistet und in dem die Einsätze still hoch sind. Der Generic Host löst Ihren Worker und seine Abhängigkeiten über den Typ auf, also werden die Klasse, ihr ExecuteAsync, ihre injizierten Services und ihr Windows-/systemd-Name umbenannt oder überleben von selbst. Die einzigen Namen, die er zur Laufzeit liest, sind die aus der Konfiguration gebundenen Options, die Eigenschaften der serialisierten DTOs und jedes konventionsbasierte Scanning — fixieren Sie diese drei und lassen Sie Nebula.NET den Rest umbenennen, Strings verschlüsseln und virtualisieren. Dieser „Rest” ist die Planungslogik, der proprietäre Algorithmus, das Lizenztor und die Integrationsgeheimnisse: In einem kopflosen Worker ist er das ganze Produkt, auf einem Server, den Sie nicht kontrollieren. Schützen Sie ihn entsprechend und führen Sie dann den geschützten Build aus — nicht den Quellcode — um es zu beweisen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.