Reverse-Engineering a Tuya Wi-Fi Air Conditioner: From APK to a Standalone Controller
Static analysis of a Tuya-based Carrier CMI AC app: recovering the cloud-MQTT payload (AES-128-ECB + CRC32), the native JNI surface, Shamir-shared keys hidden in a BMP asset, and an HMAC-SHA256 signer.
By iamkunal9 · Published
TL;DR
- The air conditioner is a Tuya “Thing” SDK app. Control goes over TLS MQTT to the vendor cloud, not over plain HTTP.
- The command format is
b"2.2" + BE32(crc32) + BE32(i) + BE32(i2) + AES-128-ECB(localKey, PKCS7(json)). Once you know that, you can generate commands — no replay needed. - The HTTP API signature lives in a native
.so: methods registered at load time, a key reconstructed from Shamir shares hidden in a BMP asset, and HMAC-SHA256 over a sorted parameter string.
The problem
The air conditioner ships with a phone-only app. There is no documented API, no web console, and no useful schedule export. I wanted it on the laptop: one command, or one HTTP call, that turns it on or off, without touching the phone.
The app is a Tuya-based white-label build. That is actually good news: Tuya devices usually speak one of two protocols — a local protocol on the LAN, or cloud MQTT to the vendor broker. The local path is faster and offline, but it only works if the machine running the controller is on the same network as the AC and can reach its local port. Here it was not, so the cloud path was the only practical one.
What you are actually talking to
The app opens an MQTT connection to the vendor broker over TLS on port 8883, authenticates with a per-session username, password and client id, and then publishes to a device-scoped topic:
# outbound commands smart/mb/out/<DEVICE_ID> # inbound device reports / acknowledgements smart/mb/in/<DEVICE_ID>
The outbound topic carries commands; the inbound topic carries state reports and acknowledgements. Both carry binary payloads — nothing human-readable on the wire.
Getting a look at the traffic
The first step was simply to see the plaintext. The app uses OkHttp for HTTP and its own MQTT client, so I instrumented both with Frida and logged the payloads before encryption and after decryption. That produced three things: a login request, an MQTT connect with its credentials, and a steady stream of device reports.
One practical note: newer Frida releases split the Java bridge out of the core runtime, so the Python bindings no longer expose Java the way older examples assume. Running the same script through the Frida CLI, against a device-attached frida-server, worked fine.
Some apps actively detect instrumentation and exit. This one does include anti-debug checks in its native library (Debug.isDebuggerConnected(), process scans). If that happens to you, the static path below still works — it is slower, but it does not need the app running.
Cracking the command format
Captured outbound payloads all started with the ASCII bytes 2.2. After that came three 4-byte big-endian integers and then a block of bytes whose length was always a multiple of 16. That is the shape of an AES-ECB body with a header.
The three integers turned out to be a signature and two counters. The signature is a plain CRC32 over the two counters plus the ciphertext — no key, no HMAC:
payload = b"2.2" + be32(sign) + be32(i) + be32(i2) + ciphertext
ciphertext = AES-128-ECB(key=local_key, data=PKCS7(json))
json = {"data": {"dps": {"1": True}}, "protocol": 5, "t": unix_time}
sign = CRC32(be32(i) + be32(i2) + ciphertext)
# i = i2 = 0 is accepted by the device; the sign covers them anyway.
# be32(x) is a 4-byte big-endian unsigned integer.Decrypting the ciphertext with the device's 16-byte local key gives ordinary JSON:
{"data":{"dps":{"1":true}},"protocol":5,"t":1792009926}The dps object is Tuya's datapoint model: a map of data-point id to value. On this unit, power is 1, target temperature is 2, mode is 4 and fan speed is 5. That is enough to build a complete controller:
import json, struct, time, zlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def be32(n):
return struct.pack(">I", n & 0xFFFFFFFF)
def build_payload(local_key, dps, i=0, i2=0):
obj = {"data": {"dps": dps}, "protocol": 5, "t": int(time.time())}
body = json.dumps(obj, separators=(",", ":")).encode()
pad = 16 - (len(body) % 16)
body += bytes([pad]) * pad
enc = Cipher(algorithms.AES(local_key), modes.ECB()).encryptor()
ct = enc.update(body) + enc.finalize()
sign = zlib.crc32(be32(i) + be32(i2) + ct) & 0xFFFFFFFF
return b"2.2" + be32(sign) + be32(i) + be32(i2) + ct
# local_key is a 16-byte AES key that comes from the device pairing data.
local_key = bytes.fromhex("<LOCAL_KEY_HEX>")
print(build_payload(local_key, {"1": True}).hex())Sending {"1": true} produced this reply on the inbound topic, which is the device confirming the new state:
{"protocol":4,"t":<unix>,"data":{"dps":{"1":true},"type":"query"}}Going deeper: the native signer
The MQTT path was done, but the credentials are session-bound — the username embeds a session token, so it eventually stops working. To refresh it automatically you need to log in, and every HTTP call is signed by a native library. That is where the real reverse engineering started.
The Java layer builds a string from the request parameters (only an allow-list, sorted, joined as key=value with ||), replaces the postData field with a shuffled base64 MD5, and hands the result to a JNI method. In the native library, that method is not an exported symbol. It is registered at load time:
JNI_OnLoad:
env->FindClass("com/.../JNICLibrary")
env->RegisterNatives(clazz, methods, 9) // table filled by relocationsThe method table is a static array, but its pointers are written by R_AARCH64_RELATIVE relocations, so a naive hex dump shows zeros. Parsing the relocation table recovers all nine names, signatures and addresses — including doCommandNative, computeDigest, genKey, getChKey and testSign.
The key was in a BMP
doCommandNative(ctx, 0, …) loads an asset called t_s.bmp, then reconstructs a secret from its pixel bytes. The selector is the 32-bit Java String.hashCode() of the app id; the pixel array is indexed with that hash, and the bytes at those offsets are read as hex-encoded coordinate pairs. Those pairs are shares of a polynomial, solved with Gaussian elimination over exact rationals — Shamir secret sharing with rational arithmetic rather than a prime field.
Interpolating at x = 0 yields a 32-byte value, the key that feeds the request signer. A correct result comes out as an exact integer with no denominator, which makes a good sanity check.
The sign is HMAC-SHA256
The native code also reads the APK signing certificate, hashes it with SHA-256, and mixes that hash into the runtime key. The final signature primitive is an HMAC over SHA-256 (the library also contains a stock MD5, used by other methods), and the 32-byte result is hex-encoded before being returned to Java:
sign = hex( HMAC-SHA256( runtime_key, sign_string ) )
The runtime_key is assembled from the certificate hash, the reconstructed share secret and an argument passed by the Java caller. That last composition step is the one I did not fully pin down statically; the device-side login still worked with the session credentials, so I left the automatic refresh as a follow-up instead of forcing it.
Tooling scars worth sharing
- Ghidra 12 dropped Jython. A
.pyscript is now routed to PyGhidra, and headless runs fail with “Ghidra was not started with PyGhidra”. A compiled Java script with an explicit-scriptPathworks. - PyGhidra can self-recurse. Its bundle finder imports
ghidra.frameworkinsidefind_spec, which re-enters the same finder. Installing a side-by-side Python 3.12 did not help; the recursion is in the launcher, not the interpreter version. - The headless project directory must exist. A one-line
mkdirsaves you a confusing “Directory not found” abort after a successful analysis. - Decompile to a file, not to the log. Multi-line output loses its prefix in the headless log; writing straight from the script to a file keeps the code readable.
- Relocations are the map. When a data table reads as zeros, parse the relocation entries — the real pointers are addends.
Limitations
- Cloud MQTT needs the vendor broker reachable. If the vendor changes the protocol or the session policy, the controller stops until the credentials are refreshed.
- Datapoint ids are product-specific. Power, temperature, mode and fan ids here apply to this unit; another model can number them differently.
- The signer research is complete for the payload and digest layers, but the final runtime key composition is only partially mapped. Automatic login is therefore future work.
- Local control would be faster and offline, but only from the same network — it wasn't an option in my setup.
FAQ
- Is the AC controlled locally or through the vendor cloud?
- In this setup it is the cloud. The app connects to the vendor's MQTT broker over TLS and publishes an encrypted command to a per-device topic; the AC receives it through its own connection. Local/LAN control exists in the SDK, but it only works if your machine is on the same network as the AC and you can reach its local port, which was not the case here.
- Do you need to capture commands and replay them?
- No. Once the payload format is known you can generate any command from scratch. The signature is a plain CRC32 over the ciphertext and the two counters, so there is no server round-trip and no captured-payload database.
- Why not just use a public Tuya library?
- Generic libraries are excellent for local control and for accounts tied to the public Tuya/Smart Life cloud, but this is a white-labelled app on a vendor-specific cloud. Its HTTP API is signed by a native library, and its MQTT credentials come from that vendor session. The protocol pieces are similar; the credentials and signing are not.
- Why do the MQTT credentials expire?
- They are minted per session by the cloud, and the username embeds a session token. When that session ends, publishing stops being authorised until the app logs in again and the credentials are refreshed. That is the main operational weakness of a pure cloud-MQTT controller.
- What was the hardest part?
- Not the payload — that fell out of a few captured packets. The hard part was the native HTTP signer: an obfuscated .so with no exported symbols, a RegisterNatives table built at load time, and a key that is reconstructed from shares hidden in a BMP asset. Tooling mattered more than theory: getting Ghidra headless to run a script at all took longer than reading the crypto.