Skip to content
← Todas las publicaciones
· Delta1 Labs Nebula.NETOfuscación.NETWorker ServiceGuía

Ofuscar un Worker Service de .NET: proteger un worker en segundo plano sin interfaz

Un Worker Service de .NET es el tipo de app más fácil de ofuscar con agresividad y el más fácil de olvidar ofuscar por completo. Se entrega al servidor de un cliente como una DLL, se ejecuta sin interfaz como servicio de Windows o unidad de systemd, y lleva tus secretos de integración y tu lógica de negocio con apenas superficie de reflexión que proteger —lo que significa que puedes renombrar casi todo—. Esta entrada cubre por qué un worker en segundo plano es un objetivo tentador de ingeniería inversa, las pocas costuras del Generic Host que aún leen nombres (enlace de options, escaneo de ensamblados, DTOs serializados) y cómo integrar Nebula.NET en el paso de publicación para que el binario entregado quede renombrado, con cadenas cifradas y virtualizado donde importa.

Un Worker Service es el caso silencioso de la conversación sobre ofuscación. No tiene controladores, ni vistas, ni API pública de la que dependan quienes la llamen —es un BackgroundService con un temporizador, o un IHostedService vaciando una cola, construido sobre el Generic Host e instalado como servicio de Windows o unidad de systemd—. Como no tiene superficie, dos cosas son ciertas a la vez: es el tipo de app de .NET más fácil de ofuscar con agresividad, porque casi nada en él se observa por nombre, y es el más fácil de olvidar, porque “es solo un trabajo en segundo plano en el servidor de nuestro cliente” da la sensación de que no está expuesto. Sí lo está. Esta entrada va de tratar un worker sin interfaz por lo que es —una DLL en manos ajenas— y protegerlo a favor del Generic Host en lugar de en contra.

Por qué merece la pena revertir un worker sin interfaz

Lo que hace de un worker un objetivo tentador es precisamente lo que lo hace sentir seguro: no tiene front end. En una app de escritorio o web, buena parte del código es presentación, fontanería y pegamento de framework, y la lógica valiosa es una fracción. Un worker es casi toda lógica valiosa. Existe para hacer algo sin supervisión —cuadrar libros de noche, revalorar un catálogo según una programación, sondear una API de un socio y transformar los resultados, enviar liquidaciones a una cámara de compensación—. Ese proceso de negocio es el producto, y en un servicio sin interfaz es todo el ensamblado.

También suele llevar las constantes más sensibles que entregas. Un worker se autentica contra los sistemas con los que se integra, así que contiene URLs de endpoint, claves de API, identificadores de inquilino, nombres de cabecera y fragmentos de cadena de conexión —a menudo en la configuración, pero con igual frecuencia en el código como valores por defecto, respaldos o la forma de una petición—. Y como se ejecuta en el servidor de un cliente, a menudo uno que el cliente administra, el atacante tiene acceso físico sin prisa al binario. No está compitiendo con un proceso en marcha; copia Contoso.Worker.dll de la máquina y lo lee con calma. Sin proteger, un decompilador le entrega tu lógica de programación, tus cálculos de precios y tu manual de integración como C# limpio.

observado por nombre — keepoptions enlazadas desde configWorkerOptions.PollInterval por nombreDTOs serializadosnombres de propiedad de cola / APIescaneo de ensambladosautorregistro por patrón de nombreregistradopor tipointerior — renombrar, cifrar, virtualizarWorker / implementaciones de IHostedServiceExecuteAsync resuelto por ranura de vtablelógica de programación y sondeobucle PeriodicTimer, reintento, backoffalgoritmo propietario + puerta de licenciavirtualizar / cifrar estos métodosclientes de integración y secretosURLs de endpoint, claves de API, cabeceras — cadenas cifradas

El worker y dónde van sus nombres

Aquí tienes un worker corriente: un BackgroundService que sondea un feed de precios en un intervalo y revalora un catálogo. No tiene nada inusual, y ese es el punto.

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);   // propietario — la razón de existir de este worker
        }
    }
}

Y su registro en el 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();

Ahora rastrea cada nombre y pregúntate si el framework lo lee en tiempo de ejecución:

  • AddHostedService<Worker>() registra el worker por su tipo, y el host enumera las instancias de IHostedService del contenedor por tipo. Renombra Worker a a y el registro AddHostedService<a>() sigue apuntando al mismo tipo. Seguro.
  • ExecuteAsync es una sobrescritura de BackgroundService.ExecuteAsync. El host la llama a través de la ranura virtual de la clase base, que el renombrado preserva por construcción —la ranura es identidad, no el nombre—. Seguro.
  • La inyección por constructor (ILogger<Worker>, IPricingFeed, IOptions<WorkerOptions>) se resuelve por tipo, exactamente igual que en cualquier contenedor de DI. El renombrado preserva la identidad de tipo, así que la inyección queda intacta. (El único patrón de DI que sí se rompe es el registro por convención —ver abajo.)
  • ServiceName = "Contoso Pricing Worker" y el nombre de la unidad de systemd son cadenas literales. El nombre por el que el Administrador de Control de Servicios conoce al servicio se fija aquí y se registra con sc.exe; no tiene nada que ver con tus nombres de tipo. Seguro.
  • ILogger<Worker> deriva su categoría del nombre de tipo completo, así que tras el renombrado tus logs dicen Category: a.b en lugar de Contoso.Worker.Worker. Inofensivo para el funcionamiento, pero si tienes alertas basadas en logs con clave en la cadena de categoría, mantén el tipo o fija una categoría explícita. Es cosmético, no una ruptura.

Esa es toda la auditoría de un worker llano, y casi todo aterriza en “seguro de renombrar”. Compáralo con ofuscar una app ASP.NET Core, donde el enrutamiento, el enlace de modelos, la serialización y Razor leen nombres: un worker tiene una fracción de la superficie, que es precisamente por lo que puedes ser agresivo con él.

Las tres costuras que aún leen nombres

Tres cosas en un worker sí se resuelven desde nombres, y cada una tiene el mismo arreglo que en todas partes.

Options enlazadas desde la configuración. services.Configure<WorkerOptions>(config.GetSection("Worker")) puebla la clase haciendo coincidir las claves de configuración con los nombres de propiedad a través de ConfigurationBinder, que lee PropertyInfo.Name. Renombra WorkerOptions.PollInterval a a y el enlazador no encuentra ninguna clave PollInterval, deja el valor por defecto, y tu worker corre en silencio sobre su intervalo de respaldo en producción. Nada lanza.

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

Mantén los nombres de miembro de la clase de options —o con una regla de renombrado (abajo) o decorando cada propiedad con [ConfigurationKeyName("pollInterval")], que fija la clave como literal y deja que el miembro se renombre libremente—. Las clases de options son la costura que más se olvida en la ofuscación de workers porque suelen ser POCOs internal sin nada que merezca atributo, así que preservePublicApi las ignora.

DTOs serializados. Si el worker lee de una cola, llama a la API de un socio o escribe en un topic, los DTOs que (des)serializa tienen nombres de propiedad que son un contrato de cable. System.Text.Json construye ese contrato a partir de PropertyInfo.Name; renombra PriceQuote.Symbol a a y el JSON de salida pasa a ser {"a":...} y la deserialización de entrada deja de encontrar Symbol. Fíjalos con [JsonPropertyName(...)] en cada propiedad o mantén el namespace del DTO. La versión general de esta trampa está en ofuscación, reflexión y trampas de serialización.

Registro por convención. Si autorregistras servicios alojados escaneando el ensamblado —un bucle sobre los tipos cuyo nombre termina en Job, o una biblioteca que hace lo mismo— entonces el nombre dirige el registro, y el renombrado borra la convención de modo que no se registra nada. El arreglo es registrar explícitamente (AddHostedService<T>() por cada worker) o escanear por interfaz/atributo marcador en lugar de por nombre. Este es el único patrón de DI en un worker que se rompe de verdad al renombrar, y tiene su propia explicación: ofuscar el escaneo de ensamblados y la inyección de dependencias.

Una nota sobre los programadores de tareas: si usas un framework que persiste el nombre de tipo y el nombre de método de un trabajo en el almacenamiento (para rehidratar trabajo programado entre reinicios), trata esos nombres de tipo y método como un contrato y mantenlos —de lo contrario un trabajo en curso serializado antes de un renombrado no podrá resolverse después.

Proteger el interior: primero las cadenas, luego las joyas de la corona

Todo lo que no esté en esa lista corta es terreno libre, y el interior de un worker es donde está el valor. Dos transformaciones llevan casi todo el peso.

Cifrado de cadenas para los secretos. Las constantes de integración de un worker —FeedEndpoint, claves de API, nombres de cabecera, cualquier URL o token incrustado como valor por defecto— son exactamente lo que un atacante greppea primero. El cifrado de cadenas las almacena cifradas y las descifra solo en el punto de uso, así que una búsqueda en el binario no devuelve nada. (No es magia: el valor se descifra en memoria en tiempo de ejecución, así que un depurador aún puede recuperarlo. Lo que eliminas es el ataque trivial, de esfuerzo cero.)

Las transformaciones más fuertes sobre los pocos métodos que importan. PricingEngine.Reprice, tu núcleo de programación y la puerta de licencia son las joyas de la corona. Reserva la virtualización de código y el cifrado de método completo —ambas funciones Pro en Nebula— para ese puñado de métodos, no para todo el ensamblado: la virtualización reescribe un método como bytecode para una VM incrustada, de modo que no queda IL que decompilar, y el cifrado de método almacena el IL original cifrado y lo reconstruye en tiempo de ejecución. Seleccionas los objetivos explícitamente, lo que mantiene rápido el bucle caliente mientras los cálculos sensibles se vuelven ilegibles:

public static class PricingEngine
{
    [Obfuscation(Feature = "virtualization")]   // Pro — el algoritmo que merece protección
    public static PriceResult Reprice(PriceQuote quote) { /* ... */ }
}

El renombrado y la ofuscación de flujo de control se ejecutan ampliamente por debajo de ambas, convirtiendo el resto del interior en símbolos sin sentido y ramas enredadas.

Integrar Nebula en la compilación

Protege la salida publicada, y hazlo con una config que fije solo las tres costuras. Para un worker dividido en Contoso.Worker (host, options, DTOs) y Contoso.Pricing (el algoritmo), nebula.config.json se ve así:

{
  "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á desactivado, porque nada fuera del proceso consume la superficie pública de este ensamblado —“público” aquí es un accidente de la plantilla de proyecto—. Las dos reglas keep fijan las clases de options y los DTOs serializados; todo lo demás en ambos ensamblados se renombra, se cifra en cadenas y se aplana en flujo de control, con la virtualización aplicada a los métodos que marcaste.

Dispárala desde el paso de publicación. El comando de la CLI es nebula:

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

O engánchala al proyecto para que se ejecute automáticamente tras publicar en el servidor de compilación —y solo en el servidor de compilación, para que el depurado local con F5 siga siendo rápido y legible—:

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

Mantener las compilaciones de desarrollo sin ofuscar y ofuscar solo en CI merece su propia disciplina; el razonamiento está en ofuscar solo en el servidor de compilación.

Verificar el worker protegido

Las pruebas unitarias no atraparán nada de esto —referencian el proyecto y nunca ejecutan protected/—. Dos comprobaciones sí lo harán. Primera, ejecuta el worker protegido como un proceso contra una configuración de staging, déjalo completar un ciclo o dos y comprueba su comportamiento: enlazó sus options (el intervalo de sondeo y el endpoint son distintos del predeterminado, lo que prueba que ninguna opción cayó en silencio a su respaldo), alcanzó la integración, transformó una entrada conocida con la salida esperada y registró un arranque limpio y un apagado grácil ante Ctrl+C / SIGTERM. Segunda, abre el ensamblado entregado en un decompilador y léelo como lo hará un atacante —confirma que los nombres internos desaparecieron, que las cadenas de integración ya no aparecen en texto claro, y que Reprice muestra una llamada a la VM en vez de tu algoritmo—. Glass.NET es el compañero gratuito y de solo análisis estático para exactamente esa comprobación; el procedimiento completo está en ¿de verdad funcionó tu ofuscación?.

La conclusión

Un Worker Service es el caso de ofuscación en el que el framework menos te pelea y en el que lo que está en juego es calladamente alto. El Generic Host resuelve tu worker y sus dependencias por tipo, así que la clase, su ExecuteAsync, sus servicios inyectados y su nombre de Windows/systemd se renombran o sobreviven por su cuenta. Los únicos nombres que lee en tiempo de ejecución son las options enlazadas desde la configuración, las propiedades de los DTOs serializados y cualquier escaneo por convención —fija esas tres cosas y deja que Nebula.NET renombre, cifre las cadenas y virtualice el resto—. Ese “resto” es la lógica de programación, el algoritmo propietario, la puerta de licencia y los secretos de integración: en un worker sin interfaz, es el producto entero, sobre un servidor que no controlas. Protégelo en consecuencia, y luego ejecuta la compilación protegida —no el código fuente— para demostrarlo.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.