Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-132 — Descellement additif couche-0-v1.15 : les anneaux io_uring ne sont plus hérités au fork (MADV_DONTFORK)

Statut : Accepté (2026-07-29). RFC de structure (ADR-015). Applique le modèle de re-sceau additif de la couche 0 (ADR-051, ADR-085, ADR-126).

Catégorie : Descellement additif de la couche 0 scellée (couche-0-v1.14couche-0-v1.15, en suite d’ADR-126). Aucun symbole ajouté, aucun symbole retiré, aucune signature modifiée : la surface publique est identique au bit près. Ce qui change est le comportement interne d’une construction — d’où le passage par un descellement, et non par un simple correctif.

Nature : correction d’une faille de sécurité ouverte par le câblage privsep du démon air-sshd (V2.6a) et consignée sans être corrigée dans docs/notes/reserve-anneaux-io-uring-herites-au-fork-fr.md (voie A, recommandée).

Contexte — la réserve

air-sshd en mode privsep fork deux fois par connexion depuis le listener, et le listener est le processus qui porte le réacteur io_uring. Or les trois zones d’un anneau (SQ, CQ, tableau de SQE) sont mappées MAP_SHARED sur le ring fd :

#![allow(unused)]
fn main() {
let map = MapFlags::SHARED | MapFlags::POPULATE;
let mut sq_ring = mmap_file(fd.as_fd(), sizes.sq_map, raw::IORING_OFF_SQ_RING, prot, map)?;
}

Un mapping MAP_SHARED n’est pas copié à la naissance d’un enfant : parent et enfant écrivent dans les mêmes pages physiques. L’enfant pré-auth — celui qu’ADR-122 et ADR-124 traitent comme hostile par hypothèse — héritait donc d’une fenêtre en écriture sur les anneaux du listener, qui est root.

Les deux mitigations existantes ne couvraient pas ce cas, parce qu’elles ne parlent que de soumission, jamais de mémoire :

  1. les profils seccomp pre-auth/sftp-worker n’autorisent aucun io_uring_* ;
  2. l’enfant ferme le descripteur de l’anneau (purge_inherited_fds, ADR-124 §D8).

Fermer un descripteur ne démappe pas la région, et le démappage n’a jamais lieu : les étages sortent par exit_group sans exécuter de destructeur (c’est voulu). Et l’enfant n’a pas besoin de soumettre lui-même — le listener soumet en permanence (sa boucle d’accept). Un enfant qui réécrit un SQE déjà préparé, ou qui avance le tail de la SQ, fait exécuter au processus root l’opération de son choix, openat2/write compris : contournement complet de la séparation de privilèges, SQPOLL ou non.

Décision

Descellement additif couche-0-v1.15. Sur air-sys-syscall::io_uring :

  • À la création d’un ring (map_rings), chacune des zones mappées est retirée de l’héritage au fork par madvise(base, len, MADV_DONTFORK) — anneau SQ, anneau CQ (quand il est mappé à part, c’est-à-dire hors IORING_FEAT_SINGLE_MMAP), tableau de SQE.
  • Idem pour l’anneau de descripteurs des buffers fournis (provided.rs, IORING_REGISTER_PBUF_RING), dans ses deux modes : en mode kernel il est MAP_SHARED sur le ring fd — exactement la même faille, avec des adresses de buffers en jeu ; en mode application il est MAP_PRIVATE mais pinné par le kernel, et le retirer de l’héritage ferme la fenêtre COW/GUP classique des régions pinnées.
  • Fail-closed : toute erreur de madvise fait échouer la construction du ring (le mapping est démappé par RAII). Un anneau sans cette protection ne doit pas exister — SÉCURITÉ > PERFORMANCE.

Le mécanisme était déjà scellé : MadviseAdvice::DontFork (air-sys-types::mem) et madvise (air-sys-syscall::mem) existent depuis le sceau initial. Ce descellement ne fait que les appeler. Aucune fonction, aucun type, aucune variante n’est ajouté.

Ce que le noyau fait, et ne fait pas

MADV_DONTFORK pose VM_DONTCOPY sur le VMA. Conséquences, toutes vérifiées par les tests :

  • le processus courant est inchangé : le listener/réacteur garde ses anneaux et continue de soumettre et de moissonner normalement ;
  • les threads (clone avec CLONE_VM) partagent toujours l’espace d’adressage : le modèle multi-fils d’air-async n’est pas affecté ;
  • seuls les descendants créés par fork (sans CLONE_VM) ne reçoivent pas la région. Un tel enfant qui y toucherait prendrait SIGSEGV — mais aucun n’y touche : ADR-122 §5 pose exactement l’inverse comme doctrine, et les étages privsep (monitor, enfant pré-auth, worker) sont en I/O bloquante (ADR-129 V2.5b, air_sshd::blocking), sans le moindre anneau.

Coût : trois madvise par ring, à la construction. Rien sur le chemin chaud.

Preuves (tests)

Couche 0air-sys-syscall::io_uring::fork_isolation_tests, trois volets :

  1. contrôle négatif — un mapping MAP_SHARED sans le conseil est hérité : l’enfant y écrit sans fauter et le parent relit l’octet. Ce volet valide la méthode de mesure (le SIGSEGV des volets suivants ne peut donc pas être imputé au fork lui-même) ;
  2. le conseil suffit — le même mapping, marqué DontFork, n’est plus dans l’espace d’adressage de l’enfant (SIGSEGV), alors que le parent y accède toujours ;
  3. sur un vrai ring — l’enfant faute sur la SQ, sur le tableau de SQE (et sur la CQ si elle est mappée à part), puis le parent soumet un NOP et moissonne sa complétion : non-régression du réacteur.

Couche 2, rootair-sshd/tests/uring_fork_isolation_root.rs : la topologie réelle (spawn_privsep) est montée avec un réacteur io_uring vivant dans le listener, et /proc/<pid>/maps est lu à trois endroits — le listener a ses anneaux (sans quoi le test serait vacant), le monitor ne les a pas (il se vérifie lui-même), l’enfant pré-auth ne les a pas (constaté par le test parent, root, pendant que l’enfant est encore vivant sur un rendez-vous).

Honnêteté de la preuve. Les deux tests ont été vérifiés rouges en retirant les trois deny_fork_inheritance de map_rings : le test couche 0 rapporte alors « l’anneau SQ a été hérité par l’enfant (Exited { code: 0 }) », et le test root « l’enfant pré-auth a hérité les anneaux io_uring du listener ».

Conséquences

  • Tag git couche-0-v1.15 posé par le superviseur sur le commit de merge, après CI verte (barrière x86 + couverture 100 % + aarch64). Signé.
  • La réserve docs/notes/reserve-anneaux-io-uring-herites-au-fork-fr.md est close (voie A). Le privsep d’air-sshd devient un confinement complet : la condition de sortie de la vague 1 est remplie.
  • Aucun re-sceau couche 1 : air-uring/air-async sont inchangés (ni signature, ni comportement observable — ils héritent simplement d’un ring durci).
  • Couverture couche 0 100 % : le chemin nominal des quatre appels est exercé par toute construction de ring (donc par la quasi-totalité de la suite io_uring) ; les lignes exécutées dans les enfants forkés des tests sont des exceptions CHILD-EXIT (ADR-035), prouvées par l’événement de sortie observé (Killed { SIGSEGV } / Exited { 0 }).

Alternatives rejetées

  • Voie B — sortir le fork du processus qui porte le réacteur (un « lanceur » privsep forké avant la création du réacteur, recevant le socket accepté par SCM_RIGHTS). Rejetée : elle coûte un second processus root permanent, s’écarte de la topologie ratifiée par ADR-124 §D1 (listener → monitor → enfant) et oblige à re-raisonner la cascade de mort. C’est une décision de conception, pas un durcissement. Elle ne redevient intéressante que si la voie A est refusée.
  • munmap des anneaux dans l’enfant, après le fork. Rejetée : c’est du fail-open — elle dépend de l’exécution de code dans le processus non fiable, au moment précis où l’on suppose qu’il peut être compromis, et elle laisse la fenêtre ouverte entre le fork et le munmap.
  • Ne marquer que la SQ et le tableau de SQE (les zones que le parent lit pour soumettre). Rejetée : arbitraire. La CQ hérité donnerait à l’enfant la fabrication de fausses complétions, et l’anneau de buffers fournis, des adresses que le kernel honore. On ferme la classe, pas un cas.
  • Ignorer l’échec de madvise (durcissement « best effort »). Rejetée : SÉCURITÉ > PERFORMANCE — un anneau non protégé qui paraît protégé est pire que pas de protection du tout.

Suite

  • Tag couche-0-v1.15 après merge. Note de re-sceau dans etat-avancement.md et INDEX.md.
  • La campagne de sécurité de V2.6b hérite d’un privsep sans cette réserve.