Tous les articles
7 min de lectureglizzykingdreko

Reconstruction du flow de login X (anciennement Twitter). Épisode 2 — UI Metrics

Voici mon deuxième épisode sur le flow de login X / Twitter et toute la génération nécessaire pour créer un script basé sur des requêtes. Si vous avez…

Reconstruction du flow de login X (anciennement Twitter). Épisode 2 — UI Metrics

Voici mon deuxième épisode sur le flow de login X / Twitter et toute la génération nécessaire pour créer un script basé sur des requêtes. Si vous avez manqué l'épisode précédent, n'hésitez pas à le consulter

Consultez le dépôt GitHub de cet épisode avec l'étude complète et ses implémentations Node/Go/Python

L'objectif d'aujourd'hui est de comprendre et de résoudre correctement le défi ui_metrics chargé depuis le script https://twitter.com/i/js_inst?c_name=ui_metrics

Le comprendre

Lors d'une requête de « username check » (sur l'endpoint https://api.x.com/1.1/onboarding/task.json?flow_name=login), vous obtiendrez en réponse le subtask_id LoginJSInstrumentationSubtask

Cela signifie que le sdk twitter va charger le script js depuis https://twitter.com/i/js_inst?c_name=ui_metrics

Un script JS dynamique qui renverra un long script JS dynamique obfusqué ressemblant à

En jetant un œil à ce fatras, on peut déjà identifier quelques informations clés :

  • Des variables utilisant des chaînes hex de 6x caractères (approche de type SHA256)
  • Un objet de retour avec rf (valeurs calculées) et s (une signature/token statique)
  • Le script crée des champs input cachés nommés ui_metrics avec le résultat JSON

La fonction de calcul principale utilise des opérations bitwise sur 4 variables numériques, puis renvoie le résultat final. Mais avant de continuer, nous devons comprendre 2 patterns d'obfuscation « complexes ».

1. Identifier les patterns d'obfuscation

Prototype XOR

Un pattern plutôt propre qui utilise la chaîne de prototypes JavaScript afin d'effectuer des opérations XOR de manière « obfusquée »

a6119e2f973a1d...4ca6d3d91310d0927 = function(POgAM, oFHVk, TvMAz) {
    function UkRmJ(yBLdW) {
        this.TWByd = function() {
            return this.mUxbW ^ yBLdW;
        }
    }
    var vjVer = {
        mUxbW: TvMAz
    };
    var aVDMH = new UkRmJ(POgAM);
    aVDMH.mUxbW = oFHVk;
    UkRmJ.prototype = vjVer;
    return aVDMH.TWByd() | (new UkRmJ(oFHVk)).TWByd();
}(arg1, arg2, arg3);

Décomposons-le :

  1. Un constructeur UkRmJ est créé, qui stocke une méthode XOR
  2. Un objet vjVer est créé avec mUxbW défini sur le troisième argument
  3. Une instance est créée, puis son mUxbW est écrasé avec le deuxième argument
  4. Le prototype est modifié APRÈS la création de la première instance
  5. Premier appel : this.mUxbW provient de la propriété d'instance (arg2), XORé avec arg1
  6. Deuxième appel : this.mUxbW provient du prototype (arg3), XORé avec arg2

Cela peut se convertir facilement en

arg2 ^ arg1 | arg3 ^ arg2

Calcul par arbre DOM

Maintenant on devient créatif. En utilisant un vrai DOM, un calcul est effectué

a4952d9090df64c4e...1e4c7a23d4f827 = function(Bcgrg, UZGVA, iKpwN) {
    var aRkCd = document.createElement('div');
    aRkCd.setAttribute('style', 'display:none;');
    document.getElementsByTagName('body')[0].appendChild(aRkCd);

    function QvFOY(jIbtN, YvKjZ) {
        for (var i = 0; i < 8; i++) {
            var RFTrj = document.createElement('div');
            jIbtN.appendChild(RFTrj);
            RFTrj.innerText = YvKjZ;
            if ((YvKjZ & 1) == 0)
                jIbtN = RFTrj;
            YvKjZ = YvKjZ >> 1;
        }
        return jIbtN;
    }

    function bUZeJ(RFTrj, aRkCd, YvKjZ) {
        if (!RFTrj || RFTrj == aRkCd)
            return YvKjZ % 256;
        while (RFTrj.children.length > 0)
            RFTrj.removeChild(RFTrj.lastElementChild);
        return bUZeJ(RFTrj.parentNode, aRkCd, YvKjZ + parseInt(RFTrj.innerText));
    }

    var YvKjZ = bUZeJ(QvFOY(QvFOY(QvFOY(aRkCd, Bcgrg), UZGVA), iKpwN), aRkCd, 0);
    aRkCd.parentNode.removeChild(aRkCd);
    return YvKjZ;
}(val1, val2, val3);

Sa logique est :

  1. Crée une div cachée et l'ajoute au body
  2. QvFOY construit une structure d'arbre à partir de la représentation binaire de la valeur d'entrée — pour chacun des 8 bits, il crée une div enfant et change conditionnellement le « parent courant » selon que le bit est à 0
  3. Ceci est appelé trois fois en chaîne pour construire des structures d'arbre imbriquées pour les trois valeurs
  4. bUZeJ remonte récursivement l'arbre, en additionnant les valeurs innerText et en renvoyant le résultat modulo 256
  5. La div cachée est nettoyée après le calcul

Au fond, un calcul « caché » sous forme de manipulation du DOM, probablement conçu pour bloquer les navigateurs headless.

Date XOR

Il existe aussi un troisième pattern, plus simple. Il ressemble à ceci

value = value ^ new Date(value * 10000000000).getUTCDate();

Celui-ci se contente de faire un XOR entre la variable et le jour du mois UTC dérivé de la valeur elle-même. Plutôt direct, et cela signifie que la sortie est indépendante du temps, puisque le timestamp est dérivé de la valeur, et non de la date courante.

2. La déobfuscation

Maintenant que nous comprenons ce que font réellement les patterns d'obfuscation, nous pouvons ignorer complètement le navigateur et réimplémenter la logique dans n'importe quel langage. Continuons.

L'approche est simple : parser le script ligne par ligne à l'aide de regex, détecter le pattern auquel on a affaire, et l'exécuter nativement.

Pour le Prototype XOR, nous connaissons déjà la formule ; donc, chaque fois que nous détectons une IIFE contenant « prototype », nous extrayons les trois arguments et calculons directement le résultat.

Pour l'arbre DOM, nous le détectons en cherchant « createElement » dans le corps de la fonction. Ensuite, au lieu de réellement construire des nœuds DOM, nous simulons l'arbre avec un simple tableau

def dom_tree_calc(val1, val2, val3):
    nodes = [{'innerText': 0, 'parentIndex': -1}]

    def build_tree(parent_idx, value):
        current_parent_idx = parent_idx
        for _ in range(8):
            new_node_idx = len(nodes)
            nodes.append({'innerText': value, 'parentIndex': current_parent_idx})
            if (value & 1) == 0:
                current_parent_idx = new_node_idx
            value = value >> 1
        return current_parent_idx

    def traverse_and_sum(node_idx, root_idx, total):
        if node_idx == -1 or node_idx == root_idx:
            return js_mod(total, 256)
        node = nodes[node_idx]
        return traverse_and_sum(node['parentIndex'], root_idx, total + node['innerText'])

    d1 = build_tree(0, val1)
    d2 = build_tree(d1, val2)
    d3 = build_tree(d2, val3)
    return traverse_and_sum(d3, 0, 0)

Chaque nœud n'est qu'un dict avec innerText et parentIndex. build_tree parcourt les 8 bits de la valeur et soit s'imbrique plus profondément (bit pair), soit reste au même niveau (bit impair). traverse_and_sum remonte en additionnant les valeurs.

La logique JavaScript agaçante

Il y a une chose critique qui mérite d'être mentionnée. Notez l'appel à js_mod au lieu d'un simple % 256. En Python, l'opérateur modulo renvoie toujours un résultat non négatif pour un diviseur positif

# Python
-208 % 256  # returns 48

Tandis qu'en javascript, le signe du dividende est préservé

// JavaScript
-208 % 256  // returns -208

Le correctif est un petit helper

def js_mod(a, b):
    if a >= 0:
        return a % b
    return -((-a) % b) if (-a) % b != 0 else 0

3. Le solveur

En rassemblant le tout, le solveur fonctionne en 4 étapes :

  1. Extraire les valeurs des 4 variables initiales à l'aide d'une regex sur les déclarations var
  2. Extraire la chaîne statique s depuis l'instruction return
  3. Parcourir le corps de la fonction ligne par ligne, en détectant le type de chaque opération
  4. Renvoyer les valeurs finales des variables sous forme d'objet rf, couplées à la chaîne s

Ainsi, en comprenant le type d'opération via cette table

| Pattern Detected          | Action                                    |
|---------------------------|-------------------------------------------|
| IIFE with `createElement` | `dom_tree_calc(arg1, arg2, arg3)`         |
| IIFE with `prototype`     | `(arg2 ^ arg1) \| (arg3 ^ arg2)`          |
| Assignment with `new Date`| `value ^ getUTCDate(value * 10000000000)` |
| Simple assignment         | Eval bitwise exp (`^`, `&`, `\|`, `~`)    |

Nous pouvons résoudre ce défi de manière directe avec juste des regex et de l'arithmétique. En obtenant au final un dict de sortie comme

{
  "rf": {
    "a426be92eaca1e3378ae3....c72d05ac7757212fc": 22,
    "b068e1e02c6e975194343...bcd335f40257cab": 207,
    "da6a57a0e5a772c8f414e...4f082ec3ef525f25d23d1c2": -195,
    "ae09b38c692fe2ba158f6...8cc7413f58b5fdf886b2ca5c": 20
  },
  "s": "eJKvRt3Dg_63RqBBrX_w9EfBvNuG8b4O..."
}

Que vous devrez renvoyer à twitter

Et ensuite ?

Nous avons terminé le défi ui_metrics, n'oubliez pas de consulter le dépôt complet sur Github où vous trouverez les solveurs pour Python, NodeJS et Golang. Pensez à lui mettre une étoile.

La prochaine étape consiste à intégrer le tout dans le flow de login complet, aux côtés de la génération du Castle Token. Pensez à vous abonner à ma newsletter pour ne pas le manquer : un script de login complet basé sur des requêtes sera publié.

Me contacter

Si vous avez apprécié cet article, suivez-moi sur GitHub et sur Medium pour recevoir des notifications chaque fois que je publie ou que j'open-source quelque chose, ou offrez-moi un café pour me garder en forme.

twitterreverse-engineeringweb-automationxsui-metrics

Sautez l'étape de la rétro-ingénierie.

Takion renvoie des cookies, en-têtes et tokens frais pour tous les grands murs antibot. Un seul POST, aucun navigateur, premier appel dans l'heure.