全部文章
5 分钟阅读glizzykingdreko

X(前 Twitter)登录流程重建。第 1 集 —— 请求头生成

今天这篇是一个关于 X / Twitter 登录自动化的小系列的第一篇。在这一集里,我会逆向 X / Twitter 登录流程所需的所有请求头。系列结束时,我们会有一个完整的、不依赖浏览器的 python 登录脚本。

X(前 Twitter)登录流程重建。第 1 集 —— 请求头生成

今天这篇是一个关于 X / Twitter 登录自动化的小系列的第一篇。在这一集里,我会逆向 X / Twitter 登录流程所需的所有请求头。系列结束时,我们会有一个完整的、不依赖浏览器的 python 登录脚本。

看这一集的 GitHub 仓库(含完整研究) 及其 Python 模块

X-Client-Transaction-Id

这个 token 会为每个请求生成,主要基于 3 样东西:

  • 请求细节(METHOD 和 URL)
  • 一个基于已加载内容的指纹(一个名为"twitter-site-verification"的 meta 标签,和一个"动态"的 js 文件)
  • 当前时间戳

原始代码长这样

稍加清理之后,我们得到这段更易读的代码


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

完整代码,以及 generateFingerprintHash 那部分(一个相当基础的、基于 css 动画的指纹),可以在我的 github 仓库里找到。

那么,我们怎么解这一个?

到出结果为止都相当直接,我们只需根据正在执行的动作重建相同的哈希,并在其中带上时间和动画 key,这里有个例子

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

动画那部分,以及完整代码,都能在我的仓库里找到。

X-Guest-Token

由一个

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

生成,相当简单直接,这一个我们可以跳过,继续往下。

X-Xp-Forwarded-For

我们来看看源代码:

所以,我们需要分析这个 window.XPForwardedForSDK,乍一看它似乎有几个属性,而且牵涉到一个 WASM 脚本

对我们重要的主要属性有:

  • init(environment)
    加载 WASM 运行时
  • getForwardedForStr()
    返回给我们一个字典,包含那个重要的字符串和过期时间

显然,既然基本上一切都是基于 wasm 的,我们就得搞懂它到底做了什么。

1. 反编译 WASM 脚本

用一段简单的代码把脚本提取并解析出来,比如

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

我们可以接着用好用的工具 wasm2wat 把二进制的 WASM 转成可读文本,命令如下

wasm2wat xpforwarded.wasm -o xpforwarded.wat

现在对局面的了解就清晰多了

我们已经能看出一个事实:这是一个基于 GO 的脚本,这让我们的调试过程容易多了。

2. 分析 WAT 数据段

WAT 文件里包含带有嵌入字符串的数据段

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

我的主要发现有:

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

我们几乎就绪了,接下来试着提取用于 AES 加密的加密密钥,好把一切都凑齐

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

# that will output
0e6be1f1e21ffc33590b888fd4dc81b19713e570e805d4e5df80a493c9571a05

3. 理解 Go 运行时

要搞清楚局面,一点理论总是需要的

Go 编译到 WASM 用的是 GOOS=js GOARCH=wasm 目标。这需要:

  1. 一个实现了 gojs imports 的 JavaScript 文件
  2. Go 在 WASM 线性内存里管理它自己的内存,所以需要一个管理器
  3. syscall/js 包提供双向通信

**值引用系统
**Go WASM 用一套引用系统在 Go 和 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
];

当 Go 调用 syscall/js.valueGet(obj, "property") 时:

  1. Go 传入对象的引用 ID
  2. JS 胶水代码在 _values 里查找这个对象
  3. 取出属性
  4. 把结果存进 _values 并返回新的 ID

**Asyncify 变换
**这个 WASM 模块用 Binaryen 的 asyncify 变换来处理异步的 JavaScript 操作:


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

这让 Go 代码能够 await JavaScript 的 Promise,这对下面这些是必需的:

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

4. 逆向方法论

我打算用来正确重建这个 wasm 的方法,是完全不直接使用这个文件。写个简单的 JS 脚本、用正确的环境把文件跑起来,那也太容易了。我实际上想从零开始重建一切。我们继续。

**证据 1:嵌入字符串
**分析 GO 二进制文件最强大的技巧,就是提取并分析嵌入的字符串,比如:

  • 包名
  • 错误信息
  • JSON 结构体 tag
  • 类型名

我们用一条命令把一切都提取出来

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

现在来研究它们

| 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

我们要用的逻辑是

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

**证据 2:包路径
**Go 二进制文件会嵌入完整的包路径:

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

**证据 3:错误信息
**错误信息会告诉我们函数的行为

strings xpforwarded.wasm | grep -i "error"

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

于是我们已经能猜到里面有类似这样的逻辑

// 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)
}

以此类推,关于这块内容,去看看 github 仓库里对原始 GO 脚本的完整重建逻辑,以及到 Python / NodeJS 的转换。还有用于轻松生成这些请求头的 python 模块 twitter-generator

接下来呢?

我们已经完成了请求头生成的部分,一定去看看 Github 上的完整仓库

下一步是着手处理第一个反机器人挑战。一定订阅我的 newsletter,别错过。

与我联系

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

reverse-engineeringxswasmtwitterpython-automation

跳过逆向工程。

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