Ofuscar una aplicación Quartz.NET sin romper un JobStore persistente
Un JobStore persistente de Quartz.NET escribe en la base de datos el nombre cualificado por ensamblado de cada tipo IJob y lo carga de vuelta por reflexión cuando dispara un trigger, a menudo en un despliegue posterior. Renombra ese tipo y el planificador lanza JobPersistenceException. Esta es la keep-list que lo arregla.
Una aplicación Quartz.NET es tentadora para ofuscar de forma agresiva. El planificador es infraestructura, las clases IJob son finas y el código valioso es lo que invocan. Pero un JobStore persistente 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: el servicio arranca, el planificador se declara sano, los trabajos recién programados se ejecutan — y entonces cada trabajo durable que ya estaba en la base de datos empieza a fallar en el momento del disparo con JobPersistenceException: Couldn't retrieve job because a required type was not found. El ensamblado es válido. Quartz simplemente no encuentra la clase que anotó antes.
El motivo es específico de cómo un JobStore ADO.NET persiste un trabajo, y en cuanto lo ves la solución es una keep-list corta. Esta es una guía práctica de ese mecanismo: qué escribe exactamente Quartz en la base de datos, por qué renombrar una clase IJob rompe la carga y no el código, y cómo proteger el interior con Nebula.NET sin tocar el nombre que el planificador lee de vuelta.
Por qué ofuscar una aplicación Quartz.NET
Los métodos Execute son finos, pero el trabajo que orquestan es el producto: la liquidación nocturna, la pasada de re-puntuación de riesgo, la reindexación del catálogo, la exportación de facturación, la limpieza de retención de datos. Ese código se ejecuta headless en un servidor — a menudo uno que administra tu cliente — y el artefacto desplegado es un DLL que cualquiera con acceso al archivo puede abrir en un decompilador y leer de vuelta como C# limpio. El nivel programado es también donde se acumulan las constantes sensibles: URLs de endpoints internos, claves de socios, fragmentos de cadenas de conexión, nombres de colas y cabeceras, porque nadie está mirando. Proteger tu propio código enviado aquí es exactamente 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 un JobStore persistente
Aquí tienes un trabajo durable corriente y el código que lo programa:
namespace Contoso.Catalog.Jobs;
public sealed class ReindexJob : IJob
{
private readonly ICatalogIndexer _indexer;
public ReindexJob(ICatalogIndexer indexer) => _indexer = indexer;
// Quartz resuelve desde una cadena almacenada el TIPO propietario de este
// método, reconstruye el JobDetail y luego llama a Execute.
public async Task Execute(IJobExecutionContext context)
{
var tenant = context.MergedJobDataMap.GetString("tenant");
await _indexer.Rebuild(tenant); // propietario — la razón de que esto exista
}
}
var job = JobBuilder.Create<ReindexJob>()
.WithIdentity("reindex", "catalog") // JobKey: nombre + grupo, ambos cadenas
.UsingJobData("tenant", "contoso")
.StoreDurably()
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity("nightly", "catalog")
.WithCronSchedule("0 0 2 * * ?") // 02:00 cada día, indefinidamente
.Build();
await scheduler.ScheduleJob(job, trigger);
Lo crucial es lo que ScheduleJob no almacena. Con un RAMJobStore mantendría en memoria el JobDetail vivo — incluido el objeto Type. Con un JobStore ADO.NET (JobStoreTX, el store que adoptas en cuanto necesitas durabilidad o clustering) serializa ese JobDetail a la tabla QRTZ_JOB_DETAILS. La columna JOB_CLASS_NAME recibe el nombre cualificado por ensamblado del tipo del trabajo como texto plano:
QRTZ_JOB_DETAILS
SCHED_NAME JOB_NAME JOB_GROUP JOB_CLASS_NAME
QuartzScheduler reindex catalog Contoso.Catalog.Jobs.ReindexJob, Contoso.Catalog
Una cadena carga la identidad del tipo. El JobKey (reindex / catalog) son datos de direccionamiento que elegiste; el JobDataMap (tenant=contoso) se serializa en la columna JOB_DATA. No hay token de metadatos ni referencia de tipo — solo el nombre.
Cuando el trigger 0 0 2 * * ? dispara, Quartz invierte el proceso. Conceptualmente, dentro del RetrieveJob del JobStore:
string jobClassName = reader["JOB_CLASS_NAME"]; // la cadena almacenada
Type jobType = typeLoadHelper.LoadType(jobClassName); // Type.GetType sobre ese nombre
IJobDetail detail = JobBuilder.Create(jobType) /* + JobDataMap */ .Build();
IJob instance = jobFactory.NewJob(detail, scheduler); // activar por tipo
await instance.Execute(context); // ejecutarlo
Esa es toda la trampa. JOB_CLASS_NAME se escribió en el momento de programar y nunca cambia. La ofuscación se ejecuta después de compilar, sobre el DLL, y si el renombrado convierte ReindexJob en a, el JOB_CLASS_NAME almacenado queda intacto — sigue nombrando Contoso.Catalog.Jobs.ReindexJob — pero el tipo que nombra ya no existe. LoadType devuelve null y Quartz lanza una JobPersistenceException: Couldn’t retrieve job because a required type was not found: Contoso.Catalog.Jobs.ReindexJob. Nada falla en el ensamblado; simplemente el nombre que el trabajo almacenado debía encontrar ya no está ahí.
Por qué los trabajos durables y cron son el filo más afilado
Un trabajo de un solo disparo lanzado segundos después de programarse, en el mismo despliegue, es indulgente: la cadena de JOB_CLASS_NAME y el código que nombra coinciden. Podrías renombrar en ese hueco y el trabajo ya habría terminado.
Los trabajos durables y los programados con cron son los que muerden. Un JobDetail durable (StoreDurably()) persiste en QRTZ_JOB_DETAILS indefinidamente, re-disparado por cada trigger futuro en cada despliegue futuro. Un trigger cron como 0 0 2 * * ? sigue disparando el mismo trabajo almacenado noche tras noche. Ambos sobreviven rutinariamente al despliegue que los creó, así que el disparo que los carga lo sirve un worker que ejecuta código más nuevo. Esa es exactamente la situación de despliegue-N-escribe, despliegue-N+1-lee, y ahí es donde renombrar una clase IJob convierte en silencio una release en verde en una JobPersistenceException a las 02:00 de la mañana siguiente — mucho después de que el despliegue pareciera exitoso. El clustering lo agudiza aún más: cualquier nodo del clúster puede recoger el disparo, así que el nombre almacenado tiene que resolverse contra la compilación que corre en cada nodo.
La auditoría: qué se renombra, qué no
Recorre cada nombre que resuelve un JobStore persistente y la keep-list se escribe sola.
- Los tipos que implementan
IJob—Contoso.Catalog.Jobs.ReindexJob. La cadenaJOB_CLASS_NAMEde cada fila los nombra, y la carga del tipo ocurre antes de llamar aExecute. Conserva los nombres de tipo. - El método
Execute— es un método de interfaz enIJob. Quartz lo llama a través de la interfaz, no por un nombre almacenado, así que no necesita estar en la keep-list para la resolución. Pero como es una implementación pública de interfaz, conservar el tipo del trabajo (type-and-members) lo deja intacto de todos modos, lo cual es inofensivo. Los nombres que deben sobrevivir son los tipos, no una cadena de método independiente — esta es la diferencia clave frente a un planificador basado en despacho por método. - Tipos propios dentro de un
JobDataMapserializado — unJobDataMapde primitivos y cadenas (tenant=contoso) está bien; el cifrado de cadenas y el renombrado nunca tocan esos valores. Pero si colocas uno de tus tipos en el mapa y el store lo serializa, el nombre de ese tipo queda embebido enJOB_DATA. Conserva cualquier tipo propio que pongas en unJobDataMappersistido (y los nombres de sus miembros serializados). Prefiere primitivos en el mapa precisamente para evitar esto. - Las cadenas
JobKeyyTriggerKey—"reindex","catalog","nightly". Son literales que pasas; son datos, no símbolos, y el cifrado de cadenas deja la identidad intacta porque tu código reproduce la misma clave en runtime. No hay que hacer nada. - La activación
IJobFactory/ DI — Quartz activa el trabajo a través de suIJobFactory, resolviendo las dependencias del constructor por tipo. El renombrado preserva la identidad de tipo. Renombra las dependencias libremente. - Todo lo que el trabajo invoca —
ICatalogIndexer,CatalogIndexer.Rebuild, los helpers privados, el algoritmo. Nada fuera del proceso nombra esto. Renombra, cifra, virtualiza.
Una costura que conservar, entonces — los tipos IJob (más cualquier tipo propio que persistas en un JobDataMap) — y todo lo demás es terreno libre.
La keep-list
Modela las clases de trabajo bajo un namespace para que una sola regla las cubra, y nebula.config.json queda así:
{
"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 primera regla fija cada tipo bajo Contoso.Catalog.Jobs para que cada clase IJob conserve el nombre que espera el JOB_CLASS_NAME almacenado; scope: "type-and-members" también deja intacta la firma del cuerpo de Execute, lo que no cuesta nada y mantiene evidente el contrato de la interfaz. Todo lo demás en el ensamblado se renombra. Aquí no hay una cadena de nombre de método que preservar como la necesita un planificador de despacho por método — Quartz direcciona los trabajos por tipo, así que toda la keep-list es esta única línea.
Si prefieres no depender de una convención de namespace, anota los handlers directamente — el atributo viaja con el código y no puede desincronizarse de un patrón:
[Obfuscation(Exclude = true, ApplyToMembers = true)] // conserva el nombre de tipo para la carga del JobStore
public sealed class ReindexJob : IJob { /* ... */ }
Conservar nombres frente a un type-load helper personalizado
Quartz sí expone el punto de resolución: puedes registrar un ITypeLoadHelper personalizado en el planificador y, en principio, traducir los nombres de clase almacenados antiguos a los tipos renombrados. Resístete. Ese enfoque acopla tu planificador en ejecución al mapa de renombrado del ofuscador, tiene que seguir siendo correcto en cada renombrado futuro, y además debe manejar cualquier tipo propio serializado en un JobDataMap persistido. La keep-list es una regla de una línea 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 type-load helper es un último recurso para una base de código que genuinamente no puede conservarlos.
Migrar con seguridad un servicio ya ofuscado
Si estás añadiendo ofuscación a un servicio que ya tiene trabajos durables en una base de datos de producción, no asumas un despliegue limpio. Los trabajos ya escritos en QRTZ_JOB_DETAILS nombran los nombres de tipo actuales, sin proteger. Secuencia el despliegue:
- Aplica primero la keep-list, sin renombrar el namespace de los trabajos — envía una compilación donde
Contoso.Catalog.Jobs.*esté fijado. Los nombres almacenados siguen coincidiendo, nada se rompe, y has probado la regla de conservación contra datos reales. - Confirma que los trabajos durables existentes siguen disparándose contra el store de producción en la compilación protegida (ver la verificación más abajo).
- Solo entonces amplía el renombrado al resto del ensamblado en releases posteriores.
Como la keep-list fija los nombres de tipo de los trabajos desde la primera compilación protegida, el paso 1 es un no-op para el planificador y una pasada de protección completa para todo lo demás — el interior ya está renombrado, con cadenas cifradas y control-flow ofuscado mientras los tipos de los trabajos se quedan en su sitio.
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.Catalog -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>
Envía la carpeta protected/, no la carpeta de publish en crudo. 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 vez de enviar un DLL sin proteger — o peor, uno que renombró una clase IJob que tu base de datos todavía referencia.
Proteger el interior
Conservar el nombre de un tipo no significa dejar legible su cuerpo. El trabajo sigue siendo cargable mientras todo lo que hace se vuelve opaco. El cifrado de cadenas oculta las URLs de endpoints, claves y fragmentos de cadenas de conexión que el nivel programado acumula. 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 reindexación, liquidación o puntuación que los trabajos envuelven, aplicada a ese puñado de métodos en vez de a todo el ensamblado. Puedes incluso virtualizar el cuerpo del servicio que un trabajo invoca mientras el nombre de tipo del trabajo se conserva:
public sealed class CatalogIndexer : ICatalogIndexer
{
[Obfuscation(Feature = "virtualization")] // Pro — el algoritmo, no la fontanería
public Task Rebuild(string tenant) { /* ... */ }
}
El renombrado y la ofuscación de control de flujo corren 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: hacen new ReindexJob(...) directamente y llaman a Execute, nunca a través de un JOB_CLASS_NAME almacenado contra el DLL protegido. Dos comprobaciones sí. Primero, ejecútalo de extremo a extremo contra un store ADO.NET real — programa un trabajo durable y con trigger cron en la compilación previa a la ofuscación, deja que Quartz lo persista, luego despliega la compilación protegida sobre esa misma base de datos y confirma que el planificador carga y dispara el trabajo ya persistido cuando el trigger vence (acorta el cron a segundos para la prueba). Un trabajo que falla con JobPersistenceException solo después del despliegue protegido es la firma exacta de una regla de conservación omitida.
Segundo, abre el ensamblado enviado en un decompilador y léelo como lo hará un atacante. Glass.NET es el compañero gratuito y puramente estático para esa pasada: carga el DLL protegido, confirma que Contoso.Catalog.Jobs.ReindexJob sigue presente por nombre — debe estarlo, para coincidir con JOB_CLASS_NAME — y luego confirma que no queda nada que leer dentro de él ni en el CatalogIndexer que invoca.
La conclusión
Un JobStore persistente de Quartz.NET encuentra tu trabajo programado a través de una cadena que escribió antes en la base de datos — el nombre cualificado por ensamblado del tipo IJob en JOB_CLASS_NAME — y lo carga de vuelta por reflexión cuando dispara un trigger, a menudo en un despliegue posterior al que lo programó. Renombra la clase del trabajo y lo sacas de debajo de esa cadena almacenada; el planificador, leyendo la fila sin cambios, falla al cargar el tipo antes de que tu código se ejecute. Los trabajos durables y con trigger cron hacen que esto sea lo primero que se rompe, porque sobreviven al despliegue que los creó, y el clustering significa que cada nodo tiene que resolver el mismo nombre. Conserva exactamente lo que el store nombra — los tipos IJob, más cualquier tipo propio que persistas en un JobDataMap — secuencia el despliegue para que la keep-list aterrice antes que el renombrado más amplio, y entrega todo el interior a Nebula.NET para renombrar, cifrar cadenas y virtualizar. Luego dispara un trabajo persistido a través de la compilación protegida, míralo cargar y completarse, y lee el DLL enviado para confirmar que la lógica detrás ha desaparecido.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.