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-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 :

  1. Un filtre seccomp est hérité et persiste à travers execve (avec NoNewPrivs). Le worker fork+execve lui-même le shell de session (worker.rs::spawn_on_terminal / spawn_on_socketpair via air_process). Un worker caged sans execve ne peut donc pas lancer de shell.
  2. 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 courant ssh -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 (test a_caged_sftp_session_and_a_local_forward_share_one_connection). Le raisonnement « -N exclut --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 SpawnSession portant 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_RIGHTS sur le canal de médiation déjà établi, le maître du PTY (ou l’extrémité parent du socketpair) et le pidfd du processus de session ;
  • le worker pompe ce descripteur exactement comme avant (SessionPump, inchangé) et récolte la sortie par le pidfd (waitid WNOHANG, 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-worker posé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 pidfd de session remplace la filiation directe : le worker n’étant plus le parent du shell, il ne peut plus le waitid par filiation — le monitor lui passe le pidfd, 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 + remise SCM_RIGHTS du 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 seccompsftp-worker sert 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 (-N signalé). 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 execve mais interdisant socket/openat. Le filtre persiste à travers l’execve du shell → le shell utilisateur perdrait socket/openat. Rejetée.

Suite

Chantier multi-incréments, à séquencer (aucun re-sceau — couche 2 + réutilisation c1) :

  1. FAIT (#606)Médiation SpawnSession : verbe RPC + implémentation monitor (PTY/fork/setuid/execve) + remise SCM_RIGHTS (maître/pidfd) ; le worker consomme au lieu de spawner. À iso-comportement, cage non encore posée.

  2. FAIT (#608)Pose de la cage sftp-worker dans tous les cas (forward-only, shell, exec, sftp), avant le premier octet lu du pair ; test de fermeture du set + preuve root Seccomp=2. Les deux gestes de contrôle de session (TIOCSWINSZ, SIGHUP) sont médiés par le monitor (verbe SessionControl), pas ajoutés au profil.

  3. FAITMédiation du shutdown d’une cible (verbe FORWARD_TARGET_CONTROL). L’incrément 2 a retiré shutdown(2) du worker sans lui donner de remplaçant : un CHANNEL_EOF du 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’EOF pour 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 TIOCSWINSZ et pidfd_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 — un CHANNEL_EOF n’y devient pas non plus un EOF pour 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.

  4. Durcissement (non bloquant) : envisager un profil session-demux sans 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.