Skip to content
← Tous les articles
· Delta1 Labs ObfuscationNebula

Obfusquer une application Quartz.NET sans casser un JobStore persistant

Un JobStore persistant de Quartz.NET écrit en base le nom qualifié par assembly de chaque type IJob et le recharge par réflexion au déclenchement d'un trigger, souvent sur un déploiement ultérieur. Renommez ce type et le planificateur lève une JobPersistenceException. Voici la keep-list qui corrige cela.

Une application Quartz.NET est tentante à obfusquer agressivement. Le planificateur est de l’infrastructure, les classes IJob sont minces, et le code de valeur est ce qu’elles appellent. Mais un JobStore persistant a une couture dans laquelle une passe d’obfuscation naïve fonce tout droit, et l’échec ne ressemble en rien à un bug d’obfuscation : le service démarre, le planificateur se déclare sain, les jobs fraîchement programmés s’exécutent — puis chaque job durable déjà présent en base se met à échouer au moment du déclenchement avec JobPersistenceException: Couldn't retrieve job because a required type was not found. L’assembly est valide. Quartz ne retrouve simplement pas la classe qu’il a notée plus tôt.

La raison est spécifique à la façon dont un JobStore ADO.NET persiste un job, et une fois que vous la voyez, le correctif est une courte keep-list. Voici un guide pratique de ce mécanisme : ce que Quartz écrit exactement en base, pourquoi renommer une classe IJob casse le chargement et non le code, et comment protéger l’intérieur avec Nebula.NET sans toucher au nom que le planificateur relit.

Pourquoi obfusquer une application Quartz.NET

Les méthodes Execute sont minces, mais le travail qu’elles orchestrent est le produit : le règlement nocturne, la passe de re-scoring du risque, la réindexation du catalogue, l’export de facturation, le nettoyage de rétention des données. Ce code s’exécute sans interface sur un serveur — souvent administré par votre client — et l’artefact déployé est une DLL que quiconque a accès au fichier peut ouvrir dans un décompilateur et relire en C# propre. Le niveau planifié est aussi là où se concentrent les constantes sensibles : URLs d’endpoints internes, clés de partenaires, fragments de chaînes de connexion, noms de files et d’en-têtes, parce que personne ne regarde. Protéger votre propre code livré ici est exactement le cas légitime de l’obfuscation — relever le coût de la relecture de votre logique métier depuis le binaire que vous avez remis.

Ce qu’un JobStore persistant écrit réellement

Voici un job durable ordinaire et le code qui le programme :

namespace Contoso.Catalog.Jobs;

public sealed class ReindexJob : IJob
{
    private readonly ICatalogIndexer _indexer;

    public ReindexJob(ICatalogIndexer indexer) => _indexer = indexer;

    // Quartz résout depuis une chaîne stockée le TYPE propriétaire de cette
    // méthode, reconstruit le JobDetail, puis appelle Execute.
    public async Task Execute(IJobExecutionContext context)
    {
        var tenant = context.MergedJobDataMap.GetString("tenant");
        await _indexer.Rebuild(tenant);     // propriétaire — la raison d'être de ceci
    }
}
var job = JobBuilder.Create<ReindexJob>()
    .WithIdentity("reindex", "catalog")       // JobKey : nom + groupe, tous deux chaînes
    .UsingJobData("tenant", "contoso")
    .StoreDurably()
    .Build();

var trigger = TriggerBuilder.Create()
    .WithIdentity("nightly", "catalog")
    .WithCronSchedule("0 0 2 * * ?")          // 02:00 chaque jour, indéfiniment
    .Build();

await scheduler.ScheduleJob(job, trigger);

L’essentiel est ce que ScheduleJob ne stocke pas. Avec un RAMJobStore, il garderait en mémoire le JobDetail vivant — y compris l’objet Type. Avec un JobStore ADO.NET (JobStoreTX, le store que vous adoptez dès que vous avez besoin de durabilité ou de clustering), il sérialise ce JobDetail dans la table QRTZ_JOB_DETAILS. La colonne JOB_CLASS_NAME reçoit le nom qualifié par assembly du type du job en texte brut :

QRTZ_JOB_DETAILS
  SCHED_NAME     JOB_NAME   JOB_GROUP   JOB_CLASS_NAME
  QuartzScheduler  reindex    catalog     Contoso.Catalog.Jobs.ReindexJob, Contoso.Catalog

Une chaîne porte l’identité du type. Le JobKey (reindex / catalog) est une donnée d’adressage que vous avez choisie ; le JobDataMap (tenant=contoso) est sérialisé dans la colonne JOB_DATA. Pas de jeton de métadonnées, pas de référence de type — juste le nom.

Quand le trigger 0 0 2 * * ? se déclenche, Quartz inverse le processus. Conceptuellement, dans le RetrieveJob du JobStore :

string jobClassName = reader["JOB_CLASS_NAME"];                 // la chaîne stockée
Type jobType = typeLoadHelper.LoadType(jobClassName);          // Type.GetType sur ce nom
IJobDetail detail = JobBuilder.Create(jobType) /* + JobDataMap */ .Build();
IJob instance = jobFactory.NewJob(detail, scheduler);          // activer par type
await instance.Execute(context);                               // l'exécuter

Voilà tout le piège. JOB_CLASS_NAME a été écrit au moment de la programmation et ne change jamais. L’obfuscation s’exécute après la compilation, sur la DLL, et si le renommage transforme ReindexJob en a, le JOB_CLASS_NAME stocké reste intact — il nomme toujours Contoso.Catalog.Jobs.ReindexJob — mais le type qu’il nomme n’existe plus. LoadType renvoie null et Quartz lève une JobPersistenceException : Couldn’t retrieve job because a required type was not found: Contoso.Catalog.Jobs.ReindexJob. Rien n’est cassé dans l’assembly ; le nom que le job stocké devait trouver n’y est simplement plus.

déploiement N — ScheduleJob écrit le nom du type comme chaîneJobBuilder.Create<ReindexJob>() .StoreDurably().Build()persisterQRTZ_JOB_DETAILS (store ADO.NET)JOB_CLASS_NAME :...ReindexJob, Contoso.Catalogle trigger cron se déclenchesur un déploiement ultérieurdéploiement N+1 — JobStore.RetrieveJob charge la chaîne par réflexiontypeLoadHelper.LoadType("...ReindexJob") · NewJob(detail) · Executerenommé → aLoadType renvoie nullJobPersistenceException : type introuvableconservé → ReindexJobse charge · Execute s'exécuteintérieur renommé · chiffré · virtualisé

Pourquoi les jobs durables et cron sont le tranchant le plus vif

Un job à un seul déclenchement lancé quelques secondes après sa programmation, sur le même déploiement, est indulgent : la chaîne de JOB_CLASS_NAME et le code qu’elle nomme concordent. Vous pourriez renommer dans cet intervalle et le job serait déjà terminé.

Les jobs durables et ceux programmés par cron sont ceux qui mordent. Un JobDetail durable (StoreDurably()) persiste dans QRTZ_JOB_DETAILS indéfiniment, redéclenché par chaque trigger futur sur chaque déploiement futur. Un trigger cron comme 0 0 2 * * ? continue de déclencher le même job stocké nuit après nuit. Tous deux survivent couramment au déploiement qui les a créés, donc le déclenchement qui les charge est servi par un worker exécutant du code plus récent. C’est exactement la situation déploiement-N-écrit, déploiement-N+1-lit, et c’est là que renommer une classe IJob transforme discrètement une release au vert en une JobPersistenceException à 02:00 le lendemain matin — bien après que le déploiement ait paru réussi. Le clustering aiguise encore cela : n’importe quel nœud du cluster peut récupérer le déclenchement, donc le nom stocké doit se résoudre contre le build qui tourne sur chaque nœud.

L’audit : ce qui est renommé, ce qui ne l’est pas

Parcourez chaque nom qu’un JobStore persistant résout, et la keep-list s’écrit d’elle-même.

  • Les types implémentant IJob — Contoso.Catalog.Jobs.ReindexJob. La chaîne JOB_CLASS_NAME de chaque ligne les nomme, et le chargement du type se produit avant l’appel à Execute. Conservez les noms de type.
  • La méthode Execute — c’est une méthode d’interface sur IJob. Quartz l’appelle via l’interface, pas par un nom stocké, donc elle n’a pas besoin de figurer dans la keep-list pour la résolution. Mais comme c’est une implémentation publique d’interface, conserver le type du job (type-and-members) la laisse intacte de toute façon, ce qui est inoffensif. Les noms qui doivent survivre sont les types, pas une chaîne de méthode autonome — c’est la différence clé avec un planificateur à dispatch par méthode.
  • Types propres dans un JobDataMap sérialisé — un JobDataMap de primitifs et de chaînes (tenant=contoso) va bien ; le chiffrement de chaînes et le renommage ne touchent jamais ces valeurs. Mais si vous placez l’un de vos types dans la map et que le store le sérialise, le nom de ce type est intégré dans JOB_DATA. Conservez tout type propre que vous mettez dans un JobDataMap persisté (et les noms de ses membres sérialisés). Préférez des primitifs dans la map précisément pour éviter cela.
  • Les chaînes JobKey et TriggerKey — "reindex", "catalog", "nightly". Ce sont des littéraux que vous passez ; ce sont des données, pas des symboles, et le chiffrement de chaînes laisse l’identité intacte car votre code reproduit la même clé à l’exécution. Aucune action nécessaire.
  • L’activation IJobFactory / DI — Quartz active le job via sa IJobFactory, résolvant les dépendances du constructeur par type. Le renommage préserve l’identité de type. Renommez les dépendances librement.
  • Tout ce que le job appelle — ICatalogIndexer, CatalogIndexer.Rebuild, les helpers privés, l’algorithme. Rien hors du processus ne nomme cela. Renommez, chiffrez, virtualisez.

Une couture à conserver, donc — les types IJob (plus tout type propre que vous persistez dans un JobDataMap) — et tout le reste est terrain libre.

La keep-list

Modélisez les classes de job sous un seul namespace pour qu’une seule règle les couvre, et nebula.config.json devient :

{
  "schemaVersion": 1,
  "inputs": [
    "publish/Contoso.Catalog.dll"
  ],
  "outputDirectory": "protected",

  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "Contoso.Catalog.Jobs.*", "action": "keep", "scope": "type-and-members" },
      { "pattern": "Contoso.Catalog.*",       "action": "rename" }
    ]
  }
}

La première règle fige chaque type sous Contoso.Catalog.Jobs pour que chaque classe IJob conserve le nom qu’attend le JOB_CLASS_NAME stocké ; scope: "type-and-members" laisse aussi intacte la signature du corps de Execute, ce qui ne coûte rien et garde le contrat de l’interface évident. Tout le reste de l’assembly est renommé. Il n’y a ici aucune chaîne de nom de méthode à préserver comme en a besoin un planificateur à dispatch par méthode — Quartz adresse les jobs par type, donc toute la keep-list tient dans cette unique ligne.

Si vous préférez ne pas dépendre d’une convention de namespace, 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)]   // conserve le nom de type pour le chargement du JobStore
public sealed class ReindexJob : IJob { /* ... */ }

Conserver les noms vs un type-load helper personnalisé

Quartz expose bien le point de résolution : vous pouvez enregistrer un ITypeLoadHelper personnalisé sur le planificateur et, en principe, traduire les anciens noms de classe stockés vers les types renommés. Résistez. Cette approche couple votre planificateur en exécution à la table de renommage de l’obfuscateur, doit rester correcte à chaque renommage futur, et doit en plus gérer tout type propre sérialisé dans un JobDataMap persisté. La keep-list est une règle d’une ligne 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 type-load helper est un dernier recours pour une base de code qui ne peut vraiment pas les conserver.

Migrer en toute sécurité un service déjà obfusqué

Si vous ajoutez l’obfuscation à un service qui a déjà des jobs durables dans une base de production, ne présumez pas un déploiement propre. Les jobs déjà écrits dans QRTZ_JOB_DETAILS nomment les noms de type actuels, non protégés. Séquencez le déploiement :

  1. Appliquez d’abord la keep-list, sans renommer le namespace des jobs — livrez un build où Contoso.Catalog.Jobs.* est figé. Les noms stockés concordent toujours, rien ne casse, et vous avez prouvé la règle de conservation contre des données réelles.
  2. Confirmez que les jobs durables existants se déclenchent toujours contre le store de production sur le build protégé (voir la vérification plus bas).
  3. Seulement alors, élargissez le renommage au reste de l’assembly dans les releases suivantes.

Comme la keep-list fige les noms de type des jobs dès le premier build protégé, l’étape 1 est un no-op pour le planificateur et une passe de protection complète pour tout le reste — l’intérieur est déjà renommé, avec les chaînes chiffrées et le flux de contrôle obfusqué, tandis que les types des jobs restent en place.

Intégrer la protection au build

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.Catalog -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>

Livrez le dossier protected/, pas le dossier de publish brut. nebula protect renvoie un code de sortie non nul en cas d’échec, donc une étape de protection cassée fait échouer le build au lieu de livrer une DLL non protégée — ou pire, une qui a renommé une classe IJob que votre base référence encore.

Protéger l’intérieur

Conserver le nom d’un type ne signifie pas laisser son corps lisible. Le job reste chargeable tandis que tout ce qu’il fait devient opaque. Le chiffrement de chaînes masque les URLs d’endpoints, clés et fragments de chaînes de connexion que le niveau planifié accumule. Les transformations les plus fortes — virtualisation de code et chiffrement de méthode entière, toutes deux des fonctions Pro dans Nebula.NET — vont sur les joyaux de la couronne : la logique de réindexation, de règlement ou de scoring que les jobs enveloppent, appliquée à cette poignée de méthodes plutôt qu’à tout l’assembly. Vous pouvez même virtualiser le corps du service qu’un job appelle tandis que le nom de type du job est conservé :

public sealed class CatalogIndexer : ICatalogIndexer
{
    [Obfuscation(Feature = "virtualization")]   // Pro — l'algorithme, pas la plomberie
    public Task Rebuild(string tenant) { /* ... */ }
}

Le renommage et l’obfuscation du flux de contrôle s’exécutent largement sous les deux, transformant le reste de l’intérieur en symboles dénués de sens et en branches enchevêtrées.

Vérifier que cela fonctionne vraiment

Les tests unitaires ne détecteront pas cela : ils font new ReindexJob(...) directement et appellent Execute, jamais via un JOB_CLASS_NAME stocké contre la DLL protégée. Deux vérifications le feront. D’abord, exécutez-le de bout en bout contre un store ADO.NET réel — programmez un job durable et à trigger cron sur le build pré-obfuscation, laissez Quartz le persister, puis déployez le build protégé par-dessus cette même base et confirmez que le planificateur charge et déclenche le job déjà persisté à l’échéance du trigger (raccourcissez le cron à quelques secondes pour le test). Un job qui échoue avec JobPersistenceException seulement après le déploiement protégé est la signature exacte d’une règle de conservation manquée.

Ensuite, ouvrez l’assembly livré 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 la DLL protégée, confirmez que Contoso.Catalog.Jobs.ReindexJob est toujours présent par son nom — il doit l’être, pour correspondre à JOB_CLASS_NAME — puis confirmez qu’il ne reste rien à lire à l’intérieur ni dans le CatalogIndexer qu’il appelle.

À retenir

Un JobStore persistant de Quartz.NET retrouve votre travail planifié via une chaîne qu’il a écrite plus tôt en base — le nom qualifié par assembly du type IJob dans JOB_CLASS_NAME — et le recharge par réflexion au déclenchement d’un trigger, souvent sur un déploiement ultérieur à celui qui l’a programmé. Renommez la classe du job et vous le sortez de dessous cette chaîne stockée ; le planificateur, lisant la ligne inchangée, échoue à charger le type avant que votre code ne s’exécute. Les jobs durables et à trigger cron en font la première chose à casser, parce qu’ils survivent au déploiement qui les a créés, et le clustering signifie que chaque nœud doit résoudre le même nom. Conservez exactement ce que le store nomme — les types IJob, plus tout type propre que vous persistez dans un JobDataMap — séquencez le déploiement pour que la keep-list atterrisse avant le renommage plus large, et confiez tout l’intérieur à Nebula.NET pour renommer, chiffrer les chaînes et virtualiser. Puis déclenchez un job persisté via le build protégé, regardez-le se charger et s’achever, et lisez la DLL livrée pour confirmer que la logique derrière a disparu.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.