Quand une VM est clonée : détecter les activations en double dans le verrouillage par machine
Une empreinte de machine identifie une machine — et une VM clonée est, par construction, la même machine deux fois. La restauration d'instantané, les images maîtresses et les instances mises à l'échelle automatiquement reproduisent exactement les attributs matériels et système qu'un verrou par machine résume en un hachage, si bien que deux copies en cours d'exécution présentent une empreinte identique et que le verrouillage par machine ne peut pas les distinguer. La solution n'est pas une meilleure empreinte ; c'est une identité d'activation que le serveur possède plus un nonce d'instance par exécution que le client ne peut pas cloner utilement, de sorte que deux copies d'une activation se trahissent dès qu'elles appellent toutes deux la maison. Ce billet lit le SDK et la politique du serveur pour exactement cela : pourquoi l'empreinte est mise en échec, comment un enregistrement d'activation et un identifiant d'instance d'exécution séparent une migration légitime d'un clone en direct, et comment Keyright choisit entre « le plus récent gagne » et « marquer et retenir » sans verrouiller un client qui a simplement déménagé.
Le verrouillage par machine repose sur une hypothèse : une empreinte de machine identifie une machine. Clonez une VM et cette hypothèse est tout simplement fausse. Un instantané restauré sur un second hôte, une image maîtresse déployée sur cinquante instances, un modèle de VM qu”un autoscaler frappe à la demande — chacun reproduit les attributs matériels et système à partir desquels une empreinte est construite, ce qui signifie que chaque copie calcule la même empreinte. Le verrouillage par machine demande « est-ce la machine sur laquelle la licence a été activée ? » et pour un clone la réponse honnête est oui.
Alors l”instinct habituel — résumer davantage d”attributs, rendre l”empreinte plus forte — est exactement le contraire de ce qu”il faut faire. L”empreinte fait son travail. Elle a été conçue pour être stable entre les redémarrages, les mises à jour de pilotes et un disque remplacé, et cette stabilité est précisément ce qui survit au clonage. Une empreinte capable de distinguer deux clones serait une empreinte qui changerait à chaque redémarrage, ce qui verrouillerait vos clients honnêtes le mardi matin.
Ce qui peut distinguer deux clones n”est pas du tout sur la machine. C”est une identité que le serveur frappe et suit : un enregistrement d”activation que le serveur possède, plus un identifiant d”instance d”exécution que chaque processus en marche génère à neuf et rapporte à chaque battement. Ce billet parcourt le mécanisme de bout en bout — pourquoi l”empreinte est mise en échec, ce qu”ajoute une activation, comment le battement transforme « la même machine deux fois » en un événement observable, et comment agir sans transformer un exercice de reprise en incendie de support.
Pourquoi l’empreinte est mise en échec, concrètement
Une empreinte de verrouillage par machine est un hachage sur des signaux stables pour une installation donnée. Une dérivation typique sous Windows prend une poignée d’identifiants, résume chacun en un hachage local pour que les numéros de série bruts ne quittent jamais la machine, et les combine :
// 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];
}
Clonez maintenant la VM. Le MachineGuid vit dans la ruche du registre, qui est sur l’image disque — il se copie. Le numéro de série du volume est une propriété du système de fichiers, écrite dans l’image — il se copie. Le numéro de série de la carte mère est ce que rapporte l’hyperviseur, et un modèle rapporte la même valeur à chaque instance frappée à partir de lui — il se copie. Même l’adresse MAC se copie, sauf si la plateforme est configurée pour la randomiser, et beaucoup de flux de restauration d’instantané la conservent. Le clone exécute ComputeFingerprint() et obtient f3a9…, la valeur exacte que l’original a produite à l’activation.
Ce n’est pas une empreinte faible. C’est une empreinte correcte, mise en échec par le fait que le clonage copie les entrées. Les seuls signaux qui différeraient entre clones sont ceux qui diffèrent aussi entre redémarrages — heure de démarrage du processus, un identifiant de démarrage éphémère, un GUID fraîchement généré — et les incorporer à l’empreinte casserait la stabilité qui rend le verrouillage par machine utilisable. L’empreinte et le détecteur de clones veulent des propriétés opposées de la même valeur, donc ils ne peuvent pas être la même valeur.
Identité d’activation : ce qu’ajoute le serveur
La moitié de la liaison que possède le serveur est l’activation. Quand un client active, il envoie son empreinte ; le serveur enregistre une activation sur la licence et renvoie deux choses que l’empreinte seule n’a jamais données : un ActivationId que le serveur a attribué, et un InstanceSecret opaque que le client persiste localement.
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
Jusqu’ici un clone gagne encore : l’ActivationId et l’InstanceSecret sont tous deux écrits sur disque, donc le clone en hérite avec l’empreinte. Ce n’est pas grave — nous ne cherchons pas à empêcher le clone de démarrer. Nous construisons vers la seule chose qu’un clone ne peut pas copier : l’identité d’un processus en cours d’exécution.
Cette identité est l’identifiant d’instance d’exécution. Il est généré une fois, en mémoire, au démarrage, et il n’est délibérément pas persisté :
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 originale, déjà en marche, détient R-1a en mémoire. Le clone démarre et génère R-9f. Les deux portent l’activation A-7721 sur disque. Les deux calculent l’empreinte f3a9…. La différence qui compte existe désormais seulement à l’exécution, et c’est exactement la différence que le battement porte au serveur.
Le battement transforme « la même machine » en événement observable
Les clients Keyright battent déjà vers le serveur pour garder un bail en vie. Le changement pour détecter les clones est petit : le battement porte l’identifiant d’instance d’exécution, et le serveur enregistre quels identifiants d’exécution il a vus récemment sur chaque activation.
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);
Sur le serveur, le gestionnaire de battement recherche l’activation, vérifie le secret d’instance, puis pose une nouvelle question : combien d’identifiants d’exécution distincts ont battu sur cette activation dans la fenêtre en direct ? Un est normal. Deux ou plus, chevauchants dans le temps, est un doublon.
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);
}
Le détail crucial est LiveRuntimeIds : il compte les identifiants d’exécution dont le battement le plus récent est dans now - (Ttl + Skew). Un identifiant d’exécution qui a battu une fois puis s’est tu vieillit et sort de l’ensemble, donc une machine que vous avez éteinte la semaine dernière ne compte pas contre vous. Deux identifiants battant maintenant est la signature du clone — l’original ne s’est jamais arrêté, et la copie a démarré. Ce chevauchement dans le temps est tout le signal.
Une migration est un relais ; un clone est un chevauchement
C’est là que la plupart des détecteurs de clones naïfs créent plus de douleur qu’ils n’en évitent. Un client qui déplace légitimement une VM — met hors service l’ancien hôte, restaure sur un nouveau — produit une séquence qui ressemble superficiellement à un clone : deux machines, une activation, la même empreinte. La différence est le timing. Dans une migration l’ancien identifiant d’exécution cesse de battre avant que le nouveau démarre ; les ensembles ne se chevauchent jamais. Dans un clone les deux continuent de battre.
LiveRuntimeIds encode déjà cette distinction, car un identifiant d’exécution qui a cessé de battre vieillit et sort. Donc la politique peut être honnête à ce sujet :
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;
}
« Le plus récent gagne » est le défaut habituel pour un logiciel interactif : la personne qui vient de lancer l’application continue de travailler, et la copie abandonnée perd en silence son activation. Pour des charges serveur vous l’inversez souvent en « le plus ancien gagne », afin qu’un autoscaling mal déclenché n’évince pas l’instance qui sert réellement le trafic. Keyright vous laisse choisir par licence, car la bonne réponse dépend de si c’est un humain ou un ordonnanceur qui fait le lancement.
Pour celui qui déménage honnêtement, l’éviction n’est pas la fin — c’est une invite. Quand le prochain battement de la copie évincée renvoie ActivationLost, le client réactive, ce qui relie l’activation au nouvel identifiant d’exécution en un seul aller-retour :
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 client qui a déplacé une VM voit, au pire, une reconnexion. Un script qui frappe dix clones à partir d’une image voit dix copies se disputer une activation, neuf d’entre elles perdant « le plus récent gagne » à chaque cycle de battement et un FlagAndHold sur l’enregistrement d’activation au moment où le compte de chevauchements franchit votre seuil haut — ce qui est l’événement que vous voulez vraiment sur votre tableau de bord.
Ce qu’il ne faut pas faire
Ne tuez pas le processus à la première détection. La première détection est aussi ce à quoi ressemble une restauration légitime depuis une sauvegarde pendant les quelques secondes avant que l’ancienne instance ne vieillisse, et un arrêt brutal transforme un exercice de reprise en panne visible pour le client. De plus il ne vous achète rien face à un vrai attaquant, qui corrige une sortie brutale en quelques minutes ; la détection appartient au serveur, où l’attaquant n’atteint pas, pas à un Environment.Exit côté client.
N’essayez pas de rendre l’empreinte à l’épreuve des clones. Tout attribut assez stable pour survivre à un redémarrage survit à un clone, et tout attribut qui différerait entre clones diffère entre redémarrages. Le travail de l’empreinte est de lier une licence à une machine ; séparer deux copies de cette machine est le travail de l’activation, et de l’identifiant d’instance d’exécution.
Et ne traitez pas un unique doublon comme une preuve de piratage. Le clonage est comment fonctionne l’infrastructure moderne — images, instantanés, modèles, reprise après sinistre. Le signal sur lequel vous agissez n’est pas « un doublon s’est produit une fois » ; c’est « deux copies sont vivantes en même temps, de façon répétée, plus qu’aucune topologie légitime ne l’explique ». C’est un nombre que vous pouvez fixer, remonter et défendre — ce qui est exactement ce que vous donne un verrouillage par machine qui comprend le clonage, et ce qu’une empreinte seule ne donne jamais.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.