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

Réserve de sécurité — les anneaux io_uring survivent au fork, en mémoire partagée

✅ CLOSE le 2026-07-29 par la voie A — descellement additif couche-0-v1.15 (ADR-132). madvise(MADV_DONTFORK) est posé, fail-closed, sur les mappings d’anneau à leur création : SQ, CQ (quand elle est mappée à part), tableau de SQE, et l’anneau de descripteurs des buffers fournis. Un enfant forké n’a plus aucun mapping d’anneau — donc plus aucune fenêtre d’écriture sur le réacteur du listener root. Le texte ci-dessous est conservé tel qu’il a été écrit (constat d’origine), avec une section « Ce qui a été fait » en fin de document.

Ouverte au câblage V2.6a du démon air-sshd sur le privsep.

Le constat

air-sshd en mode privsep (V2.6a) fork deux fois par connexion depuis le listener, et le listener est le processus qui porte le réacteur io_uring. Or les anneaux sont mappés en MAP_SHARED :

#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/io_uring/mod.rs
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 d’air-sshd — celui qu’ADR-122/124 traitent comme hostile par hypothèse — hérite donc d’une fenêtre en écriture sur la SQ, la CQ et le tableau de SQE du listener, qui est root.

Pourquoi la mitigation existante ne couvre pas ce cas

ADR-122 §5 traite explicitement l’anneau hérité, et sa réponse est double :

  1. les profils seccomp pre-auth/sftp-worker n’autorisent aucun io_uring_* ;
  2. l’enfant ferme le descripteur de l’anneau (close_range) avant la cage (ADR-124 §D8, implémenté par monitor::topology::purge_inherited_fds).

Les deux visent la même chose : empêcher l’enfant de soumettre des opérations. Et elles y parviennent — sans descripteur et sans syscall, il ne peut pas appeler io_uring_enter.

Mais elles ne disent rien de la mémoire. 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, cf. l’en-tête de monitor::topology). L’enfant garde donc, jusqu’à sa mort, un accès en écriture aux anneaux du listener.

Ce que cela permet, si l’enfant est compromis

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 que le listener a préparé mais pas encore soumis — ou qui avance le tail de la SQ — fait exécuter au processus root une opération de son choix, openat2/write compris. C’est un contournement complet de la séparation de privilèges : le processus dont tout l’édifice suppose qu’il ne peut rien faire retrouve les droits du processus qui l’a créé.

Ce que V2.6a change, et ne change pas

Rien en pire. Avant V2.6a, serve_forever servait la connexion dans le listener root lui-même : un défaut dans le KEX ou l’userauth donnait root directement, sans détour. V2.6a est donc une amélioration nette, pas une régression — mais une amélioration incomplète, et il serait malhonnête de la présenter autrement.

En clair : le privsep de V2.6a ferme le chemin d’exploitation direct et laisse ouvert un chemin indirect. La réserve doit être close avant qu’on puisse dire « air-sshd est séparé en privilèges » sans réserve.

Les deux voies de fermeture

A. MADV_DONTFORK sur les mappings d’anneau (recommandé)

Le noyau sait ne pas transmettre une région à l’enfant : madvise(MADV_DONTFORK). Appliqué aux trois mappings (SQ, CQ, SQE) à la création de l’anneau, l’enfant ne les a tout simplement pas dans son espace d’adressage — plus rien à protéger.

  • Le mécanisme est déjà là : MadviseAdvice::DontFork existe (air-sys-types::mem), madvise aussi (air-sys-syscall::mem), et l’appel se ferait dans le même module que les mmap.
  • Aucun usage légitime ne casse. Rien dans Air n’hérite d’un anneau à travers un fork : ADR-122 §5 pose exactement l’inverse comme doctrine. Un enfant qui touche la région après coup obtiendrait SIGSEGV — mais aucun n’y touche (ils sortent par exit_group, sans destructeur).
  • Coût : trois madvise par anneau, à la construction.
  • Ce qui bloque : c’est un changement de couche 0, scellée. Additif dans son effet (aucune signature ne bouge), mais c’est un changement de comportement — donc un descellement à décider (ADR-015), pas une correction à glisser.

B. Sortir le fork du processus qui porte le réacteur

Un « lanceur » privsep, forké avant la création du réacteur, recevrait le socket accepté par SCM_RIGHTS et monterait la chaîne. Aucun anneau n’existerait dans son espace d’adressage, donc aucun dans celui de ses descendants.

Coût réel : un second processus root permanent, une topologie qui s’écarte de celle qu’ADR-124 §D1 a ratifiée (listener → monitor → enfant), et une cascade de mort à re-raisonner. C’est une décision de conception, pas un durcissement.

Recommandation : la voie A. Elle est chirurgicale, ne déplace aucune frontière de confiance, et ferme la réserve entièrement. La voie B ne devient intéressante que si le descellement de couche 0 est refusé.

Statut — close

  • Découverte : V2.6a (câblage du démon), consignée sans être corrigée — corriger supposait un descellement de couche 0 que ce sous-incrément n’avait pas mandat de faire.
  • Tranchée le 2026-07-29 : voie A, descellement additif couche-0-v1.15 (ADR-132).

Ce qui a été fait

  • air-sys-syscall::io_uring::map_rings appelle madvise(…, MadviseAdvice::DontFork) sur chaque zone mappée (SQ, CQ hors SINGLE_MMAP, tableau de SQE) ; provided.rs fait de même pour l’anneau de descripteurs des buffers fournis, dans ses deux modes (MAP_SHARED sur le ring fd en mode kernel — la faille à l’identique ; région pinnée par le kernel en mode application — fenêtre COW/GUP).
  • Fail-closed : un madvise en échec fait échouer la construction du ring.
  • Aucun symbole ajouté, retiré ni modifié — le mécanisme existait déjà en couche 0 ; seul le comportement de la construction change.

Les preuves, et leur honnêteté

  • Couche 0 (io_uring::fork_isolation_tests) : un MAP_SHARED sans le conseil est hérité (contrôle négatif, qui valide la méthode de mesure) ; avec, l’enfant faute (SIGSEGV) tandis que le parent accède toujours à sa région ; et sur un vrai ring, l’enfant faute sur la SQ et sur le tableau de SQE pendant que le parent soumet un NOP et moissonne sa complétion.
  • Couche 2, root (air-sshd/tests/uring_fork_isolation_root.rs) : la topologie réelle (spawn_privsep) est montée avec un réacteur vivant dans le listener, et /proc/<pid>/maps est lu aux trois étages — le listener a ses anneaux (sans quoi le test serait vacant), le monitor et l’enfant pré-auth ne les ont pas.
  • Les deux ont été vérifiés rouges en retirant le madvise : sans lui, l’enfant écrit dans l’anneau du parent sans fauter, et /proc/<enfant>/maps porte bien anon_inode:[io_uring].