Skip to content
← Alle Beiträge
· Delta1 Labs .NETObfuskierungSicherheit

Referenz-Obfuskierung in .NET: den Aufrufgraphen verbergen

Benennen Sie alles um, und ein Decompiler liest Ihre Absicht trotzdem aus den Aufrufen, die Sie tätigen: File.ReadAllText, RSA.Create, Ihr eigenes CheckLicense. Referenz-Obfuskierung (Proxy-Aufrufe) leitet diese Aufrufe über generierte Proxys, sodass die Aufrufstelle ihr Ziel nicht mehr nennt. So funktioniert es in IL, was es abwehrt und was es kostet.

Lassen Sie einen Decompiler über einen umbenannten, aber ansonsten ungeschützten Assembly laufen, und die Methodenrümpfe sehen aus wie Rauschen: a, b, c für jede lokale Variable, M0, M1 für jede private Methode. Dann lesen Sie die Aufrufe, und der Nebel lichtet sich:

private static bool M3(string a)
{
    byte[] b = File.ReadAllText(a).Split(',').Select(Convert.FromBase64String).First();
    using var c = RSA.Create();
    c.ImportSubjectPublicKeyInfo(Nebula_Helpers.K, out _);
    return c.VerifyData(b, /* … */, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}

Jeder Bezeichner, der Ihnen gehörte, wurde umbenannt, und doch verifiziert M3 offensichtlich eine signierte Lizenzdatei. Nichts, was Sie umbenannt haben, hat es verraten — die Aufrufe taten es. File.ReadAllText, RSA.Create, VerifyData: diese nennen Member der Basisklassenbibliothek, und die BCL können Sie nicht umbenennen. Das Aufrufmuster ist die Absicht, und die Umbenennung berührt es nie.

Das ist die Lücke, die die Referenz-Obfuskierung schließt. Die Umbenennung verbirgt die Namen der Dinge, die Ihnen gehören; die Referenz-Obfuskierung verbirgt, welcher Code was aufruft — den Aufrufgraphen — einschließlich der Framework-Aufrufe, die die Umbenennung nicht erreichen kann.

Wie ein Aufruf in IL aussieht

Ein Methodenaufruf in .NET ist eine call- oder callvirt-Anweisung, die ein einzelnes Metadaten-Token trägt, das auf eine MemberRef- oder MethodDef-Zeile zeigt. Dieses Token löst sich zu einem voll qualifizierten Namen auf. Der File.ReadAllText-Aufruf oben ist buchstäblich:

call  string [System.Runtime.IO.FileSystem]System.IO.File::ReadAllText(string)

Das Token steht genau dort im Anweisungsstrom. Keine noch so große Umbenennung Ihrer eigenen Typen entfernt es, weil es einen Typ in einem fremden Assembly nennt. Ein Decompiler, oder de4dot, oder ein Mensch mit dnSpy liest es direkt.

Den Aufruf über einen Proxy leiten

Die Referenz-Obfuskierung ersetzt diese Anweisung durch einen Aufruf eines generierten Proxys — eine markenlose Methode, die Nebula injiziert — der das echte Ziel zur Laufzeit auflöst und aufruft. Die Aufrufstelle hört auf, File.ReadAllText zu nennen:

// vorher
call   string System.IO.File::ReadAllText(string)

// nachher
call   string <Module>:: (string)      // ein Proxy; das echte Ziel wird darin aufgelöst

Im Inneren hält der Proxy das ursprüngliche Ziel als Daten — ein verschlüsseltes oder indirektes Token, das bei der ersten Nutzung aufgelöst und zwischengespeichert wird — und dispatcht dorthin. Konzeptionell verhält sich der generierte Proxy so, obwohl der echte selbst obfusziert und namenlos ist:

// Generiert, einer pro eigenständigem proxyfiziertem Ziel (die Namen sind illustrativ).
static string Proxy_7(string path)
{
    // Das Ziel wird als Daten gespeichert, nicht als Token an der Aufrufstelle:
    var mi = TokenCache.Resolve(0x6F03A1);   // → System.IO.File.ReadAllText(string)
    return (string)mi.Invoke(null, new object[] { path });
}

Die entscheidende Verschiebung ist, dass die Identität des Ziels von einem Anweisungsoperanden — den jeder Leser der IL sieht — zu Daten gewandert ist, die zur Laufzeit aufgelöst werden. Eine statische Lektüre der Aufrufstelle zeigt nun einen Sprung in <Module>::‍, keinen Aufruf von File. Um zu erfahren, was Proxy_7 wirklich aufruft, müssen Sie die Indirektion so auflösen, wie es die Laufzeit tut — was ein statischer Decompiler nicht für Sie erledigt.

Nebula stellt dies als proxyReferences bereit (Pro und höher):

{
  "rename": true,
  "controlFlowObfuscation": "aggressive",
  "proxyReferences": true,
  "proxyReferencesInclude": [
    "MyApp.Licensing.LicenseChecker.*",
    "MyApp.Activation.*"
  ]
}

Wie bei jeder Transformation setzen Sie es gezielt ein. Die Einschlussliste beschränkt das Proxyfizieren auf die Namespaces, in denen das Verbergen des Aufrufgraphen sich lohnt — Lizenzierung, Aktivierung, Manipulationsschutz — statt jeden Aufruf im Assembly umzuschreiben.

Was es konkret verbirgt

Stellen Sie sich den Aufrufgraphen eines Lizenzierungsmoduls vor. Vorher ist er eine lesbare Karte: Ihre Methoden links, die BCL und Ihre Krypto-Helfer rechts, jede Kante ein beschrifteter Aufruf.

Vorher Nachher Activate() Verify() File.ReadAllText RSA.VerifyData CheckLicense Activate() Verify() ⟨proxy⟩ Ziel aufgelöst zur Laufzeit

Nachher endet jede Kante in derselben markenlosen Proxy-Schicht. Die Karte, die Ihnen sagte, dass Activate eine Datei liest und Verify in RSA ruft, ist weg: statisch betreten beide nur einen Proxy. Die Information, die ein Angreifer am frühesten will — wo ist die Lizenzprüfung und was berührt sie — ist aus der IL nicht mehr lesbar.

Das zählt am meisten für die Aufrufe, die Sie sonst nicht verbergen können. Sie können CheckLicense in M3 umbenennen, aber ein direkter Aufruf von RSA.Create() neben dem Lesen eines eingebetteten öffentlichen Schlüssels ist eine Signaturprüfung, egal wie die umgebenden lokalen Variablen heißen. Das Proxyfizieren entfernt diesen Hinweis.

Was es nicht tut

Die Referenz-Obfuskierung ist eine Abwehr gegen statische Analyse, und sie ist ehrlich über ihre Grenzen. Sie erhöht die Kosten, den Aufrufgraphen zu lesen; sie hindert einen Angreifer nicht daran, Ihren Code auszuführen und zu beobachten. Wer einen Profiler anhängt oder einen Haltepunkt setzt, kann zusehen, wie sich ein Proxy beim ersten Lauf zu File.ReadAllText auflöst. Deshalb ist es eine Schicht, keine Mauer:

  • Kontrollfluss-Obfuskierung verwürfelt die Reihenfolge, in der proxyfizierte Aufrufe stattfinden, sodass selbst beobachtete Aufrufe die Logik nicht preisgeben, die sie sequenziert.
  • String-Verschlüsselung verbirgt die literalen Pfade und Schlüssel, die diese Aufrufe verbrauchen, sodass ein aufgelöster Aufruf von ReadAllText nicht auch den Dateinamen aushändigt.
  • Anti-Debugging / Manipulationsschutz erhöht die Kosten der dynamischen Analyse, die sonst die Proxys zur Laufzeit entwirren würde.

Jede Schicht schließt eine Abkürzung. Die Umbenennung schließt die Abkürzung „die Namen lesen”; die Referenz-Obfuskierung schließt die Abkürzung „den Aufrufgraphen lesen”; zusammen mit Kontrollfluss und Verschlüsselung drängen sie einen Angreifer vom statischen Ein-Klick-Pfad in langsame, manuelle dynamische Analyse — das ist das ganze Ziel der Obfuskierung. Sie machen Reverse Engineering nicht unmöglich; Sie machen es teuer genug, dass es sich nicht lohnt.

Schützen Sie eine .NET-App, bei der der Aufrufgraph Ihre Lizenzierungs- oder Aktivierungslogik verrät? Schalten Sie proxyReferences für diese Namespaces ein — siehe automatischer Deobfuskierung widerstehen, wie es sich mit den anderen Transformationen kombiniert, und welche Edition die richtige für Sie ist, wo es freigeschaltet wird.

Nebula.NET testen

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