Obfuscating a .NET Worker Service: protecting a headless background worker
A .NET Worker Service is the easiest kind of app to obfuscate aggressively and the easiest to forget to obfuscate at all. It ships to a customer's server as a DLL, runs headless as a Windows Service or a systemd unit, and carries your integration secrets and your business logic with almost no reflection surface to protect — which means you can rename nearly everything. This post covers why a background worker is a tempting reverse-engineering target, the handful of Generic Host seams that still read names (options binding, assembly scanning, serialized DTOs), and how to wire Nebula.NET into the publish step so the shipped binary is renamed, string-encrypted and virtualized where it counts.
A Worker Service is the quiet case in the obfuscation conversation. It has no controllers, no views, no public API that callers depend on — it is a BackgroundService on a timer, or an IHostedService draining a queue, built on the Generic Host and installed as a Windows Service or a systemd unit. Because it has no surface, two things are true at once: it is the easiest kind of .NET app to obfuscate aggressively, because almost nothing in it is observed by name, and it is the easiest to forget, because “it’s just a background job on our customer’s server” feels like it isn’t exposed. It is exposed. This post is about treating a headless worker as what it is — a DLL in someone else’s hands — and protecting it with the grain of the Generic Host rather than against it.
Why a headless worker is worth reversing
The thing that makes a worker a tempting target is exactly what makes it feel safe: it has no front end. In a desktop or web app, a lot of the code is presentation, plumbing and framework glue, and the valuable logic is a fraction of it. A worker is almost all valuable logic. It exists to do something unattended — reconcile ledgers overnight, reprice a catalogue on a schedule, poll a partner API and transform the results, push settlements to a clearing house. That business process is the product, and in a headless service it is the whole assembly.
It also tends to carry the most sensitive constants you ship. A worker authenticates to the systems it integrates with, so it holds endpoint URLs, API keys, tenant identifiers, header names and connection-string fragments — often in configuration, but just as often in code as defaults, fallbacks or the shape of a request. And because it runs on a customer’s server, frequently one the customer administers, the attacker has unhurried physical access to the binary. They are not racing a running process; they copy Contoso.Worker.dll off the box and read it at leisure. Left unprotected, a decompiler hands them your scheduling logic, your pricing math and your integration playbook as clean C#.
The worker, and where its names go
Here is an ordinary worker: a BackgroundService that polls a pricing feed on an interval and reprices a catalogue. Nothing about it is unusual, and that is the point.
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); // proprietary — the reason this worker exists
}
}
}
And its registration on the 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();
Now trace every name and ask whether the framework reads it at runtime:
AddHostedService<Worker>()registers the worker by its type, and the host enumeratesIHostedServiceinstances from the container by type. RenameWorkertoaand the registrationAddHostedService<a>()still maps to the same type. Safe.ExecuteAsyncis an override ofBackgroundService.ExecuteAsync. The host calls it through the base class’s virtual slot, which renaming preserves by construction — the slot is identity, not the name. Safe.- Constructor injection (
ILogger<Worker>,IPricingFeed,IOptions<WorkerOptions>) resolves by type, exactly as in any DI container. Renaming preserves type identity, so injection is untouched. (The one DI pattern that does break is registration by convention — see below.) ServiceName = "Contoso Pricing Worker"and the systemd unit name are literal strings. The name the Service Control Manager knows the service by is set here and registered withsc.exe; it has nothing to do with your type names. Safe.ILogger<Worker>derives its category from the full type name, so after renaming your logs readCategory: a.binstead ofContoso.Worker.Worker. Harmless for operation, but if you have log-based alerting keyed on the category string, keep the type or set an explicit category. This is cosmetic, not a breakage.
That is the whole audit for a plain worker, and almost all of it lands on “safe to rename.” Compare that with obfuscating an ASP.NET Core app, where routing, model binding, serialization and Razor all read names: a worker has a fraction of the surface, which is precisely why you can be aggressive with it.
The three seams that still read names
Three things in a worker do resolve from names, and each has the same fix as everywhere else.
Options bound from configuration. services.Configure<WorkerOptions>(config.GetSection("Worker")) populates the class by matching configuration keys to property names through ConfigurationBinder, which reads PropertyInfo.Name. Rename WorkerOptions.PollInterval to a and the binder finds no PollInterval key, leaves the default, and your worker silently runs on its fallback interval in production. Nothing throws.
public sealed class WorkerOptions
{
public TimeSpan PollInterval { get; set; } = TimeSpan.FromMinutes(5);
public string FeedEndpoint { get; set; } = "";
public string ApiKey { get; set; } = "";
}
Keep the options class’s member names — either with a rename rule (below) or by decorating each property with [ConfigurationKeyName("pollInterval")], which pins the key as a literal and lets the member rename freely. Options classes are the single most-forgotten seam in worker obfuscation because they are usually internal POCOs with nothing attribute-worthy, so preservePublicApi ignores them.
Serialized DTOs. If the worker reads from a queue, calls a partner API, or writes to a topic, the DTOs it (de)serializes have property names that are a wire contract. System.Text.Json builds that contract from PropertyInfo.Name; rename PriceQuote.Symbol to a and the outbound JSON becomes {"a":...} and the inbound deserialization stops finding Symbol. Pin these with [JsonPropertyName(...)] on each property or keep the DTO namespace. The general version of this trap is covered in obfuscation, reflection and serialization pitfalls.
Convention-based registration. If you auto-register hosted services by scanning the assembly — a loop over types whose name ends in Job, or a library doing the same — then the name drives the registration, and renaming erases the convention so nothing registers. The fix is to register explicitly (AddHostedService<T>() per worker) or to scan by marker interface/attribute rather than by name. This is the one DI pattern in a worker that genuinely breaks under renaming, and it has its own write-up: obfuscating assembly scanning and dependency injection.
A note on job schedulers: if you use a framework that persists a job’s type name and method name into storage (so it can rehydrate scheduled work across restarts), treat those type and method names as a contract and keep them — otherwise an in-flight job serialized before a rename cannot be resolved after it.
Protecting the interior: strings, then crown jewels
Everything that isn’t on that short list is fair game, and a worker’s interior is where the value sits. Two transforms carry most of the weight.
String encryption for the secrets. A worker’s integration constants — FeedEndpoint, API keys, header names, any URL or token baked in as a default — are exactly what an attacker greps for first. String encryption stores them encrypted and decrypts them only at the point of use, so a search of the binary returns nothing. (It is not magic: the value is decrypted in memory at runtime, so a debugger can still recover it. What you remove is the trivial, zero-effort attack.)
The strongest transforms on the few methods that matter. PricingEngine.Reprice, your scheduling core, and the licence gate are the crown jewels. Reserve code virtualization and whole-method encryption — both Pro features in Nebula — for that handful of methods, not the whole assembly: virtualization rewrites a method into bytecode for an embedded VM so there is no IL left to decompile, and method encryption stores the original IL encrypted and reconstructs it at runtime. You select the targets explicitly, which keeps the hot loop fast while the sensitive math becomes unreadable:
public static class PricingEngine
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm worth protecting
public static PriceResult Reprice(PriceQuote quote) { /* ... */ }
}
Renaming and control-flow obfuscation run broadly underneath both, turning the rest of the interior into meaningless symbols and tangled branches.
Wiring Nebula into the build
Protect the published output, and do it with a config that pins only the three seams. For a worker split into Contoso.Worker (host, options, DTOs) and Contoso.Pricing (the algorithm), nebula.config.json looks like:
{
"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 is off, because nothing outside the process consumes this assembly’s public surface — “public” here is an accident of the project template. The two keep rules pin the options classes and the serialized DTOs; everything else in both assemblies is renamed, string-encrypted and control-flow-flattened, with virtualization applied to the methods you marked.
Drive it from the publish step. The CLI command is nebula:
dotnet publish Contoso.Worker -c Release -o publish
nebula protect --config nebula.config.json
Or hook it into the project so it runs automatically after publish on the build server — and only on the build server, so local F5 debugging stays fast and readable:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Keeping developer builds unobfuscated and obfuscating only in CI is worth its own discipline; the reasoning is in obfuscate on the build server only.
Verifying the protected worker
Unit tests will not catch any of this — they reference the project and never execute protected/. Two checks will. First, run the protected worker as a process against a staging configuration, let it complete a cycle or two, and assert on behaviour: it bound its options (the poll interval and endpoint are non-default, proving no option silently fell back), it reached the integration, it transformed a known input to the expected output, and it logged a clean start and graceful shutdown on Ctrl+C / SIGTERM. Second, open the shipped assembly in a decompiler and read it the way an attacker will — confirm the internal names are gone, the integration strings no longer appear in cleartext, and Reprice shows a VM call rather than your algorithm. Glass.NET is the free, static-only companion for exactly that check; the full procedure is in did your obfuscation actually work?.
The takeaway
A Worker Service is the obfuscation case where the framework fights you least and the stakes are quietly high. The Generic Host resolves your worker and its dependencies by type, so the class, its ExecuteAsync, its injected services and its Windows/systemd name all rename or survive on their own. The only names it reads at runtime are configuration-bound options, serialized DTO properties and any convention-based scanning — pin those three and let Nebula.NET rename, string-encrypt and virtualize the rest. That “rest” is the scheduling logic, the proprietary algorithm, the licence gate and the integration secrets: in a headless worker, it is the entire product, sitting on a server you do not control. Protect it accordingly, then run the protected build — not the source — to prove it.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.