Skip to content
← Alle Beiträge
· Delta1 Labs Nebula.NETObfuskierung.NETLeitfaden

Eine .NET-Lösung aus mehreren Projekten obfuskieren: eine stimmige Schutzstrategie

Eine Lösung mit einem Dutzend Klassenbibliotheken und einer App ist kein Dutzend Obfuskierungs-Probleme — es ist eines. Schützen Sie jedes Projekt isoliert, und Sie schützen transitive Kopien doppelt, brechen InternalsVisibleTo zwischen Ihren eigenen Assemblys und enden mit einem Haufen Umbenennungs-Maps, die nicht zusammenpassen. Hier ist der Leitfaden: entscheiden Sie, was Sie wirklich ausliefern, und schützen Sie nur das, führen Sie einen einzigen Durchlauf über die Publish-Ausgabe aus, steuern Sie jedes Assembly aus einer einzigen gemeinsamen nebula.config.json, benennen Sie die ganze Lösung um, damit Friend-Zugriff und Querverweise synchron bleiben, halten Sie eine geseedete Map pro Version und verdrahten Sie alles in die CI, damit jeder ausgelieferte Build identisch geschützt ist.

Eine Lösung mit zehn Klassenbibliotheken und einer App sieht nach zehn Obfuskierungs-Problemen aus. Es ist eines. Die Projekte referenzieren einander, stellen einander Typen bereit, teilen Interna über [InternalsVisibleTo] und liefern zusammen als eine einzige Einheit aus, die ein Kunde als ein Paket installiert oder wiederherstellt. Schützen Sie sie einzeln, und Sie bekommen das Schlechteste aus allen Welten: transitive Kopien zweimal obfuskiert, Assembly-übergreifende Referenzen, die nicht mehr auflösen, gebrochener Friend-Zugriff zwischen Ihren eigenen DLLs und eine Schublade voll Umbenennungs-Maps, die nicht zusammenpassen, wenn ein Absturzbericht drei von ihnen durchläuft. Dieser Beitrag ist der Leitfaden, es als das eine Problem zu behandeln, das es ist: entscheiden, was zu schützen ist, wo der Durchlauf läuft, wie jedes Assembly aus einer Richtlinie gesteuert wird und wie man das Ergebnis über die ganze Menge hinweg in Nebula.NET beweist.

Schritt 1 — Schützen Sie, was Sie ausliefern, und nur das

Legen Sie vor jeder Konfiguration den Umfang fest. Ihre Lösung kompiliert weit mehr, als sie ausliefert: Testprojekte, Benchmark-Gestelle, Hilfsmittel für Quellgeneratoren, Beispiel-Apps. Nichts davon erreicht einen Kunden, also braucht nichts davon Schutz — ein Test-Assembly zu obfuskieren, das Sie nie verteilen, ist reiner Aufwand. Gehen Sie von der Liste der Assemblys aus, die wirklich das Gebäude verlassen, und das ist genau das, was Ihr dotnet publish (oder Ihre .nupkg) enthält.

Die feinere Falle sind transitive Kopien. Wenn Ihre App publiziert, landet jede Abhängigkeit, die sie heranzieht, im Ausgabeordner — einschließlich Fremdpaketen und, wenn Sie nicht aufpassen, Kopien von Bibliotheken aus anderen Lösungen. Sie schützen die Assemblys, die Sie geschrieben und ausgeliefert haben; Sie obfuskieren eine Abhängigkeit, die jemand anderes bereits ausgeliefert hat, nicht erneut (Sie haben keine Map dafür, kein Recht, sie zu verändern, und nichts zu gewinnen). Die Eingabeliste nennt also Ihre eigenen Assemblys beim Namen, nicht „alles im Ordner”. Zählen Sie die mit Ihrem Namespace auf und lassen Sie den Rest in Ruhe.

Schritt 2 — Führen Sie den Durchlauf einmal aus, über die Publish-Ausgabe

Der häufigste Multi-Projekt-Fehler ist, die Obfuskierung pro Projekt auszuführen, im bin/Debug jedes einzelnen, als Teil jedes Entwickler-Builds. Das ist auf zwei Achsen falsch. Es schützt das falsche Artefakt — Debug-Binaries, die niemand ausliefert — und schützt sie isoliert, bevor die Lösung zu einer stimmigen Ausgabe zusammengesetzt wurde. Die Entwickler verlieren zudem, was sie am meisten brauchen: einen lesbaren, debugbaren Build.

Führen Sie den Durchlauf einmal aus, spät, über die zusammengesetzte Publish-Ausgabe, nur auf dem Build-Server. Publizieren Sie die ganze Lösung, und übergeben Sie Nebula dann die publizierten Assemblys als einen einzigen Durchlauf. Das ist dieselbe Disziplin wie nur auf Ihrem Build-Server zu obfuskieren: Entwicklermaschinen bauen sauber und debugbar; der Agent, der das auslieferbare Paket erzeugt, ist der eine Ort, an dem Schutz geschieht, sodass das Binary, das Sie ausliefern, das einzige Binary ist, das je obfuskiert wird.

Projekte der LösungAcme.AppAcme.CoreAcme.RulesAcme.Plugins.AbstractionsAcme.Tests (nicht ausgeliefert)ein Nebula-Durchlauf4 ausgelieferte AssemblyscrossAssemblyRenameeine gemeinsame Konfigurationgeschützte Assemblys(publish/protected)ein Map-Satzpro Version

Schritt 3 — Eine gemeinsame Richtlinie, keine Konfiguration pro Projekt

Die Transformationen, die Sie ausführen, was Sie an der Grenze öffentlich halten, wo die Seed-Map liegt — das sind Eigenschaften des Produkts, nicht eines einzelnen Projekts. Sie gehören daher in eine einzige nebula.config.json auf Lösungsebene, die der eine Durchlauf über die Publish-Ausgabe liest. Ein Konfigurations-Fragment pro Projekt ist der Weg, auf dem eine Lösung in inkonsistenten Schutz abdriftet: eine Bibliothek endet mit String-Verschlüsselung, die nächste ohne, und niemand bemerkt es, bis es ein Decompiler tut.

Hier ist die ganze Richtlinie für ein Produkt aus vier Assemblys, in einer Datei. Sie nennt die ausgelieferten Assemblys als Eingaben, benennt sie als Menge um, behält den einen Namespace, der ein öffentlicher Vertrag ist, und seedet aus der Map der vorigen Version:

{
  "schemaVersion": 1,
  "inputs": [
    "publish/Acme.App.dll",
    "publish/Acme.Core.dll",
    "publish/Acme.Rules.dll",
    "publish/Acme.Plugins.Abstractions.dll"
  ],
  "outputDirectory": "publish/protected",
  "crossAssemblyRename": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "obfuscateConstants": true,
  "seedMapFile": "maps/Acme-prev.symbols.json",
  "renaming": {
    "rules": [
      { "pattern": "Acme.Plugins.Abstractions.*", "action": "keep" },
      { "pattern": "Acme.*", "action": "rename" }
    ]
  }
}

crossAssemblyRename, unbegrenzte String-Verschlüsselung, Kontrollfluss-Obfuskierung und Konstanten-Maskierung sind Pro-Funktionen; die Umbenennung von Bezeichnern ist in jeder Edition. Die Umbenennungsregeln werden von oben nach unten ausgewertet, der erste Treffer gewinnt — das enge keep für den Plugin-Vertrag steht über der breiten Acme.*-Auffangregel, genau wie in eine Nebula-Keep-List aufbauen. Eine Datei, eine Richtlinie, jedes Assembly gleich behandelt.

Schritt 4 — Benennen Sie die Lösung um, nicht pro Projekt

crossAssemblyRename: true ist die Zeile, die eine Lösung funktionieren lässt. Ihre Assemblys referenzieren einander ständig: Acme.App ruft einen Typ in Acme.Core auf, der einen in Acme.Rules aufruft. Wenn Nebula Acme.Core.PricingEngine in a umbenennt, muss es die Aufrufstellen in Acme.App und Acme.Rules ebenfalls in a umschreiben, sonst hängen diese Referenzen ins Leere. Ein einziger Durchlauf mit aktiviertem Assembly-übergreifendem Umbenennen tut genau das: er behandelt die Eingabemenge als einen Namespace, der umzubenennen ist, und schreibt jede Referenz über sie hinweg neu.

Die scharfe Kante ist [InternalsVisibleTo] zwischen Ihren eigenen Assemblys — die internal-Typen, die eines Ihrer Projekte einem anderen bereitstellt (meist ein Testprojekt, aber auch ein zur Schichtung abgetrenntes Geschwister). Diese internen Namen sind ein Vertrag zur Kompilierzeit; benennen Sie den Erzeuger allein um, und der Konsument referenziert noch die alten Namen und bindet nicht mehr. Weil jedes Assembly, das die umbenannten Interna referenziert, eine Eingabe desselben Durchlaufs ist, hält das Assembly-übergreifende Umbenennen beide Seiten im Gleichschritt: der interne Typ bekommt an seiner Definition und an jeder Friend-Referenz denselben neuen Namen. Die vollständige Mechanik, einschließlich der Laufzeit-Reflexions-Variante, vor der kein Compiler warnt, steht in InternalsVisibleTo und Friend-Assemblys obfuskieren. Die Regel, die daraus folgt, ist die Regel dieses ganzen Beitrags: Wenn zwei Ihrer Assemblys Namen teilen, teilen sie einen Durchlauf.

Schritt 5 — Eine geseedete Map pro Version

Eine Obfuskierung pro Projekt lässt Sie mit einer Map pro Projekt zurück, erzeugt gegen die isolierten Umbenennungs-Entscheidungen dieses Projekts, und nichts garantiert, dass sie übereinstimmen. Ein Stacktrace aus dem Feld bleibt selten innerhalb eines Assemblys — er läuft von Acme.App durch Acme.Core nach Acme.Rules —, und ihn zu dekodieren verlangt, dass die Map jedes Rahmens vom selben Durchlauf erzeugt wurde. Die ganze Lösung einmal auszuführen gibt genau das: einen stimmigen Satz .symbols.json-Maps für die Version, alle gegen dieselben Entscheidungen erzeugt.

Tragen Sie die Namen dann vorwärts. Richten Sie seedMapFile auf die Maps der vorigen Version, damit Member, die sich nicht geändert haben, den obfuskierten Namen behalten, den sie bereits hatten; nur neuer Code bekommt neue Namen. Über eine Versionsreihe hinweg heißt das, dass eine archivierte Map dieselbe Methode weiter dekodiert und ein binärer Diff Ihre tatsächliche Änderung zeigt statt einer globalen Neunummerierung. Die vollständige Behandlung — warum stabile Namen Absturz-Symbolisierung und Delta-Patching handhabbar machen und warum Seeding ein operatives Werkzeug und kein Sicherheitsregler ist — steht in obfuskierte Namen mit einer Seed-Map stabil halten. Archivieren Sie eine versions-gestempelte Kopie der Maps für jede Release; sie dekodieren die Absturzberichte dieses Builds für immer.

Schritt 6 — Behalten Sie die Lösungsgrenze, benennen Sie frei dahinter um

Ein Produkt aus mehreren Projekten hat genau eine Oberfläche, die die Umbenennung überleben muss: das, woran sich die Außenwelt über den Namen bindet. Für ein ausgeliefertes SDK ist das die öffentliche API; für eine App mit Plugin-Modell ist es der Plugin-Vertrag — das Abstraktions-Assembly, gegen das ein Dritter sein Plugin kompiliert. Alles, was innerhalb der Lösungsgrenze bleibt, über wie viele Assemblys es sich auch erstreckt, gehört Ihnen zum freien Umbenennen, weil nichts von außen es referenziert.

Deshalb behält die Konfiguration oben Acme.Plugins.Abstractions.* ganz und benennt alles andere um, statt preservePublicApi für die ganze Menge einzuschalten. preservePublicApi behält jeden öffentlichen Typ über jede Eingabe — aber innerhalb Ihrer eigenen Lösung ist „öffentlich” nur der Zugriffsmodifizierer, mit dem Ihre Assemblys einander sehen; es ist kein externer Vertrag. Alles zu behalten würde den Großteil Ihres Schutzes grundlos aufgeben. Grenzen Sie das Keep auf das eine Assembly ein, das wirklich eine Grenze ist, lassen Sie das Assembly-übergreifende Umbenennen die öffentlich-aber-produktinterne Oberfläche umbenennen, und Sie schützen das Maximum, während Sie den Vertrag, von dem Ihre Plugin-Autoren abhängen, intakt halten.

Schritt 7 — Verdrahten Sie es in die CI

Setzen Sie das Ganze zu einem Pipeline-Schritt zusammen, der auf dem Agent läuft und nirgends sonst: publizieren Sie die Lösung, holen Sie die Maps der vorigen Version zum Seeden, führen Sie den einen Schutz-Durchlauf aus, und archivieren Sie dann die Maps dieses Builds, damit die nächste Version aus ihnen seeden kann.

# Release-Pipeline — läuft nur auf dem Build-Agent.
steps:
  - run: dotnet publish Acme.sln -c Release -o publish
  # Seedet aus den Maps der letzten Version (bei der allerersten Release wirkungslos).
  - run: fetch-artifact Acme-latest.symbols/ -> maps/Acme-prev.symbols.json
  - run: nebula --config nebula.config.json      # ein Durchlauf, alle ausgelieferten Assemblys
  # Archiviert die Maps dieses Builds zum Seeden der nächsten Version und zum Dekodieren von Abstürzen.
  - run: publish-artifact publish/protected/*.symbols.json as Acme-latest.symbols/
  - run: publish-artifact publish/protected/*.symbols.json as Acme-${VERSION}.symbols/
  - run: pack-and-sign publish/protected

Knüpfen Sie ihn an ein Signal, das nur auf dem Agent existiert — die Umgebungsvariable NEBULA_OBFUSCATE aus dem Nur-auf-dem-Build-Server-Aufbau —, damit ein Entwickler, der lokal dotnet publish ausführt, weiterhin einen sauberen, debugbaren Build bekommt. Der Pack-und-Signier-Schritt zeigt auf publish/protected, nie auf die rohe Publish-Ausgabe, sodass das Artefakt, das die Pipeline verlässt, das geschützte ist. Jeder ausgelieferte Build führt nun die identische Richtlinie über die identische Eingabemenge aus: Schutz ist eine Eigenschaft der Release, nicht dessen, der sie gebaut hat.

Über die Assemblys hinweg in Glass.NET verifizieren

Der Fehlermodus, gegen den dieser Leitfaden schützt, ist zur Build-Zeit unsichtbar — die App läuft, die Tests bestehen, und ein Assembly ging still im Klartext aus oder ein Querverweis hängt still ins Leere. Also prüfen Sie die Ausgabe, und prüfen Sie sie ganz. Glass.NET ist der kostenlose, rein statisch arbeitende Begleiter dafür: er liest Metadaten und IL und führt das Binary nie aus, also öffnen Sie jedes geschützte Assembly aus publish/protected der Reihe nach.

Bestätigen Sie drei Dinge über die Menge hinweg. Erstens, dass jedes ausgelieferte Assembly wirklich geschützt ist: die Interna lesen sich als a, b, c, und Ihre Strings sind in jedem verschlüsselt, nicht nur im ersten. Zweitens, dass ein in einem Assembly umbenannter Typ zum selben Namen umbenannt ist, wo ein anderes Assembly ihn referenziert — das Assembly-übergreifende Umbenennen ist stimmig, ohne eine ins Leere hängende Referenz auf einen alten Namen. Drittens, dass die Grenze überlebt hat: Ihr Plugin-Vertrags-Assembly liest sich noch mit seinen echten öffentlichen Namen. Führen Sie dann die Integrations-Suite gegen die geschützte Ausgabe aus, denn ein Namensauflösungs-Bruch über Assemblys hinweg ist erst sichtbar, sobald ein Pfad ausgeführt wird. Das vollständige Vorher/Nachher-Verfahren steht in hat Ihre Obfuskierung wirklich gewirkt?.

Behandeln Sie die Lösung als eine Einheit, und das Ganze wird einfacher, nicht schwerer: eine Konfiguration, ein Durchlauf, ein Map-Satz pro Version, eine gehaltene Grenze, ein CI-Gate. Laden Sie Nebula.NET kostenlos herunter und führen Sie es als einen einzigen Durchlauf über Ihre Publish-Ausgabe aus.

Nebula.NET testen

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