Tous les articles
8 min de lectureglizzykingdreko

Reconstruction du flow de login X (anciennement Twitter). Épisode 1 — Génération des en-têtes

L'article d'aujourd'hui est le premier d'une petite série sur l'automatisation du login X / Twitter. Dans cet épisode, je vais reverse engineer tous les…

Reconstruction du flow de login X (anciennement Twitter). Épisode 1 — Génération des en-têtes

L'article d'aujourd'hui est le premier d'une petite série sur l'automatisation du login X / Twitter. Dans cet épisode, je vais reverse engineer tous les en-têtes X / Twitter nécessaires au flow de login. À la fin de la série, nous aurons un script de login complet en python, indépendant de tout navigateur.

Consultez le dépôt GitHub avec l'étude complète de cet épisode et son module Python

X-Client-Transaction-Id

Ce token est généré pour chaque requête à partir de 3 éléments principaux :

  • Les détails de la requête (METHOD et URL)
  • Un fingerprint basé sur un chargement (une balise meta appelée « twitter-site-verification » et un fichier js « dynamique »)
  • Le timestamp courant

Le code original ressemble à ceci

Après un peu de nettoyage, on obtient ce snippet plus lisible


// Calculate seconds elapsed since reference timestamp (April 2023)
var secondsSinceEpoch = Math.floor((Date.now() - 1682924400 * 1000) / 1000);

// Convert timestamp to bytes (Float64 → Uint8Array)
var timestampBytes = new Uint8Array(new Float64Array([secondsSinceEpoch]).buffer);

// Get fingerprint from DOM element
var fingerprint = new Uint8Array(atob(
    document.querySelectorAll("[name^=tw]")[0].getAttribute("content")
)["split"]("")["map"](function (n) {
    return n["charCodeAt"](0);
}));

var fingerprintHash = generateFingerprintHash(fingerprint);

// Random salt for the payload
var randomSalt = [Math.random() * 256];

// Convert fingerprint and timestamp to arrays
var fingerprintArray = Array.from(fingerprint);
var timestampArray = Array.from(timestampBytes);

// Build the hash input: "param2!param1!timestamp" + "obfiowerehiring" + fingerprintHash
var hashInput = [t, r, secondsSinceEpoch].join("!") + "obfiowerehiring" + fingerprintHash;

// Compute SHA-256 hash asynchronously
var hashResult = crypto.subtle.digest("sha-256", new TextEncoder().encode(hashInput));

// Build the final payload by concatenating:
// [randomSalt, fingerprintArray, timestampArray, transformed(slice16(arrayFrom(hashBytes).concat(HW))), 3]
var hashBytes = new Uint8Array(hashResult);
var hashArray = Array.from(hashBytes);
var transformed = hashArray.concat(HW)["slice"](0, Math.pow(2, 4));  // Take first 16 bytes

var payload = randomSalt.concat(fingerprintArray, timestampArray, transformed, [3]);

// Encode as base64 with optional XOR and return
var finalBytes = new Uint8Array(payload);

// base64 encode with optional XOR
MW = function (n, r, t) {
    return r ? n ^ t[0] : n;
}
sW = function (n) {
    return btoa(Array.from(n)["map"](function (n) {
        return String["fromCharCode"](n);
    })["join"](""))["replace"](/=/g, "");
}
var encoded = sW(finalBytes.map(MW));

Vous pouvez trouver le code complet, ainsi que la partie generateFingerprintHash (un fingerprint de base plutôt simple, basé sur des animations css) sur mon dépôt github.

Alors, comment résout-on celui-ci ?

C'est assez direct jusqu'au résultat : il suffit de recréer le même hash en fonction de l'action que nous effectuons et d'y intégrer le temps et la clé d'animation. Voici un exemple

def _generate_transaction_id(self, method: str, path: str) -> str:
    # Calculate current time offset
    time_now = floor((time() * 1000 - 1682924400000) / 1000)
    time_now_bytes = [(time_now >> (i * 8)) & 0xFF for i in range(4)]

    # Generate hash
    hash_val = sha256(
        f"{method}!{path}!{time_now}obfiowerehiring{self.animation_key}".encode()
    ).digest()

    # Build final byte array with XOR encoding
    random_num = randint(0, 255)
    bytes_arr = [*self.key_bytes, *time_now_bytes, *list(hash_val)[:16], 3]
    out = bytearray([random_num, *[b ^ random_num for b in bytes_arr]])

    return b64encode(out).decode().rstrip("=")

# Example usage
self._generate_transaction_id(
    method='POST',
    path='/1.1/onboarding/task.json'
)

La partie animation, ainsi que le code complet, se trouvent dans mon dépôt.

X-Guest-Token

Généré à partir d'un

POST https://api.x.com/1.1/guest/activate.json

plutôt simple et direct, on peut passer celui-ci et continuer.

X-Xp-Forwarded-For

Jetons un œil au code source :

Nous devons donc analyser ce window.XPForwardedForSDK qui, à première vue, semble avoir quelques attributs, et il y a un script WASM impliqué

Les principaux attributs qui nous intéressent sont :

  • init(environment)
    Charge le runtime WASM
  • getForwardedForStr()
    Nous renvoie un dict contenant la chaîne importante et le temps d'expiration

Évidemment, comme presque tout est basé sur du wasm, nous devons comprendre ce qu'il fait.

1. Décompiler le script WASM

Après avoir extrait et parsé le script via un snippet simple comme

const fs = require('fs');
const code = fs.readFileSync('x_xp_forwarded_for.js', 'utf8');

// Find the WASM bytes array using regex
const match = code.match(
    /864585:\s*e\s*=>\s*\{[^}]*var\s+t\s*=\s*new\s+ArrayBuffer\((\d+)\);\s*new\s+Uint8Array\(t\)\.set\(\[([^\]]+)\]/s
);

if (match) {
    const size = parseInt(match[1]);
    console.log('WASM size:', size, 'bytes');

    // Parse the bytes
    const allBytes = match[2].split(',').map(b => parseInt(b.trim()));
    const buffer = Buffer.from(allBytes);

    // Save to file
    fs.writeFileSync('xpforwarded.wasm', buffer);
    console.log('Saved to xpforwarded.wasm');
}

Nous pouvons continuer en convertissant le WASM binaire en texte lisible via l'outil bien pratique wasm2wat, comme

wasm2wat xpforwarded.wasm -o xpforwarded.wat

Nous avons maintenant une bien meilleure vue de la situation

Nous pouvons déjà souligner le fait qu'il s'agit d'un script basé sur GO, ce qui rend notre processus de debug bien plus facile.

2. Analyser les segments de données WAT

Les fichiers WAT contiennent des segments de données avec des chaînes embarquées

(data (;0;) (i32.const 65536) "expand 32-byte k...")
(data (;1;) (i32.const 65842) "meta\00invalid syntax0123456789abcefz...")

les principales découvertes que j'ai faites étaient :

| String                                      | Interpretation               |
|---------------------------------------------|------------------------------|
| `forwarded-for-sdk/javascript_fingerprint`  | Go package name              |
| `crypto/aes`, `crypto/internal/fips140/aes` | AES encryption used          |
| `crypto/internal/fips140/aes/gcm`           | GCM mode specifically        |
| `crypto/internal/fips140/sha256`            | SHA-256 hashing              |
| `documentcookie;=guest_id`                  | Cookie extraction logic      |
| `navigatoruserActivationhasBeenActive`      | User interaction check       |
| `userAgentwebdriverundefined`               | Bot detection via webdriver  |
| `getForwardedForStr`                        | Exported JS function name    |
| `strexpiryTimeMillis`                       | Return object properties     |
| `json:"navigator_properties"`               | JSON field tag               |
| `json:"created_at"`                         | JSON field tag               |

Nous y sommes presque, essayons d'extraire la clé de chiffrement utilisée pour le chiffrement AES afin d'avoir tout

strings xpforwarded.wasm | grep -E "^[0-9a-f]{64}$"

# that will output
0e6be1f1e21ffc33590b888fd4dc81b19713e570e805d4e5df80a493c9571a05

3. Comprendre le runtime Go

Un peu de théorie est toujours nécessaire pour cerner la situation

Go compile en WASM en utilisant la cible GOOS=js GOARCH=wasm. Cela nécessite :

  1. Un fichier JavaScript qui implémente les imports gojs
  2. Go gère sa propre mémoire au sein de la mémoire linéaire WASM, un manager est donc requis
  3. Le package syscall/js fournit une communication bidirectionnelle

**Système de référence de valeurs
**Le WASM Go utilise un système de références pour passer des valeurs entre Go et JavaScript :

// Values are stored in an array, indexed by IDs
this._values = [
    NaN,        // 0: reserved
    0,          // 1: zero
    null,       // 2: null
    true,       // 3: true
    false,      // 4: false
    globalThis, // 5: global object
    this        // 6: Go instance
];

Quand Go appelle syscall/js.valueGet(obj, “property”) :

  1. Go passe l'ID de référence de l'objet
  2. Le code glue JS recherche l'objet dans _values
  3. Récupère la propriété
  4. Stocke le résultat dans _values et renvoie le nouvel ID

**Transformation Asyncify
**Le module WASM utilise la transformation asyncify de Binaryen pour gérer les opérations JavaScript asynchrones :


(export "asyncify_start_unwind" (func 436))
(export "asyncify_stop_unwind" (func 437))
(export "asyncify_start_rewind" (func 438))

Cela permet au code Go de faire un await sur des Promises JavaScript, ce qui est nécessaire pour :

  • crypto.subtle.encrypt()
  • crypto.subtle.digest()

4. La méthodologie de reverse engineering

L'approche que je vais utiliser pour recréer correctement ce wasm consiste à ne pas utiliser le fichier directement du tout. Ce serait trop facile de simplement écrire un script JS qui exécute le fichier avec le bon env. Je veux réellement tout recréer à partir de zéro. Continuons.

**Preuve 1 : les chaînes embarquées
**La technique la plus puissante pour analyser des binaires GO est d'extraire et d'analyser les chaînes embarquées, telles que :

  • Les noms de packages
  • Les messages d'erreur
  • Les tags de struct JSON
  • Les noms de types

Extrayons tout via une seule commande

strings xpforwarded.wasm | grep -v "^.$" | sort -u > all_strings.txt

Maintenant, étudions-les

| String Found                           | What It Tells Us
|----------------------------------------|
| `json:"navigator_properties"`          | There's a Go struct with a field that serializes to `navigator_properties`
| `json:"user_agent"`                    | Nested struct has `user_agent` field
| `json:"has_been_active"`               | Boolean field for user activation
| `json:"webdriver"`                     | Boolean field for bot detection
| `json:"created_at"`                    | Timestamp field
| `navigatoruserActivationhasBeenActive` | Concatenated JS property access path
| `userAgentwebdriverundefined`          | More JS properties being accessed
| `getForwardedForStr`                   | The exported function name
| `strexpiryTimeMillis`                  | Return object has `str` and `expiryTimeMillis` keys

La logique que nous allons utiliser est

json:"navigator_properties" + json:"user_agent" + json:"has_been_active" + json:"webdriver"
                                        ↓
                        NavigatorProperties struct with 3 fields

json:"navigator_properties" + json:"created_at"
                    ↓
        ClientSignals struct wrapping NavigatorProperties

**Preuve 2 : les chemins de packages
**Les binaires Go embarquent les chemins de packages complets :

strings xpforwarded.wasm | grep "crypto/"
# Returns
crypto/aes                       -> Uses AES encryption
crypto/cipher                    -> Uses cipher modes (block cipher interface)
crypto/internal/fips140/aes
crypto/internal/fips140/aes/gcm  -> Specifically GCM mode
crypto/internal/fips140/sha256

**Preuve 3 : les messages d'erreur
**Les erreurs nous révèlent le comportement des fonctions

strings xpforwarded.wasm | grep -i "error"

// returns
error creating AES cipher:
error creating GCM:
Error encrypting data:

On pourrait donc déjà deviner qu'il existe une logique comme

// Error message "error creating AES cipher:" implies:
block, err := aes.NewCipher(key)
if err != nil {
    return nil, fmt.Errorf("error creating AES cipher: %w", err)
}

// Error message "error creating GCM:" implies:
gcm, err := cipher.NewGCM(block)
if err != nil {
    return nil, fmt.Errorf("error creating GCM: %w", err)
}

Et ainsi de suite. Jetez un œil au dépôt github pour une logique de reconstruction complète du script GO original et sa conversion en Python / NodeJS. Ainsi que le module python twitter-generator pour générer facilement ces en-têtes

Et ensuite ?

Nous avons terminé la partie génération des en-têtes, n'oubliez pas de consulter le dépôt complet sur Github.

La prochaine étape consiste à s'attaquer au premier défi antibot. Pensez à vous abonner à ma newsletter pour ne pas le manquer.

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.

reverse-engineeringxswasmtwitterpython-automation

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.