Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETAnalyse approfondie

Obfusquer un assemblage F# : unions discriminées, l'attribut [CompilationMapping] et ce sur quoi FSharp.Core fait de la réflexion

F# compile vers le même IL que C#, donc un renommeur peut l'obfusquer — mais F# s'appuie sur une couche de métadonnées émises par le compilateur dont C# n'a aucun équivalent, et un renommeur qui ne la comprend pas casse en silence des choses qui compilent quand même. Une union discriminée n'est pas un type somme pour le CLR ; c'est une classe abstraite avec une sous-classe imbriquée par cas, un entier Tag et un attribut [CompilationMapping(SourceConstructFlags.SumType)] que la réflexion de FSharp.Core lit pour retrouver les cas. Renommez les types de cas aveuglément, ou retirez cet attribut comme des « métadonnées inutilisées », et FSharpType.GetUnionCases ne renvoie rien, l'impression avec %A cesse de nommer vos cas, et tout sérialiseur conscient de F# perd la forme — le tout avec une compilation propre. Cet article lit l'IL qu'une union, un enregistrement et un module émettent réellement, montre exactement quels noms et attributs sont porteurs parce que FSharp.Core les observe à l'exécution, et configure Nebula pour renommer la logique tout en préservant le contrat de réflexion dont dépend F#.

F# compile vers le même IL et les mêmes tables de métadonnées que C#, donc sur le papier un obfuscateur devrait le traiter à l”identique : renommer les identifiants, chiffrer les chaînes, aplatir le flux de contrôle. En pratique, F# porte une seconde couche de sens dont C# n”a aucun équivalent, et cette couche est invisible jusqu”à ce que quelque chose fasse de la réflexion dessus. Le compilateur enregistre, dans les métadonnées, des faits dont le CLR lui-même ne se soucie pas : ce type est une union discriminée, celui-là est un enregistrement, cette classe statique est en réalité un module. FSharp.Core relit ces faits à l”exécution, et un renommeur qui les traite comme du lest casse du code qui compile pourtant proprement.

Cet article lit ce que F# émet réellement, identifie exactement quels noms et attributs sont porteurs, et montre comment Nebula obfusque la logique en laissant intact le contrat d”exécution de F#.

Une union discriminée est une classe abstraite avec un Tag

Commençons par la construction qui fait de F# du F#. Cette union :

type Shape =
    | Circle of radius: float
    | Rectangle of width: float * height: float

n”est pas un type somme pour le CLR — le CLR n”a pas de types somme. Le compilateur l”abaisse en une classe de base abstraite Shape, une sous-classe imbriquée par cas, un entier Tag qui dit de quel cas est une instance, des membres fabrique statiques et — crucialement — des attributs qui enregistrent la forme F#. Décompilé, le squelette se lit :

.class public abstract auto ansi serializable Shape
       extends [runtime]System.Object
{
  .custom instance void [FSharp.Core]CompilationMappingAttribute::.ctor(valuetype SourceConstructFlags)
          = ( 01 00 01 00 00 00 00 00 )   // SourceConstructFlags.SumType

  .class nested public sealed auto ansi serializable Circle
         extends Shape
  {
    .custom instance void CompilationMappingAttribute::.ctor(SourceConstructFlags, int32, int32)
            = ( ... )   // UnionCase, caseIndex 0
    .method public hidebysig specialname instance float64 get_radius() cil managed
  }
  .method public static class Shape NewCircle(float64 radius) cil managed
  .method public hidebysig specialname instance int32 get_Tag() cil managed
}

Trois sortes de choses vivent ici, et elles ne sont pas également libres à renommer. L”attribut [CompilationMapping(SourceConstructFlags.SumType)] sur Shape est le marqueur que FSharp.Core cherche pour seulement reconnaître le type comme une union. L”entier Tag et ses valeurs par cas sont le contrat structurel — le pattern-matching est un switch sur Tag. Et les noms de cas (Circle, Rectangle) et les accesseurs de champ (get_radius) sont le contrat réflexif : ce sont les chaînes que FSharp.Core rend quand quelque chose demande « quels sont les cas de cette union ? ».

Ce qui compile, et ce que FSharp.Core relitclass Shape (abstraite)[CompilationMapping(SumType)] préserverint32 Tag — ordre : préserverimbriquée Circleget_radius()nom : préserverimbriquée Rectangleget_width/heightnom : préservercorps de méthode → a.b.c()locales, auxiliaires privés — renommer librementRéflexion de FSharp.Core, à l'exécutionFSharpType.GetUnionCases(t)→ lit l'attribut SumTypeFSharpValue.GetUnionFields(v)→ lit Tag + le nom du casprintf "%A" shape→ imprime « Circle 3.0 » depuis le nomobservé à l'exécution — pas dans votre source

Pourquoi une compilation propre casse quand même à l”exécution

Voici le piège en un exemple. Supposons qu”un renommeur naïf transforme Circle en a et Rectangle en b, parce qu”ils ressemblent à des classes imbriquées ordinaires. L”IL se lie encore — le pattern-matching fait un switch sur Tag, qui est un entier, donc le matching fonctionne encore et l”assemblage exécute bien sa logique centrale. Puis quelque chose appelle :

printfn "%A" (Circle 3.0)

%A est le formateur structuré, et il ne lit pas votre source — il fait de la réflexion. Il appelle FSharpValue.GetUnionFields, qui utilise l”attribut [CompilationMapping] pour trouver les cas, puis lit le nom du cas dans les métadonnées du membre pour imprimer. Après le renommage, ce nom est a, donc au lieu de Circle 3.0 vous obtenez a 3.0 — ou, si le renommeur a aussi retiré l”attribut [CompilationMapping] comme « inutilisé », GetUnionFields ne reconnaît plus Shape comme une union et lève ArgumentException: The type Shape is not a union type. L”égalité et la comparaison — que le compilateur F# a générées pour l”union et qui parcourent aussi la structure — peuvent échouer de la même façon. Rien de tout cela n”est une erreur de compilation. C”est une casse à l”exécution, dans du code que vous n”avez pas écrit, déclenchée par un renommage que vous croyiez libre.

Les enregistrements racontent la même histoire en miniature :

type Point = { X: float; Y: float }

compile en une classe avec [CompilationMapping(SourceConstructFlags.RecordType)] et chaque propriété portant un mappage SourceConstructFlags.Field avec son index. FSharpType.GetRecordFields les relit par nom et ordre. Renommez X/Y aveuglément et un sérialiseur conscient de F# qui fait transiter l”enregistrement par nom de champ écrit a/b dans votre JSON — un changement silencieux de format de données, pas un plantage, ce qui est pire. Un module ([CompilationMapping(SourceConstructFlags.Module)] sur la classe statique) est la même idée pour les fonctions de haut niveau.

La règle : préservez le contrat réflexif, renommez la logique

La surface porteuse dans un assemblage F# est spécifique et petite :

  • les attributs [CompilationMapping] — ils encodent *qu”*un type est une union, un enregistrement ou un module, et les retirer est ce qui aveugle la réflexion ;
  • les entiers Tag et leur ordre — l”identité structurelle de chaque cas ;
  • les noms de type de cas d”union, de champ d”enregistrement et leurs accesseurs — les chaînes que la réflexion de FSharp.Core expose ;
  • tout ce que vous faites explicitement transiter par un sérialiseur par nom.

Tout le reste se renomme : les corps de vos fonctions de module, les locales, les auxiliaires privés, et les types internes qui ne franchissent jamais une frontière de réflexion. C”est exactement la répartition que vous faites déjà sur un assemblage C# comportant de la réflexion — les métadonnées [CompilationMapping] sont la version F# des attributs [JsonPropertyName] et [DataContract] qu”un obfuscateur conscient de C# sait déjà respecter.

Configurer Nebula

La détection automatique de Nebula préserve déjà les cibles de réflexion et de sérialisation, et traite les métadonnées [CompilationMapping] comme un signal pour garder la forme F# plutôt que la retirer comme inutilisée. La configuration par défaut fait donc l”essentiel du travail ; vous ajoutez des exclusions explicites seulement pour les types que vous sérialisez par nom ou sur lesquels vous faites vous-même de la réflexion.

{
  "inputs": ["bin/Release/net8.0/MyFSharpApp.dll"],
  "outputDirectory": "protected",
  "preservePublicApi": true,
  "autoDetect": true,
  "exclude": [
    "MyFSharpApp.Domain.Shape",
    "MyFSharpApp.Dto.Point"
  ],
  "excludeStrings": ["Circle", "Rectangle"],
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "controlFlowExclude": []
}

Quelques choix délibérés. autoDetect reste activé — c”est ce qui garde intacts les attributs [CompilationMapping] et les noms de membre reflétés sans que vous listiez chaque union. preservePublicApi est activé parce que les types d”union et d”enregistrement publics d”une bibliothèque F# font partie de sa surface pour les consommateurs F#, qui se lient à ces noms à la compilation. Les entrées exclude explicites sont les deux types que cette app sérialise ou formate par nom — un nom complet de type y préserve le type et tous ses membres, donc les cas d”union et les champs d”enregistrement gardent leurs noms. Et controlFlowObfuscation s”applique librement aux corps de méthode : aplatir l”IL d”une fonction de module ne change rien de ce que la couche de réflexion observe, car la réflexion lit des métadonnées, pas du flux de contrôle.

Puis l”étape non négociable, la même sur laquelle se termine chaque article d”obfuscation de ce blog : exécutez les chemins F# contre le binaire obfusqué. Un seul test qui fait transiter chaque union et chaque enregistrement par %A, par FSharpValue.GetUnionFields et par le sérialiseur que vous utilisez — exécuté contre la sortie protégée en CI — transforme « les métadonnées ont survécu » d”un espoir en une compilation verte. Le contrat d”exécution F# casse à l”exécution ou pas du tout, c”est donc l”exécution qu”il faut vérifier.

À retenir

F# s”obfusque aussi bien que C#, avec une chose de plus à respecter : les métadonnées émises par le compilateur qui font d”une union une union, d”un enregistrement un enregistrement et d”un module un module. Le CLR les ignore ; FSharp.Core en vit. Préservez les attributs [CompilationMapping], l”ordre des Tag et les noms de cas et de champ que lit la réflexion, et renommez tout le reste — et votre assemblage F# ressort aussi difficile à lire que n”importe quel assemblage C#, tandis que %A imprime encore Circle, GetUnionFields trouve encore vos cas, et votre sérialiseur écrit encore les noms de champ que vous avez livrés. La logique est obfusquée ; le contrat dont dépend F#, non.

Essayez Nebula.NET

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