全部文章
3 分钟阅读glizzykingdreko

X(前 Twitter)登录流程重建。第 2 集 —— UI Metrics

这是我关于 X / Twitter 登录流程系列的第二集,讲的是为了做出一个基于请求(requests)的脚本所需的全部生成工作。如果你错过了上一集,一定去看看

X(前 Twitter)登录流程重建。第 2 集 —— UI Metrics

这是我关于 X / Twitter 登录流程系列的第二集,讲的是为了做出一个基于请求(requests)的脚本所需的全部生成工作。如果你错过了上一集,一定去看看

看这一集的 GitHub 仓库(含完整研究,以及 Node/Go/Python 实现)

今天的目标是正确理解并解决从脚本 https://twitter.com/i/js_inst?c_name=ui_metrics 加载的 ui_metrics 挑战

理解它

在一次"用户名检查"请求期间(在端点 https://api.x.com/1.1/onboarding/task.json?flow_name=login 上),你会在响应里拿到 subtask_id LoginJSInstrumentationSubtask

这意味着 twitter 的 sdk 会从 https://twitter.com/i/js_inst?c_name=ui_metrics 加载那段 js 脚本

一个动态 JS 脚本,会返回一段又长又动态、被混淆过的 JS 脚本,看起来像这样

看一眼这堆乱麻,我们已经能识别出一些关键信息:

  • 用到的变量是 6 位十六进制字符(类似 SHA256 的做法)
  • 一个返回对象,带有 rf(计算出的值)和 s(一个静态的签名/token)
  • 脚本创建了一个名为 ui_metrics 的隐藏 input 字段,里面装着 JSON 结果

主计算函数对 4 个数字变量做一些位运算,然后返回最终结果。但在继续之前,我们需要搞懂 2 种"复杂"的混淆模式。

1. 识别混淆模式

原型 XOR(Prototype XOR)

一个相当干净的模式,利用 JavaScript 的原型链,以一种"混淆"的方式来执行 XOR 运算

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);

我们来拆解它:

  1. 创建了一个构造函数 UkRmJ,它存了一个 XOR 方法
  2. 创建了一个对象 vjVer,其 mUxbW 被设为第三个参数
  3. 创建了一个实例,然后它的 mUxbW 被第二个参数覆盖
  4. 原型是在创建第一个实例之后才被改动的
  5. 第一次调用:this.mUxbW 来自实例属性(arg2),与 arg1 做 XOR
  6. 第二次调用:this.mUxbW 来自原型(arg3),与 arg2 做 XOR

这可以轻松转换成

arg2 ^ arg1 | arg3 ^ arg2

DOM 树计算(DOM Tree Calculation)

现在我们玩点花的。用一个真实的 DOM 来做计算

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);

它的逻辑是:

  1. 创建一个隐藏的 div 并追加到 body 上
  2. QvFOY 根据输入值的二进制表示构建一个树结构——对于 8 个比特中的每一个,它创建一个子 div,并根据该比特是否为 0 有条件地改变"当前父节点"
  3. 这被链式调用了三次,为全部三个值构建嵌套的树结构
  4. bUZeJ 沿树递归向上遍历,累加 innerText 的值,并返回结果对 256 取模
  5. 计算完成后清理掉这个隐藏的 div

本质上就是一次伪装成 DOM 操作的计算,很可能是为了拦截无头浏览器而做的。

日期 XOR(Date XOR)

还有第三种、更简单的模式。它看起来像这样

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

这个只是把变量和一个 UTC 月内日期做 XOR,而这个日期是从值本身派生出来的。相当直接,而且这意味着输出是与时间无关的,因为时间戳是从值派生的,而不是取当前日期。

2. 去混淆

现在我们理解了这些混淆模式实际做了什么,就可以完全跳过浏览器,用任何语言重新实现这套逻辑。我们继续。

思路很简单:用正则逐行解析脚本,判断我们面对的是哪种模式,然后原生地执行它。

对于原型 XOR,我们已经知道公式了,所以每当我们检测到一个包含 "prototype" 的函数 IIFE,就提取三个参数并直接算出结果。

对于 DOM 树,我们通过在函数体里查找 "createElement" 来检测它。然后不去真正构建 DOM 节点,而是用一个简单的数组来模拟这棵树

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)

每个节点就是一个带有 innerText 和 parentIndex 的字典。build_tree 遍历值的 8 个比特,要么向下嵌套(偶数位),要么停留在同一层(奇数位)。traverse_and_sum 沿树向上走,把这些值累加起来。

那个烦人的 Javascript 逻辑

有一个关键的点值得一提。注意这里用的是 js_mod 调用,而不是普通的 % 256。在 Python 里,对于正的除数,取模运算总是返回非负结果

# Python
-208 % 256  # returns 48

而在 javascript 里,被除数的符号会被保留

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

解决办法是一个小的辅助函数

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

3. 解题器

把这一切拼到一起,解题器分 4 步工作:

  1. 用正则匹配 var 声明,提取 4 个初始变量值
  2. 从 return 语句里提取静态的 s 字符串
  3. 逐行遍历函数体,检测每一种操作类型
  4. 把最终的变量值作为 rf 对象返回,与 s 字符串配成一对

于是,通过这张表来判断操作类型

| 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 (`^`, `&`, `\|`, `~`)    |

我们仅用正则和算术就能直接解决这个挑战。最终得到一个输出字典,像这样

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

你需要把它发回给 twitter

接下来呢?

我们已经搞定了 ui_metrics 挑战,一定去看看 Github 上的完整仓库,你能在里面找到 Python、NodeJS 和 Golang 三种解题器。记得给它点个 star。

下一步是着手把一切整合进完整的登录流程,同时处理 Castle Token 的生成。一定订阅我的 newsletter,别错过,我会发一个完整的、基于请求的登录脚本。

与我联系

如果你喜欢这篇文章,在 GitHub 和 Medium 上关注我,每当我发帖或开源什么东西时你都会收到通知,或者请我喝杯咖啡让我保持动力。

twitterreverse-engineeringweb-automationxsui-metrics

跳过逆向工程。

Takion 为每一道主流反机器人防护墙返回新鲜的 cookie、请求头和令牌。一次 POST,无需浏览器,一小时内完成首次调用。