Featured image of post Talos : un certificat en lecture seule qui reboot votre serveur

Talos : un certificat en lecture seule qui reboot votre serveur

Ecrit par ~ zwindler ~

TL;DR

  • Sur Talos Linux <= 1.14.2, l’API TimeService/TimeCheck vérifiait les chemins de devices PTP avec un simple strings.HasPrefix(server, "/dev/"). Un .. suffisait donc à faire ouvrir n’importe quel fichier par machined, en root. On ne peut pas exfiltrer le fichier, juste le faire ouvrir.
  • Cette API était accessible au rôle os:reader, le rôle lecture seule qu’on distribue aux outils de monitoring.
  • Ouvrir /dev/watchdog arme le timer matériel. Refermer le fichier sans l’octet magique V le laisse armé. Si personne ne le nourrit : hard reset du serveur. Un compte read-only peut donc rebooter un serveur physique.
  • Publié le 19 octobre 2026 sous GHSA-vpfm-35qx-jhvw (Moderate, 5.9), corrigé dans 1.13.11 et 1.14.3. Et c’est mon premier advisory où je suis le seul finder crédité.

Contexte

Dans mon article précédent, je racontais comment je traque des vulnérabilités dans l’open source avec des LLM depuis cet été. Depuis, certaines de ces trouvailles sont devenues publiques : deux CVE Argo CD co-créditées avec 7 autres reporters chacune, une CVE KEDA en co-crédit. Il y en a d’autres en attente, mais la saveur n’est pas la même quand on n’est pas le “premier”.

Celle-ci est ma première GHSA en solo : un seul nom dans les credits, le mien (même si c’est Devin/SWE qui a en réalité fait la découverte initiale, cf. l’article précédent). Au-delà de la fierté personnelle, elle est aussi super marrante à raconter. Martial me fera les gros yeux car c’est “encore” un DoS (il m’a surnommé DoSnis), mais moi j’aime bien les DoS.

Elle est sortie d’un audit complet de Talos (la distribution Linux immuable dédiée à Kubernetes), pendant une passe où je cherchais une chose précise : quelles méthodes de l’API sont accessibles aux rôles les plus faibles, et qu’est-ce qu’elles font vraiment ?

Talos et ses rôles en deux minutes

Talos n’a pas de SSH, pas de shell, pas de package manager. Toute l’administration passe par une API gRPC (apid, port 50000), authentifiée par certificats clients. Le rôle est embarqué dans le certificat (champ O:). Du plus puissant au plus faible :

  • os:admin : tout, y compris reboot, wipe, machine config
  • os:operator : gestion courante (upgrade, reboot…)
  • os:reader : lecture seule. État du noeud, logs, métriques, configuration. C’est le rôle qu’on file aux dashboards, aux sondes, aux outils d’observabilité, en se disant “au pire, ils regardent”.

Sauf que TimeService/TimeCheck, l’API qui demande au noeud de vérifier sa synchro temporelle contre une source donnée, était ouverte aux trois rôles, reader compris (internal/app/machined/pkg/system/services/machined.go:160).

Sur le papier, ça se tient : interroger une source de temps, c’est de la lecture. Le hic, c’est que cette “source de temps” peut être soit un serveur NTP, soit un chemin de device PTP (Precision Time Protocol, les horloges matérielles /dev/ptp*).

Le bug : un HasPrefix qui ne protège rien

Voici l’intégralité du contrôle :

// internal/pkg/ntp/ntp.go
func IsPTPDevice(server string) bool {
    return strings.HasPrefix(server, "/dev/")
}

Oui, c’est tout. Pas de filepath.Clean, pas de rejet de .., pas de restriction à /dev/ptp*. Le handler (v1alpha1_time.go) passe ensuite la chaîne telle quelle à QueryPTPDevice, qui fait :

fd, err := os.Open(device)  // machined tourne en root

Donc /dev/../dev/watchdog, /dev/../etc/shadow ou /dev/../dev/sda passent tous le filtre et sont ouverts en root par machined.

Première question qui vient : “OK, et il lit quoi ?”. Bah… rien. Le descripteur part dans des ioctls PTP, qui échouent sur tout ce qui n’est pas une horloge PTP, et le contenu n’est jamais renvoyé au client. On n’a pas de lecture de fichiers arbitraires.

Le problème est ailleurs : open() n’est pas une opération anodine.

open() n’est pas une lecture : le cas du watchdog

Un watchdog, c’est un minuteur matériel présent sur la plupart des serveurs : il redémarre la machine si l’OS ne donne plus signe de vie, typiquement en cas de freeze du kernel.

Sous Linux, ouvrir /dev/watchdog arme le watchdog matériel. C’est le contrat du driver : l’open lance le compte à rebours, et si personne ne vient écrire dedans régulièrement (“nourrir” le chien de garde), le matériel reset la machine. Pas de log applicatif, pas de shutdown propre, et l’OS n’a pas son mot à dire : c’est le hardware qui coupe.

Il existe une porte de sortie : écrire le caractère magique V dans le fichier avant de le fermer (le “magic close”), ce qui désarme le timer. Ici, Talos fait open(), les ioctls PTP échouent, puis close()… sans V. Le timer continue de tourner, personne ne le nourrit. Reboot.

Note : ça ne marche que si Talos n’a pas déjà ouvert le watchdog lui-même (pas de WatchdogTimerConfig dans la machine config, donc la grande majorité des cas). Autrement dit, ce sont précisément les noeuds qui n’utilisent pas le watchdog qui sont vulnérables : l’attaque l’arme, et personne n’est là pour le nourrir (pauvre chien-chien 🐶).

Repro sur mon lab

J’ai tout vérifié de bout en bout le 29 septembre 2026, sur mes noeuds Talos de lab, avec un talosconfig dont le certificat client est strictement O: os:reader.

D’abord, on vérifie que ce rôle ne peut pas rebooter par la voie normale :

talosctl -n <node-vm> reboot
# rpc error: code = PermissionDenied desc = not authorized

Ensuite, un test inoffensif pour prouver que la traversée passe :

talosctl -n <node-vm> time --check "/dev/../proc/version"
# rpc error ... error querying PTP device "/dev/../proc/version": invalid argument
# open() a reussi (le fichier existe), c'est l'ioctl PTP qui echoue

talosctl -n <node-vm> time --check "/dev/../nonexistent"
# rpc error ... "/dev/../nonexistent": no such file or directory
# open() a echoue : le fichier n'existe pas

Deux messages d’erreur différents, ça fait un oracle : on peut savoir si n’importe quel chemin existe sur le noeud, y compris hors de /dev. Et le .. qui apparaît tel quel dans l’erreur confirme au passage qu’il n’y a pas de filepath.Clean.

Maintenant, sur un serveur bare-metal avec un vrai watchdog matériel :

talosctl -n <node-bm> time --check "/dev/../dev/watchdog"
# rpc error ... invalid argument   (open() en root OK, ioctl PTP en echec)

Et dans la console kernel, au même moment :

kern: crit: watchdog: watchdog0: watchdog did not stop!

C’est exactement le message qu’on obtient quand le fd est refermé sans le V : le watchdog reste armé, et le noeud fait un hard reset au timeout. Un certificat censé ne faire que de la lecture vient de rebooter physiquement un serveur.

Les autres impacts

Pour être honnête, ça reste limité : ni lecture de contenu, ni écriture. Ce qu’on obtient vraiment :

  • Reboot forcé via le watchdog, qu’on peut relancer à chaque redémarrage du noeud.
  • Oracle d’existence de fichiers sur le noeud (les deux messages d’erreur). Mais en vrai, il n’y a quasiment rien sur un noeud Talos Linux (c’est toute la beauté de la chose).
  • Goroutines bloquées : certains devices et FIFO bloquent dans open(2), chaque appel peut donc figer un handler de machined.
  • Des effets de bord plus exotiques d’open() sur certains drivers (rembobinage de bande, état de terminaux…), mais là on est complètement dans la théorie.

Le maintainer m’a mieux noté que moi

Petite anecdote sur la subjectivité du CVSS.

J’avais estimé la faille à 5.4 (AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:L) : A:L pour le reboot, et AC:L parce qu’une fois le certificat os:reader en poche, l’attaque est déterministe.

Le maintainer (smira, de Sidero Labs) a publié 5.9 (AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:H) : A:H parce qu’un hard reset de serveur physique, c’est plus qu’une simple dégradation, et AC:H parce qu’il faut un watchdog matériel présent et non géré par Talos.

Et franchement, les deux choix se défendent. Le CVSS 3.1 laisse beaucoup de place à l’interprétation, surtout sur AC (les conditions que l’attaquant ne contrôle pas) et sur la manière de compter l’impact quand l’attaquant a déjà une partie des droits. Un score CVSS, c’est une opinion argumentée, pas une mesure. Et un maintainer qui prend le temps de justifier son vecteur dans l’advisory, je ne vais pas m’en plaindre.

Le fix : trois couches, defense in depth

Le correctif (mergé dans main, backporté en 1.13.11 et 1.14.3) reprend les trois pistes que j’avais suggérées dans le rapport, en mieux :

  1. Vérification du device : avant tout open(), le chemin doit pointer vers une vraie horloge PTP et le kernel doit confirmer que le device appartient bien au sous-système PTP. Ouvrir autre chose n’a donc plus aucun effet de bord. Les messages d’erreur sont aussi devenus génériques, ce qui supprime l’oracle.
  2. Validation de la machine config : les chemins PTP doivent être des chemins propres, directement sous /dev, avec un nom qui commence par ptp (/dev/ptp0, /dev/ptp_kvm, /dev/ptp_hyperv). Si vous avez des configs exotiques, attention : elles risquent d’être refusées après l’upgrade.
  3. RBAC : TimeService/TimeCheck est désormais réservé à os:admin et os:operator. TimeService/Time (la lecture de l’heure configurée, sans --check) reste accessible à os:reader.

Versions affectées : à partir de 1.10.0 (première release où TimeCheck accepte des chemins PTP), jusqu’à 1.13.11 / 1.14.3 exclus.

Si vous ne pouvez pas upgrader tout de suite :

  • ne distribuez pas de certificats os:reader à des clients en qui vous n’avez pas totalement confiance ;
  • filtrez l’accès réseau au port apid (50000) ;
  • sur les noeuds avec un watchdog matériel, déclarez un WatchdogTimerConfig pour que Talos garde lui-même le device ouvert.

“Takeaways”

  • open() n’est pas une lecture. Un appel qui “ne retourne rien” peut quand même armer du hardware, bloquer une goroutine ou servir d’oracle. Quand on audite une API “read-only”, la question n’est pas “qu’est-ce qu’elle renvoie ?” mais “qu’est-ce qu’elle fait ?”.
  • Un HasPrefix n’est pas une validation de chemin. Sans filepath.Clean, sans rejet de .. et sans allowlist, c’est purement décoratif. Si vous voulez chercher le même pattern ailleurs, grep HasPrefix(x, "/dev/"), HasPrefix(x, "/proc/") ou HasPrefix(x, "/sys/"). J’ai retrouvé ça dans de nombreux autres projets OSS.
  • “Est-ce que ça donne à l’attaquant quelque chose qu’il n’a pas déjà ?” C’est la question que je pose systématiquement pendant la contre-expertise (cf. l’article précédent), et ici la réponse ne fait pas débat : le rôle reader ne peut pas rebooter (PermissionDenied), mais il peut armer le watchdog qui, lui, reboot. Difficile pour un maintainer de répondre “working as intended”.
  • Les rôles “lecture seule” valent le détour. Tout le monde audite les chemins admin. Les API d’observabilité sont moins scrutées, distribuées beaucoup plus largement, et personne ne regarde leurs effets de bord. Dans le même audit, ce pattern “reader + effet de bord” m’a d’ailleurs donné d’autres pistes.
  • Sidero Labs a été exemplaire. Rapport accepté en moins d’une heure, fix en trois couches mergé moins de 3h après mon rapport (avec des durcissements que je n’avais pas suggérés), CVE demandée, et un advisory transparent publié moins de trois semaines plus tard. Si tous les projets traitaient les rapports comme ça, la vie de hunter serait beaucoup plus simple.

Tout est parti d’un subagent qui faisait exactement ce qu’on lui avait demandé : lire le code fichier par fichier et noter tout ce qui paraissait “même vaguement bizarre”. La suite (repro, rédaction, soumission) suit le pipeline décrit dans l’article précédent. Ça fait plaisir de pouvoir enfin raconter un cas concret de bout en bout !

Licensed under CC BY-SA 4.0

Vous aimez ce blog ou cet article ? Partagez-le avec vos amis !   Twitter Linkedin email Facebook

Vous pouvez également vous abonner à la mailing list des articles ici

L'intégralité du contenu appartenant à Denis Germain (alias zwindler) présent sur ce blog, incluant les textes, le code, les images, les schémas et les supports de talks de conf, sont distribués sous la licence CC BY-SA 4.0.

Les autres contenus (thème du blog, police de caractères, logos d'entreprises, articles invités...) restent soumis à leur propre licence ou à défaut, au droit d'auteur. Plus d'informations dans les Mentions Légales

Généré avec Hugo
Thème Stack conçu par Jimmy