Ofuscar una Minimal API sin romper el enlace de parámetros
Las Minimal API de ASP.NET Core enlazan valores de ruta y de query a los parámetros del handler por su nombre reflejado. Elimina o renombra esos nombres y el enlace falla en silencio: id llega como 0. Esta es la solución.
Una Minimal API parece lo más fácil del mundo de ofuscar. No hay controladores, ni convenciones de nombres de acciones, ni atributos [HttpGet] que aten un método a una ruta por reflexión — solo app.MapGet("/orders/{id}", handler) y una lambda. La ruta es una cadena, el handler es un delegado, y ninguno parece depender de un nombre de símbolo que un ofuscador fuera a tocar. Así que activas el renombrado, eliminas metadatos para reducir el binario, publicas — y los endpoints siguen devolviendo 200 OK mientras sirven en silencio el registro equivocado. GET /orders/42 vuelve con el pedido cero.
Nada lanzó. No hay 500, ni 400, ni línea de log. El ensamblado es válido y la ruta coincidió. Lo que se rompió es una cosa específica que hacen las Minimal API y que los controladores ocultan: enlazan los valores de ruta y de query a los parámetros de tu handler por el nombre reflejado del parámetro, y la ofuscación se ejecuta sobre los metadatos donde vive ese nombre. Esta es una guía práctica de esa costura: cómo exactamente RequestDelegateFactory conecta una petición a tu delegado, por qué eliminar o renombrar un nombre de parámetro convierte id = 42 en id = 0 sin error, y cómo proteger todo el grafo de endpoints con Nebula.NET de modo que el enlace siga funcionando.
Por qué ofuscar una Minimal API
Las lambdas handler son finas, pero todo lo que hay detrás es el producto: el cálculo de precios, las reglas de elegibilidad, la orquestación de cumplimiento, las consultas que dan forma a tu modelo de datos. Un servicio Minimal API se entrega como un DLL — a menudo a la propia infraestructura de un cliente, a un registro de contenedores que él controla, o a una máquina on-prem — y cualquiera con acceso al archivo puede abrirlo en un descompilador y leer tu lógica de negocio de vuelta como C# limpio. La capa de endpoints es también donde se concentran los secretos: URLs de servicios internos, claves de firma, fragmentos de cadenas de conexión, los nombres de cabecera internos que pone tu gateway. Elevar el coste de leer tu propio código entregado desde ese binario es la razón ordinaria y legítima para ofuscarlo.
Cómo una Minimal API enlaza una petición a tu delegado
Aquí hay un endpoint perfectamente normal:
app.MapGet("/orders/{id}", async (
int id, // del token de ruta {id}
bool detail, // de ?detail=true
IOrderService svc) => // de DI, por tipo
{
var order = await svc.Get(id, detail);
return order is null ? Results.NotFound() : Results.Ok(order);
});
Tres parámetros, tres fuentes de enlace distintas, y el framework tiene que averiguar cuál es cuál. Lo hace una vez, al arrancar, cuando RequestDelegateFactory.Create construye el RequestDelegate de esta ruta. Conceptualmente, para cada parámetro hace esto:
foreach (ParameterInfo p in handler.Method.GetParameters())
{
// 1. ¿un tipo de servicio registrado? -> resolver desde DI
if (serviceProviderIsService.IsService(p.ParameterType)) { /* enlazar desde DI */ }
// 2. ¿la plantilla de ruta contiene un token llamado p.Name?
else if (routeTemplate.HasParameter(p.Name)) { /* enlazar valor de ruta p.Name */ }
// 3. si no, tratarlo como valor de query con clave p.Name
else { /* enlazar query[p.Name] */ }
}
Mira los pasos 2 y 3. El token de ruta es {id} y el parámetro se enlaza a él porque p.Name == "id". La clave de query es detail porque p.Name == "detail". IOrderService se enlaza por tipo, no por nombre, así que está a salvo — pero id y detail se emparejan enteramente por la cadena que devuelve ParameterInfo.Name. Esa cadena es metadato, compilado en la tabla de parámetros del método.
Ahora compila esa lambda. Se convierte en un método — digamos <Program>$.<<Main>$>b__0_0 — que lleva una tabla de parámetros:
.method assembly hidebysig instance class Task
'<<Main>$>b__0_0'(int32 id, bool detail, class IOrderService svc)
// ^^^^^ ^^^^^^
// ParameterInfo.Name de #0 y #1 — las cadenas que lee el enlace
La ofuscación se ejecuta aquí, sobre el DLL, después de que el compilador de C# haya terminado. Si la pasada elimina los nombres de parámetro (una reducción común de tamaño/metadatos) o los renombra, esa tabla se convierte en int32 A_0, bool A_1, ... o sin nombres en absoluto. Al arrancar, RequestDelegateFactory llama a GetParameters(), no encuentra ningún parámetro llamado id, y cae por el camino de abajo: no hay valor de ruta que enlazar, así que para un int no anulable el binder entrega default(int) — cero — en lugar de fallar. El endpoint está cableado, la ruta coincide, y cada petición ejecuta svc.Get(0, false). La única cadena que el framework necesitaba para conectar {id} con tu parámetro ha desaparecido, y nada te lo dijo.
La trampa del fallo silencioso, con precisión
Esto es más desagradable que la rotura habitual de ofuscación porque no falla. Un objetivo de reflexión renombrado normalmente lanza — un tipo que falta, un MissingMethodException, un JsonException. El enlace de parámetros por nombre en cambio degrada al valor por defecto. Un parámetro de query string llega null, un int llega 0, un bool llega false, un anulable se queda null. El endpoint devuelve 200 con contenido calculado a partir de esos valores por defecto. Tu smoke test que comprueba un 200 pasa. El bug aflora como “el cliente dice que el pedido 42 muestra los artículos equivocados”, días después, sin traza que apunte al enlace. Trata cualquier Minimal API donde un valor de ruta o query se vuelva en silencio su valor por defecto tras un cambio de ofuscación como un problema de nombre de parámetro eliminado hasta que se demuestre lo contrario.
Qué depende realmente de un nombre, y qué no
Recorre el grafo de endpoints y la keep-list se escribe sola.
- Parámetros enlazados por ruta y query —
id,detail. Enlazados porParameterInfo.Name. Estos nombres deben sobrevivir, o el enlace no debe depender de ellos (ver las dos soluciones abajo). - Las cadenas de plantilla de ruta —
"/orders/{id}". Son literales de cadena que tu código pasa aMapGet; el cifrado de cadenas las descifra en tiempo de ejecución al mismo valor exacto, así que la coincidencia no se ve afectada. No hace falta nada. - Parámetros de servicio —
IOrderService svc. Enlazados por tipo, que el renombrado conserva (la identidad se mantiene aunque cambie el nombre). Renombra el servicio y su implementación con libertad. - El nombre del método handler —
<<Main>$>b__0_0. El framework mantiene un delegado a él y nunca lo busca por nombre. Renómbralo; no importa. - Miembros de un struct
[AsParameters]— si enlazas un struct con[AsParameters], el framework enlaza cada propiedad pública por su nombre exactamente como un parámetro de nivel superior. Conserva esos nombres de propiedad (o anótalos). La misma regla para un parámetro enlazado desde el cuerpo como JSON: los nombres de propiedad del DTO son el contrato de cable que lee tu serializador. [FromKeyedServices("cache")],[FromHeader(Name="...")]y compañía — la clave es un literal de cadena, no un símbolo. Sobrevive al cifrado de cadenas. No hace falta nada.
Así que toda la superficie sensible al nombre es: nombres de parámetro de ruta/query, nombres de propiedad [AsParameters], y nombres de propiedad del DTO del cuerpo. Todo lo demás — los handlers, los servicios, el interior — es terreno libre.
Solución uno: conservar los nombres (rápido)
Anota el ámbito que contiene los handlers para preservar los nombres de parámetro, y deja que todo lo demás se renombre. En nebula.config.json:
{
"schemaVersion": 1,
"inputs": [ "publish/Contoso.Orders.dll" ],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"stripParameterNames": false,
"renaming": {
"rules": [
{ "pattern": "Contoso.Orders.Contracts.*", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Orders.*", "action": "rename" }
]
}
}
stripParameterNames: false le dice a Nebula que deje intactas las tablas de parámetros de los métodos, de modo que RequestDelegateFactory siga encontrando un parámetro llamado id. La regla keep fija tus DTOs de petición/respuesta y cualquier struct [AsParameters] bajo Contoso.Orders.Contracts para que sus nombres de propiedad sigan coincidiendo con los tokens de ruta y el cable JSON. Todo lo demás en el ensamblado — nombres de servicios, tipos internos, cuerpos de método — se renombra. Es un cambio de dos líneas y funciona, pero deja el enlace acoplado a metadatos de los que, de otro modo, querrías librarte.
Solución dos: hacer el enlace a prueba de renombrado (robusto)
Mejor: saca la clave de enlace del nombre del parámetro y métela en una cadena que el ofuscador no pueda romper. Los atributos de enlace explícitos hacen exactamente eso:
app.MapGet("/orders/{id}", async (
[FromRoute(Name = "id")] int id,
[FromQuery(Name = "detail")] bool detail,
IOrderService svc) =>
await svc.Get(id, detail) is { } o ? Results.Ok(o) : Results.NotFound());
Ahora RequestDelegateFactory lee la clave de enlace de "id" y "detail" — literales de cadena que lleva el atributo, que el cifrado de cadenas reproduce en tiempo de ejecución y el renombrado nunca toca. El nombre de metadato del parámetro es ahora irrelevante para el enlace, así que puedes eliminarlo:
{
"stripParameterNames": true,
"encryptStrings": true,
"controlFlowObfuscation": true
}
La comparación es toda la decisión: conservar los nombres es una línea de configuración y cero cambio de código, pero mantiene metadatos que una pasada agresiva quiere fuera y es fácil de olvidar en un endpoint nuevo; los atributos explícitos son más tecleo al principio pero te dejan eliminar todos los nombres de parámetro del ensamblado y no volver a pensar en el enlace. Para un servicio que estés endureciendo en serio, anota. Para los structs [AsParameters] aplica el mismo movimiento — pon [FromRoute(Name = "...")] / [FromQuery(Name = "...")] en las propiedades públicas, y entonces sus nombres también pueden renombrarse.
Si compilas a través del Request Delegate Generator (Native AOT, o EnableRequestDelegateGenerator), el código de enlace generado conecta los argumentos sin una consulta de ParameterInfo.Name en tiempo de ejecución, así que los parámetros de ruta/query de nivel superior ya son a prueba de renombrado — pero [AsParameters] y los DTOs de cuerpo siguen enlazando por nombre de propiedad, así que consérvalos.
Integrar la protección en el build
Protege la salida publicada, y solo en el servidor de build para que el dotnet run local siga siendo rápido y depurable:
dotnet publish Contoso.Orders -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>
Entrega 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 el build en vez de entregar un binario cuyos endpoints enlazan todo a cero en silencio.
Proteger el interior
Conservar legible un nombre de parámetro no significa que el trabajo del handler lo sea. La superficie de enlace queda legible; todo lo que hay detrás se vuelve opaco. El cifrado de cadenas oculta las URLs internas, claves y fragmentos de cadenas de conexión que acumula la capa de endpoints. 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 precios, elegibilidad o liquidación que invocan los handlers, aplicada a ese puñado de métodos en vez de a todo el ensamblado:
public sealed class OrderService : IOrderService
{
[Obfuscation(Feature = "virtualization")] // Pro — el algoritmo, no la fontanería
public Task<Order?> Get(int id, bool detail) { /* ... */ }
}
El renombrado y la ofuscación de flujo de control se ejecutan ampliamente por debajo, convirtiendo el resto del interior en símbolos sin sentido y ramas enmarañadas mientras la ruta sigue encontrando su parámetro.
Verificar que de verdad funciona
Las pruebas unitarias no detectarán esto: llaman a la lambda handler directamente con argumentos reales y nunca pasan por RequestDelegateFactory contra el DLL protegido. Dos comprobaciones sí lo harán. Primero, ejecuta el build protegido y golpea una ruta real con un valor real — GET /orders/42?detail=true — y comprueba que la respuesta refleja 42 y true, no 0 y false. Un 200 que devuelve el registro de id cero es la firma exacta de un nombre de parámetro eliminado; comprueba los valores enlazados, no el código de estado. Haz esto para un parámetro de ruta, un parámetro de query, y un endpoint enlazado por [AsParameters] o por cuerpo, ya que cada uno enlaza por un nombre distinto.
Segundo, abre el ensamblado entregado 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 la superficie de enlace que querías conservar está presente — las plantillas de ruta, los nombres de propiedad del DTO o los literales [FromRoute(Name=...)] — y luego confirma que no queda nada por leer dentro de OrderService.Get y la lógica que invoca.
La conclusión
Una Minimal API conecta una petición a tu handler a través de una cadena por parámetro — el ParameterInfo.Name reflejado que RequestDelegateFactory empareja con un token {route} o una clave de query — y esa cadena es metadato que una pasada de ofuscación eliminará o renombrará. Cuando desaparece, el enlace no lanza; le entrega a tu handler default(T), así que GET /orders/42 sirve en silencio el pedido cero con un 200. Decide la superficie de enlace deliberadamente: o conserva los nombres de parámetro y DTO (stripParameterNames: false más una regla keep sobre tus contratos), o — mejor — mueve cada clave de enlace a una cadena de atributo [FromRoute(Name = "...")] / [FromQuery(Name = "...")] para poder eliminar todos los nombres del ensamblado y no volver a romper una ruta. Luego entrega todo el interior a Nebula.NET para renombrar, cifrar cadenas y virtualizar, envía un valor real a través del build protegido para probar que enlaza, y lee el DLL entregado para confirmar que la lógica detrás del endpoint ha desaparecido.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.