Skip to content
← Todas las publicaciones
· Delta1 Labs KeyrightLicenciamientoSeguridad

Fijar tu clave pública de verificación: el atacante que simplemente cambia tu clave por la suya

Tu app verifica una licencia porque una firma cuadra contra una clave pública embebida en el ensamblado. Pero esa clave son datos: una constante base64 que cualquier atacante capaz de editar tu DLL puede encontrar y sobrescribir con una clave que él controla, de modo que sus licencias autofirmadas ahora verifican y la tuya queda olvidada. Esto es la sustitución del ancla de confianza, y es el ataque de mayor apalancamiento contra el licenciamiento offline-first. Esta entrada muestra el ataque a nivel de IL —el ldstr que carga la clave, el único rango de bytes que toca un parche— y luego eleva su coste: toma la huella de la clave contra un valor entretejido en el flujo de control en lugar de dejarlo solo, la verifica en más de un sitio, lleva varias claves con AdditionalPublicKeysBase64 para que un solo cambio no baste, y empareja la comprobación del cliente con strong-naming, anti-tamper y report-home para que un build manipulado sea a la vez más difícil de hacer y visible cuando se distribuye.

Una licencia de Keyright es una afirmación —«este cliente tiene Pro, 5 asientos, hasta el próximo marzo»— y tu aplicación la cree por exactamente una razón: una firma sobre ella verifica contra una clave pública que la app lleva consigo. Ese diseño es el sentido mismo del licenciamiento offline-first. No hay servidor en la ruta de validación al que llamar, nada que pueda estar caído, nada que limitar por tasa; la app tiene la mitad pública de tu clave de firma, comprueba la firma localmente y confía en lo que lee.

Lo cual plantea una pregunta incómoda. La clave pública no es secreta: se distribuye, en claro, dentro de un ensamblado que el cliente tiene en disco. ¿Qué impide que alguien la reemplace?

Nada, por defecto. Y ese es el ataque de mayor apalancamiento contra la verificación en el cliente, porque no requiere romper ninguna criptografía. Esta entrada trata de por qué funciona el ataque, qué aspecto tiene a nivel de IL y cómo hacerlo lo bastante caro como para que deje de valer la pena.

El ataque: sustituir el ancla de confianza

La verificación de firmas es asimétrica. Tu lado emisor tiene una clave privada y firma la carga canónica de la licencia con ella; tu app tiene la clave pública correspondiente y verifica. Falsificar una firma que tu clave pública acepte significa recuperar la clave privada a partir de la pública, lo cual para Ed25519 o RSA-2048 no es trabajo de una tarde: no es trabajo de nadie.

Así que un atacante competente no ataca la firma. Ataca el ancla. La clave pública vive en tu ensamblado como una constante —en una integración de Keyright, normalmente una cadena base64 que pegaste del portal:

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=";
}

El atacante genera su propio par de claves. Sobrescribe PublicKeyBase64 con el base64 de su clave pública. Luego firma una licencia que diga lo que quiera —Pro, asientos ilimitados, sin caducidad— con su clave privada. Tu código de verificación queda byte a byte intacto. Se ejecuta, comprueba la firma contra la clave que encuentra embebida y pasa, porque la clave que encuentra embebida es ahora la suya.

PrevistaServiciosign(claims, PRIV)clave de licenciaclaims firmadosApp: verify(sig, PUB embebida)clave = la tuya → claim de confianzatier · asientos · caducidad según firmadoAtaque de sustituciónAtacantegenKeyPair()sign(forged, PRIV')licencia falsificadaPro · ∞ asientossin caducidadApp: verify(sig, PUB embebida)PUB sobrescrita → PUB' (atacante)la firma cuadra con PUB' → ACEPTADAcódigo de verificación sin cambios

No se descifró nada, no se hizo ingeniería inversa sobre nada. El atacante movió la raíz de confianza y firmó contra la nueva raíz. Todo lo que tu app hace después —habilitar funciones, respetar topes de asientos, hacer cumplir la caducidad— lo gobierna ahora una licencia que él escribió.

Qué toca en realidad el parche

La razón de que esto sea barato es que la clave está en el ensamblado como un objeto autocontenido y reconocible. Compila el const de arriba y la referencia a ella es un único ldstr que carga la cadena del heap #US (de cadenas de usuario):

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

Encuentra esa cadena en el heap —un blob base64 que decodifica a un SubjectPublicKeyInfo DER es de lo más distintivo; MCowBQYDK2Vw es el prefijo fijo de toda clave pública Ed25519 en esa codificación— y sabes exactamente qué bytes sobrescribir. El atacante ni siquiera necesita tu instrumental: un editor hexadecimal y una cadena de reemplazo de igual longitud bastan, y si la suya es más corta rellenan la entrada del heap. Un rango de bytes contiguo, sin lógica que entender, y la edición sobrevive intacta a toda versión futura de tu producto porque la clave rara vez cambia.

Esa es la propiedad que tenemos que destruir. No «¿puede leerse la clave?» —es pública, leerla está bien— sino «¿puede reemplazarse la clave sin que el resto del código se entere?».

Endurecimiento paso 1: que el código compruebe la clave, no solo la use

El primer movimiento es dejar de tratar como autoritativos los bytes que haya embebidos. Tu app sabe qué clave debería tener. Así que haz que confirme que la clave que va a confiar coincide con una huella esperada antes de verificar nada con ella:

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;
    }
}

Por sí solo esto solo desplaza el problema: ahora el atacante sobrescribe PublicKeyBase64 y ExpectedFingerprint, dos ediciones en vez de una. Pero cambia la forma del ataque de un modo útil. Una huella es un hash de longitud fija, no una estructura de clave reconocible, así que es menos obvio para qué sirve; y como la comprobación es código, puedes ponerla donde el atacante tenga que encontrarla, ejecutarla desde más de un sitio de llamada y —la verdadera victoria— plegar el valor esperado en lógica que haga otras cosas, de modo que no pueda anularse sin romperlas.

Una comprobación de integridad ingenua está sola y es fácil de recortar. Una duradera está entrelazada. Supón que los bytes de la huella se usan también para derivar un desplazamiento o una máscara de la que depende algo ajeno —un índice de búsqueda, una longitud validada, un selector de rama. Ahora un atacante que cambie la huella para que cuadre con su clave corrompe en silencio ese otro cálculo, y uno que la deje en paz falla la comprobación de la clave. No hay edición que satisfaga ambas. Este es exactamente el tipo de transformación que automatiza un ofuscador; el cifrado de constantes y las pasadas de flujo de control de Nebula existen para convertir una constante comparable aislada en un valor que se reconstruye en tiempo de ejecución y se consume en varios sitios, de modo que no hay un único ldc/ldstr que encontrar ni una única rama que recortar.

Endurecimiento paso 2: lleva más de un ancla

Una sola clave es un único punto de sustitución. Los SDK de Keyright aceptan una firma que valide contra tu clave embebida principal o contra cualquier clave listada en AdditionalPublicKeysBase64:

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);

Esto existe para la rotación de claves —distribuye la siguiente clave como ancla adicional, mueve los leases a ella, retira la antigua— pero vale a la vez como defensa contra la sustitución. Ya no hay un único blob que cambiar. Un atacante tiene que localizar y sobrescribir todas las claves de confianza, y si se le escapa una, tus licencias firmadas legítimamente siguen verificando contra la superviviente mientras su licencia falsificada no valida contra ninguna de las claves que no plantó. Empareja esto con la comprobación de huella —protege cada ancla contra su propio hash esperado— y una única edición quirúrgica se vuelve varias ediciones que cada una tiene que ser exactamente correcta.

Endurecimiento paso 3: que la edición no sea silenciosa

Todo lo anterior eleva el número y la fragilidad de las ediciones. La última capa hace que una edición exitosa sea visible y que el build sea más difícil de producir en absoluto.

Pon strong-name al ensamblado. Un strong-name es un hash del contenido del ensamblado firmado por tu clave de identidad. Cambia un byte de la clave pública embebida y el hash del strong-name deja de cuadrar: el cargador ve una identidad de ensamblado distinta, y cualquier referencia dependiente del strong-name se rompe. Un atacante puede quitar el strong-name y re-firmar con su propia clave, pero eso es más trabajo, cambia tu identidad de editor y le da a cualquier comprobación de integridad posterior algo que notar.

Verifica tus propios cuerpos de método. El strong-naming lo impone el host, que el atacante controla, así que la versión duradera es comprobar la integridad desde dentro de tu propio código. Esto es lo que hace el anti-tamper: Nebula puede hashear los cuerpos de los métodos protegidos en tiempo de build y verificarlos en tiempo de ejecución, de modo que un ensamblado cuyo IL —incluida la ruta de verificación y el guard de la clave— se haya editado se niega a ejecutar su código protegido. El atacante ya no puede parchear la comprobación de la clave ni los métodos que la rodean sin disparar una comprobación que forma parte del conjunto protegido.

Haz report-home cuando la comprobación falle. Una comprobación de tamper que solo lanza una excepción le dice al atacante que lo pillaron y no te dice nada a ti. El tamper report-home de Nebula envía una señal a un endpoint que elijas cuando una comprobación de integridad falla en el campo, de modo que un build en el que alguien cambió la clave —si llega a ejecutarse— aflora como un evento que puedes ver, correlacionar y accionar, en lugar de un crack silencioso circulando durante meses.

Elevar el coste de la sustituciónClave base64 desnudaun rango de bytes, sobrevive a cada versión+ comprobación de huella, entrelazada en lógicala edición debe cuadrar con un hash usado en otro sitio, o lo rompe+ varias anclas (AdditionalPublicKeysBase64)cada clave debe encontrarse y sobrescribirse correctamente+ strong-name · anti-tamper · report-homebuild difícil de producir; la manipulación es visible en el campo

El techo honesto

Nada de esto hace imposible la sustitución. El código que decide si una licencia es válida se ejecuta en una máquina que el atacante posee, y cualquier cosa que se ejecute en su máquina puede, con suficiente esfuerzo, hacerse que haga otra cosa. El objetivo nunca fue una prueba de imposibilidad, sino economía. Una clave base64 desnuda es una edición de treinta segundos que una persona hace una vez y comparte para siempre. Una clave con huella, entrelazada y con múltiples anclas en un build con strong-name, anti-tamper y report-home es un proyecto de investigación que hay que rehacer cada vez que distribuyes, y que te avisa de cuándo se intentó. El mismo atacante; un incentivo completamente distinto.

Esa es la manera correcta de leer toda comprobación en el cliente, incluida esta. No estás construyendo un muro. Estás moviendo el coste de superarlo por encima del valor de lo que hay al otro lado —y, con report-home, asegurándote de que si alguien paga ese coste, seas el primero en saberlo en vez del último.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.