ADR-147 — Worker de session toujours confiné : lancement de session médié par le monitor (air-sshd)
Statut : Accepté (2026-08-02, ratifié BDFL ; amendé le 2026-08-02 — bascule vers l’option B après découverte, à l’implémentation, que la classification « forward-only » n’est pas fiable côté serveur). Durcissement défense-en-profondeur du modèle de séparation de privilèges (ADR-124, ADR-146), complété par ADR-148.
Catégorie : durcissement couche 2 (air-sshd) réutilisant des primitives
couche 1 déjà scellées (air-sandbox : le profil sftp-worker existant convient ;
air-process : lancement de processus). Aucun descellement. C’est un chantier
d’architecture (nouvelle médiation monitor→session), pas un patch — dimension comparable
à la médiation du forwarding d’ADR-146.
Contexte
La campagne ADR-105 (2026-08-02) a observé de l’intérieur l’arbre privsep déployé. Le
worker de session post-auth tourne en uid non-privilégié, zéro capability,
NoNewPrivs=1 — mais sans filtre seccomp (Seccomp=0), là où les cages pre-auth et
sftp-worker en ont un. Or ce worker parse des données de canal contrôlées par le pair
(un channel open malformé, la gestion des fenêtres, le sondage de résidu MSG_PEEK) : un
détournement par faille de parsing lui offrirait toute la surface d’appels-système d’un
uid utilisateur (execve, socket/connect, openat…). Un filtre seccomp doit réduire
cette surface.
Pourquoi l’approche initiale (« cager le worker forward-only ») ne tient pas
La première rédaction posait un profil session-forward (sans execve) quand la session
n’exec aucun shell, en traitant forward et shell comme exclusifs par worker. À
l’implémentation, deux faits l’ont invalidée :
- Un filtre seccomp est hérité et persiste à travers
execve(avecNoNewPrivs). Le worker fork+execve lui-même le shell de session (worker.rs::spawn_on_terminal/spawn_on_socketpairviaair_process). Un worker caged sansexecvene peut donc pas lancer de shell. - Le serveur ne peut pas classer une connexion « forward-only » de façon fiable. Le
démux (ADR-146, V2.6-3
C.5c) sert la session comme un canal parmi d’autres, et une session peut naître
après un forward (
SessionSlot::Opening). Le cas courantssh -L 8080:… host(sans-N) ouvre un forward puis un shell sur la même connexion. Poser le seccomp au premier forward casserait ce cas et la coexistence C.5c (testa_caged_sftp_session_and_a_local_forward_share_one_connection). Le raisonnement «-Nexclut--command» est vrai côté client Air, jamais garanti côté serveur face à un client OpenSSH quelconque.
La conclusion est structurelle : pour confiner le code qui parse le transport hostile, le
détenteur du transport doit être confiné — donc ne jamais execve — donc le lancement de
session doit sortir du worker. C’est l’option B.
Décision (option B)
1. Le lancement de session (shell / exec / commande forcée) est médié par le monitor
Aujourd’hui le worker (setuid utilisateur) alloue un PTY (ou un socketpair) et fork+execve le shell. Désormais, comme pour le forwarding, le monitor (root, non confiné) fait le geste privilégié et rend un descripteur :
- nouveau verbe RPC worker→monitor
SpawnSessionportant le service (Shell/Exec(command)/ commande forcée résolue), le PTY demandé ou non, et l’environnement de login (shell, home, user) — tous déjà résolus par le worker, qui seul les connaît ; - le monitor alloue le PTY (le cas échéant), fork, largue vers l’identité de l’utilisateur (setuid/setgid + initgroups, comme le largage initial du worker) et execve le shell sur l’esclave / le socketpair ;
- il repousse au worker, par
SCM_RIGHTSsur le canal de médiation déjà établi, le maître du PTY (ou l’extrémité parent du socketpair) et lepidfddu processus de session ; - le worker pompe ce descripteur exactement comme avant (
SessionPump, inchangé) et récolte la sortie par lepidfd(waitidWNOHANG, C.5c-fix).
Le worker ne fait alors plus aucun fork/clone/execve : tout ce qui exécute du
code utilisateur vit dans un processus que le monitor a créé.
2. Le worker est toujours confiné, avec le profil sftp-worker existant
Le worker pose sa cage avant d’entrer dans la boucle de démux, dans tous les cas
(forward-only, shell, exec, sftp), en réutilisant AirSandboxProfile::sftp_worker() — qui
autorise ce que le démux et ses pilotes appellent (ppoll, read/write, recvmsg
pour SCM_RIGHTS, fcntl, close, l’accès fichier borné par Landlock pour le SFTP), à
deux gestes de contrôle de session près, médiés (voir plus bas), et
exclut execve, clone, la famille socket (au-delà du recvmsg de remise de fd),
io_uring_*, ptrace, mount, setuid/setgid. La cage est fail-closed
(KILL_PROCESS).
- SFTP : inchangé — le worker se sert déjà cage
sftp-workerposée. - shell / exec : le worker n’exec plus ; il reçoit le maître du monitor sous la même
cage. Le profil couvre le pompage du maître (
read/write). - forward-only (
-N) : le worker est désormais caged (il ne l’était pas) — le trou d’ADR-105 est fermé.
Deux gestes de contrôle de session sont médiés, pas ajoutés au profil (correctif à la
rédaction initiale de ce §2, découvert à l’inc.2). Le pilote de session — qui n’avait
jamais tourné cagé, SFTP et shell étant exclusifs — appelle deux syscalls absents du
profil : ioctl(TIOCSWINSZ) (redimensionnement du terminal, à chaque window-change) et
pidfd_send_signal(SIGHUP) (fermeture de canal). Plutôt que d’élargir le profil (ce qui
affaiblirait la cage de tous les workers) ou de les supprimer (ce qui casserait le
redimensionnement de terminal en session interactive), ils passent par le monitor —
nouveau verbe RPC SessionControl, exactement le chemin ouvert au §1 pour le lancement.
Le monitor applique le resize sur le maître qu’il a lui-même alloué et le SIGHUP sur
le pidfd qu’il détient ; la requête ne porte aucun identifiant de cible — un worker
compromis ne peut donc ni signaler un autre processus ni redimensionner un autre terminal —
et le signal est borné (jamais un numéro choisi par le worker). Couche 2 seule, aucun
descellement, aucune capacité retirée au client.
Le profil sftp-worker est légèrement plus large que nécessaire pour une connexion
purement forward (il autorise l’accès fichier borné par Landlock, jamais exercé sans SFTP) :
c’est le prix de n’avoir qu’une cage, posée avant de savoir si une session viendra,
et laquelle. L’essentiel — ni execve, ni socket, ni clone, ni io_uring — est
acquis dans tous les cas. Un profil session-demux plus étroit (sans les syscalls fichier)
est une optimisation future possible, subordonnée à savoir, au moment de la cage,
qu’aucun SFTP ne sera demandé — ce que le protocole ne garantit pas aujourd’hui.
3. Ordre et invariants
- Le largage d’identité et la pose de la cage restent dans l’ordre d’ADR-123/ADR-129 ; la cage est posée après le drop et avant le premier octet de démux.
- Le
pidfdde session remplace la filiation directe : le worker n’étant plus le parent du shell, il ne peut plus lewaitidpar filiation — le monitor lui passe lepidfd, et la récolte se fait dessus (déjà le mécanisme C.5c-fix). - Le monitor, en spawnant un shell, applique exactement le même largage que le worker
faisait (uid/gid/groups,
PDEATHSIG, environnement) : aucune élévation, le shell tourne sous l’identité du compte, comme avant.
Conséquences
- Gain : le détenteur du transport (le code exposé au parsing hostile) est toujours
seccomp-confiné — « le worker ne fait aucun syscall réseau ni
execve» passe d’intention à invariant noyau vérifié, sans jamais dépendre d’une classification « forward-only » que le serveur ne peut pas faire. La coexistence C.5c (shell et forward) est préservée. - Coût — élevé : nouvelle médiation
SpawnSession(RPC + fork/setuid/PTY/execve côté monitor + remiseSCM_RIGHTSdu maître et du pidfd), migration du lancement hors du worker, tests d’interaction (drop d’identité, PDEATHSIG, récolte pidfd, coexistence). Un chantier multi-incréments. - Réutilisation : aucun nouveau profil seccomp —
sftp-workersert de cage unique. Le nouveau code est la médiation, pas le confinement. - Non-objectif : le shell lui-même reste non confiné (c’est un processus utilisateur légitime, spawné par le monitor) — seccomper le shell casserait l’usage, comme sous OpenSSH. On confine le démux, pas la session.
Alternatives rejetées
- Option A — opt-in explicite du client (
-Nsignalé). Le serveur ne caged que les connexions dont le client déclare « forward-only ». Rejetée par le BDFL : laisse toute connexion mixte (client OpenSSH) non confinée, et fait dépendre une garantie de sécurité d’un signal du client. - Option C — exclusivité stricte (rédaction initiale). Caged au premier forward, refus
de toute session ultérieure. Rejetée : casse
ssh -L … host(forward+shell) et la coexistence C.5c ; régression d’interop OpenSSH. - Pompe de plaintext séparée (worker garde le transport, un enfant pompe la cible). Ne confine pas le parseur de transport (le vrai attaquable), qui reste dans le worker. Rejetée : rate la cible de l’ADR.
- Seccomp autorisant
execvemais interdisantsocket/openat. Le filtre persiste à travers l’execvedu shell → le shell utilisateur perdraitsocket/openat. Rejetée.
Suite
Chantier multi-incréments, à séquencer (aucun re-sceau — couche 2 + réutilisation c1) :
-
✅ FAIT (#606) — Médiation
SpawnSession: verbe RPC + implémentation monitor (PTY/fork/setuid/execve) + remiseSCM_RIGHTS(maître/pidfd) ; le worker consomme au lieu de spawner. À iso-comportement, cage non encore posée. -
✅ FAIT (#608) — Pose de la cage
sftp-workerdans tous les cas (forward-only, shell, exec, sftp), avant le premier octet lu du pair ; test de fermeture du set + preuve rootSeccomp=2. Les deux gestes de contrôle de session (TIOCSWINSZ,SIGHUP) sont médiés par le monitor (verbeSessionControl), pas ajoutés au profil. -
✅ FAIT — Médiation du
shutdownd’une cible (verbeFORWARD_TARGET_CONTROL). L’incrément 2 a retiréshutdown(2)du worker sans lui donner de remplaçant : unCHANNEL_EOFdu client ne se traduisait alors par rien du côté de la cible, qui ne voyait la fin qu’à la fermeture complète du canal. Or ce canal ne se ferme qu’aux deux sens terminés — et une cible qui attend l’EOFpour répondre ne termine jamais. Chacun attendait l’autre.Ce n’était pas une hypothèse : reproduit le 2026-08-17 sur le test de coexistence session +
-L, une fois sur dix, worker/cible/client/harnais tous bloqués en lecture avec toutes les files TCP vides. Un exécuteur de CI y a passé 8 h 50.Le geste ne revient pas dans le profil : il change de processus, comme
TIOCSWINSZetpidfd_send_signalà l’incrément 2. Le monitor garde une copie de chaque cible qu’il remet, la nomme dans sa réponse, et exécute pour le worker deux gestes : la demi-fermeture (ShutdownWrite) et le relâchement de la copie (Release).Le relâchement n’est pas une commodité : la copie gardée maintient la connexion ouverte, de sorte que sans lui la fermeture par le worker ne fermerait plus rien — le remède serait pire que le mal. Le worker relâche au fauchage du canal ; la fin de session fauche le registre entier.
Ce que cet incrément NE couvre PAS : les connexions poussées (
-R, streamlocal-R, agent). Le manque y est le même — unCHANNEL_EOFn’y devient pas non plus unEOFpour le tiers — mais le monitor n’en garde pas encore de copie. Les points d’ouverture correspondants le disent au lieu de le taire.Mesure après correction : 0 blocage en 100 tours du scénario qui en produisait un sur dix. Le déclencheur du harnais — une course sur le port du tunnel, antérieure et indépendante — subsiste et se manifeste désormais en échec net (11 tours sur 60) au lieu d’une attente infinie.
-
Durcissement (non bloquant) : envisager un profil
session-demuxsans les syscalls fichier quand l’absence de SFTP est acquise (optimisation).
Chaque incrément : sa PR, ses tests d’interaction, et la non-régression de la coexistence C.5c.