Mi Remote icone

Suite de la partie 1 sur la rétro-ingénierie de l'application Mi Remote, dans laquelle nous avions trouvé comment retrouver en clair les codes infrarouges provenant de l'API REST de Xiaomi.

Cette partie traitera de la mise en œuvre d'un procédé de récupération complet de la base de données.

Sommaire

Détail d'une requête

Je rappelle quelques exemples de requêtes envoyées au serveur par l'application, selon les choix de l'utilisateur. Nous y voyons la récupération de la liste des appareils, la liste des marques connues pour un type d'appareil, puis la liste des modèles connus pour la marque sélectionnée.

Requêtes capturées

L'ordre des paramètres est fixe et est important :

  • Préfixe de l'url: Chemin vers le controlleur de l'API interrogée :

    • /controller/device/1
    • /controller/brand/list/1
    • /controller/match/tree/1
  • Paramètres fixes :

    • version: Version de l'application
    • country: Identifiant de pays (FR, CN, US, etc.)
    • ts: Timestamp en millisecondes
    • nonce: Nombre aléatoire
  • Paramètres "utilisateur" variables :

    • devid: Type d'appareil choisi (1: TV, 10: Projecteur, etc.)
    • miyk: ? - Fixé à 1
    • brandid: Identifiant de la marque sélectionnée (64: Toshiba, etc.)
  • paramètre fixe de fin :

    • opaque: Chaine hexadécimale

Ces connaissances sont déduites de l'étude des données JSON exportées depuis Mitmproxy.

Un problème opaque...

Si vous essayez de réutiliser une requête déjà émise vous obtiendrez de nouveau la réponse (probablement durant un laps de temps modéré ; n'espérez pas pouvoir réutiliser une requête datant d'un an).

Si vous essayez de modifier certains paramètres comme brandid la réponse du serveur sera vide.

En effet, une requête est un ensemble fixe non altérable de paramètres. Le serveur refuse de traiter une requête forgée arbitrairement. Il s'agit à la fois d'une protection anti-rejeu et d'une obligation d'authentification auprès du serveur.

En clair votre application montre patte blanche pour que sa demande soit honorée.


La plupart du temps la requête contient un jeton de session obtenu par concaténation de divers paramètres envoyés dans une fonction de hachage cryptographique (fonction acceptant une entrée de taille variable et ayant une sortie de taille fixe, empêchant le re-calcul de la donnée d'entrée). SHA-1 est souvent utilisée dans ce contexte. Le jeton est ajouté à la requête avant son envoi. Vous l'avez compris ce jeton certifiant est nommé opaque dans Mi Remote.

Afin d'éviter que l'url ne puisse être réutilisée, le jeton doit changer à chaque fois même si les paramètres demandés par l'utilisateur ne changent pas. Cette notion d'aléatoire est apportée par le timestamp ts et par le nonce (une chaine ou un nombre aléatoire). En effet, le timestamp seul ne suffit pas, car émettre une requête avec une date future assurererait sa validité.

Il faut également noter que parfois un sel cryptographique est ajouté aux paramètres avant hachage. Il est en général utilisé pour que deux informations identiques aboutissent à un résultat différent de la fonction de hachage (par exemple des mots de passe d'utilisateurs identiques donneront des hashs différents avec des sels différents avant stockage en base de données). Le salage n'est évidemment efficace que si personne ne connait le sel utilisé...

L'intégration d'un sel dans les urls forgées d'une application décompilable est douteux, mais nous avons déjà vu dans la partie 1 que les développeurs se donnent du mal pour ralentir l'étude de leur application...


Le but de cet article revient donc à trouver l'algorithme produisant le paramètre opaque.

Recherche dans le projet et fausses pistes

Comme montré dans la partie 1, faites une recherche du mot opaque dans le projet. Il sera présent dans quelques fichiers.

Exemple :

// classes2.dex: ./c/k/i/b/b/b1/r/e.java
public static String a(String str, String str2) {
    [...]
    c2.append(String.format("session_secret=%s", c.k.j.a.c.a.f.a.a.b(encoded_random_str, "UTF-8")));
    c2.append(String.format("&data=%s", c.k.j.a.c.a.f.a.a.b(encoded_url_part2, "UTF-8")));
    // Ajoute opaque et retourne l'url
    c2.append(String.format("&opaque=%s", c.k.j.a.c.a.f.a.a.b(str2, "UTF-8")));
    return c2.toString();
}

Fausse alerte : Cette url ne nous concerne pas car elle contient des paramètres inconnus (session_secret et data). De même, une autre occurence proposera les bons termes mais dans le désordre. Le programme a l'air d'être truffé de code ancien.

Nous trouvons enfin:

// classes3.dex: c/k/j/a/i/.c.java
// Renommage en str_add_opaque_str2
public static String a(String str, String str2) {
    return str + "&" + "opaque" + "=" + str2;
}

À partir de maintenant, jadx-gui va amplement simplifier l'étude en nous permettant de retrouver les fonctions qui utilisent ce code, tout en renommant des items dans tout le projet en un clic.

comparaison jadx-gui vs simple éditeur de texteComparaison d'un simple éditeur de texte (à gauche) avec jadx-gui après renommage des items (à droite)

Assemblage de la requête HTTP

Quatre fichiers importants sont identifiés grâce à la recherche de chaines et à jadx-gui :

  • com/xiaomi/mitv/phone/remotecontroller/ir/activity/MatchIRActivityV52.java : Déclenche la requête et récupère les données JSON pour les analyser (cf. Partie 1).
  • ./c/k/i/b/b/y0/g.java : Controleur ; construit l'url en utilisant le préfixe, injecte les paramètres variables.
  • ./c/k/j/a/i/c.java : Construit la liste des paramètres fixes, calcule le paramètre opaque, exécute la requête.
  • ./c/k/j/a/i/e.java : Objet Query initialisé progressivement avec tous les constituants de la requête (méthode (POST/GET), url, chemin, headers, paramètres).

L'objet ./c/k/j/a/i/e.java est aisément compréhensible connu grâce aux messages des exceptions réparties dans les méthodes :

throw new IllegalArgumentException("queries == null");
throw new IllegalArgumentException("headers == null");
throw new IllegalArgumentException("method == null");
throw new IllegalArgumentException("queries == null");
throw new IllegalArgumentException("path == null");
throw new IllegalArgumentException("url == null");
throw new IllegalArgumentException("method == null");

À partir de cela il est facile d'identifier les accesseurs/mutateurs (getters/setters) et attributs de l'objet afin de faire sa rétro-ingénierie complète ; la structure de données est similaire à n'importe quelle librairie de n'importe quel langage qui manipule des requêtes.

--

Dans le fichier ./c/k/i/b/b/y0/g.java, nous voyons apparaitre, grâce au renommage des méthodes, une fonction construisant les paramètres d'une de nos requêtes d'intérêt :

public QueryObj mo21081a() {
    ArrayList arrayList = new ArrayList();
    arrayList.add(new KeyValuePair(devid_str, String.valueOf(this.devid)));     // devid
    arrayList.add(new KeyValuePair(miyk_str, "1"));                             // miyk
    arrayList.add(new KeyValuePair(brandid_str, String.valueOf(this.brandid))); // brandid
    [...]
    arrayList.add(new KeyValuePair("power", this.power ? "0" : "1"));           // power
    [...]
    return new QueryObj.C6146a()
        .set_path(C5386g.controller_match_tree_str)
        .set_url(get_server_plus_url(C5386g.controller_match_tree_str))         // https://sg-urc.io.mi.com + /controller/match/tree/1
        .set_queries(arrayList)
        .set_method("GET")
        .get_copy_instance();
}

L'étape finale consiste à ajouter le paramètre opaque à l'url ; ceci est fait dans ./c/k/j/a/i/c.java (contenant déjà str_add_opaque_str2()):

// ./c/k/j/a/i/c.java
public static Response execute_query(QueryObj eVar) {
    String str;
    Request.Builder builder = new Request.Builder();

    Headers a = C6163c.m22947a(eVar.get_headers());
    builder.headers(a);

    List<KeyValuePair> a2 = build_first_url_params();
    C5864a a3 = init_hashes(false);
    String a4 = add_query_params_to_url_and_GET_opaque(eVar, C6164d.concat_url_with_params(eVar.get_path(), a2), a3.token, a3.secret_key);
    String a5 = C6164d.concat_url_with_params(eVar.get_url(), a2);
    [...]
    str = C6164d.concat_url_with_params(a5, eVar.get_queries());

    builder.url(str_add_opaque_str2(str, a4));
    [...]
    return m22889b().newCall(builder.build()).execute();
}
  • ligne 10 : init_hashes construit un objet avec deux attributs qui une fois identifiés sont un token interne (qui est en fait le sel mentionné dans l'introduction) et une clé secrète.

    return C5864a.m21432a("0f9dfa001cba164d7bda671649c50abf", "581582928c881b42eedce96331bff5d3");
    

    Il faut noter que ces 2 chaines sont notées en dur dans le code du projet.

  • ligne 11 : La fonction add_query_params_to_url_and_GET_opaque() appelle compute_opaque() :

    /* renamed from: a */
    public static String add_query_params_to_url_and_GET_opaque(QueryObj eVar, String str, String str2, String str3) {
        String str4;
        StringBuilder sb = new StringBuilder();
        if (eVar.get_method().equals("GET")) {
            sb.append(C6164d.concat_url_with_params(str, eVar.get_queries()));
            str4 = sb.toString();
    
        } [...]
        return compute_opaque(str4, str2, str3);
    }
    

compute_opaque() obtient donc les arguments suivants :

  • str4: Le préfixe + paramètres fixes + paramètres variables
  • str2: Le token secret/sel
  • str3: La clé secrète

Sa ligne la plus importante insère le token ; celui-ci ne sert qu'au calcul de la signature. Il n'apparait pas dans l'url finale.

return SignatureUtil.getSignature((str + "&token=" + str2).getBytes(), str3.getBytes());

Voici enfin le hachage de nos paramètres :

// com.xiaomi.mitv.socialtv.common.utils.SignatureUtil
public static String getSignature(byte[] bArr, byte[] bArr2) {
    SecretKeySpec secretKeySpec = new SecretKeySpec(bArr2, "HmacSHA1");
    Mac instance = Mac.getInstance("HmacSHA1");
    instance.init(secretKeySpec);
    return IOUtil.byteArray2HexString(instance.doFinal(bArr));
}

Patchs

Essayons de faire en sorte que l'application affiche elle-même dans les logs, les informations que nous recherchons.

  • Affichage du contenu de arrayList juste avant le retour de mo21081a() ; observez l'usage d'un objet StringBuilder pour convertir la variable en String et l'envoyer dans la fonction de debug Log.e():

    # c/k/i/b/b/y0/g$o.smali
    # g$o: objet companion "o" intégré dans la classe g
    # Signature Java:
    # /* renamed from: c.k.i.b.b.y0.g$o */
    # public static class AsyncTaskC5408o extends AbstractAsyncTaskC5396e {
    
    # virtual methods
    .method public a()Lc/k/j/a/i/e;
        .locals 6
        # PATCH increment 5 to 6 for v5 var
        [...]
        :cond_3
    
        # PATCH HERE
        # v0: ArrayList
        # v1, v2, v3 are usable
        # Equivalent to: Log.e("TAG query params", "ArrayList: " + arrayList);
        sget-object v1, Ljava/lang/System;->out:Ljava/io/PrintStream;
        new-instance v2, Ljava/lang/StringBuilder;
        invoke-direct {v2}, Ljava/lang/StringBuilder;-><init>()V
        const-string v3, "ArrayList: "
        invoke-virtual {v2, v3}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
        move-result-object v2
        invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;->append(Ljava/lang/Object;)Ljava/lang/StringBuilder;
        move-result-object v5
        invoke-virtual {v5}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
        move-result-object v5
    
        const-string v3, "TAG query params"
        invoke-static {v3, v5}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;)I
        # END PATCH
    
        # Code continuation...
        # Equivalent to: QueryObj.C6146a().set_path(C5386g.controller_match_tree_str)...
        new-instance v1, Lc/k/j/a/i/e$a;
        invoke-direct {v1}, Lc/k/j/a/i/e$a;-><init>()V
        const-string v2, "/controller/match/tree/1"
        invoke-virtual {v1, v2}, Lc/k/j/a/i/e$a;->c(Ljava/lang/String;)Lc/k/j/a/i/e$a;
        move-result-object v1
        [...]
    

    Résultat :

    E TAG query params: ArrayList: [devid:10, miyk:1, brandid:1, power:1]
    

  • Affichage des paramètres de compute_opaque() juste avant son appel et de son résultat :

    .method public static a(Lc/k/j/a/i/e;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
        [...]
        # PATCH
        # v1: libre
        # p0: url
        # p2: token
        # p3: private key
        # juste avant concaténation de l'un avec l'autre
        const-string v1, "TAG url"
        invoke-static {v1, p0}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;)I
    
        const-string v1, "TAG token"
        invoke-static {v1, p2}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;)I
    
        const-string v1, "TAG private key"
        invoke-static {v1, p3}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;)I
        # END PATCH
    
        # Call compute_opaque()
        invoke-static {p0, p2, p3}, Lc/k/j/a/i/c;->a(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
        move-result-object p0
    
        # PATCH
        const-string v1, "TAG opaque"
        invoke-static {v1, p0}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;)I
        # END PATCH
        return-object p0
    .end method
    

    Résultat :

    E TAG url: /controller/device/1?version=6034&country=FR&ts=1615406505554&nonce=1126639370
    E TAG token: 0f9dfa001cba164d7bda671649c50abf
    E TAG private key: 581582928c881b42eedce96331bff5d3
    E TAG opaque: f422f7bdc77403414fdd674758af0b39435bb46e
    

Implémentation en Python

from Crypto.Hash import HMAC, SHA1

def get_opaque_http_param(url, token, secret_key):
    """Get the opaque parameter based on the url, an internal token and a
    secret key for the hash algorithm

    :return: "opaque" parameter to be inserted at the end of the url path
    :rtype: <str>
    """
    plain_text = url + "&token=" + token
    # Get signature of this concatenation
    return get_signature(plain_text, secret_key)


def get_signature(plain_text, secret_key):
    """Get signature/hash of the given plain_text with the secret_key

    Use HMAC (Hash-based Message Authentication Code) with SHA1 hash algorithm.

    :param plain_text: Clear text to be signed
    :param secret_key: Secret key
    :type plain_text: <str>
    :type secret_key: <str>
    :return: Hex digest of the signature
    :rtype: <str>
    """
    cipher = HMAC.new(secret_key.encode(), digestmod=SHA1)
    return cipher.update(plain_text.encode()).hexdigest()

Conclusion

Nous sommes dorénavant capables de forger des requêtes valides auprès de l'API REST de Mi Remote, et pourquoi pas, de réaliser une copie quasi-complète du serveur en quelques requêtes.

Le code nécessaire pour ceci est disponible sur GitHub - Mi Remote Database.