Skip to content
← Todas las publicaciones
· Delta1 Labs KeyrightLicenciamientoAnálisis a fondo

Cuando se clona una VM: detectar activaciones duplicadas en el bloqueo por equipo

Una huella de equipo identifica un equipo — y una VM clonada es, por construcción, el mismo equipo dos veces. La restauración de instantáneas, las imágenes maestras y las instancias autoescaladas reproducen exactamente los atributos de hardware y de sistema operativo que un bloqueo por equipo resume en un hash, así que dos copias en ejecución presentan una huella idéntica y el bloqueo por equipo no puede distinguirlas. La solución no es una huella mejor; es una identidad de activación que posee el servidor más un nonce de instancia por ejecución que el cliente no puede clonar de forma útil, de modo que dos copias de una activación se delatan en cuanto ambas llaman a casa. Esta entrada lee el SDK y la política del servidor para exactamente eso: por qué se derrota la huella, cómo un registro de activación y un id de instancia de ejecución separan una migración legítima de un clon en vivo, y cómo Keyright decide entre "gana el más nuevo" y "marcar y retener" sin bloquear a un cliente que simplemente se mudó.

El bloqueo por equipo descansa sobre una suposición: una huella de equipo identifica un equipo. Clona una VM y esa suposición es simplemente falsa. Una instantánea restaurada en un segundo host, una imagen maestra desplegada en cincuenta instancias, una plantilla de VM que un autoescalador estampa bajo demanda — cada una reproduce los atributos de hardware y de sistema operativo de los que se construye una huella, lo que significa que cada copia calcula la misma huella. El bloqueo por equipo pregunta “¿es este el equipo en el que se activó la licencia?” y para un clon la respuesta honesta es sí.

Así que el instinto habitual — resumir más atributos en el hash, hacer la huella más fuerte — es justo lo contrario de lo correcto. La huella está haciendo su trabajo. Se diseñó para ser estable entre reinicios, actualizaciones de controladores y un disco cambiado, y esa estabilidad es precisamente lo que sobrevive a la clonación. Una huella que pudiera distinguir dos clones sería una que cambiara cada vez que reinicias, lo que bloquearía a tus clientes honestos el martes por la mañana.

Lo que sí puede distinguir dos clones no está en el equipo en absoluto. Es una identidad que el servidor acuña y rastrea: un registro de activación que posee el servidor, más un id de instancia de ejecución que cada proceso en marcha genera de nuevo y reporta en cada latido. Esta entrada recorre el mecanismo de principio a fin — por qué se derrota la huella, qué añade una activación, cómo el latido convierte “el mismo equipo dos veces” en un evento observable, y cómo actuar sin convertir un simulacro de recuperación en un incendio de soporte.

activar una vezfingerprint = f3a9…activation = A-7721instance secret storedclonar la VMoriginal — aún en marchafingerprint = f3a9…activation = A-7721runtime id = R-1a (this boot)clon — también en marchafingerprint = f3a9… (identical)activation = A-7721 (identical)runtime id = R-9f (fresh boot)el servidor ve en A-7721heartbeat R-1a t=00:00heartbeat R-9f t=00:03heartbeat R-1a t=00:20heartbeat R-9f t=00:23dos ids de ejecución vivos,solapados → duplicado

Por qué se derrota la huella, en concreto

Una huella de bloqueo por equipo es un hash sobre señales que son estables para una instalación dada. Una derivación típica en Windows toma un puñado de identificadores, resume cada uno en un hash local para que los números de serie en crudo nunca salgan del equipo, y los combina:

// Simplified: each signal is salted + hashed locally; only the digest is sent.
public static string ComputeFingerprint()
{
    var signals = new[]
    {
        ReadMachineGuid(),          // HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid
        ReadSystemVolumeSerial(),   // volume serial of the OS drive
        ReadPrimaryMacAddress(),    // first non-virtual NIC
        ReadBaseboardSerial()       // WMI Win32_BaseBoard.SerialNumber
    };

    using var sha = SHA256.Create();
    var parts = signals.Select(s =>
        Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes("kr:" + s))));

    return Convert.ToHexString(
        sha.ComputeHash(Encoding.UTF8.GetBytes(string.Join('|', parts))))[..32];
}

Ahora clona la VM. El MachineGuid vive en la rama del registro, que está en la imagen de disco — se copia. El número de serie del volumen es una propiedad del sistema de archivos, escrita en la imagen — se copia. El número de serie de la placa base es lo que reporte el hipervisor, y una plantilla reporta el mismo valor a cada instancia estampada a partir de ella — se copia. Incluso la MAC se copia salvo que la plataforma esté configurada para aleatorizarla, y muchos flujos de restauración de instantáneas la mantienen. El clon ejecuta ComputeFingerprint() y obtiene f3a9…, el valor exacto que el original produjo al activar.

Esto no es una huella débil. Es una correcta, derrotada por el hecho de que la clonación copia las entradas. Las únicas señales que diferirían entre clones son las que también difieren entre reinicios — hora de inicio del proceso, un id de arranque efímero, un GUID recién generado — e incorporarlas a la huella rompería la estabilidad que hace usable el bloqueo por equipo. La huella y el detector de clones quieren propiedades opuestas del mismo valor, así que no pueden ser el mismo valor.

Identidad de activación: lo que añade el servidor

La mitad de la vinculación que posee el servidor es la activación. Cuando un cliente activa, envía su huella; el servidor registra una activación contra la licencia y devuelve dos cosas que la huella sola nunca dio: un ActivationId que asignó el servidor, y un InstanceSecret opaco que el cliente persiste localmente.

public sealed record ActivationResult(
    string ActivationId,     // server-assigned, unique per accepted activation
    string InstanceSecret,   // opaque; the client stores this and replays it
    SignedLicense License);  // the usual signed payload for offline enforcement

var activation = await client.ActivateAsync(licenseKey, Computefingerprint(), ct);
await _store.PersistAsync(activation);   // written to the client's protected store

Hasta aquí un clon todavía gana: el ActivationId y el InstanceSecret se escriben ambos en disco, así que el clon los hereda junto con la huella. Eso está bien — no intentamos impedir que el clon arranque. Estamos construyendo hacia la única cosa que un clon no puede copiar: la identidad de un proceso en ejecución.

Esa identidad es el id de instancia de ejecución. Se genera una vez, en memoria, al arrancar, y deliberadamente no se persiste:

public sealed class RuntimeIdentity
{
    // New value every process launch. Never written to disk, so a clone of the
    // disk image does not carry it — each copy generates its own on first run.
    public string RuntimeInstanceId { get; } = Guid.NewGuid().ToString("N");

    public DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow;
}

La VM original, ya en marcha, tiene R-1a en memoria. El clon arranca y genera R-9f. Ambos llevan la activación A-7721 en disco. Ambos calculan la huella f3a9…. La diferencia que importa ahora existe solo en tiempo de ejecución, y es exactamente la diferencia que el latido lleva al servidor.

El latido convierte “el mismo equipo” en un evento observable

Los clientes de Keyright ya laten al servidor para mantener viva una concesión. El cambio para detectar clones es pequeño: el latido lleva el id de instancia de ejecución, y el servidor registra qué ids de ejecución ha visto recientemente contra cada activación.

public sealed record HeartbeatRequest(
    string LicenseId,
    string ActivationId,
    string InstanceSecret,       // proves the caller holds the activation
    string RuntimeInstanceId,    // identifies this specific running process
    DateTimeOffset StartedAt);

En el servidor, el manejador del latido busca la activación, verifica el secreto de instancia, y entonces hace una pregunta nueva: ¿cuántos ids de ejecución distintos han latido contra esta activación dentro de la ventana en vivo? Uno es normal. Dos o más, solapados en el tiempo, es un duplicado.

public async Task<HeartbeatResult> HeartbeatAsync(HeartbeatRequest req, SeatPolicy policy, CancellationToken ct)
{
    await using var _ = await _store.LockActivationAsync(req.ActivationId, ct);

    var activation = await _store.FindActivationAsync(req.ActivationId, ct);
    if (activation is null || !activation.SecretMatches(req.InstanceSecret))
        return HeartbeatResult.ActivationLost();     // revoked, reaped, or forged secret

    var now = _clock.GetUtcNow();
    activation.RecordRuntimeBeat(req.RuntimeInstanceId, now);

    // Distinct runtime ids seen within the live window = concurrently running copies.
    var liveRuntimes = activation.LiveRuntimeIds(now, policy.Skew);

    if (liveRuntimes.Count > 1)
    {
        var decision = policy.OnDuplicate(activation, req.RuntimeInstanceId, liveRuntimes);
        if (decision == DuplicateAction.EvictCaller)
            return HeartbeatResult.ActivationLost();  // this caller is the loser; it must re-activate

        if (decision == DuplicateAction.FlagAndHold)
            activation.Flag(DuplicateReason.ConcurrentRuntimes, liveRuntimes);
        // FlagAndHold and KeepNewest both fall through and renew the winner below.
    }

    var renewed = _signer.Sign(activation.Lease with { IssuedAt = now, ExpiresAt = now + policy.Ttl });
    await _store.UpsertActivationAsync(activation, ct);
    return HeartbeatResult.Renewed(renewed);
}

El detalle crucial es LiveRuntimeIds: cuenta los ids de ejecución cuyo latido más reciente está dentro de now - (Ttl + Skew). Un id de ejecución que latió una vez y se quedó en silencio envejece y sale del conjunto, así que un equipo que apagaste la semana pasada no cuenta en tu contra. Dos ids latiendo ahora es la firma del clon — el original nunca paró, y la copia arrancó. Ese solape en el tiempo es toda la señal.

Una migración es un relevo; un clon es un solape

Aquí es donde la mayoría de los detectores de clones ingenuos crean más dolor del que evitan. Un cliente que mueve legítimamente una VM — da de baja el host antiguo, restaura en uno nuevo — produce una secuencia que parece superficialmente un clon: dos equipos, una activación, la misma huella. La diferencia es el tiempo. En una migración el id de ejecución antiguo deja de latir antes de que el nuevo arranque; los conjuntos nunca se solapan. En un clon ambos siguen latiendo.

LiveRuntimeIds ya codifica esa distinción, porque un id de ejecución que dejó de latir envejece y sale. Así que la política puede ser honesta al respecto:

public DuplicateAction OnDuplicate(
    Activation activation, string caller, IReadOnlyList<string> liveRuntimes)
{
    // A genuine migration never reaches here: the old runtime aged out before the
    // new one beat, so liveRuntimes.Count == 1. Reaching here means true overlap.

    // Newest-wins: the most recently started runtime keeps the activation, the
    // older one is evicted on its next heartbeat and must re-activate.
    var newest = activation.NewestRuntimeId(liveRuntimes);
    if (caller != newest)
        return DuplicateAction.EvictCaller;

    // The caller is the newest; keep it, but record the overlap for the dashboard.
    return activation.OverlapCount > HighWaterMark
        ? DuplicateAction.FlagAndHold     // repeated cloning: hold and surface loudly
        : DuplicateAction.KeepNewest;
}

“Gana el más nuevo” es el valor por defecto habitual para software interactivo: la persona que acaba de lanzar la aplicación sigue trabajando, y la copia abandonada pierde en silencio su activación. Para cargas de servidor a menudo lo inviertes a “gana el más antiguo”, para que un autoescalado mal disparado no expulse a la instancia que de hecho está sirviendo tráfico. Keyright te deja elegir por licencia, porque la respuesta correcta depende de si quien lanza es un humano o un planificador.

Para quien se muda de forma honesta, la expulsión no es el final — es un aviso. Cuando el siguiente latido de la copia expulsada devuelve ActivationLost, el cliente reactiva, lo que vuelve a enlazar la activación al nuevo id de ejecución en un solo viaje de ida y vuelta:

var result = await client.HeartbeatAsync(request, ct);
if (!result.Renewed)
{
    // Lost the activation — either we were cloned and lost newest-wins, or the
    // activation was reaped. Re-activate: rebinds to THIS runtime id cleanly.
    var fresh = await client.ReactivateAsync(licenseKey, ComputeFingerprint(), ct);
    await _store.PersistAsync(fresh);
    _state.Replace(fresh.License);
}

Un cliente que movió una VM ve, en el peor caso, una reconexión. Un script que estampa diez clones a partir de una imagen ve diez copias peleándose por una activación, nueve de ellas perdiendo “gana el más nuevo” en cada ciclo de latido y un FlagAndHold sobre el registro de activación en el momento en que el recuento de solapes cruza tu marca de agua alta — que es el evento que de verdad quieres en tu panel.

Lo que no hay que hacer

No mates el proceso a la primera detección. La primera detección es también lo que parece una restauración legítima desde copia de seguridad durante los pocos segundos antes de que la instancia antigua envejezca, y un cierre forzado convierte un simulacro de recuperación en una caída visible para el cliente. Además no te compra nada frente a un atacante real, que parchea una salida forzada en minutos; la detección pertenece al servidor, donde el atacante no llega, no a un Environment.Exit del lado del cliente.

No intentes hacer la huella a prueba de clones. Todo atributo lo bastante estable para sobrevivir a un reinicio sobrevive a un clon, y todo atributo que diferiría entre clones difiere entre reinicios. El trabajo de la huella es enlazar una licencia a un equipo; separar dos copias de ese equipo es el trabajo de la activación, y del id de instancia de ejecución.

Y no trates un único duplicado como prueba de piratería. La clonación es como funciona la infraestructura moderna — imágenes, instantáneas, plantillas, recuperación ante desastres. La señal sobre la que actúas no es “ocurrió un duplicado una vez”; es “dos copias están vivas a la vez, repetidamente, más de lo que cualquier topología legítima explica”. Ese es un número que puedes fijar, aflorar y defender — que es exactamente lo que te da un bloqueo por equipo que entiende la clonación, y lo que una huella por sí sola nunca da.

Prueba Nebula.NET

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