Skip to content
← Todas las publicaciones
· Delta1 Labs Nebula.NETOfuscación.NETAnálisis a fondo

Ofuscar un ensamblado de F#: uniones discriminadas, el atributo [CompilationMapping] y sobre qué reflexiona FSharp.Core

F# compila al mismo IL que C#, así que un renombrador puede ofuscarlo, pero F# se apoya en una capa de metadatos emitidos por el compilador para la que C# no tiene equivalente, y un renombrador que no la entiende rompe en silencio cosas que aun así compilan. Una unión discriminada no es un tipo suma para el CLR; es una clase abstracta con una subclase anidada por caso, un entero Tag y un atributo [CompilationMapping(SourceConstructFlags.SumType)] que la reflexión de FSharp.Core lee para recuperar los casos. Renombra los tipos de caso a ciegas, o elimina ese atributo como "metadatos sin usar", y FSharpType.GetUnionCases no devuelve nada, la impresión con %A deja de nombrar tus casos y cualquier serializador consciente de F# pierde la forma, todo con una compilación limpia. Este artículo lee el IL que una unión, un registro y un módulo emiten de verdad, muestra exactamente qué nombres y atributos son de carga porque FSharp.Core los observa en tiempo de ejecución, y configura Nebula para renombrar la lógica preservando el contrato de reflexión del que depende F#.

F# compila al mismo IL y las mismas tablas de metadatos que C#, así que sobre el papel un ofuscador debería tratarlo igual: renombrar los identificadores, cifrar las cadenas, aplanar el flujo de control. En la práctica F# lleva una segunda capa de significado para la que C# no tiene equivalente, y esa capa es invisible hasta que algo reflexiona sobre ella. El compilador registra, en metadatos, hechos que al propio CLR no le importan: este tipo es una unión discriminada, aquel es un registro, esta clase estática es en realidad un módulo. FSharp.Core relee esos hechos en tiempo de ejecución, y un renombrador que los trate como peso muerto rompe código que aun así compila limpiamente.

Este artículo lee lo que F# emite de verdad, identifica exactamente qué nombres y atributos son de carga, y muestra cómo Nebula ofusca la lógica dejando intacto el contrato de ejecución de F#.

Una unión discriminada es una clase abstracta con un Tag

Empecemos por el constructo que hace que F# sea F#. Esta unión:

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

no es un tipo suma para el CLR —el CLR no tiene tipos suma—. El compilador la rebaja a una clase base abstracta Shape, una subclase anidada por caso, un entero Tag que dice de qué caso es una instancia, miembros fábrica estáticos y —crucialmente— atributos que registran la forma de F#. Descompilado, el esqueleto se lee:

.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
}

Aquí viven tres clases de cosa, y no son igual de libres de renombrar. El atributo [CompilationMapping(SourceConstructFlags.SumType)] en Shape es la marca que FSharp.Core busca para siquiera reconocer el tipo como unión. El entero Tag y sus valores por caso son el contrato estructural: el patrón-matching es un switch sobre Tag. Y los nombres de caso (Circle, Rectangle) y los accesores de campo (get_radius) son el contrato reflexivo: son las cadenas que FSharp.Core devuelve cuando algo pregunta “¿cuáles son los casos de esta unión?”.

Qué compila, y qué relee FSharp.Coreclass Shape (abstracta)[CompilationMapping(SumType)] preservarint32 Tag — orden: preservaranidada Circleget_radius()nombre: preservaranidada Rectangleget_width/heightnombre: preservarcuerpos de método → a.b.c()locales, ayudantes privados — renombrar librementeReflexión de FSharp.Core, en ejecuciónFSharpType.GetUnionCases(t)→ lee el atributo SumTypeFSharpValue.GetUnionFields(v)→ lee Tag + el nombre del casoprintf "%A" shape→ imprime "Circle 3.0" desde el nombreobservado en ejecución — no en tu código

Por qué una compilación limpia aún se rompe en ejecución

Aquí está la trampa en un ejemplo. Supón que un renombrador ingenuo convierte Circle en a y Rectangle en b, porque parecen clases anidadas corrientes. El IL aún enlaza —el patrón-matching hace switch sobre Tag, que es un entero, así que el matching sigue funcionando y el ensamblado ejecuta bien su lógica central—. Entonces algo llama a:

printfn "%A" (Circle 3.0)

%A es el formateador estructurado, y no lee tu código fuente: reflexiona. Llama a FSharpValue.GetUnionFields, que usa el atributo [CompilationMapping] para hallar los casos y luego lee el nombre del caso de los metadatos del miembro para imprimir. Tras el renombrado ese nombre es a, así que en vez de Circle 3.0 obtienes a 3.0 —o, si el renombrador además eliminó el atributo [CompilationMapping] como “sin usar”, GetUnionFields ya no reconoce Shape como unión y lanza ArgumentException: The type Shape is not a union type—. La igualdad y la comparación —que el compilador de F# generó para la unión y que también recorren la estructura— pueden fallar igual. Nada de esto es un error de compilación. Es una rotura en ejecución, en código que tú no escribiste, disparada por un renombrado que creías libre.

Los registros cuentan la misma historia en miniatura:

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

compila a una clase con [CompilationMapping(SourceConstructFlags.RecordType)] y cada propiedad con un mapeo SourceConstructFlags.Field con su índice. FSharpType.GetRecordFields los relee por nombre y orden. Renombra X/Y a ciegas y un serializador consciente de F# que viaje de ida y vuelta el registro por nombre de campo escribe a/b en tu JSON —un cambio silencioso de formato de datos, no un fallo, que es peor—. Un módulo ([CompilationMapping(SourceConstructFlags.Module)] en la clase estática) es la misma idea para funciones de nivel superior.

La regla: preserva el contrato reflexivo, renombra la lógica

La superficie de carga en un ensamblado F# es específica y pequeña:

  • los atributos [CompilationMapping] —codifican que un tipo es una unión, registro o módulo, y quitarlos es lo que deja ciega la reflexión—;
  • los enteros Tag y su orden —la identidad estructural de cada caso—;
  • los nombres de tipo de caso de unión, los nombres de campo de registro y sus accesores —las cadenas que expone la reflexión de FSharp.Core—;
  • cualquier cosa que viajes explícitamente de ida y vuelta por un serializador por nombre.

Todo lo demás se renombra: los cuerpos de tus funciones de módulo, locales, ayudantes privados, y tipos internos que nunca cruzan un límite de reflexión. Es exactamente la división que ya haces en un ensamblado C# que usa reflexión: los metadatos [CompilationMapping] son la versión de F# de los atributos [JsonPropertyName] y [DataContract] que un ofuscador consciente de C# ya sabe respetar.

Configurar Nebula

La autodetección de Nebula ya preserva destinos de reflexión y serialización, y trata los metadatos [CompilationMapping] como una señal para mantener la forma de F# en vez de quitarla como sin usar. Así que la configuración por defecto hace casi todo el trabajo; añades exclusiones explícitas solo para tipos que serializas por nombre o sobre los que reflexionas tú mismo.

{
  "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": []
}

Unas cuantas decisiones deliberadas. autoDetect queda activo —es lo que mantiene intactos los atributos [CompilationMapping] y los nombres de miembro reflejados sin que listes cada unión—. preservePublicApi está activo porque los tipos de unión y registro públicos de una librería F# son parte de su superficie para los consumidores de F#, que enlazan a esos nombres en tiempo de compilación. Las entradas exclude explícitas son los dos tipos que esta app serializa o formatea por nombre —un nombre completo de tipo ahí preserva el tipo y todos sus miembros, así que los casos de unión y los campos de registro conservan sus nombres—. Y controlFlowObfuscation se aplica libremente a los cuerpos de método: aplanar el IL de una función de módulo no cambia nada que observe la capa de reflexión, porque la reflexión lee metadatos, no flujo de control.

Luego el paso no negociable, el mismo con el que acaba cada artículo de ofuscación de este blog: ejecuta las rutas de F# contra el binario ofuscado. Una sola prueba que viaje cada unión y registro de ida y vuelta por %A, por FSharpValue.GetUnionFields y por el serializador que uses —ejecutada contra la salida protegida en CI— convierte “los metadatos sobrevivieron” de una esperanza en una compilación verde. El contrato de ejecución de F# se rompe en ejecución o nada, así que es la ejecución lo que tienes que comprobar.

La conclusión

F# se ofusca tan bien como C#, con una cosa extra que respetar: los metadatos emitidos por el compilador que hacen que una unión sea unión, un registro sea registro y un módulo sea módulo. El CLR los ignora; FSharp.Core vive de ellos. Preserva los atributos [CompilationMapping], el orden de Tag y los nombres de caso y campo que lee la reflexión, y renombra todo lo demás —y tu ensamblado F# sale tan difícil de leer como cualquiera de C#, mientras %A aún imprime Circle, GetUnionFields aún halla tus casos y tu serializador aún escribe los nombres de campo que entregaste—. La lógica está ofuscada; el contrato del que depende F#, no.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.