commit afe099672cdcc538713974552eaf7ac5180bd8fa Author: Trammell Hudson Date: Sun Jan 30 19:01:31 2022 +0100 initial version of a very hacky websocket tool diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f4ab3e --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Interface with Home Connect appliances in Python + +This is a very, very beta interface for Bosch-Siemens Home Connect +devices through their local network connection. It has some tools +to find the TLS PSK (Pre-shared Key) that is used to allow local +access, and a Python script that can construct the proper Websocket +interface to subscribe to events. + +*WARNING: This is not ready for prime time!* + + +## Finding the PSK + +You'll need to find the PSK for your devices with a rooted +Android phone and the `find-psk.frida` script for Frida. + +``` +frida --no-pause -f com.bshg.homeconnect.android.release -U -l find-psk.frida +``` + +It should print a message like: + +``` +psk callback hint 'HCCOM_Local_App' +psk 32 0x6ee63fb2f0 + 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF +00000000 0e c8 1f d8 c6 49 fa d8 bc e7 fd 34 33 54 13 d4 .....I.....43T.. +00000010 73 f9 2e 01 fc d8 26 80 49 89 4c 19 d7 2e cd cb s.....&.I.L..... +``` + +Which gives you the 32-byte PSK value. + +## SSL logging + +The Frida script will also dump all of the SSL traffic so that you can +see different endpoints and things. Not much is documented yet. + +Note that the TX from the phone on the websocket is "masked" with an +repeating 4-byte XOR that is sent in the first part of each messages. +The script could be augmented to decode those as well. +The replies from the device are not masked so they can be read in the clear. + +## hcpy + +The `hcpy` tool can contact your device, and if the PSK is correct, it will +register for notification of events. + +``` +RX: {'sID': 2354590730, 'msgID': 3734589701, 'resource': '/ei/initialValues', 'version': 2, 'action': 'POST', 'data': [{'edMsgID': 3182729968}]} +TX: {"sID":2354590730,"msgID":3734589701,"resource":"/ei/initialValues","version":2,"action":"RESPONSE","data":[{"deviceType":"Application","deviceName":"py-hca","deviceID":"1234"}]} +TX: {"sID":2354590730,"msgID":3182729968,"resource":"/ci/services","version":1,"action":"GET"} +TX: {"sID":2354590730,"msgID":3182729969,"resource":"/iz/info","version":1,"action":"GET"} +TX: {"sID":2354590730,"msgID":3182729970,"resource":"/ei/deviceReady","version":2,"action":"NOTIFY"} +RX: {'sID': 2354590730, 'msgID': 3182729968, 'resource': '/ci/services', 'version': 1, 'action': 'RESPONSE', 'data': [{'service': 'ci', 'version': 3}, {'service': 'ei', 'version': 2}, {'service': 'iz', 'version': 1}, {'service': 'ni', 'version': 1}, {'service': 'ro', 'version': 1}]} +RX: {'sID': 2354590730, 'msgID': 3182729969, 'resource': '/iz/info', 'version': 1, 'action': 'RESPONSE', 'data': [{'deviceID': '....', 'eNumber': 'SX65EX56CN/11', 'brand': 'SIEMENS', 'vib': 'SX65EX56CN', 'mac': '....', 'haVersion': '1.4', 'swVersion': '3.2.10.20200911163726', 'hwVersion': '2.0.0.2', 'deviceType': 'Dishwasher', 'deviceInfo': '', 'customerIndex': '11', 'serialNumber': '....', 'fdString': '0201', 'shipSki': '....'}]} +``` + diff --git a/find-psk.frida b/find-psk.frida new file mode 100644 index 0000000..e3c23cc --- /dev/null +++ b/find-psk.frida @@ -0,0 +1,86 @@ +/* + * Locate the TLS PSK used by the dishwasher as the password that + * protects access over the network. + * + * Launch it with: + * frida --no-pause -f com.bshg.homeconnect.android.release -U -l find-psk.frida + * + * This will also dump all of the cleartext SSL traffic before encryption + * and after decryption. For websocket traffic from the server, this + * will be in the clear, although to the server is masked with an XOR + * value. + * + * Note that it has to delay the Interceptor attach calls until the + * library has been dlopen()'ed by the application, which is why there + * is a setTimeout(). + * + * TODO: Is the PSK deterministic or is it randomly generated and stored + * in the cloud? + * + * TODO: XOR unmask the websock TX traffic + */ + + +setTimeout(() => { +console.log("looking for libHCPService.so"); + +const SSL_get_servername = new NativeFunction( + Module.getExportByName("libHCPService.so", "SSL_get_servername"), + "pointer", + ["pointer","int"] +); + +Interceptor.attach(Module.getExportByName("libHCPService.so", "SSL_read"), +{ + onEnter(args) { + this.ssl = args[0]; + this.buf = args[1]; + }, + onLeave(retval) { + const server_ptr = SSL_get_servername(this.ssl, 0); + const server = server_ptr.readUtf8String(); + + retval |= 0; + if (retval <= 0) + return; + + console.log("RX", server); + console.log(Memory.readByteArray(this.buf, retval)); + }, +}) + +Interceptor.attach(Module.getExportByName("libHCPService.so", "SSL_write"), +{ + onEnter(args) { + this.ssl = args[0]; + const len = Number(args[2]); + const server_ptr = SSL_get_servername(this.ssl, 0); + const server = server_ptr.readUtf8String(); + + console.log("TX", server); + console.log(Memory.readByteArray(args[1], len)); + }, +}) + +/* + * hcp::client_psk_callback is called when OpenSSL has made a connection and + * the server has offered a client hint. + */ +Interceptor.attach(Module.getExportByName("libHCPService.so", "_ZN3hcp19client_psk_callbackEP6ssl_stPKcPcjPhj"), +{ + onEnter(args) { + this.ssl = args[0]; + this.identity = args[2]; + this.psk_buf = args[4]; + const hint = Memory.readUtf8String(args[1]); + console.log("psk callback hint '" + hint + "'"); + }, + onLeave(len) { + len |= 0; + console.log("psk", len, this.psk_buf); + const buf = Memory.readByteArray(this.psk_buf, len); + console.log(buf); + }, +}) + +}, 1000) diff --git a/hcpy b/hcpy new file mode 100755 index 0000000..d55d2a3 --- /dev/null +++ b/hcpy @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# Contact a Bosh-Siemens Home Connect device +# todo: document how to extract the psk +import socket +import ssl +import sslpsk +import websocket +import sys +import json +import re +import time + + +# Monkey patch for sslpsk in pip using the old _sslobj +def _sslobj(sock): + if (3, 5) <= sys.version_info <= (3, 7): + return sock._sslobj._sslobj + else: + return sock._sslobj +sslpsk.sslpsk._sslobj = _sslobj + + +debug = False +host = '10.1.0.133' +port = 443; +uri = "wss://"+host+":"+str(port)+ "/homeconnect" +psk = b'\x0e\xc8\x1f\xd8\xc6\x49\xfa\xd8\xbc\xe7\xfd\x34\x33\x54\x13\xd4\x73\xf9\x2e\x01\xfc\xd8\x26\x80\x49\x89\x4c\x19\xd7\x2e\xcd\xcb'; +session_id = None +msg_id = None + +tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +tcp_sock.connect((host,port)) +ssl_sock = sslpsk.wrap_socket( + tcp_sock, + ssl_version = ssl.PROTOCOL_TLSv1_2, + ciphers = 'ECDHE-PSK-CHACHA20-POLY1305', + psk = psk, #(psk, b'py-hca'), +) + +ws = None +def tx(msg): + buf = json.dumps(msg, separators=(',', ':') ) + # swap " for ' + buf = re.sub("'", '"', buf) + print("TX:", buf) + ws.send(buf) + +def send_initial_messages(): + global session_id, msg_id + # subscribe to stuff + tx({ + "sID": session_id, + "msgID": msg_id, + "resource": "/ci/services", + "version": 1, + "action": "GET", + }) + + msg_id += 1 + + tx({ + "sID": session_id, + "msgID": msg_id, + "resource": "/iz/info", + "version": 1, + "action": "GET", + }) + + msg_id += 1 + + tx({ + "sID": session_id, + "msgID": msg_id, + "resource": "/ei/deviceReady", + "version": 2, + "action": "NOTIFY", + }) + + msg_id += 1 + +def handle_message(buf): + global session_id, msg_id + msg = json.loads(buf) + print("RX:", msg) + + # first message from the device establishes the session etc + # {'sID': 926468163, 'msgID': 3785595876, 'resource': '/ei/initialValues', 'version': 2, 'action': 'POST', 'data': [{'edMsgID': 2569124008}]} + if session_id == None: + session_id = msg["sID"] + msg_id = msg["data"][0]["edMsgID"] + + # reply with a bogus message + tx({ + 'sID': session_id, + 'msgID': msg["msgID"], + 'resource': msg["resource"], + 'version': msg["version"], + 'action': 'RESPONSE', + 'data': [{ + "deviceType": "Application", + "deviceName": "py-hca", + "deviceID": "1234", + #"deviceName": "Pixel", + #"deviceID": "d304ee06571f0d09", + }], + }) + + send_initial_messages() + + # do other stuff? + +if debug: + websocket.enableTrace(True) +ws = websocket.WebSocket() +ws.connect(uri, + socket=ssl_sock, + origin = "", #"https://" + host, +) +# on_message = handle_message, +# on_error = exit, +#) + +#ws.run_forever() + +while True: + buf = ws.recv() + if buf is None or buf == "": + continue + handle_message(buf)