Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETGénérateurs de source

Obfusquer du code généré par générateur de source : pourquoi les contextes System.Text.Json, [GeneratedRegex] et [LoggerMessage] survivent au renommage — et le seul nom qu'ils laissent fuir

Les générateurs de source s'exécutent à l'intérieur du compilateur et émettent du C# ordinaire dans votre assembly, de sorte que l'IL qu'ils produisent est indiscernable du code que vous avez tapé. La conséquence utile pour l'obfuscation est que les générateurs résolvent les noms à la compilation : un contexte System.Text.Json capture "Name" sous forme de littéral de chaîne et atteint la propriété via un délégué, le binder de configuration bascule sur des clés littérales, et un GeneratedRegex n'est que pure identité de type. Renommez tout et le JSON, les options liées et la regex continuent de fonctionner — contrairement à leurs équivalents basés sur la réflexion. Ce que les générateurs laissent derrière eux, ce sont des littéraux : le motif de la regex, le modèle de message de journal, et un nameof() qui grave le nom original de votre méthode dans un EventId. Voici ce que chaque générateur émet, ce que Nebula.NET renomme en toute sécurité, et ce qu'il faut chiffrer ou exclure.

Un générateur de source est un plugin de compilateur qui lit votre code et émet davantage de code dans la même compilation. Lorsque le compilateur C# a terminé, les fichiers générés font simplement partie de votre assembly — mêmes tables de métadonnées, même IL, aucun marqueur dans la sortie qui distingue une méthode que vous avez écrite d’une méthode écrite par le générateur. Ce qui soulève la question évidente pour un obfuscateur : lorsque Nebula renomme vos types et membres, qu’advient-il du code qui a été généré autour d’eux ?

La réponse est meilleure que vous ne le pensez, avec une subtilité. Les générateurs résolvent les noms à la compilation, ce qui est exactement la propriété qui rend le code sûr à renommer. Mais ils le font en écrivant vos noms sous forme de littéraux de chaîne, et ces littéraux survivent au renommage tels quels — révélant à l’occasion à un attaquant comment une méthode s’appelait autrefois.

Recherche à l’exécution contre capture à la compilation

Toute la raison pour laquelle l’obfuscation casse des choses est la recherche de noms à l’exécution. Type.GetProperty("Name"), JsonSerializer.Serialize(obj) sans contexte, IConfiguration.Bind(options) — chacun parcourt les métadonnées à l’exécution, trouve un membre par sa chaîne, et cesse de le trouver une fois le membre renommé. C’est pourquoi autoDetect préserve les membres portant des attributs de sérialisation et que la documentation vous dit d’exclure vos classes de paramètres.

Un générateur effectue la recherche à la compilation et émet deux choses à la place : une référence par jeton vers le membre (un call/ldfld direct, ou un délégué) et, là où un nom est nécessaire à l’exécution, un littéral copié depuis votre source. Le renommage réécrit chaque référence par jeton et laisse les littéraux intacts.

Reflection: lookup at runtimeGetProperty("Name") → metadataCustomer.Name renamed to a"Name" not found → breaksGenerator: capture at compile timePropertyName = "Name" (literal)Getter = o => o.Name (token)Customer.Name renamed to aGetter → o.a, wire name "Name" → works

La règle pour le code généré est donc l’inverse de celle pour la réflexion : le membre peut être renommé sans risque ; c’est le littéral auquel vous devez réfléchir. Voyons ce que quatre générateurs courants émettent réellement.

System.Text.Json : le contexte capture le nom et l’accesseur

Prenez un DTO sans aucun attribut de sérialisation et un contexte généré par générateur de source :

public sealed class Customer
{
    public string Name  { get; set; } = "";
    public int    Tier  { get; set; }
}

[JsonSerializable(typeof(Customer))]
internal partial class AppJsonContext : JsonSerializerContext { }

Activez une fois la sortie des fichiers générés — <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> dans le projet — et lisez ce que le générateur a produit pour Customer. L’initialiseur de métadonnées ressemble à ceci (abrégé) :

private static JsonPropertyInfo[] CustomerPropInit(JsonSerializerOptions options)
{
    var properties = new JsonPropertyInfo[2];

    var info0 = new JsonPropertyInfoValues<string>
    {
        IsProperty    = true,
        DeclaringType = typeof(global::Customer),
        Getter        = static obj => ((global::Customer)obj).Name,
        Setter        = static (obj, value) => ((global::Customer)obj).Name = value!,
        PropertyName  = "Name",          // captured at generation time
        JsonPropertyName = null,
    };
    properties[0] = JsonMetadataServices.CreatePropertyInfo<string>(options, info0);
    // ... Tier ...
    return properties;
}

Deux choses à remarquer. Le getter et le setter sont des lambdas qui touchent directement la propriété — en IL, c’est un ldftn sur une méthode générée par le compilateur dont le corps est callvirt Customer::get_Name(). Le nom transmis est un ldstr "Name". Après que Nebula a renommé Customer en a et Name en b, le corps de la lambda devient callvirt a::get_b() — réécrit par jeton, comme tous les sites d’appel — tandis que le ldstr dit toujours "Name". Sérialisez un client et vous obtenez {"Name":"…","Tier":3} exactement comme avant.

En mode de sérialisation à chemin rapide, le générateur fait la même chose avec un nom pré-encodé — private static readonly JsonEncodedText PropName_Name = JsonEncodedText.Encode("Name"); — et un writer.WriteString(PropName_Name, value.Name) écrit à la main. Même forme : littéral plus accès direct au membre.

C’est strictement mieux que la réflexion, et cela signifie que le DTO n’a besoin d’aucun filet de sécurité [JsonPropertyName] ni d’aucune entrée exclude. La propriété peut être renommée et le contrat tient, parce que le contrat a été capturé lorsque le générateur s’est exécuté.

La seule façon dont cela casse encore est un chemin de code qui n’utilise pas le contexte. JsonSerializer.Serialize(customer) sans options, ou une chaîne de résolveurs qui se termine par DefaultJsonTypeInfoResolver, résout les noms depuis les métadonnées à l’exécution et produira {"b":"…"} après renommage. Vous ne voulez pas découvrir cela dans le build obfusqué. Désactivez le chemin par réflexion au niveau du projet :

<PropertyGroup>
  <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>

Désormais, tout appel qui se serait silencieusement rabattu sur la réflexion lève une exception en développement, et chaque chemin de sérialisation qui atteint la production est un chemin que le générateur a capturé.

[GeneratedRegex] : pure identité de type

public static partial class Parsers
{
    [GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$")]
    private static partial Regex IsoDate();
}

Le générateur complète IsoDate() pour renvoyer un singleton mis en cache d’une classe générée — un type sealed dérivant de Regex dont le constructeur définit pattern, roptions et factory, avec une RunnerFactory imbriquée qui crée un Runner imbriqué implémentant Scan, TryFindNextPossibleStartingPosition et TryMatchAtCurrentPosition. Rien là-dedans ne résout quoi que ce soit par nom. Le runner est atteint via le champ factory que la classe de base attend déjà ; l’appel de l’utilisateur est call Parsers::IsoDate() → ldsfld de l’instance. Renommez la méthode, la classe générée, la factory et le runner, et la regex correspond toujours.

Ce qui reste, c’est le littéral du motif, affecté dans le constructeur généré (base.pattern = "^\d{4}-\d{2}-\d{2}$"). C’est une chaîne ordinaire en IL et encryptStrings la masque au repos comme n’importe quelle autre.

La vraie considération ici est la performance, pas la correction. TryMatchAtCurrentPosition est la boucle de correspondance compilée — exactement le type de code serré et plein de branchements que l’aplatissement du flux de contrôle ralentit de façon mesurable. Si la regex s’exécute sur un chemin critique, tenez l’aplatissement à l’écart. Les types générés portent des noms décorés par le compilateur, trouvez donc les noms de méthodes exacts avec nebula inspect --methods et ajoutez-les à controlFlowExclude, ou — plus simple — limitez l’aplatissement aux méthodes que vous avez réellement besoin de protéger avec controlFlowInclude.

[LoggerMessage] : le générateur écrit nameof() pour vous

public static partial class Log
{
    [LoggerMessage(EventId = 12, Level = LogLevel.Warning,
                   Message = "Order {OrderId} rejected: {Reason}")]
    public static partial void OrderRejected(ILogger logger, int orderId, string reason);
}

La moitié générée est un callback LoggerMessage.Define mis en cache :

private static readonly Action<ILogger, int, string, Exception?> __OrderRejectedCallback =
    LoggerMessage.Define<int, string>(
        LogLevel.Warning,
        new EventId(12, nameof(OrderRejected)),            // ← your method name, as a string
        "Order {OrderId} rejected: {Reason}",
        new LogDefineOptions { SkipEnabledCheck = true });

public static partial void OrderRejected(ILogger logger, int orderId, string reason)
{
    if (logger.IsEnabled(LogLevel.Warning))
        __OrderRejectedCallback(logger, orderId, reason, null);
}

Ce nameof(OrderRejected) est évalué par le compilateur C#. Dans l’IL, c’est ldstr "OrderRejected" — il ne reste aucune référence à la méthode, seulement les caractères. Nebula renomme Log.OrderRejected en a.b ; l’EventId porte toujours le nom "OrderRejected". Deux conséquences :

  • Votre journalisation continue de fonctionner comme prévu. Les tableaux de bord et les alertes indexés sur le nom de l’événement voient le même nom avant et après l’obfuscation. Tant mieux.
  • Le binaire contient désormais une entrée de table de correspondance. Un décompilateur montre une méthode b dont l’initialiseur statique dit "OrderRejected". Pour une classe pleine de méthodes de journalisation, c’est un index lisible de ce que fait chaque méthode renommée. C’est le même mécanisme que celui décrit dans quand nameof fait fuir vos membres renommés, produit ici par un générateur plutôt que par votre propre code.

encryptStrings est le correctif, et c’est le bon : le littéral est chiffré dans l’assembly et déchiffré lorsque l’initialiseur statique s’exécute, de sorte que vos journaux disent toujours OrderRejected alors que le fichier sur disque ne le dit pas. Le modèle de message reçoit le même traitement, ce qui masque aussi les noms de paramètres {OrderId}/{Reason} — un détail, mais ils décrivent les arguments de la méthode.

Le générateur de liaison de configuration : des clés littérales au lieu d’un parcours des métadonnées

Le ConfigurationBinder basé sur la réflexion figure sur toutes les listes « à préserver » pour une bonne raison : Bind fait correspondre les clés de configuration aux noms de propriétés en réfléchissant sur le type d’options à l’exécution. .NET 8 a ajouté un binder généré par générateur de source :

<PropertyGroup>
  <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>

Une fois activé, builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp")) est intercepté et routé vers du code généré de cette forme :

public static void BindCore(IConfiguration configuration, ref SmtpOptions instance, BinderOptions? binderOptions)
{
    if (configuration["Host"] is string value0)
        instance.Host = value0;                                  // direct assignment, literal key

    if (configuration["Port"] is string value1)
        instance.Port = ParseInt(value1, configuration.GetSection("Port").Path);
}

"Host" et "Port" sont des ldstr ; instance.Host = … est un callvirt SmtpOptions::set_Host. Renommez SmtpOptions.Host en a.c et le binder généré lit toujours le Smtp:Host de appsettings.json et appelle toujours le setter renommé. L’entrée exclude pour vos classes de paramètres que le binder par réflexion vous imposait n’est plus nécessaire pour la liaison.

Vérifiez ce qui d’autre lit ces classes par nom avant de supprimer l’exclusion : ValidateDataAnnotations rapporte les noms de membres dans ses messages d’erreur (cosmétique), et tout appel Bind résiduel compilé sans le générateur utilise encore la réflexion. Le dossier des fichiers générés vous indique quels appels ont été interceptés.

Une configuration adaptée au code généré

En assemblant le tout pour un service typique qui utilise les quatre générateurs :

{
  "inputs": ["out/Acme.Service.dll"],
  "outputDirectory": "out/obf",
  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "controlFlowIntensity": "normal",
  "controlFlowExclude": [
    "Acme.Service.Parsers.<RegexGenerator_g>F…__IsoDate_0.Runner.TryMatchAtCurrentPosition"
  ]
}

encryptStrings est la ligne qui fait le plus gros du travail ici : elle couvre le motif de la regex, les modèles de journaux et les littéraux nameof en un seul réglage. controlFlowExclude tient l’aplatissement à l’écart de la boucle interne du runner de la regex (collez le nom décoré exact issu de nebula inspect --methods). Rien dans la configuration ne préserve le DTO, la classe d’options ou les méthodes de journalisation, parce que rien n’en a besoin.

La liste de contrôle pour tout générateur que vous n’avez pas encore rencontré est la même que celle que ce billet a appliquée quatre fois. Émettez les fichiers générés, ouvrez-les, et posez deux questions pour chaque accès à un membre : est-ce un jeton (un appel direct, un typeof, un délégué) ou une chaîne (GetProperty("…"), Type.GetType("…")) ? Les jetons sont sûrs à renommer. Puis examinez chaque littéral et demandez-vous s’il s’agit d’un contrat de transmission que vous avez besoin de garder inchangé (très bien — il est inchangé) ou d’une copie d’un nom que vous espériez masquer (chiffrez-la). Les générateurs qui font leur travail à la compilation sont, presque par construction, le code le plus facile à obfusquer de votre assembly. Voir Garder votre application fonctionnelle pour les règles côté réflexion que ces générateurs vous permettent d’assouplir.

Téléchargez Nebula.NET gratuitement et exécutez vos tests de sérialisation contre le build obfusqué.

Essayez Nebula.NET

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