Eine Hangfire-Anwendung obfuskieren, ohne persistierte Hintergrund-Jobs zu zerstören
Hangfire serialisiert jeden eingereihten, geplanten oder wiederkehrenden Job als voll qualifizierten Typnamen, Methodennamen und Parametertypen in seinen Speicher und löst das beim Ausführen durch einen Worker per Reflection wieder zu einem MethodInfo auf — möglicherweise auf einem späteren Deployment. Obfuskiert man nach dem Build, verschiebt die Umbenennung die Job-Klasse oder ihre Methode aus diesen gespeicherten Zeichenketten heraus, sodass jeder laufende und wiederkehrende Job mit einem Typ-nicht-gefunden-Fehler an der Deserialisierung scheitert. Dieser Beitrag erklärt genau, was Hangfire in seinen Speicher schreibt, warum wiederkehrende und lange aufgeschobene Jobs die schärfste Kante sind, und wie man die Job-Klassen, die aufgerufenen Methoden und ihre Parametertypen mit einer Nebula.NET-Keep-List fixiert und alles andere umbenennt.
Eine Hangfire-Anwendung sieht nach einem leichten Obfuskierungsziel aus. Die Job-Klassen sind Installationsarbeit; der wertvolle Code ist das, was sie aufrufen. Doch Hangfire hat eine Naht, in die ein naiver Obfuskierungslauf geradewegs hineinläuft, und der Fehler sieht überhaupt nicht nach einem Obfuskierungsfehler aus: Die App wird deployt, das Dashboard ist grün, neue Jobs laufen — und dann beginnt jeder wiederkehrende Job und jeder Job, der schon in der Warteschlange lag, mit Could not load type zu scheitern. Die Assembly ist gültig. Hangfire findet schlicht die Methoden nicht mehr, die es zuvor aufgeschrieben hat.
Der Grund ist spezifisch dafür, wie Hangfire einen Job persistiert, und sobald man ihn sieht, ist die Lösung eine kurze Keep-List. In diesem Beitrag geht es um diesen Mechanismus: was Hangfire genau in seinen Speicher schreibt, warum das Umbenennen einer Job-Klasse die Auflösung bricht und nicht den Code, und wie man das Innere mit Nebula.NET schützt, ohne die Namen anzutasten, die ein Worker zurückliest.
Warum eine Hangfire-Anwendung überhaupt obfuskieren
Die Job-Methoden sind dünn, aber die Arbeit, die sie orchestrieren, ist das Produkt: der Abrechnungslauf, die Mahnlogik, die Risiko-Neubewertung, der Zahlungsabgleich, der Preisexport. Dieser Code läuft kopflos auf einem Server, oft einem, den Ihr Kunde verwaltet, und das deployte Artefakt ist eine DLL, die jeder mit Dateizugriff in einem Decompiler öffnen und als sauberes C# zurücklesen kann. Die Hintergrundschicht ist auch dort, wo sich die sensiblen Konstanten sammeln — nachgelagerte Endpunkt-URLs, Partnerschlüssel, Queue- und Header-Namen — weil niemand hinsieht. Den eigenen ausgelieferten Code hier zu schützen ist genau der legitime Fall für Obfuskierung: die Kosten erhöhen, Ihre Geschäftslogik aus der Binärdatei zurückzulesen, die Sie übergeben haben.
Was Hangfire tatsächlich in den Speicher schreibt
Hier ist eine gewöhnliche Job-Klasse und die zwei Arten, wie sie eingeplant wird:
namespace Contoso.Billing.Jobs;
public sealed class InvoiceJobs
{
private readonly IInvoiceService _invoices;
public InvoiceJobs(IInvoiceService invoices) => _invoices = invoices;
// The method Hangfire records and later resolves by reflection.
public void ProcessOrder(long orderId)
{
_invoices.Settle(orderId); // proprietary — the reason this exists
}
public void GenerateDailyReport() => _invoices.ExportDaily();
}
// Fire-and-forget: runs within seconds, on this deployment.
BackgroundJob.Enqueue<InvoiceJobs>(j => j.ProcessOrder(80421));
// Recurring: the definition lives in storage and is re-enqueued
// on its cron schedule across every future deployment.
RecurringJob.AddOrUpdate<InvoiceJobs>(
"daily-invoice-report",
j => j.GenerateDailyReport(),
Cron.Daily);
Das Entscheidende ist, was die Aufrufe Enqueue und AddOrUpdate nicht speichern. Sie speichern weder einen Delegaten noch eine Referenz noch ein Token, dem die Laufzeit zu Ihrer Methode zurückfolgen könnte. Hangfire nimmt den Ausdruck j => j.ProcessOrder(80421), reduziert ihn zu einem Job-Deskriptor und serialisiert das in seinen Speicher (SQL Server, PostgreSQL, Redis — der Speicher ist Ihrer und generisch) als kleinen JSON-Datensatz namens InvocationData. Für den Fire-and-Forget-Aufruf oben sieht er so aus:
{
"Type": "Contoso.Billing.Jobs.InvoiceJobs, Contoso.Billing",
"Method": "ProcessOrder",
"ParameterTypes": "[\"System.Int64, System.Private.CoreLib\"]",
"Arguments": "[\"80421\"]"
}
Vier Zeichenketten. Type ist der voll qualifizierte, assembly-qualifizierte Typname. Method ist der Methodenname als reiner Text. ParameterTypes ist ein JSON-Array assembly-qualifizierter Parametertypnamen, das Überladungen eindeutig macht. Arguments enthält die serialisierten Argumentwerte. Kein Metadaten-Token, keine Typidentität — nur Namen.
Holt ein Worker den Job ab, kehrt er den Vorgang um. Konzeptuell:
var type = Type.GetType("Contoso.Billing.Jobs.InvoiceJobs, Contoso.Billing"); // from "Type"
var method = type.GetMethod("ProcessOrder", new[] { typeof(long) }); // name + ParameterTypes
method.Invoke(ActivatorResolve(type), DeserializeArgs(...)); // run it
Das ist die ganze Falle. Diese Zeichenketten wurden beim Einreihen geschrieben und ändern sich nie. Die Obfuskierung läuft nach dem Kompilieren auf der DLL, und wenn die Umbenennung InvoiceJobs zu a und ProcessOrder zu b macht, bleibt die gespeicherte InvocationData unberührt — sie nennt weiterhin die Originale — aber der Typ und die Methode, die sie nennt, existieren nicht mehr. Type.GetType liefert null, die Methodensuche scheitert, und Hangfire wirft eine JobLoadException: Could not load type ‘Contoso.Billing.Jobs.InvoiceJobs’ oder The type does not contain a method with signature ‘ProcessOrder’. Mit der Assembly ist nichts verkehrt; der Name, den der gespeicherte Job finden sollte, ist einfach nicht mehr da.
Warum wiederkehrende und aufgeschobene Jobs die schärfste Kante sind
Ein Fire-and-Forget-Job ist der nachsichtige Fall: Er wird eingereiht und läuft Sekunden später, auf demselben Deployment, sodass die Zeichenketten im Speicher und der Code, den sie nennen, übereinstimmen. Man könnte über diese Lücke hinweg umbenennen, und die meisten Jobs wären schon erledigt.
Wiederkehrende Jobs und lange aufgeschobene (geplante) Jobs sind die, die beißen. Die Definition eines wiederkehrenden Jobs wird einmal in den RecurringJob-Hash geschrieben und lebt dann unbegrenzt im Speicher, von jedem künftigen Deployment Ihrer App nach ihrem Cron-Zeitplan neu eingereiht. Ein mit Schedule aufgeschobener Job liegt in der geplanten Menge, bis seine Zeit kommt — was Stunden oder Tage sein können. Beide überleben regelmäßig das Deployment, das sie erzeugt hat, werden also von einem Worker aufgelöst, der neueren Code ausführt. Das ist genau die Deployment-N-schreibt-, Deployment-N+1-liest-Situation, und dort verwandelt das Umbenennen der Job-Klasse leise ein grünes Release am nächsten Morgen in einen Haufen Could not load type-Fehler.
Die Prüfung: was umbenannt wird, was nicht
Gehen Sie jeden Namen durch, den Hangfire aus dem Speicher auflöst, und die Keep-List schreibt sich von selbst.
- Die Job-Klassen —
Contoso.Billing.Jobs.InvoiceJobs. DieType-Zeichenkette jederInvocationDatanennt sie. Behalten Sie die Typnamen. - Die von Hangfire aufgerufenen Methoden —
ProcessOrder,GenerateDailyReportund alles, was einRecurringJob.AddOrUpdate- oder einEnqueue/Schedule-Ausdruck referenziert. DieMethod-Zeichenkette nennt jede. Behalten Sie diese Methodennamen. Die Klasse zu behalten genügt nicht; die Methodensuche erfolgt ebenfalls nach Name. - Eigene Parametertypen — nimmt eine Job-Methode einen Ihrer Typen statt eines
longoderstring, wird der assembly-qualifizierte Name dieses Typs inParameterTypesgeschrieben, und die Methodensuche löst ihn auf. Behalten Sie jeden eigenen Typ, der als Parameter einer Job-Methode dient (und behalten Sie die Namen seiner serialisierten Eigenschaften, da Hangfire auch den Argumentwert deserialisiert). Framework-Typen wieSystem.Int64gehören nicht Ihnen zum Umbenennen und brauchen keine Regel. - Die Job-Bezeichner-Zeichenketten —
"daily-invoice-report"ist ein Literal, das Sie übergeben; es ist Daten, kein Symbol, und die Zeichenkettenverschlüsselung lässt die Identität des wiederkehrenden Jobs unangetastet, weil Hangfire den gespeicherten Schlüssel vergleicht, den Ihr Code zur Laufzeit auf dieselbe Weise reproduziert. Keine Aktion nötig. - Konstruktor und DI — Hangfire aktiviert den Job-Typ über seinen
JobActivatorund löst Abhängigkeiten nach Typ auf. Umbenennung bewahrt die Typidentität. Frei umbenennen. - Alles, was der Job aufruft —
IInvoiceService,InvoiceService.Settle, die privaten Helfer, der Algorithmus. Nichts außerhalb des Prozesses nennt diese. Umbenennen, verschlüsseln, virtualisieren.
Zwei Nähte also zu behalten — die Job-Typen und die von Hangfire aufgerufenen Methoden (plus alle eigenen Parametertypen) — und alles andere ist Freiwild.
Die Keep-List
Modellieren Sie die Job-Klassen unter einem einzigen Namespace, damit eine Regel sie abdeckt, und nebula.config.json lautet:
{
"schemaVersion": 1,
"inputs": [
"publish/Contoso.Billing.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Contoso.Billing.Jobs.*", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Billing.Contracts.*", "action": "keep", "scope": "members" },
{ "pattern": "Contoso.Billing.*", "action": "rename" }
]
}
}
Die erste Regel fixiert jeden Typ unter Contoso.Billing.Jobs und seine Member, sodass jede aufgerufene Methode den Namen behält, den die gespeicherte InvocationData erwartet — scope: "type-and-members" ist das, was ProcessOrder bewahrt, nicht nur die Klasse. Die zweite behält die Member der DTO-Typen, die eine Job-Methode als Parameter nimmt, damit die Parametertypnamen in ParameterTypes auflösen und die serialisierten Argumenteigenschaften weiterhin binden, während die Typnamen selbst umbenannt werden dürfen, sofern nichts anderes sie nennt. Alles andere in der Assembly wird umbenannt.
Wenn Sie sich nicht auf eine Namespace-Konvention verlassen möchten, annotieren Sie die Handler direkt — das Attribut reist mit dem Code und kann nicht von einem Muster auseinanderdriften:
[Obfuscation(Exclude = true, ApplyToMembers = true)] // keep this job's names for Hangfire resolution
public sealed class InvoiceJobs { /* ... */ }
Namen behalten gegenüber einem eigenen Typauflöser
Hangfire bietet durchaus einen Auflösungspunkt: Sie können mit GlobalConfiguration.Configuration.UseTypeResolver(...) einen eigenen Typauflöser registrieren und im Prinzip die alten gespeicherten Typ-Zeichenketten auf die umbenannten Typen übersetzen. Widerstehen Sie. Dieser Ansatz koppelt Ihre laufende App an die Umbenennungstabelle des Obfuskators, muss bei jeder künftigen Umbenennung korrekt bleiben und muss immer noch die Method- und ParameterTypes-Zeichenketten behandeln — nicht nur den Typ. Die Keep-List ist eine zweizeilige Regel, die nie aus dem Takt gerät und Sie nur die Namen einer Handvoll Orchestrierungstypen kostet. Die Namen zu behalten ist der robuste Weg; der Typauflöser-Punkt ist ein letztes Mittel für eine Codebasis, die sie wirklich nicht behalten kann.
Die Warteschlange beim Deployment leeren — als Ergänzung, nicht als Ersatz
Eine gute Deployment-Praxis verengt das Fenster weiter: Hören Sie auf, neue Arbeit einzureihen, lassen Sie die Worker fertigstellen, was in Arbeit ist, und rollen Sie erst dann den neuen Build aus. Für kurzlebige Fire-and-Forget-Jobs leert das die eine Kategorie, die das Deployment überspannen könnte. Aber es rettet Sie nicht allein — die Definition eines wiederkehrenden Jobs und ein auf Tage geplanter Job bleiben beide über die Leerung hinweg im Speicher, der neue Code liest sie also trotzdem zurück. Die Leerung verringert die kurzlebige Exposition; die Namen zu behalten ist das, was die persistenten Jobs überleben lässt. Tun Sie beides.
Den Schutz in den Build einbinden
Schützen Sie die veröffentlichte Ausgabe, und zwar nur auf dem Build-Server, damit lokales Debuggen schnell und lesbar bleibt:
dotnet publish Contoso.Billing -c Release -o publish
nebula protect --config nebula.config.json
Hängen Sie es in der CI nach dem Publish ein, damit das deployte Artefakt stets das geschützte ist:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Liefern Sie den Ordner protected/ aus, nicht den rohen Publish-Ordner. nebula protect liefert bei einem Fehler einen von null verschiedenen Exit-Code, sodass ein kaputter Schutzschritt den Build scheitern lässt, statt eine ungeschützte DLL auszuliefern — oder schlimmer, eine, die eine Job-Klasse umbenannt hat, die Ihr Speicher noch referenziert.
Das Innere schützen
Den Namen einer Methode zu behalten heißt nicht, ihren Rumpf lesbar zu lassen. Der Job bleibt auflösbar, während alles, was er tut, undurchsichtig wird. Zeichenkettenverschlüsselung verbirgt die Endpunkt-URLs, Schlüssel und Queue-Namen, die die Hintergrundschicht ansammelt. Die stärksten Transformationen — Code-Virtualisierung und vollständige Methodenverschlüsselung, beides Pro-Funktionen in Nebula.NET — kommen auf die Kronjuwelen: die Abrechnungs-, Scoring- oder Abgleichslogik, die die Jobs umhüllen, angewandt auf diese Handvoll Methoden statt auf die ganze Assembly. Sie können sogar den Rumpf einer Job-Methode virtualisieren, während ihr Name erhalten bleibt:
public sealed class InvoiceService : IInvoiceService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public void Settle(long orderId) { /* ... */ }
}
Umbenennung und Kontrollfluss-Obfuskierung laufen breit unter beiden, verwandeln den Rest des Inneren in bedeutungslose Symbole und verworrene Verzweigungen.
Prüfen, dass es wirklich funktioniert
Unit-Tests fangen das nicht ab: Sie rufen ProcessOrder direkt gegen das Projekt auf, nie über eine gespeicherte InvocationData gegen die geschützte DLL. Zwei Prüfungen tun es. Erstens: Führen Sie es durchgängig gegen den echten Speicher aus — reihen Sie ein und, entscheidend, registrieren Sie einen wiederkehrenden Job auf dem Build vor der Obfuskierung, deployen Sie den geschützten Build auf denselben Speicher und bestätigen Sie, dass der Worker den bereits persistierten Job auflöst und ausführt. Ein Job, der nur nach dem geschützten Deployment mit Could not load type scheitert, ist die exakte Signatur einer vergessenen Keep-Regel.
Zweitens: Öffnen Sie die ausgelieferte Assembly in einem Decompiler und lesen Sie sie so, wie es ein Angreifer tun wird. Glass.NET ist der kostenlose, rein statische Begleiter für diesen Durchgang: Laden Sie die geschützte DLL, bestätigen Sie, dass Contoso.Billing.Jobs.InvoiceJobs.ProcessOrder noch namentlich vorhanden ist — das muss es sein, damit es zu dem passt, was im Speicher steht — und bestätigen Sie dann, dass darin und im aufgerufenen InvoiceService nichts mehr zu lesen ist.
Das Fazit
Hangfire findet Ihre Hintergrundarbeit über Zeichenketten, die es zuvor in den Speicher geschrieben hat — den voll qualifizierten Typnamen, den Methodennamen und die Parametertypen — und löst sie beim Ausführen eines Jobs durch einen Worker per Reflection wieder zu einem MethodInfo auf, oft auf einem späteren Deployment als dem, das ihn eingereiht hat. Benennen Sie die Job-Klasse oder Methode um, und Sie verschieben sie aus diesen gespeicherten Zeichenketten heraus; der Worker, der die unveränderte InvocationData liest, scheitert am Laden des Typs, bevor Ihr Code läuft. Wiederkehrende und lange aufgeschobene Jobs machen das zum Ersten, was bricht, weil sie das Deployment überleben, das sie erzeugt hat. Behalten Sie genau das, was der Speicher nennt — die Job-Klassen, die von Hangfire aufgerufenen Methoden und alle eigenen Parametertypen —, leeren Sie die kurzlebige Warteschlange beim Deployment als Ergänzung, und übergeben Sie das gesamte Innere an Nebula.NET zum Umbenennen, Verschlüsseln von Zeichenketten und Virtualisieren. Führen Sie dann einen persistierten Job mit dem geschützten Build aus, beobachten Sie, wie er auflöst und abschließt, und lesen Sie die ausgelieferte DLL, um zu bestätigen, dass die Logik dahinter verschwunden ist.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.