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

这是我关于 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);
我们来拆解它:
- 创建了一个构造函数 UkRmJ,它存了一个 XOR 方法
- 创建了一个对象 vjVer,其 mUxbW 被设为第三个参数
- 创建了一个实例,然后它的 mUxbW 被第二个参数覆盖
- 原型是在创建第一个实例之后才被改动的
- 第一次调用:this.mUxbW 来自实例属性(arg2),与 arg1 做 XOR
- 第二次调用: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);
它的逻辑是:
- 创建一个隐藏的 div 并追加到 body 上
- QvFOY 根据输入值的二进制表示构建一个树结构——对于 8 个比特中的每一个,它创建一个子 div,并根据该比特是否为 0 有条件地改变"当前父节点"
- 这被链式调用了三次,为全部三个值构建嵌套的树结构
- bUZeJ 沿树递归向上遍历,累加 innerText 的值,并返回结果对 256 取模
- 计算完成后清理掉这个隐藏的 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 步工作:
- 用正则匹配 var 声明,提取 4 个初始变量值
- 从 return 语句里提取静态的 s 字符串
- 逐行遍历函数体,检测每一种操作类型
- 把最终的变量值作为 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 上关注我,每当我发帖或开源什么东西时你都会收到通知,或者请我喝杯咖啡让我保持动力。