Reverse engineering Positivo's smart home app
Positivo released a few smart devices that can't be easily integrated into Home Assistant. So I reverse engineered their app!
Disclaimer: this was done for education and research purposes. All actual keys in this post were censored and/or changed to avoid disclosing them.
Let me give you some background first. Positivo is a brazilian brand of electronic devices. They sell some interesting gear, such as a smart outlet, RGB lamp and even an universal remote control, but as well as some low quality stuff. Anyway, I recently bought the smart outlet so I could turn off a lamp in my bedroom using Alexa. It works great, no issues at all, and I can control and monitor it using their app.
However, I wanted to integrate it with Home Assistant. You see, I've been running it locally for a while and it has been pretty amazing, so I wanted to get Positivo's gear on it as well. However, there's no native integration for it, and even though Positivo is actually rebranding Tuya Smart devices, theirs didn't work either. I could use another Tuya-based app for controlling the devices so I can access the account, but I wanted to avoid that as much as I could, as I'd like to keep it original: Home Assistant side-by-side with Positivo's app.
So now I have two options: local Tuya control or reverse engineering their app. I tried the local option and it worked, but it's a bit flaky. I'll probably publish my findings on that eventually. This post, however, is focused on the second alternative: figure out how their app works.
Sniffing communication
The first step was to sniff the app's communication. It was most likely to use HTTPS, so I set a mitmproxy and connect to it. They didn't pin it, so the self-signed certificate, once installed, was more than enough for it to work on. After some actual app usage here and there, I've managed to extract some very interesting requests, including login and device enumeration - pretty much the first ones I would need for porting their API!
The captured requests gave me some useful and interesting information on how the app works:
- All data is over HTTPS, which is nice
- All requests are sent to Tuya US, which makes sense but also indicates the app is probably rebranded one as well (we'll talk about it later)
- Some requests have a bunch of parameters in the query string
- Some requests have post data inside an encoded classic form parameter called
postData
with a JSON inside of it - All requests are signed (check the
sign
parameter in the endpoint)
Regarding the query string, its made of many parameters - a few I still have not a clue what they do, but some are quite interesting. Plus, signing the requests can be really annoying, as we would need to know the key to sign them with, plus the exact algorithm for creating the string to be signed based on the request itself.
params = {
"appVersion": "1.1.6",
"appRnVersion": "5.14",
"channel": "oem",
"deviceId": DEVICE_ID,
"platform": "SomeRando Phone 10",
"requestId": str(uuid.uuid4()),
"lang": "en",
"a": action,
"clientId": CLIENT_ID,
"osSystem": "9",
"os": "Android",
"timeZoneId": "America/Bahia",
"ttid": "sdk_tuya@" + CLIENT_ID,
"et": "0.0.1",
"v": "1.0",
"sdkVersion": "3.10.0",
"time": str(int(time.time()))
}
You can see above all the query string parameters, at least for the first request, and based on the variables you can figure out what I've been messing with :)
- Device ID can be pretty much anything - I've generated some random hexadecimal string for that
- Platform is irrelevant, it's probably for their own control
- A request ID is sent as well (probably for caching purposes) and we can use a random UUID v4 for that
- Each POST has an action (
a
), which is used to figure out what you want to do - There's a Client ID being sent which is very important as, based on my tests, this is how they know you're a Positivo user and not another-brand-rebranding-Tuya-gear user
- The rest of the information is basically versions, which we don't want to mess with, plus the request timestamp
Some extra research on Tuya API brought me to this very interesting repository, which seems to implement the basic calls and algorithms I need:
This was actually incredibly useful as I was trying to wrap my head around their communication for hours already. Its code gave me some of the authentication details and signature algorithm code, which I promptly ported to Python. However, even though this helps a lot, I still need to figure out the app keys for communication. You see, each message is signed and this signature is sent ove rthe sign
query parameter. Not everything is signed though, and the payload itself (the postData
has some weird-ass MD5 mess. Nevertheless, the server will refuse any non-signed request:
{
"t": 1599948610423,
"success": false,
"errorCode": "SING_VALIDATE_FALED",
"status": "error",
"errorMsg": "sign validate failed"
}
Did they just replied sing validate failed on the error code?
Anyway, the repository itself states you need a few multiple secret details for it work:
let api = new Cloud({key: apiKeys.key,
secret: apiKeys.secret,
secret2: apiKeys.secret2,
certSign: apiKeys.certSign,
apiEtVersion: '0.0.1',
region: 'EU'});
They do not provide these keys and secrets, but instead provide a link for another very interesting repository:
The author here explains, in details, how the TuyaSmart App was reverse engineered, and even provided test code. It's awesome, and my work is heavily based on this. So to get the secret keys we need to dig deeper into the app.
Getting our hands dirty
So, after some further research and experimentation, I ended up with a Python script that would be able to communicate with Tuya's API. However, for that, it needed 4 missing pieces:
- Client ID
- App certificate hash
- Secret key hidden inside a BMP image
- App secret
The first one is easily obtainable from the requests we captured, as its sent in plain text under HTTPS:
POST /api.json?
appVersion=1.1.6
appRnVersion=5.14
channel=oem
sign=xxx
deviceId=xxx
platform=xxx
requestId=xxx
lang=en
a=tuya.m.user.email.password.login
clientId=qk4c93XXXXXXXXXXXXXX <<----- This one is the Client ID
osSystem=9
os=Android
timeZoneId=xxx
ttid=xxx
et=0.0.1
v=3.0
sdkVersion=3.10.0
time=1599857668
The others, however, are used in the encryption key, so they were obviously a bit harder to get. We need to figure out each one, concatenate them with _
and use the final string to sign the messages.
App certificate hash
Ok, let's start by analyzing Positivo's version of the app. Extracting it with apkx and checking up the resulting code and package structure showed me it was actually just a rebranded Tuya app as well, just as we thought. This probably means I can use the same techniques as the hacking example.
The first key is the certificate hash, which can be obtained by checking the CERT.RSA certificate inside the extracted app:
$ openssl pkcs7 -inform DER -print_certs -in CERT.RSA -out cert.pem
$ openssl x509 -in cert.pem -outform der | sha256sum | tr a-f A-F | sed 's/.\{2\}/&:/g' | cut -c 1-95
61:A7:7D:A5:65:86:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx
Easy, right?
Secret key hidden in the BMP
This is where, honestly, I'll just use the provided tools. Since this is just a rebranded app, it's most likely to use the same process for hiding the key inside the BMP. And indeed it does:
$ extract_used_pixels qk4c93XXXXXXXXXXXXXX t_s.bmp positivo_used_pixes.bmp
$ read_keys qk4c93XXXXXXXXXXXXXX positivo_used_pixes.bmp
(...)
[KEY][0] str: 9v5dh8XXXXXXXXXXXXXXXXXXXXXXXXXX
It works!
App secret
Now this was funniest one: just grep'd the extracted app. For real. This was more of a guess: at first I wasn't 100% sure of it, but it made sense.
$ grep -r -C 2 "qk4c93XXXXXXXXXXXXXX"
(...)
res/values/strings.xml: <string name="appEncryptKeyCvProd">qk4c93XXXXXXXXXXXXXX</string>
res/values/strings.xml- <string name="appEncryptSecretCvProd">w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX</string>
--
smali/com/smart/app/SmartApplication.smali: const-string v2, "qk4c93XXXXXXXXXXXXXX"
smali/com/smart/app/SmartApplication.smali- const-string v3, "w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX"
That's it. The app secret is w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX
. Thanks for calling it appEncryptSecretCvProd
! Fun fact: I didn't even see this at first, I was guessing it based on the smali code.
Testing it
So, based on my findings, the encryption key would be 61:A7:7D:A5:65:86:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx_9v5dh8XXXXXXXXXXXXXXXXXXXXXXXXXX_w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX
. I tried it and... nope! Signature failed! That was weird. I tried the repository keys and IDs and it works just fine, but mine don't. Why?
Going deeper
A fellow hacker suggested Frida, a really, really cool for reverse engineering:
So I gave it a try. Since the app uses a native lib available only for ARM, I need a rooted ARM Android phone. Good thing my old Galaxy S3 still works, and after flashing it with a more recent Android, I was able to set Frida up.
The app is pretty simple and gives you access to automations and power consumption data. That can be pretty useful, actually.
The app talks to the servers, and we know that. We would love to see however how it signs the messages. After a few attempts making Frida spawn the app properly, I ended up with this script, based on the original one in the repository:
function dumpJavaBytes(v) {
var buffer = Java.array('byte', v);
var result = "";
if (buffer === null) {
return "(null)";
}
for(var i = 0; i < buffer.length; ++i){
result+= (String.fromCharCode(buffer[i]));
}
return result;
}
send("[*] Starting TUYA script");
Java.perform(function() {
send("[*] Hijacking JNICLibrary")
var jniClass = Java.use("com.tuya.smart.security.jni.JNICLibrary");
jniClass.doCommandNative.implementation = function(ctx, cmd, v2, v3, v4, v5) {
var ret = this.doCommandNative(ctx, cmd, v2, v3, v4, v5);
send("doCommandNative: cmd=" + cmd + ", v2=" + dumpJavaBytes(v2) + ", v3=" + dumpJavaBytes(v3) + ", v4=" + v4 + ", v5=" + v5 + ", ret=" + ret);
return ret;
};
});
The idea behind it is to hijack the native library used by the app to encrypt messages, so we can see the calls. This way we can see exactly what it is doing with. We expect two calls on this: a first one to get it initialized, and a second later to sign the message.
{'type': 'send', 'payload': 'doCommandNative: cmd=0, v2=w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX, v3=qk4c93XXXXXXXXXXXXXX, v4=false, v5=false, ret=null'}
This first call confirms my suspicion about w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX
being the app secret (or at least a secret). So this part is probably correct at least.
{'type': 'send', 'payload': 'doCommandNative: cmd=1, v2=a=tuya.p.time.get||appVersion=1.1.6||clientId=qk4c93XXXXXXXXXXXXXX||deviceId=XXX||et=0.0.1||lang=en||os=Android||requestId=XXXX||time=1599939099||ttid=sdk_tuya@qk4c93XXXXXXXXXXXXXX||v=1.0, v3=(null), v4=false, v5=false, ret=3f613993272807d45765c82af198c4bYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'}
This second calls shows the payload and its resulting signature, which is perfect for testing the key. We could even, in theory, brute force it if we wanted. But having a source message and its resulting signature helps a lot when rewriting the signature algorithm.
These two method calls gave me confidence that the idea behind how the app worked was correct and confirmed that nothing has changed in the 2 years since the original one was reverse engineered. Yikes!
Following on the Frida idea, I wanted to dump the app's memory. My idea was that it would be very likely that the key would be stored in there somewhere. So that's what I did, using fridump. A few minutes and access violation warnings later, we got a memory dump!
So.. how do we look for what we want? It's not like it's written as "THIS IS THE KEY = <key here>" in the memory, right? Well, let's look for the stuff we know is correct, such as the app secret:
$ strings * | grep w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX
A_9v5dh8XXXXXXXXXXXXXXXXXXXXXXXXXX_w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX
w7vm9gXXXXXXXXXXXXXXXXXXXXXXXXXX
The first string seems to be "corrupted" or missing data (as we would expect a whole hash first) and the second one is just the app secret itself. However, it wouldn't take more than 30 seconds to test that first string as the key. You know, just in case.
Then it happened. It worked.
They simplified the encryption key and stored as plain text in memory. Wow.
In Positivo's version, there's no app certificate hash, it's just the uppercase letter A
. That, together with the secret BMP key and the app secret, makes up the encryption key used for signing messages. Applying those to my script and, sure enough, I can communicate without any issues:
$ python3 test.py
* Token info: {'pbKey': 'XXXXXX', 'publicKey': 'XXXXXX', 'token': 'XXXXXX', 'exponent': 'X'}
* Login: {'timezone': '', 'tempUnit': 1, 'extras': {'developer': 0}, 'sid': 'XXXXXX', 'uid': 'XXXXXX', 'nickname': 'Ricardo Gomes da Silva', 'phoneCode': '55', 'attribute': XXXXXX, 'email': 'XXXXXX', 'improveCompanyInfo': False, 'snsNickname': '', 'receiver': 'XXXXXX', 'dataVersion': 1, 'accountType': 1, 'sex': 0, 'mobile': '', 'headPic': '', 'ecode': 'XXXXXX', 'regFrom': 0, 'domain': {(...)}, 'timezoneId': 'XXXXXX', 'userType': 1, 'partnerIdentity': 'XXXXXX', 'username': 'XXXXXX'}
Now what? Well, my plan is to first release my testing code as well, so people can play with it. I'll update this post whenever I do that. Also I would love to write a proper (yet very simple) client for this API and turn it into a Home Assistant integration. It would awesome to have it fully integrated into my automation setup.
Alexa, let's watch some YouTube.