Obfusquer une app Azure Functions en modèle isolated-worker sans casser la découverte des fonctions
Le worker isolé de .NET trouve vos fonctions via un fournisseur de métadonnées généré par source qui incruste le point d'entrée de chaque fonction dans l'assembly sous forme de chaîne littérale — "Namespace.Class.Method" — à la compilation. Obfusquez après cela et le renommage déplace la méthode hors de portée de la chaîne, de sorte que l'hôte Functions charge votre app puis ne trouve plus une seule fonction. Cet article explique exactement quels noms le modèle isolé résout à l'exécution, comment keep-lister les méthodes de trigger et les DTO de binding avec Nebula.NET, et comment chiffrer les chaînes et virtualiser la logique métier qui est la raison même d'exister de la fonction.
Une app Azure Functions écrite sur le modèle isolé (isolated worker) de .NET semble la chose la plus facile au monde à obfusquer. C’est une bibliothèque de classes — sans Main, sans interface, une poignée de méthodes décorées de [Function] — livrée au cloud d’un autre et exécutée sans tête. Presque rien n’y est consommé par nom depuis l’extérieur. Et pourtant, c’est la seule forme d’app .NET où une passe d’obfuscation naïve produit l’échec le plus déroutant : l’app se déploie, le processus du worker démarre, l’hôte ne journalise aucune erreur, puis chaque fonction renvoie 404 ou ne se déclenche jamais, parce que l’hôte a indexé zéro fonction. Le code est tout là. L’hôte ne le trouve simplement pas.
La raison est spécifique au modèle isolé, et une fois que vous la voyez, la solution est une keep-list de deux lignes. Cet article porte sur ce mécanisme : comment le worker isolé découvre vos fonctions, pourquoi l’obfuscation casse la découverte et non le code, et comment protéger les parties qui comptent avec Nebula.NET sans toucher aux parties que l’hôte lit.
Pourquoi une app Functions vaut la peine d’être protégée
Une fonction est de la pure logique métier dont la plomberie a été supprimée. Il n’y a pas de couche de présentation, pas de colle de framework à parcourir — juste le trigger et ce qu’il fait : retarifier un panier, scorer une demande de prêt, valider une signature de webhook, régler une transaction, décider d’une éligibilité. Cette logique est le produit, et dans une fonction elle est quasiment tout l’assembly. L’app porte aussi les constantes sensibles qu’on attend de quelque chose qui s’intègre à d’autres systèmes — URL d’endpoints en aval, clés d’API de partenaires, noms d’en-têtes, fragments de chaîne 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 n’administrez pas. Que ce soit l’infrastructure d’Azure ou l’abonnement d’un client exécutant votre fonction dans un conteneur, l’assembly déployé est une DLL qu’un attaquant peut extraire et ouvrir dans un décompilateur, relisant votre modèle de scoring ou vos calculs de règlement en C# propre en quelques secondes. L’absence de tête qui fait paraître une fonction inexposée est exactement ce qui la rend digne d’être rétro-conçue.
Comment le worker isolé trouve vos fonctions
Voici une fonction HTTP ordinaire :
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!); // propriétaire — la raison d'être
var res = req.CreateResponse(HttpStatusCode.OK);
await res.WriteAsJsonAsync(quote);
return res;
}
}
Dans l’ancien modèle in-process, l’hôte chargeait votre assembly et le scannait pour [Function] au démarrage. Le modèle isolé ne fonctionne pas ainsi. Votre worker est un processus séparé, et pour éviter de réfléchir sur votre assembly à travers cette frontière, le Worker SDK livre un générateur de source qui s’exécute à la compilation et émet un fournisseur de métadonnées décrivant chaque fonction trouvée :
// <auto-generated/> — émis par 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", // <-- la chaîne critique
RawBindings = { /* httpTrigger + http return, en JSON */ },
};
// ...
}
}
L’EntryPoint est une chaîne littérale — le nom de type pleinement qualifié plus le nom de méthode — et le générateur l’écrit dans l’assembly comme une constante ldstr. Au démarrage, le worker lit ces métadonnées, et pour chaque entrée il résout la méthode réelle par réflexion, en substance :
var type = assembly.GetType("Contoso.Orders.OrderFunctions");
var method = type.GetMethod("Run"); // le nom vient directement de la chaîne EntryPoint
C’est tout le piège. Le générateur s’exécute en premier et incruste "Contoso.Orders.OrderFunctions.Run" dans la DLL. L’obfuscation s’exécute après la compilation, sur cette DLL. Si le renommage transforme OrderFunctions en a et Run en b, la chaîne incrustée reste intacte — elle dit toujours Contoso.Orders.OrderFunctions.Run — mais la méthode qu’elle nomme n’existe plus. GetType renvoie null, la fonction est ignorée, et l’hôte signale une app sans fonctions. Rien ne lève d’exception, parce qu’une app avec zéro fonction est une app parfaitement valide.
L’audit : ce qui se renomme, ce qui ne se renomme pas
Parcourez chaque nom que le modèle isolé touche et la keep-list s’écrit d’elle-même.
- Méthodes de trigger et leurs types déclarants —
OrderFunctions.Run. Résolus par la chaîneEntryPointincrustée via réflexion. Conserver. C’est la seule couture propre à Functions, et toute la raison pour laquelle l’obfuscation naïve échoue. - Le nom
[Function("ProcessOrder")]— le nom public de la fonction, le segment de route, la file qu’elle écoute. C’est un argument de chaîne littérale, entièrement indépendant du nom de la méthode, il survit donc au renommage intact. Aucune action nécessaire. Votre URL reste/api/ProcessOrdermême après queRundevientb. - Paramètres de binding (
HttpTrigger,QueueTrigger,BlobInput, bindings de sortie). Leur configuration — noms de files, noms de paramètres de connexion, niveau d’auth — est capturée en JSON dans lesRawBindingsgénérés à la compilation et n’est que des littéraux. Renommer les paramètres n’y touche pas. Aucune action nécessaire. - Injection par constructeur (
IPricingService). Résolue par type depuis le conteneur de DI construit dansProgram.cs, exactement comme dans n’importe quel hôte. Le renommage préserve l’identité de type. Renommer librement. - Le
GeneratedFunctionMetadataProvidergénéré lui-même. Enregistré par type via l’extension de démarrage générée du SDK, pas par nom. Il se renomme bien — ce qui compte est le contenu des chaînes qu’il contient, qui pointe vers les méthodes de trigger que vous conservez. Renommer librement. - Auxiliaires privés, services de domaine, l’algorithme. Rien à l’extérieur du processus ne les nomme. Renommer, chiffrer, virtualiser.
- POCO de requête/réponse (
OrderRequest, le devis que vous renvoyez). Sérialisés parSystem.Text.Jsonà partir des noms de propriété, qui sont un contrat filaire avec vos appelants. Conserver les noms de propriété — ou épingler chacun avec[JsonPropertyName("...")]et laisser le membre se renommer. C’est le piège général de sérialisation, pas un spécifique à Functions.
Deux coutures à conserver, donc — méthodes de trigger et DTO de binding — et tout le reste est de bonne guerre.
La keep-list
Modélisez les classes de fonction par une convention de nommage pour qu’une règle les couvre toutes. Avec une solution scindée en Contoso.Orders (les fonctions et les DTO) et Contoso.Pricing (l’algorithme), nebula.config.json se lit :
{
"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 première règle épingle chaque type *Functions et ses membres pour que les méthodes de trigger gardent les noms que les chaînes générées attendent ; scope: "type-and-members" est ce qui préserve Run, pas seulement la classe. La deuxième épingle les DTO sérialisés. Tout le reste dans les deux assemblies est renommé. Si vous préférez ne pas vous reposer sur une convention, annotez directement les méthodes de trigger — l’attribut voyage avec le code et ne peut pas se désynchroniser d’un motif de renommage :
[Function("ProcessOrder")]
[Obfuscation(Exclude = true, ApplyToMembers = false)] // garder le nom de cette méthode pour la découverte
public async Task<HttpResponseData> Run(/* ... */) { /* ... */ }
Protéger l’intérieur
Conserver le nom d’une méthode ne veut pas dire laisser son corps lisible. Le trigger reste découvrable tandis que tout ce qu’il fait devient opaque.
Chiffrement de chaînes pour les constantes. Les URL d’endpoints, les clés d’API, les noms d’en-têtes et les fragments de connexion sont ce qu’un attaquant grep en premier ; le chiffrement de chaînes les stocke chiffrés et les déchiffre au point d’utilisation, de sorte qu’une recherche dans le binaire 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 sans effort.
Les transformations les plus fortes sur les joyaux de la couronne. IPricingService.Reprice et quelle que soit la 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 — toutes deux des fonctionnalités Pro de 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 virtualiser le corps du trigger lui-même même si son nom est conservé :
public sealed class PricingService : IPricingService
{
[Obfuscation(Feature = "virtualization")] // Pro — l'algorithme, pas la plomberie
public Quote 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 emmêlées.
Câbler Nebula dans la publication
Protégez la sortie publiée, et uniquement sur le serveur de build pour que le débogage local reste rapide et lisible :
dotnet publish Contoso.Orders -c Release -o publish
nebula protect --config nebula.config.json
Accrochez-le après la publication en CI pour que l’artefact déployé soit toujours celui protégé :
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Le dossier protected/ protégé est ce que votre étape de déploiement (zip-deploy, image de conteneur, ou func azure functionapp publish contre la sortie buildée) devrait livrer. Garder les builds développeur non obfusqués et n’obfusquer qu’en CI est une discipline en soi — le raisonnement est dans n’obfusquer que sur le serveur de build.
Vérifier que ça marche vraiment
Les tests unitaires n’attraperont pas un échec de découverte — ils référencent le projet et n’exécutent jamais la DLL protégée. Deux vérifications le feront. D’abord, exécutez l’app protégée de bout en bout : démarrez l’hôte Functions contre la sortie protégée (localement avec func start, ou un emplacement de staging) et confirmez que le journal de l’hôte liste vos fonctions par nom — ProcessOrder indexée, pas une app vide — puis appelez chaque trigger et vérifiez le comportement réel, qu’une requête connue produit le devis attendu. Une app qui démarre proprement mais ne liste aucune fonction est la signature exacte d’une règle de conservation manquée. Ensuite, ouvrez l’assembly livré dans un décompilateur et lisez-le comme le fera un attaquant : les noms de type *Functions sont présents (ils doivent l’être), mais leurs corps, le service de tarification, les auxiliaires 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 ; la procédure complète est dans votre obfuscation a-t-elle vraiment fonctionné ?.
À retenir
Le worker isolé est inhabituel en ce qu’il trouve vos fonctions via des chaînes qu’un générateur de source a incrustées dans l’assembly à la compilation, avant même que l’obfuscation ne s’exécute. Renommez une méthode de trigger et vous la déplacez hors de portée de la chaîne qui la nomme, et l’hôte — lisant la chaîne périmée — indexe silencieusement rien. Conservez exactement deux choses : les méthodes de trigger, pour que leurs noms correspondent toujours aux chaînes EntryPoint générées, et les DTO dont les propriétés sont un contrat de sérialisation. Tout le reste — la tarification, le scoring, les secrets, tout l’intérieur qui est la seule raison d’exister de la fonction — confiez-le à Nebula.NET pour le renommer, chiffrer ses chaînes et le virtualiser. Puis exécutez le build protégé, regardez l’hôte indexer vos fonctions par nom, et lisez la DLL livrée pour confirmer que la logique derrière elles a disparu.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.