Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETWorker ServiceGuide

Obfusquer un Worker Service .NET : protéger un worker d'arrière-plan sans interface

Un Worker Service .NET est le type d'application le plus facile à obfusquer agressivement et le plus facile à oublier d'obfusquer tout court. Il est livré sur le serveur d'un client sous forme de DLL, s'exécute sans interface comme service Windows ou unité systemd, et porte vos secrets d'intégration et votre logique métier avec presque aucune surface de réflexion à protéger — ce qui signifie que vous pouvez renommer presque tout. Cet article explique pourquoi un worker d'arrière-plan est une cible tentante de rétro-ingénierie, les quelques coutures du Generic Host qui lisent encore des noms (liaison des options, scan d'assembly, DTO sérialisés) et comment brancher Nebula.NET sur l'étape de publication pour que le binaire livré soit renommé, ses chaînes chiffrées et virtualisé là où ça compte.

Un Worker Service est le cas discret de la conversation sur l’obfuscation. Il n’a pas de contrôleurs, pas de vues, pas d’API publique dont dépendent des appelants — c’est un BackgroundService sur un minuteur, ou un IHostedService vidant une file, construit sur le Generic Host et installé comme service Windows ou unité systemd. Parce qu’il n’a pas de surface, deux choses sont vraies à la fois : c’est le type d’application .NET le plus facile à obfusquer agressivement, car presque rien en lui n’est observé par nom, et c’est le plus facile à oublier, car « ce n’est qu’une tâche d’arrière-plan sur le serveur de notre client » donne l’impression qu’il n’est pas exposé. Il l’est. Cet article consiste à traiter un worker sans interface pour ce qu’il est — une DLL entre les mains d’autrui — et à le protéger dans le sens du Generic Host plutôt qu’à son encontre.

Pourquoi un worker sans interface vaut la peine d’être rétro-conçu

Ce qui fait d’un worker une cible tentante est précisément ce qui le fait paraître sûr : il n’a pas de front-end. Dans une application de bureau ou web, une grande partie du code est présentation, plomberie et colle de framework, et la logique de valeur n’en est qu’une fraction. Un worker est presque entièrement logique de valeur. Il existe pour faire quelque chose sans surveillance — rapprocher des grands livres la nuit, revaloriser un catalogue sur une planification, interroger l’API d’un partenaire et transformer les résultats, pousser des règlements vers une chambre de compensation. Ce processus métier est le produit, et dans un service sans interface c’est tout l’assembly.

Il tend aussi à porter les constantes les plus sensibles que vous livrez. Un worker s’authentifie auprès des systèmes avec lesquels il s’intègre, il contient donc des URL d’endpoint, des clés d’API, des identifiants de locataire, des noms d’en-tête et des fragments de chaîne de connexion — souvent dans la configuration, mais tout aussi souvent dans le code sous forme de valeurs par défaut, de replis ou de la forme d’une requête. Et comme il tourne sur le serveur d’un client, fréquemment administré par le client lui-même, l’attaquant a un accès physique sans hâte au binaire. Il ne court pas après un processus en marche ; il copie Contoso.Worker.dll depuis la machine et le lit à loisir. Non protégé, un décompilateur lui remet votre logique de planification, vos calculs de tarification et votre manuel d’intégration en C# propre.

observé par nom — keepoptions liées depuis la configWorkerOptions.PollInterval par nomDTO sérialisésnoms de propriété de file / APIscan d'assemblyauto-enregistrement par motif de nomenregistrépar typeintérieur — renommer, chiffrer, virtualiserWorker / implémentations d'IHostedServiceExecuteAsync résolu par emplacement de vtablelogique de planification et de pollingboucle PeriodicTimer, réessai, backoffalgorithme propriétaire + porte de licencevirtualiser / chiffrer ces méthodesclients d'intégration et secretsURL d'endpoint, clés d'API, en-têtes — chaînes chiffrées

Le worker, et où vont ses noms

Voici un worker ordinaire : un BackgroundService qui interroge un flux de prix sur un intervalle et revalorise un catalogue. Rien d’inhabituel, et c’est là tout l’intérêt.

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);   // propriétaire — la raison d'être de ce worker
        }
    }
}

Et son enregistrement sur le 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();

Tracez maintenant chaque nom et demandez-vous si le framework le lit à l’exécution :

  • AddHostedService<Worker>() enregistre le worker par son type, et l’hôte énumère les instances d’IHostedService du conteneur par type. Renommez Worker en a et l’enregistrement AddHostedService<a>() pointe toujours vers le même type. Sûr.
  • ExecuteAsync est une surcharge de BackgroundService.ExecuteAsync. L’hôte l’appelle via l’emplacement virtuel de la classe de base, que le renommage préserve par construction — l’emplacement est une identité, pas le nom. Sûr.
  • L’injection par constructeur (ILogger<Worker>, IPricingFeed, IOptions<WorkerOptions>) se résout par type, exactement comme dans n’importe quel conteneur de DI. Le renommage préserve l’identité de type, l’injection est donc intacte. (Le seul motif de DI qui casse est l’enregistrement par convention — voir plus bas.)
  • ServiceName = "Contoso Pricing Worker" et le nom de l’unité systemd sont des chaînes littérales. Le nom sous lequel le Gestionnaire de contrôle des services connaît le service est défini ici et enregistré avec sc.exe ; il n’a rien à voir avec vos noms de type. Sûr.
  • ILogger<Worker> dérive sa catégorie du nom de type complet, donc après le renommage vos journaux affichent Category: a.b au lieu de Contoso.Worker.Worker. Sans conséquence pour le fonctionnement, mais si vous avez des alertes basées sur les journaux indexées sur la chaîne de catégorie, conservez le type ou fixez une catégorie explicite. C’est cosmétique, pas une rupture.

C’est tout l’audit d’un worker simple, et presque tout atterrit sur « sûr à renommer ». Comparez avec obfusquer une application ASP.NET Core, où le routage, la liaison de modèle, la sérialisation et Razor lisent tous des noms : un worker a une fraction de la surface, et c’est précisément pourquoi vous pouvez être agressif avec lui.

Les trois coutures qui lisent encore des noms

Trois choses dans un worker se résolvent bien depuis des noms, et chacune a le même correctif que partout ailleurs.

Options liées depuis la configuration. services.Configure<WorkerOptions>(config.GetSection("Worker")) peuple la classe en faisant correspondre les clés de configuration aux noms de propriété via ConfigurationBinder, qui lit PropertyInfo.Name. Renommez WorkerOptions.PollInterval en a et le lieur ne trouve aucune clé PollInterval, laisse la valeur par défaut, et votre worker tourne en silence sur son intervalle de repli en production. Rien ne lève d’exception.

public sealed class WorkerOptions
{
    public TimeSpan PollInterval { get; set; } = TimeSpan.FromMinutes(5);
    public string FeedEndpoint { get; set; } = "";
    public string ApiKey { get; set; } = "";
}

Conservez les noms de membre de la classe d’options — soit avec une règle de renommage (ci-dessous), soit en décorant chaque propriété avec [ConfigurationKeyName("pollInterval")], qui fixe la clé en littéral et laisse le membre se renommer librement. Les classes d’options sont la couture la plus souvent oubliée dans l’obfuscation de worker, car ce sont généralement des POCO internal sans rien qui mérite un attribut, et preservePublicApi les ignore donc.

DTO sérialisés. Si le worker lit depuis une file, appelle l’API d’un partenaire ou écrit dans un topic, les DTO qu’il (dé)sérialise ont des noms de propriété qui sont un contrat de câble. System.Text.Json construit ce contrat à partir de PropertyInfo.Name ; renommez PriceQuote.Symbol en a et le JSON sortant devient {"a":...} et la désérialisation entrante ne trouve plus Symbol. Épinglez-les avec [JsonPropertyName(...)] sur chaque propriété ou conservez le namespace du DTO. La version générale de ce piège est traitée dans obfuscation, réflexion et pièges de sérialisation.

Enregistrement par convention. Si vous auto-enregistrez des services hébergés en scannant l’assembly — une boucle sur les types dont le nom finit par Job, ou une bibliothèque faisant de même — alors le nom pilote l’enregistrement, et le renommage efface la convention de sorte que rien ne s’enregistre. Le correctif est d’enregistrer explicitement (AddHostedService<T>() par worker) ou de scanner par interface/attribut marqueur plutôt que par nom. C’est le seul motif de DI dans un worker qui casse vraiment sous le renommage, et il a sa propre explication : obfusquer le scan d’assembly et l’injection de dépendances.

Une note sur les planificateurs de tâches : si vous utilisez un framework qui persiste le nom de type et le nom de méthode d’une tâche dans le stockage (pour réhydrater le travail planifié entre les redémarrages), traitez ces noms de type et de méthode comme un contrat et conservez-les — sinon une tâche en cours sérialisée avant un renommage ne pourra pas être résolue après.

Protéger l’intérieur : d’abord les chaînes, puis les joyaux de la couronne

Tout ce qui n’est pas sur cette courte liste est du terrain libre, et l’intérieur d’un worker est là où réside la valeur. Deux transformations portent l’essentiel du poids.

Le chiffrement des chaînes pour les secrets. Les constantes d’intégration d’un worker — FeedEndpoint, clés d’API, noms d’en-tête, toute URL ou tout jeton incrusté comme valeur par défaut — sont exactement ce qu’un attaquant grep en premier. Le chiffrement des chaînes les stocke chiffrées et ne les déchiffre qu’au point d’usage, de sorte qu’une recherche dans le binaire ne renvoie rien. (Ce n’est pas magique : la valeur est déchiffrée en mémoire à l’exécution, donc un débogueur peut encore la récupérer. Ce que vous retirez, c’est l’attaque triviale, à effort nul.)

Les transformations les plus fortes sur les quelques méthodes qui comptent. PricingEngine.Reprice, votre cœur de planification et la porte de licence sont les joyaux de la couronne. Réservez la virtualisation de code et le chiffrement de méthode complète — toutes deux fonctions Pro dans Nebula — à cette poignée de méthodes, pas à tout l’assembly : la virtualisation réécrit une méthode en bytecode pour une VM intégrée, de sorte qu’il ne reste aucun IL à décompiler, et le chiffrement de méthode stocke l’IL original chiffré et le reconstruit à l’exécution. Vous sélectionnez les cibles explicitement, ce qui garde la boucle chaude rapide tandis que les calculs sensibles deviennent illisibles :

public static class PricingEngine
{
    [Obfuscation(Feature = "virtualization")]   // Pro — l'algorithme qui mérite protection
    public static PriceResult Reprice(PriceQuote quote) { /* ... */ }
}

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 dénués de sens et en branches emmêlées.

Brancher Nebula sur la build

Protégez la sortie publiée, et faites-le avec une config qui n’épingle que les trois coutures. Pour un worker scindé en Contoso.Worker (hôte, options, DTO) et Contoso.Pricing (l’algorithme), nebula.config.json ressemble à :

{
  "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 est désactivé, car rien hors du processus ne consomme la surface publique de cet assembly — « public » n’est ici qu’un accident du modèle de projet. Les deux règles keep épinglent les classes d’options et les DTO sérialisés ; tout le reste dans les deux assemblies est renommé, ses chaînes chiffrées et son flux de contrôle aplati, avec la virtualisation appliquée aux méthodes que vous avez marquées.

Pilotez-la depuis l’étape de publication. La commande CLI est nebula :

dotnet publish Contoso.Worker -c Release -o publish
nebula protect --config nebula.config.json

Ou accrochez-la au projet pour qu’elle s’exécute automatiquement après la publication sur le serveur de build — et uniquement sur le serveur de build, pour que le débogage local en F5 reste rapide et lisible :

<Target Name="NebulaProtect" AfterTargets="Publish"
        Condition="'$(CI)' == 'true'">
  <Exec Command="nebula protect --config nebula.config.json" />
</Target>

Garder les builds de développement non obfusquées et n’obfusquer qu’en CI mérite sa propre discipline ; le raisonnement est dans obfusquer uniquement sur le serveur de build.

Vérifier le worker protégé

Les tests unitaires n’attraperont rien de tout cela — ils référencent le projet et n’exécutent jamais protected/. Deux vérifications le feront. D’abord, exécutez le worker protégé comme un processus contre une configuration de staging, laissez-le compléter un cycle ou deux, et vérifiez son comportement : il a lié ses options (l’intervalle de polling et l’endpoint sont différents des valeurs par défaut, preuve qu’aucune option n’est retombée en silence sur son repli), il a atteint l’intégration, il a transformé une entrée connue vers la sortie attendue, et il a journalisé un démarrage propre et un arrêt gracieux sur Ctrl+C / SIGTERM. Ensuite, ouvrez l’assembly livré dans un décompilateur et lisez-le comme le fera un attaquant — confirmez que les noms internes ont disparu, que les chaînes d’intégration n’apparaissent plus en clair, et que Reprice affiche un appel à la VM plutôt que votre algorithme. Glass.NET est le compagnon gratuit et purement statique pour exactement cette vérification ; la procédure complète est dans votre obfuscation a-t-elle vraiment marché ?.

À retenir

Un Worker Service est le cas d’obfuscation où le framework vous résiste le moins et où les enjeux sont discrètement élevés. Le Generic Host résout votre worker et ses dépendances par type, donc la classe, son ExecuteAsync, ses services injectés et son nom Windows/systemd se renomment ou survivent d’eux-mêmes. Les seuls noms qu’il lit à l’exécution sont les options liées depuis la configuration, les propriétés des DTO sérialisés et tout scan par convention — épinglez ces trois-là et laissez Nebula.NET renommer, chiffrer les chaînes et virtualiser le reste. Ce « reste », c’est la logique de planification, l’algorithme propriétaire, la porte de licence et les secrets d’intégration : dans un worker sans interface, c’est le produit entier, posé sur un serveur que vous ne contrôlez pas. Protégez-le en conséquence, puis exécutez la build protégée — et non les sources — pour le prouver.

Essayez Nebula.NET

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