Skip to content
← Tous les articles
· Delta1 Labs ObfuscationNebula

Obfusquer une Minimal API sans casser la liaison des paramètres

Les Minimal API ASP.NET Core lient les valeurs de route et de query aux paramètres du handler par leur nom réfléchi. Supprimez ou renommez ces noms et la liaison échoue en silence : id arrive à 0. Voici le correctif.

Une Minimal API semble la chose la plus facile au monde à obfusquer. Pas de contrôleurs, pas de conventions de noms d’actions, pas d’attributs [HttpGet] liant une méthode à une route par réflexion — juste app.MapGet("/orders/{id}", handler) et une lambda. La route est une chaîne, le handler est un délégué, et aucun ne semble dépendre d’un nom de symbole qu’un obfuscateur toucherait. Alors vous activez le renommage, supprimez les métadonnées pour réduire le binaire, publiez — et les endpoints continuent de renvoyer 200 OK tout en servant en silence le mauvais enregistrement. GET /orders/42 revient avec la commande zéro.

Rien n’a levé d’exception. Pas de 500, pas de 400, pas de ligne de log. L’assembly est valide et la route a correspondu. Ce qui a cassé est une chose précise que font les Minimal API et que les contrôleurs masquent : elles lient les valeurs de route et de query aux paramètres de votre handler par le nom réfléchi du paramètre, et l’obfuscation s’exécute sur les métadonnées où vit ce nom. Voici un guide pratique de cette couture : comment exactement RequestDelegateFactory relie une requête à votre délégué, pourquoi supprimer ou renommer un nom de paramètre transforme id = 42 en id = 0 sans erreur, et comment protéger tout le graphe d’endpoints avec Nebula.NET pour que la liaison continue de fonctionner.

Pourquoi obfusquer une Minimal API

Les lambdas handler sont minces, mais tout ce qui se trouve derrière est le produit : le calcul des prix, les règles d’éligibilité, l’orchestration de l’exécution, les requêtes qui façonnent votre modèle de données. Un service Minimal API est livré sous forme de DLL — souvent vers l’infrastructure propre d’un client, un registre de conteneurs qu’il contrôle, ou une machine on-prem — et quiconque a accès au fichier peut l’ouvrir dans un décompilateur et relire votre logique métier en C# propre. La couche des endpoints est aussi là où se concentrent les secrets : URLs de services internes, clés de signature, fragments de chaînes de connexion, les noms d’en-tête internes que pose votre gateway. Relever le coût de la relecture de votre propre code livré depuis ce binaire est la raison ordinaire et légitime de l’obfusquer.

Comment une Minimal API lie une requête à votre délégué

Voici un endpoint parfaitement normal :

app.MapGet("/orders/{id}", async (
        int id,                         // du jeton de route {id}
        bool detail,                    // de ?detail=true
        IOrderService svc) =>           // de la DI, par type
{
    var order = await svc.Get(id, detail);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

Trois paramètres, trois sources de liaison distinctes, et le framework doit déterminer lequel est lequel. Il le fait une seule fois, au démarrage, quand RequestDelegateFactory.Create construit le RequestDelegate de cette route. Conceptuellement, pour chaque paramètre il fait ceci :

foreach (ParameterInfo p in handler.Method.GetParameters())
{
    // 1. un type de service enregistré ?  -> résoudre depuis la DI
    if (serviceProviderIsService.IsService(p.ParameterType)) { /* lier depuis la DI */ }

    // 2. le modèle de route contient-il un jeton nommé p.Name ?
    else if (routeTemplate.HasParameter(p.Name)) { /* lier la valeur de route p.Name */ }

    // 3. sinon, le traiter comme une valeur de query avec clé p.Name
    else { /* lier query[p.Name] */ }
}

Regardez les étapes 2 et 3. Le jeton de route est {id} et le paramètre s’y lie parce que p.Name == "id". La clé de query est detail parce que p.Name == "detail". IOrderService se lie par type, pas par nom, il est donc en sécurité — mais id et detail sont appariés entièrement par la chaîne que renvoie ParameterInfo.Name. Cette chaîne est une métadonnée, compilée dans la table des paramètres de la méthode.

Compilez maintenant cette lambda. Elle devient une méthode — disons <Program>$.<<Main>$>b__0_0 — portant une table de paramètres :

.method assembly hidebysig instance class Task
        '<<Main>$>b__0_0'(int32 id, bool detail, class IOrderService svc)
//                        ^^^^^       ^^^^^^
//        ParameterInfo.Name de #0 et #1 — les chaînes que lit la liaison

L’obfuscation s’exécute ici, sur le DLL, une fois le compilateur C# terminé. Si la passe supprime les noms de paramètre (une réduction courante de taille/métadonnées) ou les renomme, cette table devient int32 A_0, bool A_1, ... ou sans noms du tout. Au démarrage, RequestDelegateFactory appelle GetParameters(), ne trouve aucun paramètre nommé id, et retombe au chemin du bas : il n’y a aucune valeur de route à lier, donc pour un int non nullable le binder fournit default(int) — zéro — au lieu d’échouer. L’endpoint est câblé, la route correspond, et chaque requête exécute svc.Get(0, false). La seule chaîne dont le framework avait besoin pour relier {id} à votre paramètre a disparu, et rien ne vous l’a signalé.

GET /orders/42?detail=truejeton de route {id} · query detailRequestDelegateFactoryhandler.GetParameters() → .Nameapparie chaque nomnoms conservésp.Name == "id" → 42p.Name == "detail" → truesvc.Get(42, true) · correctnoms supprimés / renommésaucun param nommé "id"default(int) → 0svc.Get(0, false) · erreur silencieuse[FromRoute(Name="id")]nom depuis l'attributchaîne → 42insensible au renommage · correctl'intérieur — renommé · chaînes chiffrées · virtualiséOrderService.Get · prix · règles · requêtes · URLs + clés internesles clés de liaison restent lisibles à dessein ; tout ce qu'elles appellent, non

Le piège de l’échec silencieux, précisément

C’est plus vicieux que la casse d’obfuscation habituelle parce que ça n’échoue pas. Une cible de réflexion renommée lève normalement une exception — un type manquant, une MissingMethodException, une JsonException. La liaison de paramètres par nom, au contraire, dégrade vers la valeur par défaut. Un paramètre de query string arrive null, un int arrive 0, un bool arrive false, un nullable reste null. L’endpoint renvoie 200 avec un contenu calculé à partir de ces valeurs par défaut. Votre smoke test qui vérifie un 200 passe. Le bug se manifeste par « le client dit que la commande 42 montre les mauvais articles », des jours plus tard, sans trace pointant vers la liaison. Traitez toute Minimal API où une valeur de route ou de query devient en silence sa valeur par défaut après un changement d’obfuscation comme un problème de nom de paramètre supprimé jusqu’à preuve du contraire.

Ce qui dépend réellement d’un nom, et ce qui n’en dépend pas

Parcourez le graphe d’endpoints et la keep-list s’écrit toute seule.

  • Paramètres liés par route et par query — id, detail. Liés par ParameterInfo.Name. Ces noms doivent survivre, ou la liaison ne doit pas en dépendre (voir les deux correctifs ci-dessous).
  • Les chaînes de modèle de route — "/orders/{id}". Ce sont des littéraux de chaîne que votre code passe à MapGet ; le chiffrement de chaînes les déchiffre à l’exécution vers la même valeur exacte, donc la correspondance n’est pas affectée. Rien à faire.
  • Paramètres de service — IOrderService svc. Liés par type, que le renommage conserve (l’identité demeure même si le nom change). Renommez le service et son implémentation en toute liberté.
  • Le nom de la méthode handler — <<Main>$>b__0_0. Le framework garde un délégué vers elle et ne la recherche jamais par nom. Renommez-la ; ça n’a pas d’importance.
  • Membres d’un struct [AsParameters] — si vous liez un struct avec [AsParameters], le framework lie chaque propriété publique par son nom exactement comme un paramètre de premier niveau. Conservez ces noms de propriété (ou annotez-les). Même règle pour un paramètre lié depuis le corps en JSON : les noms de propriété du DTO sont le contrat de câble que lit votre sérialiseur.
  • [FromKeyedServices("cache")], [FromHeader(Name="...")] et consorts — la clé est un littéral de chaîne, pas un symbole. Survit au chiffrement de chaînes. Rien à faire.

Toute la surface sensible au nom est donc : les noms de paramètre de route/query, les noms de propriété [AsParameters], et les noms de propriété du DTO de corps. Tout le reste — les handlers, les services, l’intérieur — est terrain libre.

Correctif un : conserver les noms (rapide)

Annotez la portée contenant les handlers pour préserver les noms de paramètre, et laissez tout le reste être renommé. Dans 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 indique à Nebula de laisser intactes les tables de paramètres des méthodes, pour que RequestDelegateFactory continue de trouver un paramètre nommé id. La règle keep fige vos DTOs de requête/réponse et tout struct [AsParameters] sous Contoso.Orders.Contracts pour que leurs noms de propriété correspondent toujours aux jetons de route et au câble JSON. Tout le reste dans l’assembly — noms de services, types internes, corps de méthode — est renommé. C’est un changement de deux lignes et ça fonctionne, mais cela laisse la liaison couplée à des métadonnées dont vous voudriez autrement vous débarrasser.

Correctif deux : rendre la liaison insensible au renommage (robuste)

Mieux : sortez la clé de liaison du nom du paramètre et placez-la dans une chaîne que l’obfuscateur ne peut pas casser. Les attributs de liaison explicites font exactement cela :

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());

Maintenant RequestDelegateFactory lit la clé de liaison depuis "id" et "detail" — des littéraux de chaîne portés par l’attribut, que le chiffrement de chaînes reproduit à l’exécution et que le renommage ne touche jamais. Le nom de métadonnée du paramètre est désormais sans importance pour la liaison, vous pouvez donc le supprimer :

{
  "stripParameterNames": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true
}

La comparaison résume toute la décision : conserver les noms est une ligne de configuration et zéro changement de code, mais garde des métadonnées qu’une passe agressive veut éliminer et c’est facile à oublier sur un nouvel endpoint ; les attributs explicites sont plus de frappe au départ mais vous laissent supprimer tous les noms de paramètre de l’assembly et ne plus jamais penser à la liaison. Pour un service que vous durcissez sérieusement, annotez. Pour les structs [AsParameters], le même geste s’applique — placez [FromRoute(Name = "...")] / [FromQuery(Name = "...")] sur les propriétés publiques, et alors leurs noms peuvent aussi être renommés.

Si vous compilez via le Request Delegate Generator (Native AOT, ou EnableRequestDelegateGenerator), le code de liaison généré câble les arguments sans recherche de ParameterInfo.Name à l’exécution, donc les paramètres de route/query de premier niveau sont déjà insensibles au renommage — mais [AsParameters] et les DTOs de corps lient encore par nom de propriété, alors conservez-les.

Intégrer la protection dans le build

Protégez la sortie publiée, et uniquement sur le serveur de build pour que le dotnet run local reste rapide et débogable :

dotnet publish Contoso.Orders -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 le protégé :

<Target Name="NebulaProtect" AfterTargets="Publish"
        Condition="'$(CI)' == 'true'">
  <Exec Command="nebula protect --config nebula.config.json" />
</Target>

Livrez protected/, pas le dossier de publish brut. nebula protect renvoie un code de sortie non nul en cas d’échec, donc une étape de protection cassée fait échouer le build au lieu de livrer un binaire dont les endpoints lient tout à zéro en silence.

Protéger l’intérieur

Garder lisible le nom d’un paramètre ne signifie pas que le travail du handler l’est. La surface de liaison reste lisible ; tout ce qui est derrière devient opaque. Le chiffrement de chaînes cache les URLs internes, clés et fragments de chaînes de connexion qu’accumule la couche des endpoints. Les transformations les plus fortes — virtualisation de code et chiffrement de méthode complète, toutes deux des fonctionnalités Pro dans Nebula.NET — vont sur les joyaux de la couronne : la logique de prix, d’éligibilité ou de règlement qu’appellent les handlers, appliquée à cette poignée de méthodes plutôt qu’à tout l’assembly :

public sealed class OrderService : IOrderService
{
    [Obfuscation(Feature = "virtualization")]   // Pro — l'algorithme, pas la tuyauterie
    public Task<Order?> Get(int id, bool detail) { /* ... */ }
}

Le renommage et l’obfuscation du flux de contrôle s’exécutent largement en dessous, transformant le reste de l’intérieur en symboles dénués de sens et en branches enchevêtrées pendant que la route trouve toujours son paramètre.

Vérifier que ça fonctionne vraiment

Les tests unitaires ne détecteront pas ceci : ils appellent la lambda handler directement avec de vrais arguments et ne passent jamais par RequestDelegateFactory contre le DLL protégé. Deux vérifications le feront. D’abord, exécutez le build protégé et frappez une vraie route avec une vraie valeur — GET /orders/42?detail=true — et vérifiez que la réponse reflète 42 et true, pas 0 et false. Un 200 qui renvoie l’enregistrement d’id zéro est la signature exacte d’un nom de paramètre supprimé ; vérifiez les valeurs liées, pas le code de statut. Faites-le pour un paramètre de route, un paramètre de query, et un endpoint lié par [AsParameters] ou par corps, puisque chacun lie par un nom différent.

Ensuite, ouvrez l’assembly livré dans un décompilateur et lisez-le comme le fera un attaquant. Glass.NET est le compagnon gratuit et purement statique pour cette passe : chargez le DLL protégé, confirmez que la surface de liaison que vous vouliez conserver est présente — les modèles de route, les noms de propriété du DTO ou les littéraux [FromRoute(Name=...)] — puis confirmez qu’il ne reste rien à lire dans OrderService.Get et la logique qu’il appelle.

À retenir

Une Minimal API relie une requête à votre handler par une chaîne par paramètre — le ParameterInfo.Name réfléchi que RequestDelegateFactory apparie à un jeton {route} ou à une clé de query — et cette chaîne est une métadonnée qu’une passe d’obfuscation supprimera ou renommera. Quand elle disparaît, la liaison ne lève pas d’exception ; elle remet à votre handler default(T), donc GET /orders/42 sert en silence la commande zéro avec un 200. Décidez la surface de liaison délibérément : soit conservez les noms de paramètre et de DTO (stripParameterNames: false plus une règle keep sur vos contrats), soit — mieux — déplacez chaque clé de liaison dans une chaîne d’attribut [FromRoute(Name = "...")] / [FromQuery(Name = "...")] pour pouvoir supprimer tous les noms de l’assembly et ne plus jamais casser une route. Puis confiez tout l’intérieur à Nebula.NET pour renommer, chiffrer les chaînes et virtualiser, envoyez une vraie valeur à travers le build protégé pour prouver qu’il lie, et lisez le DLL livré pour confirmer que la logique derrière l’endpoint a disparu.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.