Aller au contenu

Blog ·

Un scan qui fermait son propre créneau

Chaque scan planifié réussi marquait son créneau comme expiré, à cause d’un mot venu de la mauvaise énumération. Deux défauts que seule une vraie mesure montre.

Par Provibr

Notre analyse d’exposition possède un planificateur. Il réserve un créneau, envoie une commande de scan à l’agent capable d’atteindre la machine, et attend deux choses : la mesure elle-même et la fin de la commande qui l’a portée. Quand un créneau s’achève sans réponse, il est marqué expiré, et la machine apparaît comme « sans mesure récente ».

Chaque scan réussi marquait son propre créneau comme expiré. Quatre sur quatre, mesurés contre un vrai agent, alors que les quatre mesures elles-mêmes étaient lues et enregistrées correctement. Le panneau annonçait que quatre machines n’avaient pas été scannées récemment, quelques minutes après les avoir scannées.

Un mot, venu d’une autre table

Le gestionnaire qui ferme un créneau resté sans réponse commençait par une sortie anticipée : si la commande a réussi, il n’y a rien à fermer, donc on s’en va.

Le mot auquel il se comparait était succeeded. Ce mot n’existe pas dans l’énumération des états de commande, qui contient pending, sent, completed, failed, timeout et cancelled. C’est le mot d’une tout autre table, celle qui suit les installations d’agents, et il est arrivé là parce que quelqu’un a écrit le gestionnaire en regardant les états de cette autre table et parce que le paramètre était typé comme du texte simple.

Le texte simple est toute l’histoire. La comparaison se compile face à n’importe quel texte, donc rien ne proteste, et l’échec n’est pas une exception mais une branche jamais empruntée. Le gestionnaire a suivi son chemin « aucune réponse n’est arrivée » pour chaque scan réussi et a docilement marqué chaque créneau comme expiré.

Aucun test unitaire n’aurait pu le trouver. Il n’y a pas de cycle de vie de commande dans une imitation : l’imitation est ce qui vous tend l’état, donc l’état est ce qu’a écrit l’auteur du test, et cet auteur a écrit le même mot erroné. Seul un vrai agent, envoyant de vraies fins de commande par le vrai répartiteur, produit l’état qui le révèle. Le paramètre est désormais typé sur les trois états qui peuvent réellement atteindre cette fonction, comme le gestionnaire de supervision équivalent l’avait toujours été. Deux fonctions, la même question, et une seule écrite avec soin.

Et les deux messages arrivent dans le désordre

Réparer la comparaison ne suffisait pas, et la raison en est une seconde chose que seul un vrai agent montre.

L’agent envoie deux messages distincts : le résultat du scan et la fin de la commande. Ils voyagent indépendamment. Mesuré, le résultat est arrivé le premier, quatorze millisecondes avant la fin. Cet intervalle est assez petit pour que personne n’y pense et assez grand pour se produire toujours.

Quand le gestionnaire de fin s’exécute, le créneau est donc en général déjà fermé par le résultat. Une mise à jour sans condition rouvre alors un créneau terminé et le marque à nouveau expiré, ce qui est le défaut initial coiffé d’un autre chapeau. La mise à jour est donc bornée aux deux états dans lesquels un créneau attend encore une réponse. S’il est déjà tranché, le gestionnaire ne change rien.

C’est une forme générale : quand un fait peut arriver par deux chemins, l’écrivain qui n’est pas le principal doit déclarer les états qu’il a le droit de quitter.

Une constatation fermée qui a arrêté toute la flotte

Le second défaut est plus discret et il a fait plus de dégâts.

Quand une source de vulnérabilités se met à jour, les constatations ouvertes sont réévaluées face aux nouvelles données. Une constatation dont la correspondance a disparu est fermée, et fermée comme non vérifiée et non comme résolue, car rien n’a été mesuré : ce qui a changé est notre déduction, pas la machine. Cette distinction est la raison d’être de cet état.

La réévaluation récupérait les constatations dérivées existantes afin de distinguer « mettre celle-ci à jour » de « en créer une nouvelle ». Elle ne récupérait que les ouvertes. C’est un filtre d’apparence raisonnable et il est faux, car la contrainte d’unicité en dessous ignore les états : elle porte sur le serveur et sur une empreinte de la constatation, point.

La suite se déroule ainsi : une base nationale de vulnérabilités corrige une plage de versions, notre correspondance disparaît, la constatation se ferme. Des semaines plus tard, cette même base révise la plage en sens inverse, ce qui arrive vraiment, et la correspondance revient. La requête ne voit pas la ligne fermée. Le code prend la branche « cela n’existe pas encore ». L’insertion heurte l’index unique et lève une violation de contrainte.

Et cette violation s’échappe. L’appelant la reçoit comme « la manche des avis a échoué », et cette manche ne réévalue plus rien du tout, pour tous les clients de la plateforme. La condition qui l’a provoquée ne disparaît pas d’elle-même, donc chaque manche suivante a échoué de la même façon. Une constatation fermée puis rouverte a gelé la réévaluation d’une flotte entière, durablement, avec une ligne de journal pour seul symptôme.

La correction est petite : inclure les lignes fermées et, quand une constatation fermée correspond à nouveau, la rouvrir avec sa chronologie intacte plutôt que d’en commencer une neuve. Une constatation rouverte est un fait différent d’une constatation neuve, et celui qui la traite mérite de voir que cela est revenu et non que cela est arrivé.

Une boucle de sources, mesurée trois fois

Le processus de sources avait été écrit avant qu’il existe un moyen de le lancer sans toucher une vraie base, et c’est le piège dans lequel est tombée la manche de sécurité précédente : importer le paquet de base lit l’environnement et se connecte, si bien qu’un test unitaire avec une fonction de téléchargement injectée a silencieusement écrit quatre avis dans une base de développement tout en annonçant du vert.

Le côté stockage est désormais une couture, et la boucle s’éprouve contre une comptabilité en mémoire tandis que le code de production utilise la vraie, inchangée. Elle a ensuite tourné trois fois contre les sources publiques réelles.

MancheRequêtesCe qui s’est passéDurée
À froid47 066 avis enregistrés, 31,4 Mo après décompression18,7 s
Inchangé1304 Not Modified, validateurs conservés, zéro écriture0,3 s
Curseur reculé de deux jours3355 avis mis à jour, aucun nouveausecondes

Le 304 est tout l’objet de l’exercice. Un catalogue d’avis servi de façon statique répond à une requête conditionnelle par « rien n’a changé », et le comportement correct est de garder les validateurs enregistrés, de ne rien écrire et de compter la manche comme réussie.

Quelques tailles méritent d’être connues, car c’est précisément là que l’on devine : le catalogue des vulnérabilités activement exploitées pèse environ 188 ko sur le fil parce qu’il est compressé, et une page de deux mille enregistrements de la source la plus grande fait à peu près 18,7 Mo. Les deux sont lus derrière un plafond d’octets strict et non par l’aide JSON ordinaire, qui n’a aucune limite ; ce sont les deux plus grosses réponses que la plateforme ingère et elles viennent de machines que nous n’exploitons pas.

Une fenêtre qui se coupe en deux au lieu de sauter

La plus grande source se parcourt page par page, à l’intérieur d’une fenêtre temporelle mobile. Deux choses clochaient dans cette boucle, et les deux perdaient des données sans jamais rien signaler.

Si une fenêtre contenait plus d’enregistrements que le plafond de pages ne le permettait, la boucle s’arrêtait et déplaçait tout de même le curseur à la fin de la fenêtre. Tout ce qui dépassait le plafond était sauté en silence, et n’aurait été repris que si un autre changement avait touché à nouveau ces mêmes enregistrements.

Et une page vide au milieu d’une fenêtre, alors que d’autres enregistrements étaient encore annoncés, avançait le curseur d’une page entière. Deux mille enregistrements perdus.

Les deux sont désormais traités selon le même principe : si la réponse ne tient pas, poser une question plus petite, et si la réponse est impossible, ne rien déplacer.

Une fenêtre trop grande est coupée en deux plutôt que tronquée, six fois au plus, jusqu’à un plancher d’une heure. Six coupes ramènent une semaine à un peu plus de deux heures, ce qui suffit pour tout retard réaliste. En dessous d’une heure, cela s’arrête : une fenêtre de quelques minutes qui rend encore vingt mille enregistrements n’est pas un problème de fenêtre mais une panne, et ne rien écrire en laissant le curseur en place se trouve être la seule réponse honnête disponible. Une page vide en milieu de fenêtre est traitée de même, comme une erreur qui laisse le curseur tranquille, pour que la manche suivante réessaie au lieu de passer par-dessus un trou.

La coupe intervient après l’écriture de la page, pas avant. Ce que nous avons déjà lu appartient à la fenêtre d’origine et reste valide ; le jeter échangerait une forme de perte de données contre une autre.

Une petite chose du même coin, digne d’être répétée parce qu’elle rate facilement sous la pression : la clé d’accès va dans un en-tête et jamais dans la chaîne de requête. Les URL finissent dans les journaux, dans les caches intermédiaires et dans les rapports d’erreur. Les en-têtes non.

Ce que nous noterions

  1. Typer le paramètre, surtout s’il s’agit d’un état. Une comparaison de texte avec un mot venu de l’énumération d’une autre table se compile, s’exécute et prend pour toujours la mauvaise branche.
  2. Deux messages sur un même événement arrivent dans le désordre. Borner l’écrivain secondaire aux états qu’il peut quitter.
  3. Une contrainte d’unicité qui ignore les états rencontrera du code qui ne les ignore pas. Récupérer aussi les lignes fermées, sinon la première réouverture devient une violation.
  4. Se demander ce qu’un seul échec empoisonne. Une exception qui s’échappe d’une boucle par élément transforme une mauvaise ligne en arrêt à l’échelle de la plateforme, et la condition ne se dissipe pas seule.
  5. Une requête conditionnelle à laquelle on répond 304 est une réussite. Garder les validateurs, ne rien écrire et ne pas la compter comme un échec.
  6. Si une fenêtre est trop grande, la couper ; si une page est impossible, s’arrêter. Avancer un curseur au-delà de données non lues est la seule issue sans retour.
  7. Un processus qui ne tourne pas sans base sera éprouvé contre votre base. Une couture de stockage coûte moins cher que le nettoyage.

Pour être clair : l’analyse d’exposition est construite et se trouve derrière un interrupteur de déploiement, aucun client ne l’a donc devant lui. La réévaluation, les sources et la répartition sur plusieurs adresses sont exercées ; restent non prouvés le rétrécissement de fenêtre face à une source vivante plutôt qu’injectée, le comportement de l’index sur un catalogue réellement plein, et un scan lancé depuis une machine autre que celle qui l’a demandé.

Issu du travail de plateforme sur Provibr. Pourquoi le comparateur refuse d’être sûr d’une version est expliqué dans une correspondance qui refuse d’être sûre, et pourquoi la sonde derrière n’envoie presque rien dans pourquoi un scanner de vulnérabilités devrait envoyer zéro octet.

Commencez avec un hôte et un agent

Créez un compte, installez l’agent avec une seule commande et connectez votre premier hôte Proxmox. La période d’essai dure 14 jours et ne demande aucune coordonnée de paiement.