Reverse engineering TP-Link TAPO
Jak aplikacja TAPO rozmawia z urządzeniami TP-Link po zwykłym HTTP: handshake, własne szyfrowanie, żądanie securePassthrough i co trzeba zrobić, żeby włączyć i wyłączyć gniazdko samym HTTP.
Plain HTTP i własna metoda szyfrowania.#
Aplikacja TAPO komunikuje się na dwa sposoby: przez Bluetooth i przez HTTP. Bluetooth służy do łączenia się z niesparowanymi urządzeniami (wymiana wifi ssid&psk itd.). HTTP jest używane do każdego innego requestu po początkowym procesie parowania (odczyt/ustawianie stanu plug i ustawień, aktualizacja firmware itd.).
Mój cel#
Móc włączać/wyłączać moją wtyczkę za pomocą requestu HTTP.
Requesty HTTP#
Aplikacja TAPO wysyła wszystkie requesty na http://<ip-of-the-tapo-device>/app.
Handshake#
Przy uruchomieniu aplikacja wysyła dwa requesty (session init); pierwszym z nich jest request handshake.
POST http://192.168.137.203/app HTTP/1.1
{ "method": "handshake", "params": { "key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiHkY5laTugGN1Hf/sBHiiw6mnnkohmvVHHHGJqwRx59RjQaL/SPBoLpeNRgN3B/uykzYTLUVMpTcWSZHsS6FfhdoOkJ1B6nit6nheIfltbP99uJduP1JQ44S9dqUr73w++Lpl6TKrzK3KOc5z/vc9xmqiKK6PYbFZu2evCsL19wIDAQAB-----END PUBLIC KEY-----\n" }, "requestTimeMils": 0}Pola są oczywiste; ten request wysyła do urządzenia TAPO klucz publiczny wygenerowany przez aplikację.
Implementacja w aplikacji TAPO - generowanie handshake
Po zdekompilowaniu androidowej aplikacji TAPO widzimy, jak generowany jest ten klucz publiczny:
public String getPublicKey() { /* Not relevant code, omitted */ return "-----BEGIN PUBLIC KEY-----\n" + this.f20965b.get(0) + "-----END PUBLIC KEY-----\n";}f20965b to HashMap zawierająca klucz publiczny i prywatny (i=0 to klucz publiczny, a i=1 to klucz prywatny). Dalsze poszukiwania doprowadziły mnie do funkcji, w której generowane były klucze:
public void mo35029c() { KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA"); instance.initialize(1024, new SecureRandom()); KeyPair generateKeyPair = instance.generateKeyPair(); String str = new String(Base64.encode(((RSAPublicKey) generateKeyPair.getPublic()).getEncoded(), 0)); String str2 = new String(Base64.encode(((RSAPrivateKey) generateKeyPair.getPrivate()).getEncoded(), 0)); this.f20965b.put(0, str); this.f20965b.put(1, str2);}To RSA 1024 bit key pair zakodowane w Base64. Po (re)implementacji tej funkcji w javie jestem w stanie wygenerować własny (poprawny) key pair.
Response
Ok, wracamy więc do requestu handshake. Po wysłaniu go do urządzenia TAPO otrzymamy (jeśli wszystko pójdzie dobrze) następujące response body:
{ "error_code":0, "result":{ "key":"ZvHUZ2EZ1LLkrh9YG0ShBINL59Rna1++j8iW2r44klFseH17A6C8HH2TqN8UkNpi+MHxFgQ4Jvs/nvz8QoNPgVxWCsgVBI01GTtDdwHtaRXRNh2VuIp6NDUJ0/1NSydiMfeUs1AZT2vwxSg7/cI1DVHFzL7jNr1WNHEsDiYtm48=" }}oraz header Set-Cookie z TP_SESSIONID: TP_SESSIONID=D31BB81A0B0A3...EF0A790A150AD60A;TIMEOUT=1440 (który jest potrzebny do kolejnych requestów)
Jak widać, request handshake jest ważny przez 24 godziny.
Implementacja w aplikacji TAPO - deszyfrowanie klucza
Aplikacja TAPO deszyfruje klucz urządzenia w tej funkcji; ja (z)reimplementowałem to tak:
public void mo35024a(String tapokey) { byte[] decode = KspB64.decode(key.getBytes("UTF-8")); byte[] decode2 = KspB64.decode(keyPair.getPrivateKey()); Cipher instance = Cipher.getInstance("RSA/ECB/PKCS1Padding"); instance.init(2, (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decode2))); byte[] doFinal = instance.doFinal(decode); byte[] bArr = new byte[16]; byte[] bArr2 = new byte[16]; System.arraycopy(doFinal, 0, bArr, 0, 16); System.arraycopy(doFinal, 16, bArr2, 0, 16); return new C658a(bArr, bArr2);}Ten fragment kodu generuje Cipher z RSA/NONE/PKCS1Padding, a następnie wykonuje na nim dalsze operacje i metody.
W ostatniej linii tej funkcji definiowany jest obiekt C6586a:
public class C658a { Cipher f21776a_enc; Cipher f21777b_dec; public C658a(byte[] bArr, byte[] bArr2) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES"); IvParameterSpec ivParameterSpec = new IvParameterSpec(bArr2); f21776a_enc = Cipher.getInstance("AES/CBC/PKCS7Padding"); f21776a_enc.init(1, secretKeySpec, ivParameterSpec); f21777b_dec = Cipher.getInstance("AES/CBC/PKCS7Padding"); f21777b_dec.init(2, secretKeySpec, ivParameterSpec); } /* more code */}Tutaj widzimy, że bArr to secret key spec dla AES. A bArr2 to iv.
Po tym konstruktorze są dwie funkcje, które obsługują szyfrowanie i deszyfrowanie zawartości param:
public String mo38009b_enc(String str) throws Exception { byte[] doFinal; doFinal = this.f21776a_enc.doFinal(str.getBytes()); String encrypted = KspB64.encodeToString(doFinal); return encrypted.replace("\r\n","");}public String mo38006a_dec(String str) throws Exception{ byte[] doFinal; doFinal = this.f21777b_dec.doFinal(KspB64.decode(str.getBytes("utf-8"))); return new String(doFinal);}Dzięki tym dwóm funkcjom możemy szyfrować requesty, które będziemy wysyłać do urządzenia TAPO, i deszyfrować responsy!
Pierwszy securePassthrough#
Drugim requestem wysyłanym do urządzenia jest uniwersalny request z metodą securePassthrough. Będzie on później używany do każdego innego requestu, takiego jak zmiana stanu wtyczki, pobieranie informacji itd.
Request body z aplikacji TAPO wygląda tak:
{ "method": "securePassthrough", "params": { "request": "vQewGPIlmr3G2l8uL0O3Yjnxc6dKUAMBzOA4xGwJe81N4iYrFzEEoLxY2Jxr5qxQ5uE84gMgQVHJ\nT174Z6z1/lDglp0FOtcFdXw6lUsvj5hcgjpHjaD+6CxcA5z1XF4xyfDJIIBcb5eJ+ZCyiw9wO+WN\nNnBg5SH6Lmq06+AzbP8I6R6X8SgrEt2OUjclJWnuYjJlxffwFD243VU30fKhjMthzGo0+UU+bXgA\nEE/LITY=\n" }}Jak widać, wszystko jest czytelne poza params.request. Jak wiemy, jest on zaszyfrowany za pomocą mo38009b_enc z obiektu C658a utworzonego krok wcześniej. Niezaszyfrowany params.request wygląda tak:
{ "method": "login_device", "params": { "password": "ITcyNjU....", "username": "MzhhNTk2NT..." }, "requestTimeMils": 0}Widzimy więc, że drugi request to próba autoryzacji za pomocą danych logowania TP-Link.
Implementacja w aplikacji TAPO - kodowanie danych logowania
Jak widać, hasło i nazwa użytkownika nie są w plain text. Są zakodowane. Każde w inny sposób.
Hasło jest kodowane za pomocą B64 encode, a nazwa użytkownika korzysta z MessageDigest typu SHA1:
public static String shaDigestUsername(String str) throws NoSuchAlgorithmException { byte[] bArr = str.getBytes(); byte[] digest = MessageDigest.getInstance("SHA1").digest(bArr); StringBuilder sb = new StringBuilder(); for(byte b : digest){ String hexString = Integer.toHexString(b & 255); if(hexString.length() == 1){ sb.append("0"); sb.append(hexString); } else { sb.append(hexString); } } return sb.toString();}A następnie ta nazwa użytkownika jest też kodowana w B64.
Response
{ "error_code": 0, "result": { "response": "zQMfnu0DQcB9xaJ9srqWVqbxC/2vuKnDT4jyFVqKyCb4GBas06djUCchwdwbp8iFr9Z5gFtrMmy/SHVjKl3eruAqe+vzVtgQtWUjeVrhSyE=" }}Ten response można odszyfrować, a po odszyfrowaniu zobaczymy:
{ "error_code": 0, "result": { "token": "E0AA81A79277AA712...BF127322B523" }}I oto on: kolejny token, który będzie potrzebny do następnych requestów. W tym momencie jesteśmy uwierzytelnieni, żeby wykonywać inne requesty.
Implementacja mojego celu#
Po podsłuchaniu pierwszych dwóch requestów możemy podsłuchać ostatni: request stanu żarówki. Odszyfrowany wygląda tak:
{ "method": "set_device_info", "params": { "device_on": false }, "requestTimeMils": 1602840338865, "terminalUUID": "88-54-DE-AD-52-E1"}Większość pól jest oczywista; terminalUUID to adres MAC wtyczki.
Ten params.request można wstawić do requestu z metodą securePassthrough.
Podsumowanie#
Mając te informacje, da się więc stworzyć bibliotekę do sterowania urządzeniami TP-Link TAPO.
Na razie zaimplementowałem tylko set_device_info, ale w tym momencie mogę po prostu podsłuchać każdy inny request wykonywany przez aplikację, odszyfrować go i w końcu zaimplementować.
PoC napisany w Javie zamieszczę później.
PS#
Aby odszyfrować nieznane requesty (bez RE), będziesz musiał zpatchować mo35029c, tak żeby wstawiał twój własny klucz prywatny i publiczny.