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

Ofuscar una app ASP.NET Core: controladores, DI, enlace de modelos y qué debe seguir siendo público

ASP.NET Core encuentra casi todo por su nombre en tiempo de ejecución: los tipos de controlador por su sufijo, las acciones por el nombre del método, las rutas desde los tokens [controller] y [action], los parámetros de query y de formulario por los nombres de parámetro, las propiedades JSON por los nombres de propiedad, los métodos de hub por cadenas, las clases de opciones por las claves de configuración. Renombra cualquiera de ellos y la app sigue compilando, sigue arrancando y devuelve 404 o un DTO vacío. Esta entrada cartografía cada costura dirigida por reflexión de una app ASP.NET Core —MVC, Minimal APIs, vistas y tag helpers de Razor, SignalR, el patrón options, servicios alojados—, muestra cómo se ve realmente la metadata renombrada, y construye un nebula.config.json que renombra y endurece los servicios y el dominio mientras fija exactamente la superficie que el framework observa.

ASP.NET Core está construido sobre la reflexión de una forma en que la mayoría de los frameworks de aplicación no lo están. No se limita a usar la reflexión para construir cosas; usa nombres —nombres de tipo, de método, de parámetro, de propiedad— como fuente de verdad para el enrutamiento, el enlace, la serialización y la configuración. Eso lo convierte en el peor caso para un ofuscador que renombra con agresividad, y en un caso muy instructivo para uno que renombra con precisión, porque la costura entre “lo que el framework lee por nombre” y “lo que nadie fuera del proceso ve nunca” es nítida y está bien definida una vez sabes dónde mirar.

Esta entrada recorre toda la costura. Para cada característica del framework mostramos qué descubre por nombre, qué le hace un renombrado ingenuo, y qué fijar. Luego ensamblamos un nebula.config.json que deja en paz la superficie observada y endurece todo lo que hay detrás —los servicios, el dominio, la comprobación de licencia, la lógica de integración—, que es donde vive realmente el código que merece protección.

nombres observados desde fuera — keepenrutamiento: tokens [controller] / [action]sufijo del tipo + nombre del método → URLenlace: nombres de parámetro y propiedad?page=2, campos de formulario, valores de rutaJSON: nombres de propiedad del DTOcontratos de System.Text.Json / NewtonsoftSignalR: nombres de método de hubinvocados por cadena desde el clienteRazor: asp-for → nombres de campoModelExpression lee MemberInfo.Nameoptions: claves de config → propiedadesIConfiguration.Bind por nombre de propiedadDI resuelvepor tipointerno — renombrar, cifrar, aplanarservicios de aplicaciónIOrderService → a, PricingEngine → bmodelo de dominio y reglasentidades, objetos de valor, políticasrepositorios / acceso a datosel mapeo de EF Core se fija apartepuerta de licenciaflujo de control + cadenas cifradasclientes de integración y secretosURLs de endpoint, nombres de cabecera cifradosservicios en segundo plano / alojadosregistrados por tipo — seguros de renombrar

MVC: los controladores y las acciones se encuentran por nombre y se enrutan por nombre

Un controlador MVC se descubre porque su tipo es público, no abstracto, y o bien termina en Controller o lleva [Controller]/[ApiController]. Sus acciones se descubren porque son métodos de instancia públicos. Hasta ahí es solo visibilidad, que el renombrado preserva. El problema es lo que ocurre después: el framework construye las rutas a partir de los nombres.

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orders;
    public OrdersController(IOrderService orders) => _orders = orders;

    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> GetById(int id) => ...;

    [HttpGet("[action]")]
    public async Task<ActionResult<PagedResult<OrderDto>>> Search([FromQuery] int page = 1,
                                                                    [FromQuery] string? customer = null) => ...;
}

La plantilla de ruta api/[controller] se resuelve en el arranque tomando el nombre del tipo de controlador, quitando el sufijo Controller y sustituyendo: api/Orders. El token [action] sustituye el nombre del método: api/Orders/Search. Ambas sustituciones ocurren en la construcción del ApplicationModel a partir de TypeInfo.Name y MethodInfo.Name —lecturas puras de metadata—. Renombra el tipo a b y el método a c y las URLs pasan a ser api/b y api/b/c. La app arranca limpia. Todo cliente recibe 404.

Los parámetros [FromQuery] tienen el mismo problema un nivel más abajo. Sin un Name explícito, el enlazador usa el nombre de metadata del parámetro: ?page=2&customer=acme. Un ofuscador que reescriba los nombres de parámetro rompe el enlace en silencio —el parámetro simplemente se queda en su valor por defecto, que para page es 1 y para customer es null, y nadie ve un error.

Tres cosas mantienen esto funcionando, y deberías hacer las tres con independencia de la ofuscación porque además hacen explícito tu contrato de API:

  1. Prefiere plantillas de ruta explícitas a los tokens: [Route("api/orders")], [HttpGet("search")]. Una cadena literal en un atributo no es un nombre que el ofuscador vea como miembro; sobrevive.
  2. Nombra tus parámetros enlazados donde el nombre sea parte del contrato: [FromQuery(Name = "page")]. El mismo razonamiento.
  3. Mantén los nombres de tipo y acción de los controladores con una regla, para los lugares donde no hiciste 1 y 2. preservePublicApi lo cubre para controladores públicos; una regla de patrón cubre los internos.

El renombrado de Nebula reescribe tipos y miembros. Deja en su sitio los nombres de parámetro de método, así que el paso 2 es defensa en profundidad más que una necesidad —pero lo quieres igual, porque el día en que un compañero refactorice page a pageNumber tu API pública cambia sin que nadie lo note, y un Name explícito es lo que lo evita.

Enlace de modelos y JSON: los nombres de propiedad son el formato de cable

Los nombres de propiedad de un DTO son su JSON. System.Text.Json construye su contrato a partir de PropertyInfo.Name (en camelCase por las opciones web predeterminadas), y el enlace de POSTs de formulario y cadenas de query usa los mismos nombres. Renombra OrderDto.CustomerName a a y el cuerpo de la respuesta es {"a":"Acme"}.

public sealed class OrderDto
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = "";
    public decimal Total { get; set; }
}

Tienes dos opciones correctas. La primera es decorar cada propiedad serializada con [JsonPropertyName("customerName")], momento en el que el nombre del miembro es irrelevante para el formato de cable y puede renombrarse libremente. También es un buen hábito: hace explícito el contrato y sobrevive a cualquier renombrado futuro, de ofuscador o humano. La segunda es mantener los nombres de miembro del DTO con una regla. Para una API grande la regla es menos tecleo; para una pequeña los atributos son más robustos. En cualquier caso, lo que no debe pasar es un DTO que no esté ni atribuido ni mantenido.

Nota la asimetría con el modelo de dominio. Tu entidad Order, PricingPolicy, DiscountRule y el resto nunca se serializan al cable —se mapean a DTOs por código, por acceso a propiedades que el compilador resolvió a tokens de miembro, que el renombrado actualiza de forma consistente—. Esos se pueden renombrar, y ahí es donde está la lógica valiosa.

Minimal APIs: lambdas, closures y los nombres de parámetro del handler

Las Minimal APIs parecen no tener nombres en absoluto, y eso es cierto a medias. El handler es una lambda, que el compilador reduce a un método en una clase de closure —<>c.<<Main>$>b__0_0 o Program.<>c__DisplayClass0_0.<<Main>$>b__0 si capturó algo— y nada de esos nombres importa al enrutamiento porque la ruta es la cadena literal que pasaste a MapGet.

app.MapGet("/api/orders/search",
    async (int page, string? customer, IOrderService orders, CancellationToken ct)
        => TypedResults.Ok(await orders.SearchAsync(page, customer, ct)));

El RequestDelegateFactory del framework inspecciona el MethodInfo de la lambda y, para cada parámetro, decide de dónde viene el valor: un valor de ruta si el nombre coincide con un token de plantilla de ruta, un servicio si el tipo está registrado, el cuerpo si es un tipo complejo, y en otro caso la cadena de query por nombre de parámetro. Así que page y customer se enlazan desde ?page= y ?customer= puramente por cómo se llaman los parámetros de la lambda, y orders se enlaza desde DI puramente por su tipo. Los nombres de parámetro son el contrato; el nombre de la clase de closure es ruido.

Eso pone a las Minimal APIs en buen lugar: no se observa ningún nombre de tipo o miembro, solo nombres de parámetro, que Nebula no reescribe. Hazlos explícitos igualmente —[FromQuery(Name = "page")] int page— por la misma razón de estabilidad de contrato de antes, y deja que las clases de closure se renombren; ya tienen nombres que ningún humano lee.

Vistas y tag helpers de Razor: la model expression lee MemberInfo.Name

Las vistas de Razor compilan a clases C# —en .NET 6+ por defecto a tu ensamblado principal bajo AspNetCoreGeneratedDocument, cada una etiquetada con [RazorCompiledItem(typeof(Views_Orders_Index), "mvc.1.0.view", "/Views/Orders/Index.cshtml")]—. El atributo referencia el tipo por typeof, así que renombrar la clase de la vista es seguro: el token se actualiza, la cadena identificadora (la ruta) es lo que el motor de vistas busca, y es un literal.

Los tag helpers son donde se filtran los nombres. Toma un formulario:

<form asp-action="Create" asp-controller="Orders" method="post">
    <input asp-for="CustomerName" />
    <span asp-validation-for="CustomerName"></span>
    <input asp-for="Total" />
</form>

Ocurren tres cosas, cada una con una respuesta distinta:

  • asp-action="Create" asp-controller="Orders" son cadenas literales resueltas contra la tabla de rutas en tiempo de render —la misma tabla cuyas entradas vinieron de nombres de controlador y acción—. Si esos nombres se mantuvieron (sección anterior), esto funciona; si se renombraron, la generación de enlaces devuelve un atributo action vacío y el formulario hace POST a la URL actual.
  • asp-for="CustomerName" compila a ModelExpressionProvider.CreateModelExpression(ViewData, __model => __model.CustomerName). En tiempo de render el tag helper recorre ese árbol de expresión, lee MemberInfo.Name y emite name="CustomerName" id="CustomerName". Si la propiedad se renombra a a, el HTML emitido dice name="a" —y como el formulario hace POST de vuelta a un enlazador de modelos que también usa el nombre renombrado, el enlace sigue funcionando—. Lo que se rompe es todo lo que referenciaba el campo desde fuera: tu JavaScript haciendo document.getElementById("CustomerName"), tu CSS, tus pruebas end-to-end y las reglas de validación de cliente de asp-validation-for con clave en ese mismo nombre.
  • El propio modelo de vista es una frontera parecida a la serialización: se lee por nombre a través de expresiones, y sus nombres llegan al navegador.

La regla práctica: trata los modelos de vista y de página exactamente como DTOs. Mantén los nombres de sus miembros (o, para Razor Pages, mantén las propiedades [BindProperty] del PageModel), renombra las clases de vista libremente, y nunca referencies un tipo de dominio renombrado desde una vista —las vistas deberían hablar con modelos de vista, que es lo que ya querías.

SignalR: los métodos de hub se invocan por cadena

Un cliente SignalR llama a connection.invoke("PlaceOrder", dto) y el servidor resuelve "PlaceOrder" contra los métodos públicos del hub por nombre. La cadena del cliente no la toca el ofuscador; el método del servidor sí. Renómbralo y toda invocación falla con Method does not exist. Los nombres de cliente a servidor deben mantenerse —preservePublicApi cubre los métodos públicos de hub— y las llamadas de servidor a cliente (Clients.All.SendAsync("OrderUpdated", ...)) son cadenas literales que sobreviven por su cuenta. Los hubs fuertemente tipados (Hub<IOrderClient>) resuelven los nombres de los métodos de cliente a partir de los nombres de miembro de la interfaz, así que la interfaz también debe mantenerse.

Options, configuración y las cosas que olvidaste que se enlazaban por nombre

services.Configure<SmtpOptions>(config.GetSection("Smtp")) puebla SmtpOptions haciendo coincidir las claves de configuración con los nombres de propiedad a través de ConfigurationBinder, que usa PropertyInfo.Name con una comparación sin distinción de mayúsculas. Renombra SmtpOptions.Host a a y el valor enlazado es null. Nada lanza; tus correos simplemente dejan de enviarse en producción. Las clases de opciones son la costura que más se olvida en la ofuscación de apps web porque son internal (así que preservePublicApi las ignora) y parecen POCOs normales sin nada que merezca atributo.

Lo mismo aplica a cualquier otra cosa que lea la configuración por convención: [FromKeyedServices("...")] con un nameof, nombres de health-check derivados de nombres de tipo, nombres de categoría de ILogger<T> (estos pasan a ser a.b en tus logs —inofensivo pero feo; el mapa de renombrado lo resuelve—) e implementaciones de IHostedService (registradas por tipo —seguras).

Juntándolo todo: la config

Aquí tienes un nebula.config.json para una app web en capas típica: MyApp.Web (controladores, DTOs, modelos de vista, hubs, options, arranque), MyApp.Application (servicios), MyApp.Domain (entidades y reglas), MyApp.Infrastructure (EF Core, clientes de integración). La capa web recibe keeps selectivos; las tres capas internas se renombran y endurecen por completo.

{
  "schemaVersion": 1,
  "inputs": [
    "bin/Release/net8.0/publish/MyApp.Web.dll",
    "bin/Release/net8.0/publish/MyApp.Application.dll",
    "bin/Release/net8.0/publish/MyApp.Domain.dll",
    "bin/Release/net8.0/publish/MyApp.Infrastructure.dll"
  ],
  "outputDirectory": "protected",

  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "MyApp.Web.Controllers.*",   "action": "keep" },
      { "pattern": "MyApp.Web.Hubs.*",          "action": "keep" },
      { "pattern": "MyApp.Web.Contracts.*",     "action": "keep" },
      { "pattern": "MyApp.Web.ViewModels.*",    "action": "keep" },
      { "pattern": "MyApp.Web.Pages.*Model",    "action": "keep" },
      { "pattern": "MyApp.Web.Options.*",       "action": "keep" },
      { "pattern": "AspNetCoreGeneratedDocument.*", "action": "rename" },
      { "pattern": "MyApp.Web.*",               "action": "rename" },
      { "pattern": "MyApp.Application.*",       "action": "rename" },
      { "pattern": "MyApp.Domain.*",            "action": "rename" },
      { "pattern": "MyApp.Infrastructure.*",    "action": "rename" }
    ]
  },

  "exclude": [
    "MyApp.Infrastructure.Persistence.Entities.*"
  ]
}

Leyéndolo de arriba abajo: preservePublicApi está desactivado, porque en una app web “público” es en su mayoría un accidente de la plantilla de proyecto y queremos ser explícitos. Las primeras seis reglas fijan exactamente la superficie que el framework observa —controladores, hubs, contratos de petición/respuesta, modelos de vista, modelos de página, options—. Los documentos Razor generados y todo lo demás en la capa web se renombran. Los tres ensamblados internos se renombran por completo, con cifrado de cadenas y ofuscación de flujo de control aplicados a todos ellos —ahí es donde viven la puerta de licencia, el motor de precios y los secretos de integración—. El namespace de entidades de EF Core se excluye del renombrado por las razones que recorre la entrada de Entity Framework (el model builder y el snapshot de migraciones referencian miembros por nombre); todo lo demás en Infrastructure, incluidos los clientes de integración, es terreno libre.

Si prefieres atributos a patrones, [JsonPropertyName] en cada propiedad de DTO y plantillas de ruta explícitas en cada acción te permiten eliminar los keeps de Contracts y Controllers por completo, y la config se encoge en consecuencia.

Verificar la compilación protegida

Las pruebas unitarias no atraparán nada de esto, porque referencian el proyecto y nunca ejecutan el ensamblado ofuscado. WebApplicationFactory<Program> es lo mismo: construye tu app en proceso a partir de la referencia del proyecto de prueba, no desde protected/. Lo que lo atrapa es ejecutar la salida publicada y protegida como un proceso y dirigirla por HTTP.

# CI, after obfuscation
dotnet protected/MyApp.Web.dll --urls http://localhost:5199 --environment Smoke &
sleep 3
dotnet test tests/MyApp.Smoke --filter Category=Protected -- BaseUrl=http://localhost:5199

La suite de humo es deliberadamente pequeña y deliberadamente aburrida: una petición por ruta comprobando estado y forma, un round-trip JSON por DTO afirmando que cada propiedad está presente bajo su nombre esperado, un POST de formulario por página Razor afirmando que el modelo enlazó, una invocación de método de hub, y una aserción de arranque de que cada clase de opciones enlazó un valor distinto del predeterminado desde la configuración de humo. Ejecútala contra la compilación protegida en el mismo pipeline que produce el artefacto, y el token de ruta renombrado o la clase de opciones olvidada falla la compilación en lugar del despliegue de un cliente. Combinado con mantener limpias las compilaciones de desarrollo y ofuscar solo en el servidor de compilación, ese es todo el bucle.

La conclusión

ASP.NET Core lee nombres de una lista corta y bien definida de lugares: tokens de ruta, nombres de parámetro enlazados, nombres de propiedad serializados, nombres de método de hub, expresiones de modelo de Razor y options enlazadas desde la configuración. Cada uno de ellos es o bien un literal que puedes hacer explícito o bien un miembro que mantienes con una regla, y ninguno de ellos es donde está tu código valioso. Fija esa superficie, haz el contrato explícito con atributos donde puedas, y deja que el ofuscador renombre, cifre y aplane los servicios, el dominio, la comprobación de licencia y las integraciones de detrás. Luego ejecuta la compilación protegida, no el código fuente, por una suite de humo, porque el framework no te dirá qué no logró encontrar —simplemente devolverá 404.

Prueba Nebula.NET

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