Obfusquer une fonction AWS Lambda .NET sans casser la chaîne du handler
Le runtime .NET géré d'AWS Lambda localise votre code via une chaîne de handler de la forme Assembly::Namespace.Type::Method, qu'il résout par réflexion à chaque démarrage à froid. Si vous obfusquez après la compilation, le renommage sort le type ou la méthode du handler de sous cette chaîne : l'assembly est valide, mais Lambda n'arrive pas à résoudre le point d'entrée et l'invocation échoue avant même que votre code ne s'exécute. Cet article explique où vit la chaîne du handler, quels symboles le runtime géré et le sérialiseur Lambda résolvent par nom, comment épingler exactement ceux-ci avec une keep-list Nebula.NET tout en renommant le reste, et comment intégrer nebula protect à dotnet lambda package.
Une fonction AWS Lambda .NET est presque la cible d”obfuscation idéale. C”est une petite bibliothèque de classes — sans Main dans le cas géré, sans interface utilisateur, une seule méthode que Lambda appelle — déployée sur une infrastructure que vous n”administrez pas et qui s”exécute sans interface. Presque rien n”y est consommé par nom depuis l”extérieur. Et pourtant, comme d”autres formes serverless, elle cache une couture où une passe d”obfuscation naïve produit une défaillance qui ne ressemble en rien à un bug d”obfuscation : le paquet se déploie, l”assembly se charge, et la toute première invocation échoue avec une erreur de résolution du handler, bien avant que votre code ne s”exécute. Le DLL est valide. Lambda ne trouve simplement pas l”unique méthode qu”on lui a dit d”appeler.
La raison tient à la manière dont le runtime .NET géré de Lambda localise votre point d”entrée, et une fois que vous la voyez, le correctif est une keep-list brève. Cet article traite de ce mécanisme : où vit la chaîne du handler, pourquoi renommer le handler casse la résolution et non le code, et comment protéger l”intérieur avec Nebula.NET sans perturber les noms que lit le runtime.
Pourquoi une fonction Lambda vaut la peine d”être protégée
Une fonction, c”est de la logique métier dont on a retiré la plomberie. Il n”y a pas de couche de présentation et presque pas de colle de framework : juste le handler et ce qu”il fait — retarifer un panier, scorer une demande, valider la signature d”un webhook, régler une transaction, décider d”une éligibilité. Cette logique est le produit, et dans une Lambda elle constitue presque tout l”assembly. La fonction porte aussi les constantes sensibles qu”on attend de quelque chose qui s”intègre à d”autres systèmes — URLs d”endpoints internes, clés d”API de partenaires, noms d”en-têtes et de files, fragments de chaînes de connexion — souvent comme valeurs par défaut dans le code plutôt que comme configuration.
Et elle s”exécute quelque part que vous ne contrôlez pas. L”artefact déployé est un DLL (ou un exécutable autonome) que quiconque a accès à la fonction — ou au compte AWS d”un client qui exécute votre fonction — peut extraire du paquet et ouvrir dans un décompilateur, lisant votre modèle de scoring ou vos calculs de règlement en C# propre en quelques secondes. Cette absence d”interface qui fait paraître une fonction non exposée est exactement ce qui la rend intéressante à rétro-concevoir, et à protéger.
Comment le runtime géré trouve votre handler
Voici une fonction Lambda ordinaire sur le runtime géré :
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 ne scanne pas cet assembly à la recherche d”un attribut pour décider quoi appeler. À la place, la fonction est configurée avec une chaîne de handler qui nomme le point d”entrée avec précision :
OrderApi::Contoso.Orders.OrderFunction::FunctionHandler
Les trois parties, séparées par ::, sont le nom de l”assembly, le nom de type pleinement qualifié et le nom de la méthode. Cette chaîne n”est pas stockée dans l”assembly : elle vit dans la configuration de la fonction. Dans un projet construit avec la CLI Amazon.Lambda.Tools, elle est écrite dans aws-lambda-tools-defaults.json :
{
"profile": "default",
"region": "us-east-1",
"configuration": "Release",
"function-runtime": "dotnet8",
"function-handler": "OrderApi::Contoso.Orders.OrderFunction::FunctionHandler"
}
et dans un modèle SAM ou CDK, c”est la propriété Handler :
Resources:
OrderFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: dotnet8
Handler: OrderApi::Contoso.Orders.OrderFunction::FunctionHandler
CodeUri: ./publish
À chaque démarrage à froid, le runtime géré lit cette chaîne et résout la méthode par réflexion, en pratique :
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
Voilà tout le piège. La chaîne du handler est figée au moment du déploiement et pointe vers Contoso.Orders.OrderFunction.FunctionHandler. L”obfuscation s”exécute après la compilation, sur le DLL, et si le renommage transforme OrderFunction en a et FunctionHandler en b, la chaîne configurée reste intacte — elle nomme toujours l”original — mais le type et la méthode qu”elle nomme n”existent plus. GetType renvoie null, la résolution échoue, et l”invocation se termine par une erreur de résolution du handler. Rien ne cloche dans l”assembly ; le nom qu”on a dit au runtime de trouver n”est simplement plus là.
L”audit : ce qui se renomme, ce qui ne se renomme pas
Parcourez chaque nom que touchent le runtime géré et le sérialiseur, et la keep-list s”écrit d”elle-même.
- Le type et la méthode du handler —
Contoso.Orders.OrderFunction.FunctionHandler. Résolus par la chaîne de handler configurée via réflexion au démarrage à froid. Conservez le type et la méthode. C”est l”unique couture propre à Lambda, et toute la raison pour laquelle l”obfuscation naïve échoue. - Le nom de l”assembly —
OrderApi, le premier segment de la chaîne du handler. C”est le nom de l”assembly de sortie, pas un symbole renommable ; l”obfuscation ne le change pas et vous ne devriez pas non plus, puisque la chaîne du handler le nomme. - Le type du sérialiseur Lambda —
DefaultLambdaJsonSerializer, nommé dans[assembly: LambdaSerializer(typeof(...))]. Résolu par identité de type via les métadonnées de l”assembly, pas par nom. Renommable sans risque — mais voyez les POCO ci-dessous. - POCO d”entrée et de sortie —
OrderRequest,QuoteResponse. Le sérialiseur lie le JSON par nom de propriété, et ces noms sont le contrat de la charge de l”événement et de la réponse. Conservez les noms de propriété, ou épinglez chacun avec[JsonPropertyName("...")]et laissez le membre se renommer en dessous. Les noms des types eux-mêmes peuvent se renommer ; ce sont les propriétés qui sont le contrat du message. - Le constructeur et l”injection de dépendances — résolus lorsque le runtime construit votre type de handler, et tout service résolu par type. Le renommage préserve l”identité de type. Renommez librement.
- Assistants privés, services de domaine, l”algorithme —
IPricingService,PricingService.Reprice, tout ce que le handler appelle. Rien en dehors du processus ne nomme cela. Renommez, chiffrez, virtualisez.
Deux coutures à conserver, donc — le type-et-méthode du handler et les noms de propriété des POCO — et tout le reste est libre.
La keep-list
Modélisez les types de handler par une convention de nommage pour qu”une seule règle les couvre, et nebula.config.json se lit ainsi :
{
"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 première règle épingle chaque type *Function et ses membres, de sorte que la méthode du handler conserve le nom qu”attend la chaîne du handler ; scope: "type-and-members" est ce qui préserve FunctionHandler, et pas seulement la classe. La deuxième conserve les membres des types DTO — les propriétés qui sont le contrat JSON — tout en laissant les noms des types eux-mêmes se renommer. Tout le reste dans les deux assemblies est renommé.
Si vous préférez ne pas dépendre d”une convention, annotez le handler directement : l”attribut voyage avec le code et ne peut pas se désynchroniser d”un motif :
[Obfuscation(Exclude = true, ApplyToMembers = false)] // keep this method's name for the handler string
public QuoteResponse FunctionHandler(OrderRequest input, ILambdaContext context) { /* ... */ }
Une note sur le runtime personnalisé
Si vous livrez un runtime personnalisé (provided.al2023) au lieu du runtime géré dotnet8, le câblage est différent. Vous enregistrez le handler sous forme de délégué dans Main, si bien qu”il n”y a aucune chaîne de handler résolue par réflexion : le handler configuré est juste bootstrap, le nom de l”exécutable :
// 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();
Cela supprime le piège du renommage sur la méthode du handler — le délégué le lie par référence, pas par nom — mais déplace l”exigence de conservation vers le point d”entrée de l”assembly : Main doit survivre (Nebula préserve le point d”entrée par défaut), et les noms de propriété des POCO du sérialiseur restent un contrat à préserver. Le runtime géré échange Main contre une chaîne configurée ; le runtime personnalisé échange la chaîne contre un point d”entrée conservé.
Protéger l”intérieur
Conserver le nom d”une méthode ne signifie pas laisser son corps lisible. Le handler reste résoluble tandis que tout ce qu”il fait devient opaque.
Chiffrement de chaînes pour les constantes. Les URLs d”endpoints, les clés d”API, les noms d”en-têtes et les fragments de connexion sont ce qu”un attaquant cherche en premier avec grep ; le chiffrement de chaînes les stocke chiffrées et les déchiffre au point d”utilisation, de sorte qu”une recherche dans le binaire livré ne renvoie rien. Ce n”est pas un coffre-fort — la valeur est vivante en mémoire à l”exécution — mais cela supprime l”attaque à effort zéro.
Les transformations les plus fortes sur les joyaux de la couronne. PricingService.Reprice et toute logique de scoring, de règlement ou d”éligibilité que la fonction enveloppe sont là où vous recourez à la virtualisation de code et au chiffrement de méthode complète — deux fonctionnalités Pro dans Nebula.NET — appliquées à cette poignée de méthodes plutôt qu”à tout l”assembly. La virtualisation réécrit une méthode en bytecode pour une VM embarquée, de sorte qu”il ne reste aucun IL à décompiler ; le chiffrement de méthode stocke l”IL original chiffré et le reconstruit à l”exécution. Vous pouvez même virtualiser le corps du handler lui-même tout en conservant son nom :
public sealed class PricingService : IPricingService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public QuoteResponse Reprice(OrderRequest order) { /* ... */ }
}
Le renommage et l”obfuscation de flux de contrôle s”exécutent largement sous les deux, transformant le reste de l”intérieur en symboles dénués de sens et en branches enchevêtrées.
Intégrer la protection à l”empaquetage
Protégez la sortie publiée, et uniquement sur le serveur de build, pour que le débogage local reste rapide et lisible. La CLI Amazon.Lambda.Tools publie et compresse en une seule étape avec dotnet lambda package ; exécutez Nebula sur cette sortie publiée avant la construction du zip, ou protégez puis réempaquetez :
dotnet publish OrderApi -c Release -o publish
nebula protect --config nebula.config.json
Accrochez-le après le publish dans la CI pour que l”artefact déployé soit toujours celui qui est protégé :
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Le dossier protégé protected/ est ce que votre étape d”empaquetage doit compresser et déployer : pointez dotnet lambda package --package-type zip (ou votre CodeUri SAM / asset CDK) vers la sortie protégée plutôt que vers le dossier de publish brut. nebula protect renvoie un code de sortie non nul en cas d”échec, de sorte qu”une étape de protection cassée fait échouer le build au lieu de livrer un DLL non protégé.
Vérifier que cela fonctionne réellement
Les tests unitaires ne détecteront pas un échec de résolution du handler : ils référencent le projet et appellent FunctionHandler directement, sans jamais passer par la chaîne configurée contre le DLL protégé. Deux vérifications le feront. D”abord, invoquez le paquet protégé de bout en bout : déployez-le sur une fonction de test (ou exécutez-le sous l”outil de test .NET Lambda / sam local invoke contre la sortie protégée) avec un événement connu, et confirmez que le handler se résout et renvoie le devis attendu. Une fonction qui se déploie proprement mais échoue à la première invocation avec un message de résolution du handler est la signature exacte d”une règle keep oubliée, ou d”une chaîne de handler qui a dérivé par rapport aux noms conservés.
Ensuite, ouvrez l”assembly livré dans un décompilateur et lisez-le comme le fera un attaquant : les noms du type et de la méthode du handler sont présents (ils doivent l”être, pour correspondre à la chaîne), et les noms de propriété des POCO sont présents (ils sont un contrat), mais le corps du handler, le service de tarification, les assistants internes et les chaînes d”intégration ont disparu ou sont illisibles. Glass.NET est le compagnon gratuit et purement statique pour cette passe : chargez le DLL protégé, confirmez que Contoso.Orders.OrderFunction.FunctionHandler est toujours là par son nom, puis confirmez qu”il ne reste rien à lire à l”intérieur.
À retenir
Le runtime .NET géré de Lambda est singulier en ce qu”il trouve votre code via une chaîne de handler — Assembly::Namespace.Type::Method — qui vit dans la configuration de la fonction, pas dans l”assembly, et qu”il la résout par réflexion à chaque démarrage à froid. Renommez le type ou la méthode du handler et vous le sortez de sous la chaîne qui le nomme ; le runtime, lisant la chaîne inchangée, échoue à résoudre le point d”entrée avant que votre code ne s”exécute. Conservez exactement deux choses : le type et la méthode du handler, pour que leurs noms correspondent toujours à la chaîne configurée, et les noms de propriété des POCO qui sont un contrat de sérialisation sur l”événement et la réponse. Tout le reste — la tarification, le scoring, les secrets, tout l”intérieur qui est la seule raison d”être de la fonction — confiez-le à Nebula.NET pour le renommer, chiffrer ses chaînes et le virtualiser. Puis invoquez le paquet protégé, observez le handler se résoudre et répondre, et lisez le DLL livré pour confirmer que la logique derrière lui a disparu.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.