Skip to content
← Alle Beiträge
· Delta1 Labs KeyrightLizenzierungSicherheit

Den öffentlichen Prüfschlüssel verankern: der Angreifer, der einfach deinen Schlüssel gegen seinen tauscht

Deine App verifiziert eine Lizenz, weil eine Signatur gegen einen im Assembly eingebetteten öffentlichen Schlüssel stimmt. Doch dieser Schlüssel sind Daten: eine Base64-Konstante, die jeder Angreifer, der deine DLL bearbeiten kann, findet und durch einen von ihm kontrollierten Schlüssel überschreibt — so verifizieren nun seine selbst signierten Lizenzen und deine ist vergessen. Das ist der Austausch des Vertrauensankers und der Angriff mit der größten Hebelwirkung auf Offline-First-Lizenzierung. Dieser Beitrag zeigt den Angriff auf IL-Ebene — das ldstr, das den Schlüssel lädt, den einen Byte-Bereich, den ein Patch berührt — und hebt dann seine Kosten: Fingerabdruck des Schlüssels gegen einen in den Kontrollfluss verwobenen Wert statt allein, Prüfung an mehr als einer Stelle, mehrere Schlüssel per AdditionalPublicKeysBase64, damit ein einziger Tausch nicht reicht, und Verbindung der Client-Prüfung mit Strong-Naming, Anti-Tamper und Report-Home, sodass ein manipulierter Build zugleich schwerer herzustellen und sichtbar ist, wenn er ausgeliefert wird.

Eine Keyright-Lizenz ist eine Behauptung — „dieser Kunde besitzt Pro, 5 Plätze, bis nächsten März” — und deine Anwendung glaubt ihr aus genau einem Grund: Eine Signatur darauf verifiziert gegen einen öffentlichen Schlüssel, den die App mit sich trägt. Dieses Design ist der ganze Sinn der Offline-First-Lizenzierung. Es gibt keinen Server im Validierungspfad, den man anrufen müsste, nichts, das ausfallen könnte, nichts, das man ratenbegrenzen müsste; die App hält die öffentliche Hälfte deines Signierschlüssels, prüft die Signatur lokal und vertraut dem, was sie liest.

Was eine unangenehme Frage aufwirft. Der öffentliche Schlüssel ist nicht geheim: Er wird im Klartext ausgeliefert, in einem Assembly, das der Kunde auf der Platte hat. Was hindert jemanden daran, ihn zu ersetzen?

Nichts, standardmäßig. Und das ist der Angriff mit der größten Hebelwirkung auf die clientseitige Verifikation, weil er keine Kryptografie brechen muss. Dieser Beitrag handelt davon, warum der Angriff funktioniert, wie er auf IL-Ebene aussieht und wie man ihn teuer genug macht, dass er sich nicht mehr lohnt.

Der Angriff: den Vertrauensanker ersetzen

Signaturprüfung ist asymmetrisch. Deine ausstellende Seite hält einen privaten Schlüssel und signiert die kanonische Lizenznutzlast damit; deine App hält den passenden öffentlichen Schlüssel und verifiziert. Eine Signatur zu fälschen, die dein öffentlicher Schlüssel akzeptiert, hieße, den privaten aus dem öffentlichen zurückzugewinnen, was für Ed25519 oder RSA-2048 keine Nachmittagsarbeit ist — es ist niemandes Arbeit.

Ein kompetenter Angreifer greift also nicht die Signatur an. Er greift den Anker an. Der öffentliche Schlüssel lebt in deinem Assembly als Konstante — in einer Keyright-Integration typischerweise eine Base64-Zeichenkette, die du aus dem Portal kopiert hast:

public static class LicensingConfig
{
    // The PUBLIC half of your product signing key. Safe to ship — but not safe to trust blindly.
    public const string PublicKeyBase64 =
        "MCowBQYDK2VwAyEA9mF3rQ2pxK8vN1cWbYh6Zt0uQ4sJ7eD3rL5nX8kH2A=";
}

Der Angreifer erzeugt sein eigenes Schlüsselpaar. Er überschreibt PublicKeyBase64 mit dem Base64 seines öffentlichen Schlüssels. Dann signiert er eine Lizenz, die sagt, was immer er will — Pro, unbegrenzte Plätze, kein Ablauf — mit seinem privaten Schlüssel. Dein Prüfcode bleibt Byte für Byte unverändert. Er läuft, prüft die Signatur gegen den eingebetteten Schlüssel, den er findet, und besteht, weil der eingebettete Schlüssel, den er findet, nun seiner ist.

VorgesehenDienstsign(claims, PRIV)Lizenzschlüsselsignierte ClaimsApp: verify(sig, eingeb. PUB)Schlüssel = deiner → Claim vertrautTier · Plätze · Ablauf wie signiertSubstitutionsangriffAngreifergenKeyPair()sign(forged, PRIV')gefälschte LizenzPro · ∞ Plätzekein AblaufApp: verify(sig, eingeb. PUB)PUB überschrieben → PUB' (Angreifer)Signatur passt zu PUB' → AKZEPTIERTPrüfcode unverändert

Nichts wurde entschlüsselt, nichts wurde rückentwickelt. Der Angreifer verschob den Vertrauensanker und signierte gegen den neuen Anker. Alles, was deine App danach tut — Funktionen freischalten, Platzobergrenzen einhalten, Ablauf durchsetzen — wird nun von einer Lizenz gesteuert, die er geschrieben hat.

Was der Patch tatsächlich berührt

Billig ist es, weil der Schlüssel im Assembly als ein in sich geschlossenes, erkennbares Objekt sitzt. Kompiliere das const oben, und die Referenz darauf ist ein einziges ldstr, das die Zeichenkette aus dem #US-Heap (Benutzerzeichenketten) lädt:

// IL for: var pub = LicensingConfig.PublicKeyBase64;
ldstr        "MCowBQYDK2VwAyEA9mF3rQ2pxK8vN1cWbYh6Zt0uQ4sJ7eD3rL5nX8kH2A="
stloc.0

Finde diese Zeichenkette im Heap — ein Base64-Blob, der zu einem DER-SubjectPublicKeyInfo dekodiert, ist extrem auffällig; MCowBQYDK2Vw ist das feste Präfix jedes Ed25519-Public-Keys in dieser Kodierung — und du weißt genau, welche Bytes zu überschreiben sind. Der Angreifer braucht nicht einmal deine Werkzeuge: ein Hex-Editor und eine Ersatzzeichenkette gleicher Länge reichen, und ist seine kürzer, füllt er den Heap-Eintrag auf. Ein zusammenhängender Byte-Bereich, keine Logik zu verstehen, und die Bearbeitung überlebt unberührt in jeder künftigen Version deines Produkts, weil der Schlüssel selten wechselt.

Genau diese Eigenschaft müssen wir zerstören. Nicht „kann der Schlüssel gelesen werden” — er ist öffentlich, ihn zu lesen ist harmlos — sondern „kann der Schlüssel ersetzt werden, ohne dass der Rest des Codes es bemerkt”.

Härtung, Schritt 1: Der Code soll den Schlüssel prüfen, nicht nur verwenden

Der erste Zug ist, aufzuhören, die eingebetteten Bytes als maßgeblich zu behandeln, welche auch immer es sind. Deine App weiß, welchen Schlüssel sie haben sollte. Lass sie also bestätigen, dass der Schlüssel, dem sie gleich vertrauen will, einem erwarteten Fingerabdruck entspricht, bevor sie irgendetwas damit verifiziert:

static class KeyGuard
{
    // SHA-256 of the DER-encoded public key you shipped with. Not a secret; an integrity anchor.
    static readonly byte[] ExpectedFingerprint = Convert.FromHexString(
        "7b2a1f9c4e0d8a63b5f1c24e6a8d0b3f9e5c7a12d4b60e8f1a3c5d7b9e0f2a4c");

    public static ReadOnlySpan<byte> Trusted(string embeddedBase64)
    {
        byte[] der = Convert.FromBase64String(embeddedBase64);
        byte[] actual = SHA256.HashData(der);
        if (!CryptographicOperations.FixedTimeEquals(actual, ExpectedFingerprint))
            throw new SecurityException("Licensing trust anchor failed integrity check.");
        return der;
    }
}

Für sich genommen verschiebt das nur das Problem: Nun überschreibt der Angreifer PublicKeyBase64 und ExpectedFingerprint, zwei Bearbeitungen statt einer. Doch es ändert die Form des Angriffs auf nützliche Weise. Ein Fingerabdruck ist ein Hash fester Länge, keine erkennbare Schlüsselstruktur, also ist weniger offensichtlich, wozu er dient; und weil die Prüfung Code ist, kannst du sie dorthin legen, wo der Angreifer sie finden muss, sie von mehr als einer Aufrufstelle ausführen und — der eigentliche Gewinn — den erwarteten Wert in Logik falten, die anderes tut, sodass er nicht ausgenullt werden kann, ohne sie zu brechen.

Eine naive Integritätsprüfung steht allein und lässt sich leicht herausschneiden. Eine dauerhafte ist verflochten. Angenommen, die Fingerabdruck-Bytes dienen auch dazu, einen Versatz oder eine Maske abzuleiten, von der etwas Unverwandtes abhängt — ein Nachschlageindex, eine validierte Länge, ein Branch-Selektor. Nun verfälscht ein Angreifer, der den Fingerabdruck an seinen Schlüssel anpasst, stillschweigend jene andere Berechnung, und einer, der ihn unberührt lässt, scheitert an der Schlüsselprüfung. Keine Bearbeitung erfüllt beides. Genau diese Art von Transformation automatisiert ein Obfuskator; Nebulas Konstanten-Verschlüsselung und Kontrollfluss-Durchläufe gibt es, um eine einzelne vergleichbare Konstante in einen Wert zu verwandeln, der zur Laufzeit rekonstruiert und an mehreren Stellen verbraucht wird, sodass es kein einzelnes ldc/ldstr zu finden und keinen einzelnen Branch herauszuschneiden gibt.

Härtung, Schritt 2: trage mehr als einen Anker

Ein einzelner Schlüssel ist ein einzelner Substitutionspunkt. Keyright-SDKs akzeptieren eine Signatur, die gegen deinen primären eingebetteten Schlüssel oder gegen jeden in AdditionalPublicKeysBase64 gelisteten Schlüssel validiert:

var options = new LicenseVerifierOptions
{
    PublicKeyBase64 = LicensingConfig.PublicKeyBase64,
    AdditionalPublicKeysBase64 =
    {
        // a second, independently generated anchor — rotation today, defense in depth always
        "MCowBQYDK2VwAyEAq7F2dN8kP0wZ5bYh3Rt9uL1cXe4sJ6D2rN5mV8kH0B=",
    },
};
var verifier = new LicenseVerifier(options);

Das existiert für die Schlüsselrotation — liefere den nächsten Schlüssel als zusätzlichen Anker aus, rolle Leases darauf, ziehe den alten zurück — dient aber zugleich als Substitutionsabwehr. Es gibt nicht mehr einen einzelnen Blob zum Tauschen. Ein Angreifer muss jeden vertrauten Schlüssel lokalisieren und überschreiben, und übersieht er einen, verifizieren deine rechtmäßig signierten Lizenzen weiterhin gegen den Überlebenden, während seine gefälschte Lizenz gegen keinen der nicht gepflanzten Schlüssel validiert. Kopple das mit der Fingerabdruckprüfung — schütze jeden Anker gegen seinen eigenen erwarteten Hash — und eine einzelne chirurgische Bearbeitung wird zu mehreren Bearbeitungen, von denen jede genau richtig sein muss.

Härtung, Schritt 3: lass die Bearbeitung nicht still sein

Alles Obige hebt Zahl und Brüchigkeit der Bearbeitungen. Die letzte Schicht macht eine erfolgreiche Bearbeitung sichtbar und den Build überhaupt schwerer herstellbar.

Strong-name das Assembly. Ein Strong-Name ist ein mit deinem Identitätsschlüssel signierter Hash des Assembly-Inhalts. Ändere ein Byte des eingebetteten öffentlichen Schlüssels, und der Strong-Name-Hash stimmt nicht mehr — der Loader sieht eine andere Assembly-Identität, und jede Strong-Name-abhängige Referenz bricht. Ein Angreifer kann den Strong-Name entfernen und mit seinem eigenen Schlüssel neu signieren, aber das ist mehr Arbeit, ändert deine Herausgeberidentität und gibt jeder nachgelagerten Integritätsprüfung etwas zu bemerken.

Verifiziere deine eigenen Methodenkörper. Strong-Naming erzwingt der Host, den der Angreifer kontrolliert, also ist die dauerhafte Variante, die Integrität von innen zu prüfen. Genau das tut Anti-Tamper: Nebula kann die geschützten Methodenkörper zur Build-Zeit hashen und zur Laufzeit verifizieren, sodass ein Assembly, dessen IL — einschließlich des Verifikationspfads und des Schlüssel-Guards — bearbeitet wurde, sich weigert, seinen geschützten Code auszuführen. Der Angreifer kann nun weder die Schlüsselprüfung noch die umgebenden Methoden patchen, ohne eine Prüfung auszulösen, die selbst Teil der geschützten Menge ist.

Mach Report-Home, wenn die Prüfung fehlschlägt. Eine Manipulationsprüfung, die nur eine Ausnahme wirft, sagt dem Angreifer, dass er erwischt wurde, und dir nichts. Nebulas Tamper-Report-Home sendet ein Signal an einen Endpunkt deiner Wahl, wenn eine Integritätsprüfung im Feld fehlschlägt, sodass ein Build, in dem jemand den Schlüssel getauscht hat — falls er überhaupt läuft — als Ereignis auftaucht, das du sehen, korrelieren und bearbeiten kannst, statt als stiller Crack, der monatelang zirkuliert.

Die Kosten der Substitution erhöhenNackter Base64-Schlüsselein Byte-Bereich, überlebt jedes Release+ Fingerabdruckprüfung, in Logik verflochtenBearbeitung muss zu anderswo genutztem Hash passen, sonst bricht sie ihn+ mehrere Anker (AdditionalPublicKeysBase64)jeder Schlüssel muss gefunden und korrekt überschrieben werden+ Strong-Name · Anti-Tamper · Report-HomeBuild schwer herstellbar; Manipulation ist im Feld sichtbar

Die ehrliche Obergrenze

Nichts davon macht Substitution unmöglich. Der Code, der entscheidet, ob eine Lizenz gültig ist, läuft auf einer Maschine, die der Angreifer besitzt, und alles, was auf seiner Maschine läuft, kann mit genug Aufwand dazu gebracht werden, etwas anderes zu tun. Das Ziel war nie ein Unmöglichkeitsbeweis — es war Ökonomie. Ein nackter Base64-Schlüssel ist eine Dreißig-Sekunden-Bearbeitung, die eine Person einmal macht und für immer teilt. Ein fingerabdruckgeprüfter, verflochtener, mehrfach verankerter Schlüssel in einem Build mit Strong-Name, Anti-Tamper und Report-Home ist ein Forschungsprojekt, das bei jeder Auslieferung neu gemacht werden muss und dir meldet, wann es versucht wurde. Derselbe Angreifer; völlig anderer Anreiz.

So liest man jede clientseitige Prüfung richtig, auch diese. Du baust keine Mauer. Du hebst die Kosten, sie zu überwinden, über den Wert dessen, was auf der anderen Seite liegt — und sorgst mit Report-Home dafür, dass du, falls jemand diese Kosten zahlt, der Erste bist, der es erfährt, statt der Letzte.

Nebula.NET testen

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