Skip to content
← Todas las publicaciones
· Delta1 Labs ObfuscationNebula

Ofuscar una función AWS Lambda de .NET sin romper la cadena del handler

El runtime gestionado de .NET en AWS Lambda localiza tu código mediante una cadena de handler con la forma Assembly::Namespace.Type::Method, que resuelve por reflexión en cada arranque en frío. Si ofuscas después de compilar, el renombrado saca al tipo o al método del handler de debajo de esa cadena: el ensamblado es válido, pero Lambda no logra resolver el punto de entrada y la invocación falla antes de que se ejecute tu código. Este artículo explica dónde vive la cadena del handler, qué símbolos resuelven por nombre el runtime gestionado y el serializador de Lambda, cómo fijar exactamente esos con una keep-list de Nebula.NET renombrando todo lo demás, y cómo integrar nebula protect en dotnet lambda package.

Una función AWS Lambda de .NET está muy cerca del objetivo ideal para la ofuscación. Es una pequeña biblioteca de clases —sin Main en el caso gestionado, sin interfaz de usuario, un único método al que Lambda llama— que se despliega en una infraestructura que tú no administras y se ejecuta sin interfaz. Casi nada en ella se consume por nombre desde fuera. Y sin embargo, como otras formas serverless, esconde una costura en la que un paso de ofuscación ingenuo produce un fallo que no se parece en nada a un error de ofuscación: el paquete se despliega, el ensamblado se carga y la primera invocación falla con un error de resolución del handler, mucho antes de que se ejecute nada de tu código. El DLL es válido. Lambda simplemente no encuentra el único método que le dijeron que llamara.

El motivo es específico de cómo el runtime gestionado de .NET de Lambda localiza tu punto de entrada, y una vez que lo ves, la solución es una keep-list breve. Este artículo trata de ese mecanismo: dónde vive la cadena del handler, por qué renombrar el handler rompe la resolución y no el código, y cómo proteger el interior con Nebula.NET sin alterar los nombres que lee el runtime.

Por qué vale la pena proteger una función Lambda

Una función es lógica de negocio con la fontanería eliminada. No hay capa de presentación y casi nada de pegamento de framework: solo el handler y lo que hace, repreciar una cesta, puntuar una solicitud, validar la firma de un webhook, liquidar una transacción, decidir la elegibilidad. Esa lógica es el producto, y en una Lambda es casi todo el ensamblado. La función también suele llevar las constantes sensibles que cabría esperar de algo que se integra con otros sistemas —URLs de endpoints internos, claves de API de socios, nombres de cabeceras y colas, fragmentos de cadenas de conexión— con frecuencia como valores por defecto en el código y no como configuración.

Y se ejecuta en un lugar que no controlas. El artefacto desplegado es un DLL (o un ejecutable autocontenido) que cualquiera con acceso a la función —o a la propia cuenta de AWS de un cliente que ejecuta tu función— puede extraer del paquete y abrir en un descompilador, leyendo tu modelo de puntuación o tus cálculos de liquidación como C# limpio en segundos. Esa ausencia de interfaz que hace que una función parezca no expuesta es exactamente lo que la hace valiosa de revertir, y valiosa de proteger.

Cómo encuentra el runtime gestionado tu handler

Aquí tienes una función Lambda corriente sobre el runtime gestionado:

using Amazon.Lambda.Core;
using Amazon.Lambda.Serialization.SystemTextJson;

// Resolved by TYPE through assembly metadata — renames safely.
[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]

namespace Contoso.Orders;

public sealed class OrderFunction
{
    private readonly IPricingService _pricing;

    public OrderFunction() => _pricing = new PricingService();

    // The entry point Lambda resolves from the handler string.
    public QuoteResponse FunctionHandler(OrderRequest input, ILambdaContext context)
    {
        context.Logger.LogInformation($"Repricing order {input.OrderId}");
        return _pricing.Reprice(input);          // proprietary — the reason this exists
    }
}

public sealed class OrderRequest
{
    public string OrderId { get; set; } = "";
    public decimal Subtotal { get; set; }
    public string CurrencyCode { get; set; } = "USD";
}

public sealed class QuoteResponse
{
    public string OrderId { get; set; } = "";
    public decimal Total { get; set; }
}

Lambda no escanea este ensamblado en busca de un atributo para decidir qué llamar. En su lugar, la función se configura con una cadena de handler que nombra el punto de entrada con exactitud:

OrderApi::Contoso.Orders.OrderFunction::FunctionHandler

Las tres partes, separadas por ::, son el nombre del ensamblado, el nombre del tipo completamente cualificado y el nombre del método. Esa cadena no se almacena en el ensamblado: vive en la configuración de la función. En un proyecto construido con la CLI de Amazon.Lambda.Tools se escribe en aws-lambda-tools-defaults.json:

{
  "profile": "default",
  "region": "us-east-1",
  "configuration": "Release",
  "function-runtime": "dotnet8",
  "function-handler": "OrderApi::Contoso.Orders.OrderFunction::FunctionHandler"
}

y en una plantilla de SAM o CDK es la propiedad Handler:

Resources:
  OrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: dotnet8
      Handler: OrderApi::Contoso.Orders.OrderFunction::FunctionHandler
      CodeUri: ./publish

En cada arranque en frío el runtime gestionado lee esa cadena y resuelve el método por reflexión, en la práctica:

var assembly = Assembly.Load("OrderApi");
var type = assembly.GetType("Contoso.Orders.OrderFunction");   // from the handler string
var method = type.GetMethod("FunctionHandler");                 // name from the handler string

Esa es toda la trampa. La cadena del handler queda fijada en el momento del despliegue y apunta a Contoso.Orders.OrderFunction.FunctionHandler. La ofuscación se ejecuta después de compilar, sobre el DLL, y si el renombrado convierte OrderFunction en a y FunctionHandler en b, la cadena configurada queda intacta —sigue nombrando al original— pero el tipo y el método que nombra ya no existen. GetType devuelve null, la resolución falla y la invocación termina con un error de resolución del handler. No hay nada mal en el ensamblado; el nombre que se le dijo al runtime que encontrara simplemente ya no está.

handler string (function config · not in the assembly)OrderApi :: Contoso.Orders.OrderFunction :: FunctionHandlerassemblytype — keepmethod — keepcold start — resolve by reflectionAssembly.Load("OrderApi") · GetType("...OrderFunction") · GetMethod("FunctionHandler")renamed to a / b → GetType returns null → handler-resolution error, before your code runskeep-list restores the matchkeep OrderFunction + FunctionHandlernames survive · resolution succeeds+ keep POCO property names (JSON)rename body · services · helpersencrypt strings · virtualize thealgorithm — the reason it exists

La auditoría: qué se renombra y qué no

Recorre cada nombre que tocan el runtime gestionado y el serializador, y la keep-list se escribe sola.

  • El tipo y el método del handler — Contoso.Orders.OrderFunction.FunctionHandler. Resueltos por la cadena del handler configurada mediante reflexión en el arranque en frío. Conserva tanto el tipo como el método. Esta es la única costura propia de Lambda y la razón entera por la que la ofuscación ingenua falla.
  • El nombre del ensamblado — OrderApi, el primer segmento de la cadena del handler. Es el nombre del ensamblado de salida, no un símbolo renombrable; la ofuscación no lo cambia y tú tampoco deberías, ya que la cadena del handler lo nombra.
  • El tipo del serializador de Lambda — DefaultLambdaJsonSerializer, nombrado en [assembly: LambdaSerializer(typeof(...))]. Resuelto por identidad de tipo a través de los metadatos del ensamblado, no por nombre. Seguro de renombrar, pero mira los POCO más abajo.
  • POCO de entrada y salida — OrderRequest, QuoteResponse. El serializador enlaza el JSON por nombre de propiedad, y esos nombres son el contrato de la carga del evento y de la respuesta. Conserva los nombres de propiedad, o fija cada uno con [JsonPropertyName("...")] y deja que el miembro se renombre por debajo. Los nombres de los tipos en sí pueden renombrarse; son las propiedades las que son el contrato del mensaje.
  • El constructor y la DI — resueltos cuando el runtime construye tu tipo de handler, y cualquier servicio resuelto por tipo. El renombrado preserva la identidad de tipo. Renómbralos libremente.
  • Ayudantes privados, servicios de dominio, el algoritmo — IPricingService, PricingService.Reprice, todo lo que el handler invoca. Nada fuera del proceso nombra esto. Renómbralo, cífralo, virtualízalo.

Dos costuras que conservar, entonces —el tipo-y-método del handler y los nombres de propiedad de los POCO— y todo lo demás es terreno libre.

La keep-list

Modela los tipos de handler con una convención de nombres para que una sola regla los cubra, y nebula.config.json queda así:

{
  "schemaVersion": 1,
  "inputs": [
    "publish/OrderApi.dll",
    "publish/Contoso.Pricing.dll"
  ],
  "outputDirectory": "protected",

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

  "renaming": {
    "rules": [
      { "pattern": "Contoso.Orders.*Function",   "action": "keep", "scope": "type-and-members" },
      { "pattern": "Contoso.Orders.Contracts.*",  "action": "keep", "scope": "members" },
      { "pattern": "Contoso.Orders.*",            "action": "rename" },
      { "pattern": "Contoso.Pricing.*",           "action": "rename" }
    ]
  }
}

La primera regla fija cada tipo *Function y sus miembros, de modo que el método del handler conserva el nombre que espera la cadena del handler; scope: "type-and-members" es lo que preserva FunctionHandler, no solo la clase. La segunda conserva los miembros de los tipos DTO —las propiedades que son el contrato JSON— permitiendo que los nombres de los tipos en sí se renombren. Todo lo demás en ambos ensamblados se renombra.

Si prefieres no depender de una convención, anota el handler directamente: el atributo viaja con el código y no puede desincronizarse de un patrón:

[Obfuscation(Exclude = true, ApplyToMembers = false)]   // keep this method's name for the handler string
public QuoteResponse FunctionHandler(OrderRequest input, ILambdaContext context) { /* ... */ }

Una nota sobre el runtime personalizado

Si despliegas un runtime personalizado (provided.al2023) en lugar del runtime gestionado dotnet8, el cableado es distinto. Registras el handler como un delegado en Main, de modo que no hay ninguna cadena de handler que se resuelva por reflexión: el handler configurado es solo bootstrap, el nombre del ejecutable:

// Program.cs — custom runtime entry point
var handler = (OrderRequest input, ILambdaContext ctx) => new OrderFunction().FunctionHandler(input, ctx);
await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer())
    .Build()
    .RunAsync();

Eso elimina la trampa del renombrado sobre el método del handler —el delegado lo enlaza por referencia, no por nombre— pero traslada el requisito de conservación al punto de entrada del ensamblado: Main debe sobrevivir (Nebula preserva el punto de entrada por defecto), y los nombres de propiedad de los POCO del serializador siguen siendo un contrato que preservar. El runtime gestionado cambia Main por una cadena configurada; el runtime personalizado cambia la cadena por un punto de entrada conservado.

Proteger el interior

Conservar el nombre de un método no significa dejar legible su cuerpo. El handler sigue siendo resoluble mientras todo lo que hace se vuelve opaco.

Cifrado de cadenas para las constantes. Las URLs de endpoints, las claves de API, los nombres de cabeceras y los 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 desplegado 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. PricingService.Reprice y cualquier lógica de puntuación, liquidación o elegibilidad que la función envuelva son donde recurres a la virtualización de código y al cifrado de método completo —ambas funciones Pro en 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 descompilar; el cifrado de método almacena el IL original cifrado y lo reconstruye en tiempo de ejecución. Incluso puedes virtualizar el cuerpo del propio handler mientras conservas su nombre:

public sealed class PricingService : IPricingService
{
    [Obfuscation(Feature = "virtualization")]   // Pro — the algorithm, not the plumbing
    public QuoteResponse Reprice(OrderRequest order) { /* ... */ }
}

El renombrado y la ofuscación de flujo de control se ejecutan de forma amplia por debajo de ambas, convirtiendo el resto del interior en símbolos sin sentido y ramas enredadas.

Integrar la protección en el empaquetado

Protege la salida publicada, y solo en el servidor de compilación, para que la depuración local siga siendo rápida y legible. La CLI de Amazon.Lambda.Tools publica y comprime en un solo paso con dotnet lambda package; ejecuta Nebula sobre esa salida publicada antes de construir el zip, o protege y vuelve a empaquetar:

dotnet publish OrderApi -c Release -o publish
nebula protect --config nebula.config.json

Engánchalo después del 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 protegida protected/ es lo que tu paso de empaquetado debe comprimir y desplegar: apunta dotnet lambda package --package-type zip (o tu CodeUri de SAM / asset de CDK) a la salida protegida y no a la carpeta de publish en bruto. nebula protect devuelve un código de salida distinto de cero ante un fallo, de modo que un paso de protección roto hace fallar la compilación en lugar de desplegar un DLL sin proteger.

Verificar que de verdad funciona

Las pruebas unitarias no detectarán un fallo de resolución del handler: referencian el proyecto y llaman a FunctionHandler directamente, sin pasar nunca por la cadena configurada contra el DLL protegido. Dos comprobaciones sí lo harán. Primero, invoca el paquete protegido de extremo a extremo: despliégalo en una función de prueba (o ejecútalo bajo la herramienta de pruebas de .NET Lambda / sam local invoke contra la salida protegida) con un evento conocido, y confirma que el handler se resuelve y devuelve la cotización esperada. Una función que se despliega limpiamente pero falla en la primera invocación con un mensaje de resolución del handler es la firma exacta de una regla keep olvidada, o de una cadena de handler que se desvió de los nombres conservados.

Segundo, abre el ensamblado desplegado en un descompilador y léelo como lo hará un atacante: los nombres del tipo y del método del handler están presentes (deben estarlo, para coincidir con la cadena), y los nombres de propiedad de los POCO están presentes (son un contrato), pero el cuerpo del handler, el servicio de precios, los ayudantes internos y las cadenas de integración han desaparecido o son ilegibles. Glass.NET es el compañero gratuito y estático para ese repaso: carga el DLL protegido, confirma que Contoso.Orders.OrderFunction.FunctionHandler sigue ahí por nombre y luego confirma que no queda nada que leer dentro.

La conclusión

El runtime gestionado de .NET de Lambda es peculiar en que encuentra tu código mediante una cadena de handler —Assembly::Namespace.Type::Method— que vive en la configuración de la función, no en el ensamblado, y la resuelve por reflexión en cada arranque en frío. Renombra el tipo o el método del handler y lo sacas de debajo de la cadena que lo nombra; el runtime, al leer la cadena sin cambiar, no logra resolver el punto de entrada antes de que se ejecute tu código. Conserva exactamente dos cosas: el tipo y el método del handler, para que sus nombres sigan coincidiendo con la cadena configurada, y los nombres de propiedad de los POCO que son un contrato de serialización del evento y la respuesta. Todo lo demás —los precios, la puntuación, 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 renombrar, cifrar cadenas y virtualizar. Después invoca el paquete protegido, observa cómo el handler se resuelve y responde, y lee el DLL desplegado para confirmar que la lógica detrás de él ha desaparecido.

Prueba Nebula.NET

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