Ofuscar una app de Azure Functions en modelo isolated-worker sin romper el descubrimiento de funciones
El worker aislado de .NET encuentra tus funciones a través de un proveedor de metadatos generado por código fuente que incrusta el punto de entrada de cada función en el ensamblado como una cadena literal —"Namespace.Class.Method"— en tiempo de compilación. Si ofuscas después, el renombrado mueve el método fuera del alcance de esa cadena, de modo que el host de Functions carga tu app y luego no encuentra ni una sola función. Esta entrada explica exactamente qué nombres resuelve el modelo aislado en tiempo de ejecución, cómo mantener en una keep-list los métodos de trigger y los DTOs de binding con Nebula.NET, y cómo cifrar cadenas y virtualizar la lógica de negocio que es la razón misma de existir de la función.
Una app de Azure Functions escrita sobre el modelo aislado (isolated worker) de .NET parece lo más fácil del mundo de ofuscar. Es una biblioteca de clases —sin Main, sin interfaz, un puñado de métodos decorados con [Function]— que se entrega a la nube de otro y se ejecuta sin interfaz. Casi nada en ella se consume por nombre desde fuera. Y, sin embargo, es la única forma de app .NET en la que una pasada de ofuscación ingenua produce el fallo más desconcertante: la app se despliega, el proceso del worker arranca, el host no registra errores, y luego cada función devuelve 404 o nunca se dispara, porque el host ha indexado cero funciones. El código está todo ahí. El host simplemente no lo encuentra.
El motivo es específico del modelo aislado y, una vez lo ves, la solución es una keep-list de dos líneas. Esta entrada va sobre ese mecanismo: cómo descubre el worker aislado tus funciones, por qué la ofuscación rompe el descubrimiento y no el código, y cómo proteger las partes que importan con Nebula.NET sin tocar las partes que el host lee.
Por qué vale la pena proteger una app de Functions
Una función es lógica de negocio pura con la fontanería eliminada. No hay capa de presentación, no hay pegamento de framework que atravesar: solo el trigger y lo que hace: recalcular una cesta, puntuar una solicitud de crédito, validar la firma de un webhook, liquidar una transacción, decidir elegibilidad. Esa lógica es el producto, y en una función es casi todo el ensamblado. La app también lleva las constantes sensibles que cabría esperar de algo que se integra con otros sistemas —URLs de endpoints descendentes, claves de API de socios, nombres de cabecera, fragmentos de cadena de conexión— a menudo como valores por defecto en código en lugar de en configuración.
Y se ejecuta en un sitio que no administras tú. Ya sea la infraestructura de Azure o la propia suscripción de un cliente ejecutando tu función en un contenedor, el ensamblado desplegado es una DLL que un atacante puede extraer y abrir en un decompilador, leyendo tu modelo de scoring o tus cálculos de liquidación como C# limpio en segundos. La ausencia de interfaz que hace que una función parezca no expuesta es precisamente lo que la hace valiosa para la ingeniería inversa.
Cómo encuentra el worker aislado tus funciones
Aquí tienes una función HTTP corriente:
namespace Contoso.Orders;
public sealed class OrderFunctions
{
private readonly IPricingService _pricing;
public OrderFunctions(IPricingService pricing) => _pricing = pricing;
[Function("ProcessOrder")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
var order = await req.ReadFromJsonAsync<OrderRequest>();
var quote = _pricing.Reprice(order!); // proprietary — the reason this exists
var res = req.CreateResponse(HttpStatusCode.OK);
await res.WriteAsJsonAsync(quote);
return res;
}
}
En el antiguo modelo in-process, el host cargaba tu ensamblado y lo escaneaba buscando [Function] al arrancar. El modelo aislado no funciona así. Tu worker es un proceso aparte y, para evitar reflexionar sobre tu ensamblado a través de esa frontera, el Worker SDK incluye un generador de código fuente que se ejecuta en tiempo de compilación y emite un proveedor de metadatos que describe cada función que encontró:
// <auto-generated/> — emitted by Microsoft.Azure.Functions.Worker.Sdk.Generators
internal class GeneratedFunctionMetadataProvider : IFunctionMetadataProvider
{
public Task<ImmutableArray<IFunctionMetadata>> GetFunctionMetadataAsync(string dir)
{
var metadata = new DefaultFunctionMetadata
{
Name = "ProcessOrder",
ScriptFile = "Contoso.Orders.dll",
EntryPoint = "Contoso.Orders.OrderFunctions.Run", // <-- the critical string
RawBindings = { /* httpTrigger + http return, as JSON */ },
};
// ...
}
}
El EntryPoint es una cadena literal —el nombre de tipo completamente cualificado más el nombre del método— y el generador la escribe en el ensamblado como una constante ldstr. Al arrancar, el worker lee estos metadatos y, por cada entrada, resuelve el método real por reflexión, en esencia:
var type = assembly.GetType("Contoso.Orders.OrderFunctions");
var method = type.GetMethod("Run"); // name comes straight from the EntryPoint string
Esa es toda la trampa. El generador se ejecuta primero e incrusta "Contoso.Orders.OrderFunctions.Run" en la DLL. La ofuscación se ejecuta después de la compilación, sobre esa DLL. Si el renombrado convierte OrderFunctions en a y Run en b, la cadena incrustada queda intacta —sigue diciendo Contoso.Orders.OrderFunctions.Run— pero el método que nombra ya no existe. GetType devuelve null, la función se omite y el host informa de una app sin funciones. No se lanza nada, porque una app con cero funciones es una app perfectamente válida.
La auditoría: qué se renombra y qué no
Recorre cada nombre que toca el modelo aislado y la keep-list se escribe sola.
- Métodos de trigger y sus tipos declarantes —
OrderFunctions.Run. Resueltos por la cadenaEntryPointincrustada mediante reflexión. Mantener. Esta es la única costura exclusiva de Functions y la razón entera por la que la ofuscación ingenua falla. - El nombre de
[Function("ProcessOrder")]— el nombre público de la función, el segmento de ruta, la cola que escucha. Es un argumento de cadena literal, totalmente independiente del nombre del método, así que sobrevive al renombrado intacto. No hace falta ninguna acción. Tu URL sigue siendo/api/ProcessOrderincluso después de queRunpase a serb. - Parámetros de binding (
HttpTrigger,QueueTrigger,BlobInput, bindings de salida). Su configuración —nombres de cola, nombres de ajustes de conexión, nivel de autorización— se captura como JSON en elRawBindingsgenerado en tiempo de compilación y es todo literales. Renombrar los parámetros no lo afecta. No hace falta ninguna acción. - Inyección por constructor (
IPricingService). Resuelta por tipo desde el contenedor de DI construido enProgram.cs, exactamente como en cualquier host. El renombrado preserva la identidad de tipo. Renombrar libremente. - El propio
GeneratedFunctionMetadataProvidergenerado. Registrado por tipo a través de la extensión de arranque generada por el SDK, no por nombre. Se renombra sin problema: lo que importa es el contenido de cadena dentro de él, que apunta a los métodos de trigger que estás manteniendo. Renombrar libremente. - Auxiliares privados, servicios de dominio, el algoritmo. Nada fuera del proceso nombra esto. Renombrar, cifrar, virtualizar.
- POCOs de petición/respuesta (
OrderRequest, la cotización que devuelves). Serializados porSystem.Text.Jsonusando nombres de propiedad, que son un contrato de cable con tus llamadores. Mantener los nombres de propiedad —o fijar cada uno con[JsonPropertyName("...")]y dejar que el miembro se renombre. Este es el problema general de serialización, no uno específico de Functions.
Dos costuras que mantener, entonces —métodos de trigger y DTOs de binding— y todo lo demás es terreno libre.
La keep-list
Modela las clases de función con una convención de nombres para que una sola regla las cubra todas. Con una solución dividida en Contoso.Orders (las funciones y los DTOs) y Contoso.Pricing (el algoritmo), nebula.config.json queda así:
{
"schemaVersion": 1,
"inputs": [
"bin/Release/net8.0/publish/Contoso.Orders.dll",
"bin/Release/net8.0/publish/Contoso.Pricing.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Contoso.Orders.*Functions", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Orders.Contracts.*", "action": "keep" },
{ "pattern": "Contoso.Orders.*", "action": "rename" },
{ "pattern": "Contoso.Pricing.*", "action": "rename" }
]
}
}
La primera regla fija cada tipo *Functions y sus miembros para que los métodos de trigger conserven los nombres que esperan las cadenas generadas; scope: "type-and-members" es lo que preserva Run, no solo la clase. La segunda fija los DTOs serializados. Todo lo demás en ambos ensamblados se renombra. Si prefieres no depender de una convención, anota los métodos de trigger directamente —el atributo viaja con el código y no puede desincronizarse de un patrón de renombrado:
[Function("ProcessOrder")]
[Obfuscation(Exclude = true, ApplyToMembers = false)] // keep this method's name for discovery
public async Task<HttpResponseData> Run(/* ... */) { /* ... */ }
Proteger el interior
Mantener el nombre de un método no significa dejar su cuerpo legible. El trigger sigue siendo descubrible mientras todo lo que hace se vuelve opaco.
Cifrado de cadenas para las constantes. URLs de endpoint, claves de API, nombres de cabecera y fragmentos de conexión son lo primero que busca un atacante con grep; el cifrado de cadenas las almacena cifradas y las descifra en el punto de uso, de modo que una búsqueda en el binario no devuelve nada. No es una caja fuerte —el valor está vivo en memoria en tiempo de ejecución— pero elimina el ataque de esfuerzo cero.
Las transformaciones más fuertes sobre las joyas de la corona. IPricingService.Reprice y cualquier lógica de scoring, liquidación o elegibilidad que la función envuelva es donde recurres a la virtualización de código y al cifrado de método completo —ambas funciones Pro de Nebula.NET— aplicadas a ese puñado de métodos y no a todo el ensamblado. La virtualización reescribe un método en bytecode para una VM embebida, de modo que no queda IL que decompilar; el cifrado de método almacena el IL original cifrado y lo reconstruye en tiempo de ejecución. Puedes virtualizar el cuerpo del propio trigger aunque se mantenga su nombre:
public sealed class PricingService : IPricingService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public Quote Reprice(OrderRequest order) { /* ... */ }
}
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.
Integrar Nebula en el publish
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.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>
La carpeta protected/ resultante es lo que tu paso de despliegue (zip-deploy, imagen de contenedor o func azure functionapp publish contra la salida compilada) debe entregar. Mantener las compilaciones de desarrollo sin ofuscar y ofuscar solo en CI es una disciplina en sí misma —el razonamiento está en ofuscar solo en el servidor de compilación.
Verificar que de verdad funciona
Las pruebas unitarias no detectarán un fallo de descubrimiento —referencian el proyecto y nunca ejecutan la DLL protegida. Dos comprobaciones sí lo harán. Primera, ejecuta la app protegida de extremo a extremo: arranca el host de Functions contra la salida protegida (en local con func start, o en un slot de staging) y confirma que el log del host lista tus funciones por nombre —ProcessOrder indexada, no una app vacía— y luego llama a cada trigger y comprueba el comportamiento real, que una petición conocida produce la cotización esperada. Una app que arranca limpia pero no lista funciones es la firma exacta de una regla keep que falta. Segunda, abre el ensamblado entregado en un decompilador y léelo como lo hará un atacante: los nombres de los tipos *Functions están presentes (deben estarlo), pero sus cuerpos, el servicio de precios, los auxiliares internos y las cadenas de integración han desaparecido o son ilegibles. Glass.NET es el compañero gratuito y puramente estático para esa pasada; el procedimiento completo está en ¿funcionó de verdad tu ofuscación?.
La conclusión
El worker aislado es inusual en que encuentra tus funciones a través de cadenas que un generador de código fuente incrustó en el ensamblado en tiempo de compilación, antes de que la ofuscación llegue a ejecutarse. Renombra un método de trigger y lo mueves fuera del alcance de la cadena que lo nombra, y el host —leyendo la cadena obsoleta— indexa silenciosamente nada. Mantén exactamente dos cosas: los métodos de trigger, para que sus nombres sigan coincidiendo con las cadenas EntryPoint generadas, y los DTOs cuyas propiedades son un contrato de serialización. Todo lo demás —los precios, el scoring, los secretos, el interior entero que es la única razón por la que existe la función— se lo entregas a Nebula.NET para que renombre, cifre cadenas y virtualice. Luego ejecuta la compilación protegida, observa cómo el host indexa tus funciones por nombre, y lee la DLL entregada para confirmar que la lógica tras ellas ha desaparecido.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.