Skip to content
← Todas las publicaciones
· Delta1 Labs Nebula.NETOfuscación.NETAnálisis a fondo

Ofuscar una app que se registra por convención: la DI por escaneo de ensamblados se liga a nombres de tipo, así que renombrar desregistra tus servicios

Muchísimas apps .NET nunca escriben explícitamente sus registros de DI. Escanean un ensamblado al arrancar —con Scrutor, o con un bucle de reflexión hecho a mano— y registran cada tipo cuyo nombre termina en "Service", "Handler" o "Repository". Esa convención es una comparación de cadenas contra el nombre del tipo, y la ofuscación por renombrado cambia todos esos nombres, de modo que el predicado t.Name.EndsWith("Handler") no coincide con nada, los servicios nunca se registran en silencio y la app lanza "Unable to resolve service for type ..." en la primera petición: un fallo en tiempo de ejecución con una compilación en verde, lejos de la línea que lo causó. Esta entrada separa las dos formas en que un escaneo puede encontrar un tipo: por su nombre, que no sobrevive al renombrado, y por su forma —asignable a una interfaz marcadora, decorado con un atributo, derivado de una base—, que sí sobrevive, porque son identidades por token de metadatos que el renombrado reescribe de forma consistente en ambos lados. Muestra los registros de Scrutor que se rompen y los equivalentes que sobreviven, explica por qué AsImplementedInterfaces no da problemas mientras que el predicado por nombre sí, traza la línea que traza Nebula, ofrece tanto el refactor recomendado como la regla de exclusión para cuando no puedes refactorizar, y termina con una prueba de humo de arranque que hace fallar la compilación ofuscada rápido en lugar de en la primera petición.

Muchísimas aplicaciones .NET nunca escriben realmente sus registros de servicios. Escanean. Al arrancar, la app recorre un ensamblado, encuentra cada tipo que parece un servicio —por una convención de nombres, normalmente— y lo registra. Scrutor convirtió esto en una línea, y los bucles de reflexión hechos a mano lo hacían mucho antes. Es limpio, es convención sobre configuración, y tiene un modo de fallo específico y silencioso bajo ofuscación: la convención es casi siempre una comparación de cadenas contra el nombre del tipo, y el renombrado cambia todos los nombres.

El resultado es la peor forma que puede tomar un bug. La compilación está en verde: el escaneo es código válido. La app arranca: el escaneo se ejecuta sin error. Y entonces la primera petición pide al contenedor IOrderService, el contenedor nunca ha oído hablar de él, y obtienes Unable to resolve service for type 'IOrderService' apuntando al controlador, muy lejos del escaneo que silenciosamente no registró nada. Esta entrada trata de por qué ocurre eso con Nebula, y del cambio de una línea que lo hace desaparecer.

El registro que se rompe

Esta es la forma que se escribe con más frecuencia: escanea el ensamblado, toma todo lo que termine en una palabra de convención, regístralo contra sus interfaces:

builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))   // <-- string match on the name
        .AsImplementedInterfaces()
        .WithScopedLifetime());

Lee lo que hace realmente ese predicado: al arrancar enumera los tipos del ensamblado y se queda con aquellos cuyo Name termina en los siete caracteres S-e-r-v-i-c-e. Antes de ofuscar eso es OrderService, PricingService, InventoryService. Después de que Nebula los renombre a a, b, c, el predicado pregunta a cada uno «¿termina tu nombre en ‘Service’?» y cada uno responde no. El escaneo se queda con cero tipos, AsImplementedInterfaces no tiene nada que ligar, e IOrderService nunca se registra.

Nada de eso es un bug que el compilador o el ofuscador pudieran detectar. El predicado es válido. El renombrado hizo exactamente lo que se le dijo. Los dos hechos son simplemente incompatibles, y la incompatibilidad solo se hace visible cuando algo intenta resolver un servicio que el escaneo vacío nunca añadió.

La distinción que importa: nombre frente a forma

No todos los escaneos se rompen. La diferencia está en si el escaneo encuentra un tipo por su nombre o por su forma, y esa línea lo es todo. Un nombre es una cadena que el renombrado reescribe, y el literal de cadena en tu predicado no se reescribe con él, así que la comparación queda obsoleta. Una forma —implementa esta interfaz, lleva este atributo, deriva de esta base— es una relación en el grafo de metadatos, y el renombrado es una reescritura consistente de ese grafo: renombra la interfaz y el tipo que la implementa juntos, así que «implementa» sigue cumpliéndose.

// SURVIVES — matches on the implements-relationship, a metadata token identity
.AddClasses(c => c.AssignableTo<ICommandHandler>())

// SURVIVES — matches on an attribute type token; renaming keeps attribute and target in sync
.AddClasses(c => c.WithAttribute<ServiceAttribute>())

// BREAKS — matches on a frozen string the rename cannot reach
.AddClasses(c => c.Where(t => t.Name.EndsWith("Handler")))

// BREAKS WORSE — resolves a type from a name string that no longer names anything
.AddSingleton(Type.GetType("Acme.Orders.OrderService")!)
Dos escaneos, un renombradoOrderServicePricingServiceInventoryServicepasada de renombradonombres → a, b, cenlaces implementsreescritos en sincroníaName.EndsWith("Service")a,b,c vs literal "Service" → 0 coincidenciasnada registrado → resolve lanza excepciónAssignableTo<ICommandHandler>el enlace implements se mantiene → coincideregistrado como antespor qué el escaneo por nombre queda obsoletoEl literal "Service" del predicado está congelado en tu código;el renombrado mueve los nombres de tipo pero no ese literal, asíque la comparación que antes coincidía ahora compara a,b,c con unapalabra sin cambios y no encuentra nada. Compara forma, no grafía.por qué el escaneo por forma sobreviveAssignableTo resuelve a través de losmetadatos implements, que el renombradoreescribió tanto en el tipo como en lainterfaz, así que el enlace se mantiene.

La solución es una línea, y recupera los nombres

Dale a la convención un marcador y escanea por él en lugar de por la grafía. Una interfaz marcadora es lo más limpio, porque no cuesta nada en tiempo de ejecución y dice exactamente lo que la convención quería decir:

public interface IAppService { }                    // marker; renaming keeps it resolvable by token

public sealed class OrderService : IAppService, IOrderService { /* ... */ }
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(c => c.AssignableTo<IAppService>())  // <-- shape, not name
        .AsImplementedInterfaces()
        .WithScopedLifetime());

Ahora el escaneo pregunta a cada tipo «¿implementas IAppService?», lo que los metadatos responden correctamente antes y después de la ofuscación. Nebula es libre de renombrar OrderService, IOrderService y cualquier otro tipo de la capa, porque el escaneo ya no lee ni un solo nombre: lo único de lo que depende es de la relación implementa, y el renombrado la preserva por construcción. Obtienes la ofuscación que querías y el registro por convención que querías, sin solapamiento entre ambos.

Un atributo funciona de forma idéntica si prefieres no añadir una interfaz a la jerarquía del tipo:

[Service(ServiceLifetime.Scoped)]
public sealed class PricingService : IPricingService { /* ... */ }
.AddClasses(c => c.WithAttribute<ServiceAttribute>())

El atributo se compara por su token de tipo, que el renombrado mantiene consistente en la clase del atributo y en cada tipo decorado con él.

Cuando no puedes cambiar el escaneo

A veces el escaneo está en una biblioteca que no es tuya, y filtra por un nombre sobre el que no puedes influir. La vía de escape es conservar los nombres de los que depende el escaneo, pero conservarlos de forma acotada, y entender el coste. Una configuración de Nebula excluye del renombrado únicamente los tipos con nombre de convención:

{
  "inputs": [ "bin/Release/net8.0/Acme.Orders.dll" ],
  "outputDirectory": "protected",

  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "*Service",    "action": "keep"   },
      { "pattern": "*Handler",    "action": "keep"   },
      { "pattern": "*Repository", "action": "keep"   },
      { "pattern": "Acme.Orders.*", "action": "rename" }
    ]
  }
}

Sé consciente del precio: cada regla keep de aquí deja un nombre de clase en los metadatos, y el conjunto de nombres que estás exponiendo es precisamente tu capa de servicios, el mapa de lo que hace la app. Cuanto antes aparece la regla, más prevalece sobre el rename amplio, así que un keep de *Service fija el nombre de todos los servicios. Por eso el refactor es la mejor respuesta siempre que el escaneo sea tuyo: cambiar un predicado de Name.EndsWith a AssignableTo devuelve todo el bloque keep a rename y entrega los nombres de vuelta al ofuscador. Usa la exclusión solo para el escaneo de terceros al que de verdad no puedes llegar.

Fallar rápido en lugar de en la primera petición

La razón por la que este bug es caro es la distancia: el escaneo vacío y la resolución fallida están muy separados. Acorta la distancia con una prueba de humo de arranque que afirme que los servicios que esperas están realmente registrados, y ejecútala contra la compilación ofuscada, porque es la única compilación en la que el escaneo puede salir vacío:

[Fact]
public void Expected_services_are_registered_after_scanning()
{
    using var provider = BuildProductionServiceProvider();   // same Scan(...) as the app

    // Resolve the way the app will — if the scan registered nothing, this throws here,
    // in a test, instead of on the first production request.
    Assert.NotNull(provider.GetService<IOrderService>());
    Assert.NotNull(provider.GetService<IPricingService>());

    // Or assert the count the convention should have produced:
    var handlers = provider.GetServices<ICommandHandler>().ToList();
    Assert.Equal(expected: 7, handlers.Count);
}

BuildServiceProvider(validateOnBuild: true) no detectará esto por sí solo: valida que los registros presentes pueden construirse, y un registro ausente simplemente no está, no es inválido. Tienes que afirmar la presencia de lo que el escaneo debería haber encontrado. Ejecuta esta prueba sobre el ensamblado protegido en CI y un escaneo que el renombrado vació se pone en rojo en la compilación, que es exactamente donde lo quieres, en lugar de en una traza de pila de producción que culpa al controlador.

Conclusión

El registro de DI basado en convención es una comparación de cadenas contra nombres de tipo mucho más a menudo de lo que parece, y la ofuscación por renombrado cambia todos esos nombres mientras deja congelado el literal de cadena de tu predicado, así que el escaneo no registra nada en silencio y la app falla en la primera resolución, a una compilación en verde de distancia de la causa. La solución es hacer que el escaneo coincida por forma en lugar de por grafía: AssignableTo una interfaz marcadora o WithAttribute un atributo marcador, que el renombrado reescribe de forma consistente en todo el grafo de metadatos, de modo que el escaneo sigue encontrando tus tipos se llamen como se llamen. Ese único cambio permite a Nebula renombrar toda tu capa de servicios —los nombres que ofuscaste para ocultar— mientras el registro sigue funcionando. Cuando el escaneo no es tuyo para cambiarlo, fija solo los tipos con nombre de convención mediante reglas keep acotadas y acepta que estás dejando esos nombres visibles. En cualquier caso, demuéstralo con una prueba de arranque que resuelva los servicios esperados contra la compilación ofuscada, para que un escaneo vaciado falle en CI y nunca en producción.

Prueba Nebula.NET

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