Ein F#-Assembly obfuskieren: diskriminierte Unions, das Attribut [CompilationMapping] und worüber FSharp.Core Reflection betreibt
F# kompiliert zu demselben IL wie C#, also kann ein Umbenenner es obfuskieren — aber F# stützt sich auf eine Schicht vom Compiler emittierter Metadaten, für die C# kein Gegenstück hat, und ein Umbenenner, der sie nicht versteht, zerbricht still Dinge, die trotzdem kompilieren. Eine diskriminierte Union ist für die CLR kein Summentyp; sie ist eine abstrakte Klasse mit einer verschachtelten Unterklasse pro Fall, einem Tag-Integer und einem Attribut [CompilationMapping(SourceConstructFlags.SumType)], das die Reflection von FSharp.Core liest, um die Fälle wiederherzustellen. Benennen Sie die Fall-Typen blind um oder entfernen Sie dieses Attribut als „ungenutzte Metadaten", und FSharpType.GetUnionCases gibt nichts zurück, die Ausgabe mit %A benennt Ihre Fälle nicht mehr, und jeder F#-bewusste Serialisierer verliert die Form — alles bei sauberer Kompilierung. Dieser Artikel liest das IL, das eine Union, ein Record und ein Modul wirklich emittieren, zeigt genau, welche Namen und Attribute tragend sind, weil FSharp.Core sie zur Laufzeit beobachtet, und konfiguriert Nebula so, dass es die Logik umbenennt und zugleich den Reflection-Vertrag bewahrt, von dem F# abhängt.
F# kompiliert zu demselben IL und denselben Metadatentabellen wie C#, also sollte ein Obfuskator es auf dem Papier identisch behandeln: die Bezeichner umbenennen, die Strings verschlüsseln, den Kontrollfluss abflachen. In der Praxis trägt F# eine zweite Bedeutungsschicht, für die C# kein Gegenstück hat, und diese Schicht ist unsichtbar, bis etwas Reflection darüber betreibt. Der Compiler verzeichnet in den Metadaten Fakten, die der CLR selbst gleichgültig sind: dieser Typ ist eine diskriminierte Union, jener ist ein Record, diese statische Klasse ist eigentlich ein Modul. FSharp.Core liest diese Fakten zur Laufzeit zurück, und ein Umbenenner, der sie als Ballast behandelt, zerbricht Code, der dennoch sauber kompiliert.
Dieser Artikel liest, was F# wirklich emittiert, benennt genau, welche Namen und Attribute tragend sind, und zeigt, wie Nebula die Logik obfuskiert und zugleich den F#-Laufzeitvertrag intakt lässt.
Eine diskriminierte Union ist eine abstrakte Klasse mit einem Tag
Beginnen wir mit dem Konstrukt, das F# zu F# macht. Diese Union:
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
ist für die CLR kein Summentyp — die CLR hat keine Summentypen. Der Compiler senkt sie zu einer abstrakten Basisklasse Shape ab, einer verschachtelten Unterklasse pro Fall, einem Integer Tag, der sagt, von welchem Fall eine Instanz ist, statischen Fabrikmembern und — entscheidend — Attributen, die die F#-Form verzeichnen. Dekompiliert liest sich das Gerüst:
.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
}
Drei Arten von Dingen leben hier, und sie sind nicht gleich frei umzubenennen. Das Attribut [CompilationMapping(SourceConstructFlags.SumType)] auf Shape ist das Kennzeichen, nach dem FSharp.Core sucht, um den Typ überhaupt als Union zu erkennen. Der Integer Tag und seine Werte pro Fall sind der strukturelle Vertrag — Pattern-Matching ist ein switch auf Tag. Und die Fallnamen (Circle, Rectangle) und die Feld-Zugriffsmethoden (get_radius) sind der reflexive Vertrag: Sie sind die Strings, die FSharp.Core zurückgibt, wenn etwas fragt „welche Fälle hat diese Union?”.
Warum eine saubere Kompilierung dennoch zur Laufzeit zerbricht
Hier die Falle an einem Beispiel. Angenommen, ein naiver Umbenenner macht aus Circle ein a und aus Rectangle ein b, weil sie wie gewöhnliche verschachtelte Klassen aussehen. Das IL bindet noch — Pattern-Matching macht einen switch auf Tag, einen Integer, also funktioniert das Matching noch und das Assembly führt seine Kernlogik gut aus. Dann ruft etwas auf:
printfn "%A" (Circle 3.0)
%A ist der strukturierte Formatierer, und er liest nicht Ihren Quellcode — er betreibt Reflection. Er ruft FSharpValue.GetUnionFields auf, das über das [CompilationMapping]-Attribut die Fälle findet und dann den Fallnamen aus den Member-Metadaten liest, um ihn auszugeben. Nach der Umbenennung ist dieser Name a, also erhalten Sie statt Circle 3.0 ein a 3.0 — oder, falls der Umbenenner auch das [CompilationMapping]-Attribut als „ungenutzt” entfernt hat, erkennt GetUnionFields Shape nicht mehr als Union und wirft ArgumentException: The type Shape is not a union type. Gleichheit und Vergleich — die der F#-Compiler für die Union generiert hat und die ebenfalls die Struktur durchlaufen — können genauso scheitern. Nichts davon ist ein Kompilierfehler. Es ist ein Bruch zur Laufzeit, in Code, den Sie nicht geschrieben haben, ausgelöst durch eine Umbenennung, die Sie für frei hielten.
Records erzählen dieselbe Geschichte im Kleinen:
type Point = { X: float; Y: float }
kompiliert zu einer Klasse mit [CompilationMapping(SourceConstructFlags.RecordType)], wobei jede Eigenschaft ein SourceConstructFlags.Field-Mapping mit ihrem Index trägt. FSharpType.GetRecordFields liest diese nach Name und Reihenfolge zurück. Benennen Sie X/Y blind um, und ein F#-bewusster Serialisierer, der den Record nach Feldnamen hin und zurück führt, schreibt a/b in Ihr JSON — eine stille Datenformat-Änderung, kein Absturz, was schlimmer ist. Ein Modul ([CompilationMapping(SourceConstructFlags.Module)] auf der statischen Klasse) ist dieselbe Idee für Top-Level-Funktionen.
Die Regel: den reflexiven Vertrag bewahren, die Logik umbenennen
Die tragende Oberfläche in einem F#-Assembly ist spezifisch und klein:
- die
[CompilationMapping]-Attribute — sie kodieren, dass ein Typ eine Union, ein Record oder ein Modul ist, und sie zu entfernen macht die Reflection blind; - die
Tag-Integer und ihre Reihenfolge — die strukturelle Identität jedes Falls; - die Union-Fall-Typnamen, Record-Feldnamen und ihre Zugriffsmethoden — die Strings, die die Reflection von FSharp.Core offenlegt;
- alles, was Sie explizit nach Namen durch einen Serialisierer hin und zurück führen.
Alles andere wird umbenannt: die Rümpfe Ihrer Modulfunktionen, Locals, private Hilfsfunktionen und interne Typen, die nie eine Reflection-Grenze überschreiten. Es ist genau die Aufteilung, die Sie bereits bei einem C#-Assembly mit Reflection vornehmen — die [CompilationMapping]-Metadaten sind die F#-Version der Attribute [JsonPropertyName] und [DataContract], die ein C#-bewusster Obfuskator bereits zu respektieren weiß.
Nebula konfigurieren
Nebulas Autoerkennung bewahrt bereits Reflection- und Serialisierungsziele und behandelt die [CompilationMapping]-Metadaten als Signal, die F#-Form zu behalten statt sie als ungenutzt zu entfernen. Die Standardkonfiguration erledigt also den Großteil der Arbeit; Sie fügen explizite Ausnahmen nur für Typen hinzu, die Sie namentlich serialisieren oder über die Sie selbst Reflection betreiben.
{
"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": []
}
Einige bewusste Entscheidungen. autoDetect bleibt an — es hält die [CompilationMapping]-Attribute und die reflektierten Member-Namen intakt, ohne dass Sie jede Union auflisten. preservePublicApi ist an, weil die öffentlichen Union- und Record-Typen einer F#-Bibliothek Teil ihrer Oberfläche für F#-Verbraucher sind, die sich zur Kompilierzeit an diese Namen binden. Die expliziten exclude-Einträge sind die zwei Typen, die diese App namentlich serialisiert oder formatiert — ein voller Typname dort bewahrt den Typ und alle seine Member, sodass die Union-Fälle und Record-Felder ihre Namen behalten. Und controlFlowObfuscation gilt frei für die Methodenrümpfe: Das Abflachen des IL einer Modulfunktion ändert nichts, was die Reflection-Schicht beobachtet, denn Reflection liest Metadaten, nicht Kontrollfluss.
Dann der nicht verhandelbare Schritt, derselbe, mit dem jeder Obfuskierungs-Artikel dieses Blogs endet: Führen Sie die F#-Pfade gegen das obfuskierte Binary aus. Ein einziger Test, der jede Union und jeden Record durch %A, durch FSharpValue.GetUnionFields und durch den von Ihnen verwendeten Serialisierer hin und zurück führt — ausgeführt gegen die geschützte Ausgabe in CI — macht aus „die Metadaten haben überlebt” aus einer Hoffnung eine grüne Kompilierung. Der F#-Laufzeitvertrag zerbricht zur Laufzeit oder gar nicht, also ist es die Laufzeit, die Sie prüfen müssen.
Fazit
F# lässt sich so gut obfuskieren wie C#, mit einer zusätzlichen Sache zu respektieren: den vom Compiler emittierten Metadaten, die eine Union zur Union, einen Record zum Record und ein Modul zum Modul machen. Die CLR ignoriert sie; FSharp.Core lebt von ihnen. Bewahren Sie die [CompilationMapping]-Attribute, die Tag-Reihenfolge und die Fall- und Feldnamen, die die Reflection liest, und benennen Sie alles andere um — und Ihr F#-Assembly kommt so schwer lesbar heraus wie jedes C#-Assembly, während %A noch Circle ausgibt, GetUnionFields noch Ihre Fälle findet und Ihr Serialisierer noch die ausgelieferten Feldnamen schreibt. Die Logik ist obfuskiert; der Vertrag, von dem F# abhängt, nicht.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.