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 :
- les profils seccomp
pre-auth/sftp-workern’autorisent aucunio_uring_*; - l’enfant ferme le descripteur de l’anneau (
close_range) avant la cage (ADR-124 §D8, implémenté parmonitor::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::DontForkexiste (air-sys-types::mem),madviseaussi (air-sys-syscall::mem), et l’appel se ferait dans le même module que lesmmap. - 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 obtiendraitSIGSEGV— mais aucun n’y touche (ils sortent parexit_group, sans destructeur). - Coût : trois
madvisepar 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_ringsappellemadvise(…, MadviseAdvice::DontFork)sur chaque zone mappée (SQ, CQ horsSINGLE_MMAP, tableau de SQE) ;provided.rsfait de même pour l’anneau de descripteurs des buffers fournis, dans ses deux modes (MAP_SHAREDsur 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
madviseen é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) : unMAP_SHAREDsans 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 unNOPet 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>/mapsest 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>/mapsporte bienanon_inode:[io_uring].