Eingebettete Ressourcen verschlüsseln: die Daten, die Ihr Obfuskator vergisst
Sie haben jeden Typ umbenannt, jede Zeichenkette verschlüsselt und die heißen Methoden virtualisiert – und dann hat jemand Ihr Assembly in einem Ressourcen-Viewer geöffnet und Ihr eingebettetes SQL, Ihr Lizenzschlüsselmaterial und den vollständigen Text Ihrer Fehlermeldungen direkt aus dem .resources-Blob gelesen. Verwaltete Ressourcen sind kein Code, daher rührt der Schutz auf Methodenebene sie nie an; sie liegen im Manifest als längenpräfixierte Werte, die jeder auslesen kann. So werden eingebettete Ressourcen tatsächlich in einer PE gespeichert, warum die Umbenennung sie unberührt lässt, und wie Nebula sie zur Buildzeit verschlüsselt und über einen Resolver transparent entschlüsselt, sodass Ihre Aufrufe an ResourceManager und GetManifestResourceStream unverändert weiterlaufen.
Ein Schutzdurchlauf konzentriert sich meist auf die spannende Oberfläche: die Methodenkörper. Sie benennen den Typgraphen um, flachen den Kontrollfluss ab, verschlüsseln die Zeichenkettenliterale, virtualisieren vielleicht die Methoden, die die Lizenzierung durchsetzen. Dann liefern Sie aus, und eine Woche später schickt Ihnen jemand per E-Mail den exakten Text Ihrer internen Fehlermeldungen, die Vorlage einer Verbindungszeichenkette und eine Datendatei, die Sie für proprietär hielten – alles aus dem Assembly extrahiert, ohne eine einzige Methode zu dekompilieren. Sie haben Ihre Obfuskierung nicht gebrochen. Sie haben die Ressourcen geöffnet, die Ihre Obfuskierung nie berührt hat, denn Ressourcen sind kein Code.
Das ist der blinde Fleck. Eingebettete Ressourcen werden als Daten in einem Teil der PE gespeichert, den Transformationen auf Methodenebene keinen Grund haben zu besuchen. Dieser Artikel handelt davon, was wirklich in diesem Blob steckt, warum jeder codezentrierte Schutz ihn im Klartext lässt, und wie Nebula Ressourcen zur Buildzeit verschlüsselt, während Ihre Aufrufe an ResourceManager und GetManifestResourceStream genau so weiterlaufen, wie Sie sie geschrieben haben.
Wo Ressourcen wirklich leben
Eine verwaltete Ressource ist kein Member. Wenn Sie eine Datei als eingebettete Ressource markieren, schreibt der Compiler ihre Bytes in einen Ressourcen-Blob und fügt der Metadatentabelle ManifestResource eine Zeile hinzu, die sie benennt und ihren Offset angibt. Eine .resx-Datei wird in einen binären .resources-Stream kompiliert und genauso eingebettet. Nichts an diesem Vorgang betrifft IL, Methodenkörper oder Member-Namen – die beiden Subsysteme kennen einander kaum.
Weil der Ressourcen-Blob eine separate Region ist, geht jede Code-Transformation, die Sie ausführen – Umbenennung, Kontrollfluss, Zeichenkettenverschlüsselung, Virtualisierung – daran vorbei. Es gibt keinen Namen zu verstümmeln und keinen IL umzuschreiben. Der Blob, den Sie ausgeliefert haben, ist Byte für Byte der Blob, den der Compiler erzeugt hat.
Was ein Leser sieht
Das .resources-Format ist bewusst einfach, und die BCL liefert den Leser mit. Sie brauchen keinen Dekompiler; Sie brauchen System.Resources.ResourceReader, dieselbe Klasse, die das Framework intern verwendet:
using var reader = new ResourceReader("MyApp.Strings.resources");
foreach (DictionaryEntry entry in reader)
Console.WriteLine($"{entry.Key} = {entry.Value}");
// ConnectionTemplate = Server={0};Database=acme;User Id={1};Password={2};
// ActivationSeed = 7f3a9c... (your "secret" byte[] in plain hex)
// Error_LicenseVoid = This build's licence was revoked on {0:d}.
Für eine rohe eingebettete Datei (kein .resx) ist es noch direkter – GetManifestResourceStream gibt die exakten Bytes zurück:
var asm = Assembly.LoadFrom("MyApp.dll");
using var s = asm.GetManifestResourceStream("MyApp.Data.rules.bin");
using var ms = new MemoryStream();
s!.CopyTo(ms);
File.WriteAllBytes("rules.bin", ms.ToArray()); // your proprietary file, extracted
Nichts hier berührt Ihre geschützten Methoden. Die Ressourcennamen sind im Hash-Index des Streams aufgelistet, die Zeichenkettenwerte sind UTF-8 mit einem 7-Bit-Längenpräfix, und beliebige Objekte kommen über ihren Serialisierer zurück. Ein Ressourcen-Viewer ist nur eine GUI über dieser API. „Ich habe es in die DLL eingebettet” hat Ihnen dagegen nichts gebracht.
Den Blob zur Buildzeit verschlüsseln
Nebula behandelt Ressourcen als erstklassiges Schutzziel. In der Konfiguration aktiviert, durchläuft der Ressourcenverschlüsselungs-Durchlauf die von Ihnen gewählten ManifestResource-Einträge, ersetzt die Klartextbytes durch Chiffretext und injiziert einen Resolver, der bei Bedarf entschlüsselt. Die Konfiguration benennt, was zu schützen ist:
<Nebula>
<ResourceEncryption Enabled="true">
<!-- Raw embedded files -->
<Include Pattern="MyApp.Data.*" />
<!-- .resx-backed resource sets -->
<Include Pattern="MyApp.Strings.resources" />
<!-- Leave framework/designer resources that must stay plain -->
<Exclude Pattern="*.g.resources" />
</ResourceEncryption>
</Nebula>
Zur Buildzeit schreibt der Durchlauf jede passende Ressource um. Konzeptionell ist die Transformation diese – den Klartext lesen, unter einem Schlüssel verschlüsseln, den der geschützte Code reproduzieren kann, und den Chiffretext zurück in den Blob schreiben:
// Build-time transform (inside the Nebula pass, shown for intuition)
foreach (var res in module.Resources.OfType<EmbeddedResource>())
{
if (!selector.Matches(res.Name)) continue;
byte[] plain = res.GetResourceData();
byte[] cipher = ResourceCrypto.Encrypt(plain, deriveKeyFor(res.Name));
module.Replace(res, new EmbeddedResource(res.Name, cipher));
}
Der Schlüssel wird nicht als Konstantenfeld ausgeschrieben. Er wird von einer kleinen Entschlüsselungsroutine erzeugt, die Nebula injiziert und dann denselben Schutzmaßnahmen wie den Rest Ihres Codes unterwirft – Zeichenkettenverschlüsselung seiner Konstanten, Kontrollfluss-Abflachung, optional Virtualisierung –, sodass ein Angreifer nicht nach einem statischen Schlüssel oder einer erkennbaren Algorithmuskennung greppen kann. Genau das ist der Punkt: Der Ressourcen-Chiffretext ist nur so wiederherstellbar wie der obfuskierte Code, der ihn auspackt.
Die Entschlüsselung bleibt für Ihren Code transparent
Der Durchlauf wäre nutzlos, wenn er Sie zwänge, jeden Aufruf von GetString und GetManifestResourceStream umzuschreiben. Das tut er nicht. Für rohe eingebettete Dateien installiert Nebula einen Hook, sodass die geschützten Namen einen entschlüsselnden Stream zurückgeben, und Ihre vorhandene Aufrufstelle ist unverändert:
// Your code — unchanged. The resolver decrypts behind GetManifestResourceStream.
using var s = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("MyApp.Data.rules.bin");
var rules = RuleSet.Parse(s!); // receives plaintext
Für .resx-gestützte Abfragen liefert Nebula einen ResourceManager, dessen Resource Set die Werte beim Lesen entschlüsselt, sodass GetString und GetObject Ihrem Code weiterhin Klartext übergeben:
// Your code — unchanged. The injected ResourceManager decrypts on read.
var rm = Strings.ResourceManager; // Nebula-supplied, decrypting
string msg = rm.GetString("Error_LicenseVoid")!; // plaintext at the call site
Die Mechanik, die das möglich macht, sind dieselben Framework-Erweiterungspunkte, die die Lokalisierung bereits nutzt – ein Resource Set wird pro Ressourcen-Stream konstruiert, und den dahinterliegenden Leser liefern Sie selbst. Nebula schiebt einen entschlüsselnden Leser in diese Pipeline. Weil Satelliten- (lokalisierte) Assemblys nur Assemblys mit eigenen .resources-Streams sind, deckt die identische Behandlung sie ab: Jedes lokalisierte .resources wird auf dieselbe Weise verschlüsselt und aufgelöst, sodass auch Ihre kulturspezifischen Zeichenketten geschützt sind, nicht nur das neutrale Set.
Führen Sie den Ressourcenleser jetzt gegen das geschützte Assembly aus, und der Unterschied ist drastisch:
using var reader = new ResourceReader("MyApp.Strings.resources");
foreach (DictionaryEntry entry in reader)
Console.WriteLine($"{entry.Key} = {entry.Value}");
// __nebula_enc_0 = System.Byte[] (opaque ciphertext; no names, no plaintext)
// __nebula_enc_1 = System.Byte[]
Die Namen sind weg, die Werte sind Chiffretext, und das Einzige, was sie wieder in Zeichenketten verwandeln kann, ist der obfuskierte Codepfad in Ihrem Assembly – genau dort, wo Sie bereits Aufwand investiert haben, die Wiederherstellung teuer zu machen.
Was Sie mitnehmen sollten
Code-Schutz schützt Code. Ressourcen sind Daten, und sie liegen in einer separaten Region des Assemblys, die Umbenennung, Kontrollfluss-Obfuskierung, Zeichenkettenverschlüsselung und Virtualisierung nie besuchen – weshalb ein Ressourcen-Viewer Ihr eingebettetes SQL, Ihr Schlüsselmaterial und Ihren Fehlertext aus einer vollständig obfuskierten DLL in einem Klick lesen kann. Behandeln Sie Ressourcen als eigenständiges Schutzziel: Verschlüsseln Sie den Blob zur Buildzeit, leiten Sie den Schlüssel über denselben geschützten Code ab, der Ihre Methoden absichert, sodass es keinen statischen Schlüssel zum Greppen gibt, und entschlüsseln Sie transparent über den GetManifestResourceStream-Hook und einen entschlüsselnden ResourceManager, sodass sich Ihre Aufrufstellen nie ändern. Nichts, was an einen Client ausgeliefert wird, ist unknackbar, Ressourcen eingeschlossen – aber Sie können die triviale Offenlegung beseitigen und jede Wiederherstellung durch den obfuskierten Code zwingen, wo sie den Angreifer echte Arbeit kostet. Wenn Zeichenketten in Ihrem Code die andere Hälfte dieses Problems sind, kombinieren Sie es mit Zeichenkettenverschlüsselung in .NET; für das größere Bild dessen, was Obfuskierung abdeckt und was nicht, siehe Obfuskierung jenseits der Umbenennung.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.