Skip to content
← Alle Beiträge
· Delta1 Labs Nebula.NETObfuskierungNative AOT

.NET-Apps mit Native AOT und Single-File obfuskieren

Die Veröffentlichung als Single-File oder mit Native AOT ist Verpackung und Kompilierung, keine Schutzmaßnahme. Hier steht, was jeder Modus wirklich mit Ihrem Code macht, warum Single-File-Apps weiterhin vollständig wiederherstellbares verwaltetes IL ausliefern, was Native AOT verbirgt und was nicht – und warum die Obfuskierung auf dem IL laufen muss, bevor das Bundle gebaut oder der AOT-Compiler es entfernt.

In .NET-Teams kursiert eine beruhigende Überzeugung: Wenn man seine App als Single-File veröffentlicht oder mit Native AOT kompiliert, muss man sie nicht mehr obfuskieren. Die Begründung klingt plausibel – „es gibt keine DLL mehr zum Dekompilieren”. Sie ist auch falsch, und im Single-File-Fall gefährlich falsch. Das Veröffentlichungsformat ist keine Schutzgrenze. Single-File ist Verpackung; Native AOT ist Kompilierung. Keines von beiden wurde entworfen, um Reverse Engineering zu stoppen, und sie so zu behandeln, lässt genau den Code, den Sie am meisten verbergen wollten, offen liegen.

Dieser Beitrag handelt davon, was jeder Veröffentlichungsmodus wirklich mit Ihrem Code macht, was er schützt und was nicht, und von der einen strukturellen Tatsache, die alles entscheidet: Die Obfuskierung muss auf dem verwalteten IL stattfinden, bevor das Bundle gebaut wird oder der AOT-Compiler läuft – denn danach bleibt nichts mehr zum Transformieren übrig.

Single-File ist ein Container, kein Schloss

Wenn Sie PublishSingleFile setzen, verschmilzt das SDK Ihren Code nicht zu einem unteilbaren Block. Es erzeugt eine native Host-Executable mit den Dateien Ihrer Anwendung – verwaltete Assemblys, Konfiguration, native Abhängigkeiten – als Ressourcen darin eingebettet. Beim Start findet der Host diese eingebetteten Assemblys und lädt sie direkt aus dem Bundle. Und das Entscheidende: Die verwalteten Assemblys sind weiterhin genau das, was der C#-Compiler ausgegeben hat – gewöhnliche .NET-Assemblys mit gewöhnlichem IL und vollständigen Metadaten.

Das heißt, die einzelne .exe ist ein Container, und der Container hat ein dokumentiertes Layout. Öffentlich verfügbare Bundle-Extractoren lesen dieses Layout und schreiben jedes eingebettete Assembly als normale .dll wieder auf die Festplatte. Der Hin- und Rückweg dauert Sekunden und erfordert kein Können:

# ein Angreifer vor Ihrer „geschützten" Single-File-App
single-file-extractor MyApp.exe -o extracted/
# extracted/MyApp.dll, extracted/MyApp.Core.dll, ...  alles gewöhnliches IL

Von da an ist es dieselbe Geschichte wie bei jedem ungeschützten Assembly: Öffnen Sie MyApp.dll in einem Decompiler und lesen Sie nahezu originalen C#-Code. Ihre Lizenzprüfung, Ihre in Zeichenfolgenliterale eingebrannten API-Schlüssel, Ihre Geschäftslogik – alles davon. Single-File hat ein Bereitstellungsproblem gelöst (eine Datei zum Kopieren) und absolut nichts für die Vertraulichkeit getan. Hätten Sie die DLLs vorher obfuskiert, müssen Sie es jetzt immer noch tun; das Bundle verbirgt sie nur vor einer beiläufigen Dateiauflistung, nicht vor jemandem, der einen Befehl eintippt.

Native AOT entfernt das IL – aber das ist keine Sicherheitsfunktion

Native AOT ist etwas wirklich anderes. Mit PublishAot kompiliert der IL-Compiler (ILC) Ihren verwalteten Code vorab in nativen Maschinencode. Das veröffentlichte Artefakt enthält keinen JIT, und für den vollständig per AOT kompilierten Code gibt es kein IL und keinen verwalteten Methodenkörper, der sich zurückgewinnen ließe. Richten Sie einen .NET-Decompiler darauf – es gibt nichts zu lesen: Er versteht nur IL. Reverse Engineering bedeutet nun natives Disassemblieren, was ein echter Kostensprung ist. Das ist eine angenehme Eigenschaft.

Es ist auch ein Nebeneffekt, keine Garantie, und es bringt wichtige Grenzen mit sich, die Teams routinemäßig übersehen:

  • AOT ist nicht für jede Arbeitslast verfügbar. Es verbietet Codegenerierung zur Laufzeit, ist streng bei Reflexion und dynamischem Laden, und viele Frameworks, Plugin-Architekturen und reflektionslastige Apps lassen sich schlicht nicht so kompilieren. Die große Mehrheit realer .NET-Anwendungen ist heute nicht vollständig AOT, was bedeutet, dass dieser „Schutz” für sie nicht existiert.
  • Gemischte und partielle Bereitstellungen liefern weiterhin verwaltetes IL. Ist ein Teil Ihres Produkts eine anderswo genutzte Klassenbibliothek, ein Plugin oder eine Komponente außerhalb des AOT-Images, ist dieser Teil wieder gewöhnliches, dekompilierbares IL.
  • Daten überleben die Kompilierung. Zeichenfolgenliterale, eingebettete Ressourcen und die Form Ihrer Konstanten bleiben im nativen Image im Allgemeinen vorhanden. Ein Disassembler plus ein Strings-Dump lässt weiterhin das Präfix "LIC-" und die Endpunkt-URL durchsickern, selbst wenn die umgebende Logik nativ ist.
  • Es ist nicht als Schutz dokumentiert. Sich auf AOT zu verlassen, um Ihren Algorithmus zu verbergen, heißt, sich auf das beiläufige Verhalten eines Compilers zu verlassen, dessen Beibehaltung Microsoft nie zugesichert hat. Das ist keine Sicherheitsgrundlage, auf der man Durchsetzung bauen will.

Native AOT hebt die Hürde also dort, wo es anwendbar ist, und ist eine willkommene zusätzliche Schicht. Es ist kein Ersatz für den Schutz der verwalteten Schicht – schon gar nicht für die vielen Apps und Komponenten, die sich von vornherein nicht per AOT kompilieren lassen.

Die strukturelle Tatsache: Obfuskieren Sie das IL, bevor es weg ist

Hier ist der Punkt, von dem alles abhängt. Obfuskierung – Umbenennen, Zeichenfolgenverschlüsselung, Kontrollfluss-Abflachung, Methodenvirtualisierung – ist eine IL-zu-IL-Transformation. Sie liest Ihre kompilierten Assemblys, schreibt das IL um und schreibt geschützte Assemblys zurück. Sie braucht IL sowohl als Eingabe als auch als Ausgabe.

Sehen Sie nun, wo IL in der Veröffentlichungspipeline existiert:

  • Der C#-Compiler verwandelt Ihren Quellcode in verwaltete Assemblys (IL). ← die Obfuskierung kann hier laufen
  • dotnet publish mit Single-File bettet diese IL-Assemblys in den Host ein. Das IL existiert noch, ist aber jetzt verpackt – und wenn Sie erst danach obfuskieren, müssten Sie entpacken, transformieren und neu verpacken, was den Sinn zunichtemacht.
  • dotnet publish mit Native AOT kompiliert dieses IL in nativen Code und verwirft es. Nach diesem Schritt gibt es überhaupt kein IL mehr. Es bleibt nichts zum Obfuskieren.

Die Schlussfolgerung ist unausweichlich: Der Schutz muss auf die Assemblys zwischen Kompilierung und Veröffentlichung fallen. Obfuskieren Sie zuerst das IL; dann lassen Sie den Bundler es verpacken oder den AOT-Compiler es verarbeiten. Nebula.NET ist dafür gebaut, genau in diesen Platz zu passen – es arbeitet auf den kompilierten Assemblys, vor der Veröffentlichung.

Quelle .csVerwaltetes IL(von csc)Nebulaobfuskiert das ILSingle-File-BundleIL als Ressourcen eingebettetNative-AOT-KompilierungIL zu nativem Code wegkompiliertDie Obfuskierung läuft auf dem IL – bevor das Bundle gebaut wird oder AOT es entfernt.

Einbinden in einen Veröffentlichungs-Build

Die .csproj eines Single-File-Projekts sieht so aus:

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net8.0</TargetFramework>
  <PublishSingleFile>true</PublishSingleFile>
  <SelfContained>true</SelfContained>
  <!-- oder, für Native AOT: -->
  <!-- <PublishAot>true</PublishAot> -->
</PropertyGroup>

Die Aufgabe ist, einen Obfuskierungsschritt nach dem Bau Ihrer Assemblys und vor dem Bündeln oder AOT-Kompilieren durch publish einzufügen. Sie haben zwei saubere Optionen.

Option A – die Build-Ausgabe in der CI schützen und dann veröffentlichen. Bauen Sie normal, führen Sie Nebula über die Ausgabe-Assemblys aus und richten Sie publish auf die geschützten Kopien:

dotnet build -c Release
nebula --config nebula.config.json          # schreibt das IL vor Ort um / nach protected/
dotnet publish -c Release --no-build -o out  # bündelt oder AOT-kompiliert das geschützte IL

mit einer nebula.config.json, die alles bewahrt, was AOT und Reflexion brauchen:

{
  "schemaVersion": 1,
  "inputs": ["bin/Release/net8.0/MyApp.dll", "bin/Release/net8.0/MyApp.Core.dll"],
  "outputDirectory": "bin/Release/net8.0",
  "preservePublicApi": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "autoProtectLicensing": true
}

Option B – ein MSBuild-Hook, der zwischen Build und Publish läuft, sodass ein schlichtes dotnet publish bereits geschützt ist:

<Target Name="NebulaProtect" AfterTargets="Build" BeforeTargets="Publish">
  <Exec Command="nebula --config $(ProjectDir)nebula.config.json" />
</Target>

So oder so verarbeitet der AOT-Compiler oder der Single-File-Bundler nun IL, das bereits obfuskiert ist. Für AOT gilt dieselbe Disziplin wie beim Trimming: Das Umbenennen muss Ihre öffentliche Oberfläche und jeden per Reflexion oder Serialisierung erreichten Member bewahren, denn die AOT-Toolchain ist bei beidem streng. Siehe die Konfiguration für die Bewahrungsregeln und die allgemeine Anleitung in wie man ein .NET-Assembly obfuskiert.

Vorher und nachher

Der Test ist immer derselbe: Betrachten Sie das ausgelieferte Artefakt durch die Werkzeuge eines Angreifers.

Ohne Obfuskierung ist der Single-File-Fall das schlechteste Ergebnis – ein falsches Sicherheitsgefühl über trivial wiederherstellbarem Code:

// nach dem Lauf eines Bundle-Extractors auf der Single-File-.exe und dann eines Decompilers:
public bool IsLicensed(string key)
{
    if (!key.StartsWith("LIC-")) return false;
    return ValidateSignature(key, "https://api.example.com/v1/activate");
}

Mit zuerst auf das IL angewandter Obfuskierung erzählt der extrahierte (oder native) Code seine Geschichte nicht mehr: Die Methode ist ein abgeflachter Dispatcher, Präfix und URL werden zur Laufzeit entschlüsselt statt ausgedruckt, und in Enterprise ist die Durchsetzungsmethode zu einem Bytecode ohne Decompiler virtualisiert:

public bool a(string A_0)
{
    int s = 0;
    while (true) { switch (s) { /* Dispatcher über umsortierte Blöcke */
        case 0: if (!A_0.StartsWith(Strings.Get(0x3f1))) { s = 9; break; } s = 4; break;
        case 9: return false;
        /* ... */ } }
}

Single-File ließ die erste Version vollkommen lesbar. AOT hätte den nativen Fall schwerer dekompilierbar gemacht – aber nur für den vollständig kompilierten Code, nur dort, wo AOT überhaupt eine Option ist, und nie für die Zeichenfolgen. Zuerst das IL zu obfuskieren, ist das, was die Durchsetzungslogik in beiden Welten wirklich schützt.

Das Fazit

Single-File und Native AOT sind gute technische Entscheidungen für Verteilung und Startleistung. Keines ist eine Schutzstrategie. Eine Single-File-App liefert Ihr IL unversehrt in einem Container aus, den jeder öffnen kann; eine Native-AOT-App härtet die native Ausgabe als Nebeneffekt, kann aber nicht überall eingesetzt werden und lässt weiterhin Ihre Daten durchsickern. Der einzige verlässliche Ort, um Ihren Code zu schützen, ist das verwaltete IL, zwischen Kompilierung und Veröffentlichung: Obfuskieren Sie es dort und lassen Sie dann den Bundler oder den AOT-Compiler ihre Arbeit tun. Läuft der Schutz erst nach dem Anwenden des Formats, bleibt nichts mehr zum Schützen.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.