Skip to content
← Todas las publicaciones
· Delta1 Labs ObfuscationNebula

Ofuscar una aplicación Hangfire sin romper los trabajos en segundo plano persistidos

Hangfire serializa en su almacenamiento cada trabajo encolado, programado o recurrente como el nombre de tipo totalmente cualificado, el nombre del método y los tipos de parámetro, y luego lo resuelve de vuelta a un MethodInfo por reflexión cuando un worker lo ejecuta, posiblemente en un despliegue posterior. Si ofuscas después de compilar, el renombrado saca la clase del trabajo o su método de debajo de esas cadenas almacenadas, de modo que todos los trabajos en curso y recurrentes fallan al deserializarse con un error de tipo no encontrado. Este artículo explica exactamente qué escribe Hangfire en su almacenamiento, por qué los trabajos recurrentes y los diferidos largos son el filo más afilado, y cómo fijar las clases de trabajo, los métodos invocados y sus tipos de parámetro con una keep-list de Nebula.NET renombrando todo lo demás.

Una aplicación Hangfire parece un objetivo de ofuscación fácil. Las clases de trabajo son fontanería; el código valioso es lo que invocan. Pero Hangfire tiene una costura en la que una pasada de ofuscación ingenua se mete de cabeza, y el fallo no se parece en nada a un error de ofuscación: la app se despliega, el panel está en verde, los trabajos nuevos se ejecutan — y entonces cada trabajo recurrente y cada trabajo que ya estaba en la cola empieza a fallar con Could not load type. El ensamblado es válido. Hangfire simplemente no encuentra los métodos que anotó antes.

El motivo es específico de cómo Hangfire persiste un trabajo, y en cuanto lo ves la solución es una keep-list corta. Este artículo trata de ese mecanismo: qué escribe exactamente Hangfire en su almacenamiento, por qué renombrar una clase de trabajo rompe la resolución y no el código, y cómo proteger el interior con Nebula.NET sin tocar los nombres que un worker lee de vuelta.

Por qué ofuscar una aplicación Hangfire

Los métodos de trabajo son delgados, pero el trabajo que orquestan es el producto: la facturación, la lógica de reclamación de impagos, el recálculo de riesgo, la conciliación de liquidaciones, la exportación de precios. Ese código se ejecuta sin interfaz en un servidor, a menudo uno que administra tu cliente, y el artefacto desplegado es un DLL que cualquiera con acceso a los archivos puede abrir en un descompilador y leer de vuelta como C# limpio. La capa de segundo plano es también donde se agrupan las constantes sensibles —URLs de endpoints de salida, claves de socios, nombres de colas y cabeceras— porque nadie la mira. Proteger tu propio código distribuido aquí es precisamente el caso legítimo de la ofuscación: elevar el coste de leer tu lógica de negocio de vuelta desde el binario que entregaste.

Qué escribe realmente Hangfire en el almacenamiento

Aquí tienes una clase de trabajo corriente y las dos formas en que se programa:

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);

Lo crucial es lo que las llamadas Enqueue y AddOrUpdate no almacenan. No almacenan un delegado, una referencia ni un token que el runtime pueda seguir de vuelta a tu método. Hangfire toma la expresión j => j.ProcessOrder(80421), la reduce a un descriptor Job y serializa eso a su almacenamiento (SQL Server, PostgreSQL, Redis — el almacén es tuyo y genérico) como un pequeño registro JSON llamado InvocationData. Para la llamada fire-and-forget de arriba se ve así:

{
  "Type": "Contoso.Billing.Jobs.InvoiceJobs, Contoso.Billing",
  "Method": "ProcessOrder",
  "ParameterTypes": "[\"System.Int64, System.Private.CoreLib\"]",
  "Arguments": "[\"80421\"]"
}

Cuatro cadenas. Type es el nombre de tipo totalmente cualificado y cualificado por ensamblado. Method es el nombre del método como texto plano. ParameterTypes es un array JSON de nombres de tipo de parámetro cualificados por ensamblado, usado para desambiguar sobrecargas. Arguments guarda los valores de argumento serializados. No hay token de metadatos, no hay identidad de tipo — solo nombres.

Cuando un worker recoge el trabajo, invierte el proceso. Conceptualmente:

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

Esa es toda la trampa. Esas cadenas se escribieron al encolar y nunca cambian. La ofuscación se ejecuta después de compilar, sobre el DLL, y si el renombrado convierte InvoiceJobs en a y ProcessOrder en b, el InvocationData almacenado queda intacto —sigue nombrando a los originales— pero el tipo y el método que nombra ya no existen. Type.GetType devuelve null, la búsqueda del método falla y Hangfire lanza una JobLoadException: Could not load type ‘Contoso.Billing.Jobs.InvoiceJobs’ o The type does not contain a method with signature ‘ProcessOrder’. Nada falla en el ensamblado; el nombre que el trabajo almacenado debía encontrar simplemente ya no está ahí.

Por qué los trabajos recurrentes y diferidos son el filo más afilado

Un trabajo fire-and-forget es el caso indulgente: se encola y se ejecuta segundos después, en el mismo despliegue, así que las cadenas del almacenamiento y el código que nombran concuerdan. Podrías renombrar en esa brecha y la mayoría de los trabajos ya estarían hechos.

Los trabajos recurrentes y los diferidos largos (programados) son los que muerden. La definición de un trabajo recurrente se escribe una vez en el hash RecurringJob y luego vive en el almacenamiento indefinidamente, re-encolada según su programación cron por cada despliegue futuro de tu app. Un trabajo diferido con Schedule permanece en el conjunto programado hasta que llega su momento — lo que puede ser horas o días. Ambos sobreviven rutinariamente al despliegue que los creó, así que los resuelve un worker que ejecuta código más nuevo. Esa es precisamente la situación despliegue-N-escribe, despliegue-N+1-lee, y es donde renombrar la clase del trabajo convierte en silencio una release en verde en una pila de fallos Could not load type a la mañana siguiente.

deploy N — enqueue writes names as stringsEnqueue<InvoiceJobs>(j => j.ProcessOrder(80421))serializeHangfire storage · InvocationDataType: ...InvoiceJobs, Contoso.BillingMethod: ProcessOrder · ParameterTypesrecurring / delayed:read on a later deploydeploy N+1 — worker resolves the strings by reflectionType.GetType("...InvoiceJobs") · GetMethod("ProcessOrder", [long]) · Invokerenamed → a / bGetType returns nullJobLoadException: Could not load typekept → InvoiceJobs / ProcessOrderresolves · job runsinterior still renamed · encrypted · virtualized

La auditoría: qué se renombra y qué no

Recorre cada nombre que Hangfire resuelve desde el almacenamiento y la keep-list se escribe sola.

  • Las clases de trabajo — Contoso.Billing.Jobs.InvoiceJobs. La cadena Type de cada InvocationData las nombra. Conserva los nombres de tipo.
  • Los métodos que invoca Hangfire — ProcessOrder, GenerateDailyReport y cualquier cosa referenciada por una expresión de RecurringJob.AddOrUpdate o de Enqueue/Schedule. La cadena Method nombra cada uno. Conserva estos nombres de método. Conservar la clase no basta; la búsqueda del método también es por nombre.
  • Tipos de parámetro propios — si un método de trabajo toma uno de tus tipos en lugar de un long o un string, el nombre cualificado por ensamblado de ese tipo se escribe en ParameterTypes, y la búsqueda del método lo resuelve. Conserva cualquier tipo propio usado como parámetro de un método de trabajo (y conserva los nombres de sus propiedades serializadas, ya que Hangfire también deserializa el valor del argumento). Los tipos del framework como System.Int64 no son tuyos para renombrar y no necesitan regla.
  • Las cadenas de identificador de trabajo — "daily-invoice-report" es un literal que pasas; es dato, no símbolo, y el cifrado de cadenas deja intacta la identidad del trabajo recurrente porque Hangfire compara la clave almacenada, que tu código reproduce en runtime de la misma forma. No hace falta nada.
  • El constructor y la DI — Hangfire activa el tipo de trabajo a través de su JobActivator, resolviendo dependencias por tipo. El renombrado preserva la identidad de tipo. Renombra libremente.
  • Todo lo que el trabajo invoca — IInvoiceService, InvoiceService.Settle, los ayudantes privados, el algoritmo. Nada fuera del proceso los nombra. Renombra, cifra, virtualiza.

Dos costuras que conservar, pues —los tipos de trabajo y los métodos que invoca Hangfire (más cualquier tipo de parámetro propio)— y todo lo demás es terreno libre.

La keep-list

Modela las clases de trabajo bajo un único espacio de nombres para que una sola regla las cubra, y nebula.config.json queda así:

{
  "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 primera regla fija cada tipo bajo Contoso.Billing.Jobs y sus miembros, de modo que cada método invocado conserva el nombre que espera el InvocationData almacenado — scope: "type-and-members" es lo que preserva ProcessOrder, no solo la clase. La segunda conserva los miembros de los tipos DTO que cualquier método de trabajo toma como parámetro, para que los nombres de tipo de parámetro de ParameterTypes resuelvan y las propiedades del argumento serializado sigan enlazando, mientras que los nombres de tipo en sí pueden renombrarse si nada más los nombra. Todo lo demás del ensamblado se renombra.

Si prefieres no depender de una convención de espacio de nombres, anota los handlers directamente — el atributo viaja con el código y no puede desincronizarse de un patrón:

[Obfuscation(Exclude = true, ApplyToMembers = true)]   // keep this job's names for Hangfire resolution
public sealed class InvoiceJobs { /* ... */ }

Conservar nombres frente a un resolvedor de tipos personalizado

Hangfire sí expone un punto de resolución: puedes registrar un resolvedor de tipos personalizado con GlobalConfiguration.Configuration.UseTypeResolver(...) y, en principio, traducir las antiguas cadenas de tipo almacenadas a los tipos renombrados. Resístete. Ese enfoque acopla tu app en ejecución al mapa de renombrado del ofuscador, tiene que seguir siendo correcto en cada renombrado futuro, y además debe manejar las cadenas de Method y ParameterTypes — no solo la del tipo. La keep-list es una regla de dos líneas que nunca se desincroniza y te cuesta solo los nombres de un puñado de tipos de orquestación. Conservar los nombres es el camino robusto; el punto del resolvedor de tipos es un último recurso para una base de código que genuinamente no puede conservarlos.

Vaciar la cola durante el despliegue — como complemento, no como sustituto

Una buena práctica de despliegue estrecha aún más la ventana: deja de encolar trabajo nuevo, deja que los workers terminen lo que tienen en curso y solo entonces publica la nueva compilación. Para los trabajos fire-and-forget transitorios eso vacía la única categoría que podría quedar a caballo del despliegue. Pero no te salva por sí solo — la definición de un trabajo recurrente y un trabajo programado a días vista persisten ambos en el almacenamiento a través del vaciado, así que el nuevo código los lee igualmente de vuelta. El vaciado reduce la exposición transitoria; conservar los nombres es lo que hace que los trabajos persistentes sobrevivan. Haz ambas cosas.

Integrar la protección en la compilación

Protege la salida publicada, y solo en el servidor de compilación para que la depuración local siga siendo rápida y legible:

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

Engánchalo tras el publish en CI para que el artefacto desplegado sea siempre el protegido:

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

Distribuye la carpeta protected/, no la carpeta de publish en bruto. nebula protect devuelve un código de salida distinto de cero en caso de fallo, así que un paso de protección roto hace fallar la compilación en lugar de distribuir un DLL sin proteger — o peor, uno que renombró una clase de trabajo que tu almacenamiento aún referencia.

Proteger el interior

Conservar el nombre de un método no significa dejar su cuerpo legible. El trabajo sigue siendo resoluble mientras todo lo que hace se vuelve opaco. El cifrado de cadenas oculta las URLs de endpoints, las claves y los nombres de colas que acumula la capa de segundo plano. Las transformaciones más fuertes —virtualización de código y cifrado de método completo, ambas funciones Pro en Nebula.NET— van sobre las joyas de la corona: la lógica de liquidación, puntuación o conciliación que envuelven los trabajos, aplicada a ese puñado de métodos en lugar de a todo el ensamblado. Incluso puedes virtualizar el cuerpo de un método de trabajo mientras se conserva su nombre:

public sealed class InvoiceService : IInvoiceService
{
    [Obfuscation(Feature = "virtualization")]   // Pro — the algorithm, not the plumbing
    public void Settle(long orderId) { /* ... */ }
}

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.

Verificar que de verdad funciona

Las pruebas unitarias no detectarán esto: llaman a ProcessOrder directamente contra el proyecto, nunca a través de un InvocationData almacenado contra el DLL protegido. Dos comprobaciones sí lo harán. Primero, ejecútalo de extremo a extremo contra el almacenamiento real — encola y, sobre todo, registra un trabajo recurrente en la compilación previa a la ofuscación, despliega la compilación protegida encima de ese mismo almacenamiento y confirma que el worker resuelve y ejecuta el trabajo ya persistido. Un trabajo que falla con Could not load type solo tras el despliegue protegido es la firma exacta de una regla keep olvidada.

Segundo, abre el ensamblado distribuido en un descompilador y léelo como lo hará un atacante. Glass.NET es el compañero gratuito y solo estático para esa pasada: carga el DLL protegido, confirma que Contoso.Billing.Jobs.InvoiceJobs.ProcessOrder sigue presente por nombre —debe estarlo, para coincidir con lo que hay en el almacenamiento— y luego confirma que no queda nada que leer dentro de él ni en el InvoiceService que invoca.

La conclusión

Hangfire encuentra tu trabajo en segundo plano a través de cadenas que escribió antes en el almacenamiento —el nombre de tipo totalmente cualificado, el nombre del método y los tipos de parámetro— y las resuelve de vuelta a un MethodInfo por reflexión cuando un worker ejecuta el trabajo, a menudo en un despliegue posterior al que lo encoló. Renombra la clase del trabajo o el método y lo sacas de debajo de esas cadenas almacenadas; el worker, que lee el InvocationData sin cambios, falla al cargar el tipo antes de que se ejecute tu código. Los trabajos recurrentes y los diferidos largos hacen que esto sea lo primero que se rompe, porque sobreviven al despliegue que los creó. Conserva exactamente lo que el almacenamiento nombra —las clases de trabajo, los métodos que invoca Hangfire y cualquier tipo de parámetro propio—, vacía la cola transitoria durante el despliegue como complemento, y entrega todo el interior a Nebula.NET para renombrar, cifrar cadenas y virtualizar. Luego ejecuta un trabajo persistido con la compilación protegida, obsérvalo resolver y completarse, y lee el DLL distribuido para confirmar que la lógica que hay detrás ha desaparecido.

Prueba Nebula.NET

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