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

Eine Assembly obfuskieren, deren Tests weiterhin bestehen müssen: InternalsVisibleTo und Friend-Assemblies

Das Umbenennen von Internals ist der Punkt, an dem Obfuskierung ihren Wert beweist — doch [InternalsVisibleTo] macht einige dieser Internals stillschweigend zu einem Vertrag. Ein Testprojekt, eine Geschwister-Assembly oder ein generiertes Mock greift namentlich auf Ihre internen Typen zu, und dieser Name existiert nur zur Compile-Zeit. Obfuskieren Sie den Produzenten allein, kompiliert der Friend nicht mehr — oder schlimmer: Ein reflexionsbasiertes Mock schlägt zur Laufzeit fehl. Hier erfahren Sie genau, was [InternalsVisibleTo] in den Metadaten tut, warum Nebula.NET den Einzel-Assembly-Fall für Sie bewahrt und wie Sie eine echte Friend-Assembly grün halten, indem Sie das Paar gemeinsam obfuskieren.

Das Wertvollste, was ein Obfuskator tut, ist das Umbenennen dessen, was ein Konsument nicht sehen kann: Ihre internal- und private-Typen und -Member. Dort lebt Ihre eigentliche Implementierung, und nichts außerhalb der Assembly soll sie namentlich referenzieren — internal class PricingEngine in internal class a umzubenennen kostet Sie also nichts und einen Angreifer viel.

[InternalsVisibleTo] ist das eine Attribut, das diese Annahme bricht. Es erlaubt einer namentlich benannten Friend-Assembly, Ihre Internals namentlich zu sehen, zur Compile-Zeit — und befördert damit stillschweigend einige Ihrer Internals vom „Implementierungsdetail“ zum „Vertrag“. Der häufigste Friend ist Ihr eigenes Unit-Test-Projekt, das hineingreift, um eine Klasse zu testen, die Sie nicht öffentlich machen wollten. Obfuskieren Sie den Produzenten für sich allein, kann dieser Friend nicht mehr kompilieren oder, wenn er per Reflexion bindet, zur Laufzeit fehlschlagen — ohne jede Fehlermeldung aus dem Obfuskierungsschritt selbst.

Was [InternalsVisibleTo] eigentlich ist

Das Attribut ist ein Metadatum auf Assembly-Ebene. Im Quellcode lautet es:

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Acme.Pricing.Tests")]

Im kompilierten Modul ist es ein einzelnes benutzerdefiniertes Attribut auf der Assembly, das den Namen des Friends trägt (und bei einem signierten Produzenten den öffentlichen Schlüssel des Friends):

.assembly Acme.Pricing
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices
      .InternalsVisibleToAttribute::.ctor(string)
      = ( 01 00 11 41 63 6D 65 2E 50 72 69 63 69 6E 67      // ...Acme.Pricing
          2E 54 65 73 74 73 00 00 )                         // .Tests
}

Zur Laufzeit tut es nichts — die CLR zieht es nicht zu Rate. Es ist eine Anweisung an den Compiler der Friend-Assembly: „Wenn du Acme.Pricing.Tests baust, lass sie an die Internals von Acme.Pricing binden.“ Sobald Sie es gewähren, ist dies im Friend also zulässig:

// In Acme.Pricing.Tests — only compiles because of InternalsVisibleTo
[Fact]
public void Discount_is_applied_once()
{
    var engine = new PricingEngine();          // internal type
    Assert.Equal(90m, engine.Apply(100m, 0.1)); // internal method
}

PricingEngine und Apply sind nun als externe Referenzen auf Acme.Pricing in die Metadaten der Test-Assembly eingebrannt. Diese Referenzen sind namensbasiert.

Wo es bricht — und wo nicht

Hier ist die Falle, aufgezeichnet. Produzent und Friend sind zwei getrennte Assemblies, und ob eine Umbenennung sicher ist, hängt ausschließlich davon ab, ob der Friend ebenfalls umgeschrieben wird.

Producer obfuscated aloneAcme.PricingPricingEngine → aAcme.Pricing.Testscalls PricingEngineunresolved → failsBoth obfuscated togetherAcme.PricingPricingEngine → aAcme.Pricing.Testscalls aresolves → passes

Der Einzel-Assembly-Fall, den Nebula am Laufen hält. Das Attribut [InternalsVisibleTo] und die darin benannte Friend-Identität bleiben bei einem Lauf erhalten — Nebula beschädigt weder das Attribut noch die Identität der Assembly selbst, sodass Friend-Beziehungen strukturell gültig bleiben (das ist die Einzel-Assembly-Garantie der Engine). Was ein Lauf über den Produzenten allein nicht leisten kann, ist in eine Friend-Assembly hineinzugreifen, die ihm nie übergeben wurde, und deren Referenzen auf die nun umbenannten Internals des Produzenten umzuschreiben. Ist Acme.Pricing.Tests keine Eingabe, enthält sie weiterhin eine Referenz auf PricingEngine, und nachdem aus PricingEngine a geworden ist, löst diese Referenz ins Leere auf.

Die Laufzeit-Variante ist schlimmer. Ein Mocking-Framework oder ein Test, der per Reflexion in Internals hineinstochert — Type.GetType("Acme.Pricing.PricingEngine") oder assembly.GetType("...") — kompiliert problemlos gegen den nicht obfuskierten Produzenten und wirft dann zur Laufzeit null/MissingMethodException, sobald der Typ umbenannt ist, weil der String nicht mehr passt. Kein Build-Fehler warnt Sie; nur der Testlauf tut es.

Die Lösung: den Friend mit dem Produzenten obfuskieren

Eine Friend-Assembly ist nur eine assemblyübergreifende Referenz, die zufällig die interne Grenze überschreitet. Behandeln Sie sie wie jeden anderen Multi-Assembly-Lauf: Geben Sie Nebula beide Assemblies und lassen Sie es die gemeinsam genutzten Internals auf beiden Seiten konsistent umbenennen.

{
  "inputs": [
    "out/Acme.Pricing.dll",
    "out/Acme.Pricing.Tests.dll"
  ],
  "outputDirectory": "out/obf",
  "preservePublicApi": true,
  "crossAssemblyRename": true,
  "encryptStrings": true
}

crossAssemblyRename ist die entscheidende Zeile: Sie benennt Typen und Member um, die über die Eingabe-Assemblies hinweg geteilt werden, und schreibt jede assemblyübergreifende Referenz um — wenn also PricingEngine im Produzenten zu a wird, wird die Aufrufstelle in der Test-Assembly ebenfalls zu a umgeschrieben. Das Attribut benennt weiterhin eine echte Assembly, die Referenz löst auf, und die Tests laufen gegen die obfuskierten Internals — was der ideale Abnahmetest ist, denn Sie testen jetzt genau die IL, die Sie ausliefern.

Damit das funktioniert, muss jede Assembly, die die umbenannten Internals referenziert, eine Eingabe sein. Das ist die allgemeine Regel für crossAssemblyRename, und [InternalsVisibleTo] ist schlicht der Fall, in dem die referenzierende Assembly interne statt öffentliche Namen erreicht.

Wenn Sie den Friend nicht aufnehmen können

Manchmal gehört der Friend wirklich nicht Ihnen — die Assembly eines Partners oder ein Werkzeug, das Sie nicht kontrollieren, dem interner Zugriff gewährt wurde. Geben Sie dann nicht die gesamte interne Oberfläche preis; bewahren Sie nur genau die Internals, die er berührt. exclude erzwingt die Bewahrung eines Typs (samt seiner Member) oder eines einzelnen Members anhand des vollständigen Namens:

{
  "inputs": ["out/Acme.Pricing.dll"],
  "preservePublicApi": true,
  "exclude": [
    "Acme.Pricing.PricingEngine",            // whole type the friend constructs
    "Acme.Pricing.Internals.FeeTable.Lookup" // one member a partner tool calls
  ]
}

Alles andere Interne wird weiterhin umbenannt, abgeflacht und string-verschlüsselt — Sie haben den Zugriff mit zwei Typen bezahlt, statt ihn für die gesamte Assembly herzugeben. Finden Sie mit nebula inspect genau heraus, welche Internals ein Friend liest, bevor Sie entscheiden; einen Typ zu bewahren, den Sie nicht bewahren mussten, ist Schutz, den Sie umsonst verschenkt haben.

Eine Anmerkung zum Signieren: Hat Ihr Produzent einen starken Namen, trägt [InternalsVisibleTo] den öffentlichen Schlüssel des Friends, und eine Obfuskierung, die die Ausgabe neu signiert (strongNameKeyFile), ändert daran nichts — die Friend-Klausel benennt den Schlüssel des Friends, nicht den des Produzenten. Das Neusignieren des Produzenten ist davon unabhängig; siehe eine obfuskierte Assembly neu signieren.

Die Faustregel

[InternalsVisibleTo] macht einen Ausschnitt Ihrer Implementierung zu einem Vertrag mit einer bestimmten Assembly. Der Schutz, den Sie wollen — das Umbenennen von Internals — ist genau das, worauf dieser Vertrag empfindlich reagiert, also müssen die beiden miteinander versöhnt und nicht vermieden werden:

  • Standard: Obfuskieren Sie den Friend (meist Ihr Testprojekt) in CI gemeinsam mit dem Produkt, mit eingeschaltetem crossAssemblyRename. Internals werden auf beiden Seiten umbenannt und Ihre Tests binden an die ausgelieferte IL.
  • Wenn der Friend nicht Ihnen gehört: Nutzen Sie exclude nur für die konkreten internen Typen oder Member, die er erreicht, niemals für die gesamte interne Oberfläche.
  • Niemals: die Umbenennung von Internals abschalten, weil ein Test eine Klasse liest. Das tauscht den Großteil Ihres Schutzes gegen ein Problem mit einer präzisen Lösung ein.

Die Einzel-Assembly-Garantie bedeutet, dass Nebula eine Friend-Beziehung nicht stillschweigend für Sie beschädigt; den Friend über eine Umbenennung hinweg grün zu halten, ist das Einzige, was in Ihrer Verantwortung liegt, und ihn in den Lauf aufzunehmen ist der Weg dorthin. Siehe Mehrere Assemblies für das assemblyübergreifende Modell und Ihre Anwendung lauffähig halten für die umfassendere Checkliste „was per Name aufgelöst wird“.

Nebula.NET kostenlos herunterladen und Ihre Testsuite gegen den obfuskierten Build laufen lassen.

Nebula.NET testen

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