Obfusquer une application Hangfire sans casser les tâches de fond persistées
Hangfire sérialise dans son stockage chaque tâche mise en file, planifiée ou récurrente sous la forme du nom de type pleinement qualifié, du nom de méthode et des types de paramètres, puis la résout en un MethodInfo par réflexion lorsqu’un worker l’exécute — possiblement sur un déploiement ultérieur. Si vous obfusquez après la compilation, le renommage sort la classe de tâche ou sa méthode de sous ces chaînes stockées, de sorte que toutes les tâches en cours et récurrentes échouent à la désérialisation avec une erreur de type introuvable. Cet article explique exactement ce que Hangfire écrit dans son stockage, pourquoi les tâches récurrentes et les tâches différées longues sont le tranchant le plus affûté, et comment épingler les classes de tâches, les méthodes invoquées et leurs types de paramètres avec une keep-list Nebula.NET en renommant tout le reste.
Une application Hangfire ressemble à une cible d’obfuscation facile. Les classes de tâches sont de la tuyauterie ; le code de valeur est ce qu’elles invoquent. Mais Hangfire possède une couture dans laquelle une passe d’obfuscation naïve fonce tête baissée, et la panne ne ressemble en rien à un bug d’obfuscation : l’application se déploie, le tableau de bord est au vert, les nouvelles tâches s’exécutent — puis chaque tâche récurrente et chaque tâche déjà présente dans la file commence à échouer avec Could not load type. L’assembly est valide. Hangfire ne trouve simplement plus les méthodes qu’il avait notées plus tôt.
La raison est propre à la manière dont Hangfire persiste une tâche, et dès que vous la voyez, la solution est une keep-list courte. Cet article porte sur ce mécanisme : ce que Hangfire écrit exactement dans son stockage, pourquoi renommer une classe de tâche casse la résolution et non le code, et comment protéger l’intérieur avec Nebula.NET sans toucher aux noms qu’un worker relit.
Pourquoi obfusquer une application Hangfire
Les méthodes de tâche sont minces, mais le travail qu’elles orchestrent est le produit : la facturation, la logique de relance d’impayés, le recalcul de risque, le rapprochement de règlements, l’export de tarifs. Ce code s’exécute sans interface sur un serveur, souvent administré par votre client, et l’artefact déployé est un DLL que quiconque a accès aux fichiers peut ouvrir dans un décompilateur et relire en C# propre. La couche de fond est aussi là où se regroupent les constantes sensibles — URLs d’endpoints sortants, clés de partenaires, noms de files et d’en-têtes — parce que personne ne la regarde. Protéger votre propre code distribué ici est précisément le cas légitime de l’obfuscation : élever le coût de la relecture de votre logique métier depuis le binaire que vous avez livré.
Ce que Hangfire écrit réellement dans le stockage
Voici une classe de tâche ordinaire et les deux façons dont elle est planifiée :
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);
Le point crucial est ce que les appels Enqueue et AddOrUpdate ne stockent pas. Ils ne stockent ni délégué, ni référence, ni jeton que le runtime pourrait suivre jusqu’à votre méthode. Hangfire prend l’expression j => j.ProcessOrder(80421), la réduit à un descripteur Job et sérialise cela dans son stockage (SQL Server, PostgreSQL, Redis — le magasin est le vôtre et générique) sous forme d’un petit enregistrement JSON appelé InvocationData. Pour l’appel fire-and-forget ci-dessus, cela ressemble à ceci :
{
"Type": "Contoso.Billing.Jobs.InvoiceJobs, Contoso.Billing",
"Method": "ProcessOrder",
"ParameterTypes": "[\"System.Int64, System.Private.CoreLib\"]",
"Arguments": "[\"80421\"]"
}
Quatre chaînes. Type est le nom de type pleinement qualifié et qualifié par assembly. Method est le nom de méthode en texte brut. ParameterTypes est un tableau JSON de noms de types de paramètres qualifiés par assembly, utilisé pour désambiguïser les surcharges. Arguments contient les valeurs d’arguments sérialisées. Aucun jeton de métadonnées, aucune identité de type — seulement des noms.
Quand un worker récupère la tâche, il inverse le processus. Conceptuellement :
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
Voilà tout le piège. Ces chaînes ont été écrites à la mise en file et ne changent jamais. L’obfuscation s’exécute après la compilation, sur le DLL, et si le renommage transforme InvoiceJobs en a et ProcessOrder en b, l’InvocationData stocké reste intact — il nomme toujours les originaux — mais le type et la méthode qu’il nomme n’existent plus. Type.GetType renvoie null, la recherche de méthode échoue et Hangfire lève une JobLoadException : Could not load type ‘Contoso.Billing.Jobs.InvoiceJobs’ ou The type does not contain a method with signature ‘ProcessOrder’. Rien ne cloche dans l’assembly ; le nom que la tâche stockée devait trouver n’est simplement plus là.
Pourquoi les tâches récurrentes et différées sont le tranchant le plus affûté
Une tâche fire-and-forget est le cas indulgent : elle est mise en file et s’exécute quelques secondes plus tard, sur le même déploiement, de sorte que les chaînes du stockage et le code qu’elles nomment concordent. Vous pourriez renommer sur cet intervalle et la plupart des tâches seraient déjà terminées.
Les tâches récurrentes et les tâches différées longues (planifiées) sont celles qui mordent. La définition d’une tâche récurrente est écrite une fois dans le hash RecurringJob puis vit dans le stockage indéfiniment, remise en file selon sa planification cron par chaque déploiement futur de votre application. Une tâche différée par Schedule reste dans l’ensemble planifié jusqu’à son échéance — ce qui peut être des heures ou des jours. Toutes deux survivent couramment au déploiement qui les a créées, elles sont donc résolues par un worker qui exécute du code plus récent. C’est précisément la situation déploiement-N-écrit, déploiement-N+1-lit, et c’est là que renommer la classe de tâche transforme en silence une release au vert en une pile d’échecs Could not load type le lendemain matin.
L’audit : ce qui est renommé, ce qui ne l’est pas
Parcourez chaque nom que Hangfire résout depuis le stockage et la keep-list s’écrit d’elle-même.
- Les classes de tâches —
Contoso.Billing.Jobs.InvoiceJobs. La chaîneTypede chaqueInvocationDatales nomme. Conservez les noms de type. - Les méthodes qu’invoque Hangfire —
ProcessOrder,GenerateDailyReportet tout ce qui est référencé par une expression deRecurringJob.AddOrUpdateou d’Enqueue/Schedule. La chaîneMethodnomme chacune. Conservez ces noms de méthode. Conserver la classe ne suffit pas ; la recherche de méthode se fait aussi par nom. - Les types de paramètres vous appartenant — si une méthode de tâche prend l’un de vos types plutôt qu’un
longou unstring, le nom qualifié par assembly de ce type est écrit dansParameterTypes, et la recherche de méthode le résout. Conservez tout type vous appartenant utilisé comme paramètre d’une méthode de tâche (et conservez les noms de ses propriétés sérialisées, car Hangfire désérialise aussi la valeur de l’argument). Les types du framework commeSystem.Int64ne sont pas à vous pour les renommer et ne nécessitent aucune règle. - Les chaînes d’identifiant de tâche —
"daily-invoice-report"est un littéral que vous passez ; c’est une donnée, pas un symbole, et le chiffrement de chaînes laisse intacte l’identité de la tâche récurrente car Hangfire compare la clé stockée, que votre code reproduit au runtime de la même manière. Aucune action nécessaire. - Le constructeur et la DI — Hangfire active le type de tâche via son
JobActivator, résolvant les dépendances par type. Le renommage préserve l’identité de type. Renommez librement. - Tout ce que la tâche invoque —
IInvoiceService,InvoiceService.Settle, les aides privées, l’algorithme. Rien à l’extérieur du processus ne les nomme. Renommez, chiffrez, virtualisez.
Deux coutures à conserver, donc — les types de tâches et les méthodes qu’invoque Hangfire (plus tout type de paramètre vous appartenant) — et tout le reste est terrain libre.
La keep-list
Modélisez les classes de tâches sous un unique espace de noms pour qu’une seule règle les couvre, et nebula.config.json devient :
{
"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" }
]
}
}
La première règle épingle chaque type sous Contoso.Billing.Jobs et ses membres, de sorte que chaque méthode invoquée conserve le nom qu’attend l’InvocationData stocké — scope: "type-and-members" est ce qui préserve ProcessOrder, pas seulement la classe. La seconde conserve les membres des types DTO que toute méthode de tâche prend en paramètre, pour que les noms de types de paramètres de ParameterTypes résolvent et que les propriétés de l’argument sérialisé se lient toujours, tandis que les noms de type eux-mêmes peuvent être renommés si rien d’autre ne les nomme. Tout le reste de l’assembly est renommé.
Si vous préférez ne pas dépendre d’une convention d’espace de noms, annotez les handlers directement — l’attribut voyage avec le code et ne peut pas se désynchroniser d’un motif :
[Obfuscation(Exclude = true, ApplyToMembers = true)] // keep this job's names for Hangfire resolution
public sealed class InvoiceJobs { /* ... */ }
Conserver les noms plutôt qu’un résolveur de types personnalisé
Hangfire expose bien un point de résolution : vous pouvez enregistrer un résolveur de types personnalisé avec GlobalConfiguration.Configuration.UseTypeResolver(...) et, en principe, traduire les anciennes chaînes de type stockées vers les types renommés. Résistez. Cette approche couple votre application en cours d’exécution à la table de renommage de l’obfuscateur, doit rester correcte à chaque renommage futur, et doit encore gérer les chaînes de Method et de ParameterTypes — pas seulement celle du type. La keep-list est une règle de deux lignes qui ne dérive jamais et ne vous coûte que les noms d’une poignée de types d’orchestration. Conserver les noms est la voie robuste ; le point du résolveur de types est un dernier recours pour une base de code qui ne peut véritablement pas les conserver.
Vider la file pendant le déploiement — en complément, pas en substitut
Une bonne pratique de déploiement resserre encore la fenêtre : cessez de mettre en file du nouveau travail, laissez les workers terminer ce qui est en cours, et ne publiez la nouvelle compilation qu’ensuite. Pour les tâches fire-and-forget transitoires, cela vide la seule catégorie qui pourrait chevaucher le déploiement. Mais cela ne vous sauve pas à soi seul — la définition d’une tâche récurrente et une tâche planifiée à plusieurs jours persistent toutes deux dans le stockage à travers le vidage, le nouveau code les relit donc quand même. Le vidage réduit l’exposition transitoire ; conserver les noms est ce qui fait survivre les tâches persistantes. Faites les deux.
Intégrer la protection dans la compilation
Protégez la sortie publiée, et uniquement sur le serveur de build pour que le débogage local reste rapide et lisible :
dotnet publish Contoso.Billing -c Release -o publish
nebula protect --config nebula.config.json
Accrochez-le après le publish en CI pour que l’artefact déployé soit toujours le protégé :
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Distribuez le dossier protected/, pas le dossier de publish brut. nebula protect renvoie un code de sortie non nul en cas d’échec, de sorte qu’une étape de protection cassée fait échouer la compilation au lieu de distribuer un DLL non protégé — ou pire, un DLL qui a renommé une classe de tâche que votre stockage référence encore.
Protéger l’intérieur
Conserver le nom d’une méthode ne signifie pas laisser son corps lisible. La tâche reste résoluble tandis que tout ce qu’elle fait devient opaque. Le chiffrement de chaînes masque les URLs d’endpoints, les clés et les noms de files qu’accumule la couche de fond. Les transformations les plus fortes — virtualisation de code et chiffrement de méthode complète, toutes deux fonctionnalités Pro dans Nebula.NET — vont sur les joyaux de la couronne : la logique de règlement, de scoring ou de rapprochement qu’enveloppent les tâches, appliquée à cette poignée de méthodes plutôt qu’à tout l’assembly. Vous pouvez même virtualiser le corps d’une méthode de tâche tout en conservant son nom :
public sealed class InvoiceService : IInvoiceService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public void Settle(long orderId) { /* ... */ }
}
Le renommage et l’obfuscation de flux de contrôle s’exécutent largement en dessous des deux, transformant le reste de l’intérieur en symboles sans signification et en branches enchevêtrées.
Vérifier que cela fonctionne vraiment
Les tests unitaires ne détecteront pas cela : ils appellent ProcessOrder directement contre le projet, jamais à travers un InvocationData stocké contre le DLL protégé. Deux vérifications le feront. D’abord, exécutez-le de bout en bout contre le stockage réel — mettez en file et, surtout, enregistrez une tâche récurrente sur la compilation d’avant obfuscation, déployez la compilation protégée par-dessus ce même stockage, et confirmez que le worker résout et exécute la tâche déjà persistée. Une tâche qui échoue avec Could not load type seulement après le déploiement protégé est la signature exacte d’une règle keep oubliée.
Ensuite, ouvrez l’assembly distribué dans un décompilateur et lisez-le comme le fera un attaquant. Glass.NET est le compagnon gratuit et purement statique pour cette passe : chargez le DLL protégé, confirmez que Contoso.Billing.Jobs.InvoiceJobs.ProcessOrder est toujours présent par son nom — il doit l’être, pour correspondre à ce qui est dans le stockage — puis confirmez qu’il ne reste rien à lire à l’intérieur ni dans l’InvoiceService qu’il invoque.
À retenir
Hangfire retrouve votre travail de fond via des chaînes qu’il a écrites plus tôt dans le stockage — le nom de type pleinement qualifié, le nom de méthode et les types de paramètres — et les résout en un MethodInfo par réflexion lorsqu’un worker exécute la tâche, souvent sur un déploiement ultérieur à celui qui l’a mise en file. Renommez la classe de tâche ou la méthode et vous la sortez de sous ces chaînes stockées ; le worker, lisant l’InvocationData inchangé, échoue à charger le type avant que votre code ne s’exécute. Les tâches récurrentes et différées longues font de cela la première chose à casser, car elles survivent au déploiement qui les a créées. Conservez exactement ce que le stockage nomme — les classes de tâches, les méthodes qu’invoque Hangfire et tout type de paramètre vous appartenant —, videz la file transitoire pendant le déploiement en complément, et confiez tout l’intérieur à Nebula.NET pour renommer, chiffrer les chaînes et virtualiser. Exécutez ensuite une tâche persistée avec la compilation protégée, observez-la résoudre et se terminer, et lisez le DLL distribué pour confirmer que la logique derrière elle a disparu.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.