k4czp3r.xyz Blog

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

json7 linii · przewiń
{  "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:

java4 linie · przewiń
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:

java9 linii · przewiń
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:

java6 linii · przewiń
{   "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:

java12 linii · przewiń
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:

java18 linii · przewiń
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:

java12 linii · przewiń
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:

json6 linii · przewiń
{  "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:

json8 linii · przewiń
{  "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:

java16 linii · przewiń
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

json6 linii · przewiń
{  "error_code": 0,  "result": {    "response": "zQMfnu0DQcB9xaJ9srqWVqbxC/2vuKnDT4jyFVqKyCb4GBas06djUCchwdwbp8iFr9Z5gFtrMmy/SHVjKl3eruAqe+vzVtgQtWUjeVrhSyE="  }}

Ten response można odszyfrować, a po odszyfrowaniu zobaczymy:

json6 linii · przewiń
{  "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:

json8 linii · przewiń
{  "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.