Eine Multi-Target-Assembly obfuskieren: eine Konfiguration für net48 und net8.0
Eine Bibliothek, die net48;net8.0 anvisiert, wird zu zwei verschiedenen Assemblies gebaut, mit zwei verschiedenen Referenzmengen und manchmal zwei verschiedenen IL-Varianten. Nur eine davon zu schützen liefert einen unobfuskierten Build aus. So behandelt Nebula.NET jedes Zielframework als eigenen Obfuskierungslauf mit einer gemeinsamen Policy — und das sind die Pro-TFM-Fallstricke, die zuschnappen, wenn Sie es nicht tun.
Fügen Sie einer .csproj ein zweites Zielframework hinzu, und Sie haben Ihre Auslieferungsfläche stillschweigend verdoppelt. <TargetFrameworks>net48;net8.0</TargetFrameworks> weist den Compiler an, das Projekt zweimal zu bauen — in zwei getrennte Ordner, als zwei eigenständige Assemblies. Sie teilen Namen und Version, sind aber nicht dasselbe Binary: Sie binden gegen unterschiedliche Referenz-Assemblies, und sobald Ihr Code ein einziges #if NET8_0_OR_GREATER enthält, enthalten sie nicht einmal dieselbe IL. Ein Kunde, der den net48-Build installiert, und ein Kunde auf net8.0 führen unterschiedliche DLLs aus.
Das wird in dem Moment relevant, in dem Sie das Ganze schützen. Richten Sie Ihren Obfuskator auf einen Ausgabeordner — den, den Ihr Entwicklungsrechner zufällig baut —, und Sie haben genau eine der beiden Assemblies gehärtet, die Sie ausliefern. Die andere geht im Klartext raus. In diesem Beitrag geht es darum, es mit Nebula.NET richtig zu machen: jedes Zielframework als eigenen Obfuskierungslauf gegen seine eigenen Referenzen zu behandeln, während eine einzige Policy alle regiert — und um die Handvoll Pro-TFM-Details, die Sie beißen, wenn Sie nicht darauf achten.
Was „Multi-Target“ tatsächlich erzeugt
Ein Multi-Target-Build ist nicht ein Build mit einer Wahl am Ende. MSBuild führt den inneren Build einmal pro TFM aus und legt die Ergebnisse nebeneinander ab:
MyLib/bin/Release/
├─ net48/
│ └─ MyLib.dll → references mscorlib, System, System.Core, …
└─ net8.0/
└─ MyLib.dll → references System.Runtime, System.Private.CoreLib, …
Derselbe Name MyLib.dll, zwei verschiedene Assemblies. In einem NuGet-Paket zeigt sich dieselbe Aufteilung als lib/net48/MyLib.dll und lib/net8.0/MyLib.dll, und die Restore-Engine wählt für jeden Konsumenten die beste Übereinstimmung. „MyLib.dll obfuskieren“ ist also mehrdeutig — es gibt zwei davon, und beide müssen geschützt werden, jede gegen die Referenzmenge, für die sie kompiliert wurde, sonst sind Signaturprüfung und Referenz-Rewriting falsch.
Der richtige Weg: eine Policy, ein Lauf pro Framework
Nebula obfuskiert kompilierte Assemblies, nicht Quellcode — nichts davon erfordert also Codeänderungen; es hängt sich an die Build-Ausgabe. Die sauberste Integration ist das MSBuild-Paket, das Multi-Targeting nativ versteht. Fügen Sie das Build-only-Paket hinzu und knüpfen Sie es an ein Signal, das nur auf Ihrem Build-Agenten existiert, damit Entwickler-Builds unberührt bleiben:
<ItemGroup>
<PackageReference Include="Delta1Labs.Nebula.MSBuild" Version="1.1.6" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup Condition="'$(NEBULA_OBFUSCATE)' == '1'">
<NebulaObfuscate>true</NebulaObfuscate>
<NebulaConfigFile>$(MSBuildProjectDirectory)\nebula.config.json</NebulaConfigFile>
</PropertyGroup>
Das ist die gesamte Verdrahtung für ein Multi-Target-Projekt. Das Paket läuft nach jedem inneren Build, daher wird jedes Zielframework automatisch in seinen eigenen Pro-TFM-Ausgabeordner nebula geschützt — Sie listen die Frameworks nicht auf. Die net48-DLL wird innerhalb des net48-Builds mit den net48-Referenzen obfuskiert; die net8.0-DLL innerhalb des net8.0-Builds mit dessen Referenzen. Eine nebula.config.json steuert beides.
Und diese Konfiguration enthält keine inputs — sie ist eine reine Einstellungs-Policy. Der Build liefert die Assembly; die Datei liefert die Transformationen und Regeln:
{
"preservePublicApi": true,
"controlFlowObfuscation": true,
"encryptStrings": true,
"obfuscateConstants": true,
"metadataHardening": true,
"antiTamper": true,
"exclude": [
"MyLib.Interop.NativeMethods"
],
"excludeNamespaces": [
"MyLib.Abstractions"
]
}
Diese Transformationen (unbegrenzte String-/Kontrollfluss-Obfuskierung, Konstantenmaskierung, Metadaten-Härtung, Anti-Tamper) sind Pro-Funktionen; die Umbenennung von Bezeichnern ist in jeder Edition enthalten. Der Punkt ist, dass dieselbe Policy auf beide Assemblies identisch angewendet wird — Sie pflegen keine nebula.net48.json und nebula.net8.json.
Wenn Sie lieber gar keine Paketreferenzen haben möchten, steuern Sie die CLI aus einer Directory.Build.targets an und lassen die Pro-TFM-Bedingung die Verteilung für Sie übernehmen:
<Project>
<Target Name="NebulaObfuscate" AfterTargets="Build"
Condition="'$(NEBULA_OBFUSCATE)' == '1' and '$(TargetFramework)' != ''">
<Exec Command="nebula --config "$(MSBuildThisFileDirectory)nebula.config.json" --input "$(TargetPath)" --output "$(TargetDir)nebula"" />
</Target>
</Project>
Die Bedingung '$(TargetFramework)' != '' leistet hier echte Arbeit. In einem Multi-Target-Build läuft das Target einmal innerhalb jedes konkreten inneren Builds — wo $(TargetFramework) net48 ist, dann net8.0 — und $(TargetPath) zeigt auf die eigene DLL dieses Frameworks. Die Bedingung überspringt den äußeren aggregierten Build, in dem $(TargetFramework) leer ist und es keine einzelne Assembly zu verarbeiten gibt. Ohne sie laufen Sie entweder gegen nichts oder laufen einmal und verpassen ein Framework.
Die Pro-TFM-Fallstricke
Referenzen werden pro Framework aufgelöst — lassen Sie den Build sie übergeben
Umbenennung und der abschließende Verifikationsdurchlauf müssen beide die echten Referenzen der Assembly sehen: um eine Querreferenz umzuschreiben oder um zu bestätigen, dass ein transformiertes Modul weiterhin auflösbar ist. Diese Referenzen unterscheiden sich je TFM — mscorlib/System.* für net48, die System.Runtime-Fassadenmenge für net8.0. Die Obfuskierung innerhalb jedes inneren Builds auszuführen (beide obigen Ansätze tun das) bedeutet, dass Nebula gegen genau die richtige Referenzmenge für das jeweilige Framework bindet. Lassen Sie verifyOutput eingeschaltet (standardmäßig ist es das): Es prüft das transformierte Modul vor dem Schreiben erneut auf hängende Referenzen und weigert sich, ein Modul zu emittieren, das nicht laden würde — genau der Fehler, den Sie sonst erst entdecken würden, wenn die App des net48-Kunden nicht startet.
#if bedeutet: Die beiden Assemblies haben nicht dieselben Member
Bedingte Kompilierung ist der subtile Fall. Betrachten Sie:
public sealed class Cache
{
#if NET8_0_OR_GREATER
public void Store(ReadOnlySpan<byte> data) { /* span path */ }
#else
public void Store(byte[] data) { /* array path */ }
#endif
internal void Evict() { /* both */ }
}
Die net8.0-Assembly hat ein Store(ReadOnlySpan<byte>); die net48-Assembly ein Store(byte[]). Wenn Sie einen exclude- oder include-Eintrag für einen Member schreiben, trifft er unter den TFMs, in denen dieser Member existiert, und tut unter den anderen stillschweigend nichts. Das ist meist in Ordnung — aber es heißt, Sie dürfen nicht annehmen, dass eine für net8.0 vollständige Allowlist auch für net48 vollständig ist. Verlassen Sie sich auf autoDetect (standardmäßig aktiv), um Namen zu bewahren, die für die Korrektheit nötig sind — Reflection-, Serialisierungs-, DI- und XAML-Ziele — über jedes Target hinweg, und reservieren Sie manuelle exclude-Einträge für die wirklich frameworkspezifische Oberfläche.
Die öffentliche API für eine ausgelieferte Bibliothek konsistent bewahren
Für eine Bibliothek, die andere referenzieren, behalten Sie preservePublicApi: true, damit die öffentlichen Typ- und Member-Namen in beiden Assemblies erhalten bleiben — ein Konsument, der selbst Multi-Targeting gegen Sie betreibt, muss dieselbe öffentliche Oberfläche sehen, egal welche Ihrer DLLs er auflöst. Nur die Interna umzubenennen härtet weiterhin die wertvolle Logik und lässt die aufrufbare API intakt. (Für eine Endanwender-App, die niemand referenziert, schalten Sie es aus und benennen alles Zulässige um.)
Jedes Framework bekommt seine eigene Symbol-Map
Jeder Lauf emittiert neben seiner Ausgabe eine Symbol-(Umbenennungs-)Map — und weil die beiden Assemblies unabhängig voneinander umbenannt wurden, unterscheiden sich die Maps pro TFM. Wenn Sie einen Stack-Trace aus dem Feld deobfuskieren, müssen Sie die Map des Frameworks verwenden, aus dem der Absturz stammt: Einen net8.0-Trace durch die net48-Map zu schicken erzeugt Unsinn. Bewahren Sie die Pro-TFM-Ausgabeordner nebula als Build-Artefakte auf, nach Framework benannt, damit die richtige Map immer auffindbar ist. Die Mechanik der Rückabbildung eines Traces finden Sie in Stack-Traces deobfuskieren.
Erst schützen, dann packen
Wenn Sie ein NuGet-Paket ausliefern, müssen die DLLs unter lib/net48 und lib/net8.0 innerhalb der .nupkg jeweils die obfuskierte Variante sein, nicht die rohe Compiler-Ausgabe. Führen Sie den Schutz vor dem Pack-Schritt aus und richten Sie das Packaging auf die geschützten Assemblies — das vollständige Rezept steht in Ein NuGet-Paket obfuskieren. Bei einem Multi-Assembly-Produkt, in dem sich Ihre TFMs gegenseitig referenzieren, obfuskieren Sie die veröffentlichte Ausgabe in einem Schritt, damit Querreferenzen zwischen Assemblies konsistent bleiben; siehe Mehrere Assemblies.
Beide prüfen, nicht eine
Der Fehlerfall, vor dem dieser ganze Beitrag schützt, ist unsichtbar: Ihre App läuft weiter, Ihre Tests bleiben grün, und eine Ihrer beiden ausgelieferten Assemblies ist im Klartext. Prüfen Sie also beide. Öffnen Sie die Ausgabe-DLL jedes Frameworks in einem Decompiler und bestätigen Sie, dass die Interna umbenannt und die Strings verschlüsselt sind — der kostenlose Decompiler Glass.NET ist praktisch für einen schnellen Vorher/Nachher-Vergleich je TFM. Bestätigen Sie, dass für jedes eine Pro-TFM-Symbol-Map existiert. Und halten Sie das Build-Agent-Gate ehrlich, damit der geschützte Build derjenige ist, der das Haus verlässt.
Multi-Targeting ist für Bibliotheken inzwischen die Norm, und das bedeutet, dass Ihre Obfuskierungsgeschichte eine Pro-Framework-Geschichte ist — ob Sie es geplant haben oder nicht. Die gute Nachricht: Es kostet fast nichts, es richtig zu machen — eine reine Einstellungs-Policy, Schutz, der innerhalb jedes inneren Builds läuft, und die Disziplin, jedes ausgelieferte TFM zu prüfen. Starten Sie mit dem MSBuild- & CI-Leitfaden und der Konfigurationsreferenz für die genauen Eigenschaften und Felder.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.