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.14 →
couche-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 :
- les profils seccomp
pre-auth/sftp-workern’autorisent aucunio_uring_*; - 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 auforkparmadvise(base, len, MADV_DONTFORK)— anneau SQ, anneau CQ (quand il est mappé à part, c’est-à-dire horsIORING_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 estMAP_SHAREDsur le ring fd — exactement la même faille, avec des adresses de buffers en jeu ; en mode application il estMAP_PRIVATEmais 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
madvisefait é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 (
cloneavecCLONE_VM) partagent toujours l’espace d’adressage : le modèle multi-fils d’air-asyncn’est pas affecté ; - seuls les descendants créés par
fork(sansCLONE_VM) ne reçoivent pas la région. Un tel enfant qui y toucherait prendraitSIGSEGV— 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 0 — air-sys-syscall::io_uring::fork_isolation_tests, trois volets :
- contrôle négatif — un mapping
MAP_SHAREDsans 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 (leSIGSEGVdes volets suivants ne peut donc pas être imputé auforklui-même) ; - 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 ; - 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
NOPet moissonne sa complétion : non-régression du réacteur.
Couche 2, root — air-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.15posé 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.mdest close (voie A). Le privsep d’air-sshddevient un confinement complet : la condition de sortie de la vague 1 est remplie. - Aucun re-sceau couche 1 :
air-uring/air-asyncsont 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
forkdu processus qui porte le réacteur (un « lanceur » privsep forké avant la création du réacteur, recevant le socket accepté parSCM_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. munmapdes anneaux dans l’enfant, après lefork. 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 leforket lemunmap.- 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.15après merge. Note de re-sceau dansetat-avancement.mdetINDEX.md. - La campagne de sécurité de V2.6b hérite d’un privsep sans cette réserve.